Go Contextタイムアウトトラブルシューティング:context deadline exceededの7つの根本原因と精密制御実践

编程语言

またマイクロサービスがcontext deadline exceededでクラッシュした

午前3時、アラートチャンネルが爆発——注文サービスP99レイテンシが5秒に急上昇、上流ゲートウェイがcontext deadline exceededを連発。タイムアウトを3秒から5秒、5秒から10秒に増やしたが、問題を先延ばしにしただけ。さらに恐ろしいのは、数千のgoroutineリークを発見し、メモリ使用量が上昇し続け、最終的にOOM Killされたことだ。

Go Contextタイムアウト制御context.WithTimeoutを1行追加するだけではない。タイムアウトはどれくらいにすべきか?キャンセル信号はどう伝播するか?子goroutineはどう回収するか?マイクロサービスチェーンのタイムアウトはどうカスケードするか?これらを理解しないと、context deadline exceededが幽霊のように繰り返し現れる。

本記事は7つの根本原因から出発し、タイムアウト設定→キャンセル伝播→goroutine回収→マイクロサービスカスケード制御のフルパイプラインを実践する。


Contextコア概念

概念 説明
context.Context Go標準ライブラリインターフェース、デッドライン、キャンセル信号、リクエストスコープ値を運ぶ
context.WithTimeout タイムアウト付き子Contextを作成、経過後自動キャンセル
context.WithDeadline 絶対デッドライン付き子Contextを作成
context.WithCancel 手動キャンセル可能な子Contextを作成
context.WithoutCancel Go 1.21+、親Contextのキャンセルの影響を受けない子Contextを作成
context.AfterFunc Go 1.21+、Contextキャンセル後に自動コールバック実行
context.Cause Go 1.20+、Contextキャンセルの根本原因を取得

タイムアウト伝播メカニズム

リクエストチェーン:
Gateway(5s) → OrderService(3s) → PaymentService(2s) → InventoryService(1s)

伝播ルール:
1. 子Contextのタイムアウトは親Contextを超えられない
2. 親Contextのキャンセルは全ての子Contextを自動キャンセル
3. タイムアウト後、Doneチャネルが閉じ、Errがdeadline exceededを返す
4. キャンセル操作は冪等、複数回Cancel()を呼んでもエラーにならない

問題分析:context deadline exceededの7つの根本原因

  1. タイムアウトが短すぎる:ネットワークジッタやサービス負荷を考慮せず、P99レイテンシがタイムアウト閾値を超過
  2. Contextが伝播されていない:関数シグネチャはContextを受け取るが、呼び出し側がcontext.TODO()context.Background()を渡す
  3. goroutineリーク:Contextキャンセル後、子goroutineがDoneチャネルをチェックせず実行を継続
  4. タイムアウトカスケード増幅:マイクロサービスチェーンの各層でタイムアウトを設定、総タイムアウトが層ごとに圧縮
  5. HTTP Clientにタイムアウト未設定http.Client{}はデフォルトでタイムアウトなし、リクエストが永遠にブロックする可能性
  6. データベースクエリにタイムアウトなし:SQL実行時間が制御不能、長時間クエリがリクエスト全体を引きずる
  7. Context上書き:内側の関数がcontext.Background()で新Contextを作成、外側のキャンセル信号を消失

ステップバイステップ:精密タイムアウト制御実装

Step 1:基本タイムアウト制御

package main

import (
    "context"
    "fmt"
    "time"
)

func fetchUserData(ctx context.Context, userID string) (string, error) {
    ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
    defer cancel()

    resultCh := make(chan string, 1)
    errCh := make(chan error, 1)

    go func() {
        data, err := queryDatabase(ctx, userID)
        if err != nil {
            errCh <- err
            return
        }
        resultCh <- data
    }()

    select {
    case data := <-resultCh:
        return data, nil
    case err := <-errCh:
        return "", err
    case <-ctx.Done():
        return "", fmt.Errorf("fetch user data: %w", ctx.Err())
    }
}

func queryDatabase(ctx context.Context, userID string) (string, error) {
    select {
    case <-time.After(2 * time.Second):
        return fmt.Sprintf("user_data_%s", userID), nil
    case <-ctx.Done():
        return "", ctx.Err()
    }
}

func main() {
    ctx := context.Background()
    data, err := fetchUserData(ctx, "12345")
    if err != nil {
        fmt.Printf("Error: %v\n", err)
        return
    }
    fmt.Printf("Data: %s\n", data)
}

Step 2:マイクロサービスチェーンタイムアウト伝播

package middleware

import (
    "context"
    "time"
)

type TimeoutConfig struct {
    GatewayTimeout        time.Duration
    OrderServiceTimeout   time.Duration
    PaymentTimeout        time.Duration
    InventoryTimeout      time.Duration
}

func DefaultTimeoutConfig() *TimeoutConfig {
    return &TimeoutConfig{
        GatewayTimeout:        5 * time.Second,
        OrderServiceTimeout:   3 * time.Second,
        PaymentTimeout:        2 * time.Second,
        InventoryTimeout:      1 * time.Second,
    }
}

type contextKey string

const timeoutKey contextKey = "timeout_config"

func WithTimeoutConfig(ctx context.Context, cfg *TimeoutConfig) context.Context {
    return context.WithValue(ctx, timeoutKey, cfg)
}

func TimeoutConfigFromContext(ctx context.Context) *TimeoutConfig {
    if cfg, ok := ctx.Value(timeoutKey).(*TimeoutConfig); ok {
        return cfg
    }
    return DefaultTimeoutConfig()
}

func DeriveServiceTimeout(ctx context.Context, serviceTimeout time.Duration) (context.Context, context.CancelFunc) {
    if deadline, ok := ctx.Deadline(); ok {
        remaining := time.Until(deadline)
        if remaining < serviceTimeout {
            return context.WithDeadline(ctx, deadline)
        }
    }
    return context.WithTimeout(ctx, serviceTimeout)
}

Step 3:goroutineリーク防止

package pool

import (
    "context"
    "sync"
)

type WorkerPool struct {
    maxWorkers int
    tasks      chan func() error
    wg         sync.WaitGroup
}

func NewWorkerPool(maxWorkers, queueSize int) *WorkerPool {
    return &WorkerPool{
        maxWorkers: maxWorkers,
        tasks:      make(chan func() error, queueSize),
    }
}

func (p *WorkerPool) Start(ctx context.Context) {
    for i := 0; i < p.maxWorkers; i++ {
        p.wg.Add(1)
        go func(workerID int) {
            defer p.wg.Done()
            for {
                select {
                case <-ctx.Done():
                    return
                case task, ok := <-p.tasks:
                    if !ok {
                        return
                    }
                    task()
                }
            }
        }(i)
    }
}

func (p *WorkerPool) Submit(ctx context.Context, task func() error) error {
    select {
    case p.tasks <- task:
        return nil
    case <-ctx.Done():
        return ctx.Err()
    default:
        return fmt.Errorf("task queue is full")
    }
}

func (p *WorkerPool) Stop() {
    close(p.tasks)
    p.wg.Wait()
}

Step 4:HTTP Clientタイムアウト設定

package httpclient

import (
    "context"
    "net"
    "net/http"
    "time"
)

type ClientConfig struct {
    Timeout             time.Duration
    DialTimeout         time.Duration
    TLSHandshakeTimeout time.Duration
    MaxIdleConns        int
    MaxIdleConnsPerHost int
    IdleConnTimeout     time.Duration
}

func DefaultClientConfig() *ClientConfig {
    return &ClientConfig{
        Timeout:             10 * time.Second,
        DialTimeout:         3 * time.Second,
        TLSHandshakeTimeout: 3 * time.Second,
        MaxIdleConns:        100,
        MaxIdleConnsPerHost: 10,
        IdleConnTimeout:     90 * time.Second,
    }
}

func NewClient(cfg *ClientConfig) *http.Client {
    transport := &http.Transport{
        DialContext: (&net.Dialer{
            Timeout:   cfg.DialTimeout,
            KeepAlive: 30 * time.Second,
        }).DialContext,
        TLSHandshakeTimeout:   cfg.TLSHandshakeTimeout,
        MaxIdleConns:          cfg.MaxIdleConns,
        MaxIdleConnsPerHost:   cfg.MaxIdleConnsPerHost,
        IdleConnTimeout:       cfg.IdleConnTimeout,
        ResponseHeaderTimeout: cfg.Timeout,
    }
    return &http.Client{
        Timeout:   cfg.Timeout,
        Transport: transport,
    }
}

func DoRequest(ctx context.Context, client *http.Client, req *http.Request) (*http.Response, error) {
    req = req.WithContext(ctx)

    resp, err := client.Do(req)
    if err != nil {
        if ctx.Err() != nil {
            return nil, fmt.Errorf("request cancelled: %w", ctx.Err())
        }
        return nil, fmt.Errorf("request failed: %w", err)
    }

    return resp, nil
}

Step 5:データベースクエリタイムアウト

package db

import (
    "context"
    "database/sql"
    "time"
)

type DBOption struct {
    MaxOpenConns    int
    MaxIdleConns    int
    ConnMaxLifetime time.Duration
    ConnMaxIdleTime time.Duration
    QueryTimeout    time.Duration
}

func DefaultDBOption() *DBOption {
    return &DBOption{
        MaxOpenConns:    25,
        MaxIdleConns:    5,
        ConnMaxLifetime: 5 * time.Minute,
        ConnMaxIdleTime: 1 * time.Minute,
        QueryTimeout:    3 * time.Second,
    }
}

func OpenDB(driverName, dataSource string, opt *DBOption) (*sql.DB, error) {
    db, err := sql.Open(driverName, dataSource)
    if err != nil {
        return nil, err
    }

    db.SetMaxOpenConns(opt.MaxOpenConns)
    db.SetMaxIdleConns(opt.MaxIdleConns)
    db.SetConnMaxLifetime(opt.ConnMaxLifetime)
    db.SetConnMaxIdleTime(opt.ConnMaxIdleTime)

    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()

    if err := db.PingContext(ctx); err != nil {
        return nil, fmt.Errorf("ping database: %w", err)
    }

    return db, nil
}

func QueryWithTimeout(ctx context.Context, db *sql.DB, query string, args ...any) (*sql.Rows, error) {
    ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
    defer cancel()

    rows, err := db.QueryContext(ctx, query, args...)
    if err != nil {
        if ctx.Err() == context.DeadlineExceeded {
            return nil, fmt.Errorf("query timeout after 3s: %w", err)
        }
        return nil, err
    }
    return rows, nil
}

落とし穴ガイド

落とし穴1:Contextを構造体に格納する

// ❌ 誤り:Contextを構造体に格納
type UserService struct {
    ctx context.Context
}

func (s *UserService) GetUser(id string) error {
    return s.queryDatabase(s.ctx, id)
}

// ✅ 正しい:Contextを関数パラメータとして渡す
type UserService struct {
    db *sql.DB
}

func (s *UserService) GetUser(ctx context.Context, id string) error {
    return s.queryDatabase(ctx, id)
}

落とし穴2:cancelの呼び出し忘れによるリーク

// ❌ 誤り:WithTimeoutのcancelが呼ばれない
ctx, _ := context.WithTimeout(parentCtx, 5*time.Second)
result, err := doWork(ctx)

// ✅ 正しい:必ずdefer cancel
ctx, cancel := context.WithTimeout(parentCtx, 5*time.Second)
defer cancel()
result, err := doWork(ctx)

落とし穴3:context.Background()で親Contextを上書き

// ❌ 誤り:内部でBackgroundを使いキャンセル信号を消失
func processOrder(ctx context.Context, orderID string) error {
    innerCtx := context.Background()
    return paymentService.charge(innerCtx, orderID)
}

// ✅ 正しい:親Contextを渡してキャンセル伝播を維持
func processOrder(ctx context.Context, orderID string) error {
    return paymentService.charge(ctx, orderID)
}

落とし穴4:goroutineがDoneチャネルをチェックしない

// ❌ 誤り:goroutineがキャンセルに応答しない
go func() {
    result := heavyComputation()
    resultCh <- result
}()

// ✅ 正しい:goroutine内でContextキャンセルをチェック
go func() {
    result, err := heavyComputationWithCtx(ctx)
    if err != nil {
        return
    }
    select {
    case resultCh <- result:
    case <-ctx.Done():
    }
}()

落とし穴5:HTTP ClientとTransportのタイムアウト衝突

// ❌ 誤り:Client.TimeoutとTransportタイムアウトが重複、早期タイムアウトの可能性
client := &http.Client{
    Timeout: 5 * time.Second,
    Transport: &http.Transport{
        ResponseHeaderTimeout: 5 * time.Second,
    },
}

// ✅ 正しい:Client.Timeoutは全体、Transportは接続フェーズのみ
client := &http.Client{
    Timeout: 10 * time.Second,
    Transport: &http.Transport{
        DialContext: (&net.Dialer{Timeout: 3 * time.Second}).DialContext,
        TLSHandshakeTimeout: 3 * time.Second,
    },
}

エラートラブルシューティング

# エラーメッセージ 原因 解決方法
1 context deadline exceeded 操作がContextのタイムアウトを超過 タイムアウト設定が適切か確認、遅いクエリ/リクエストを最適化
2 context canceled Contextが手動でキャンセル(cancel()呼び出し) キャンセル元を確認、期待される動作か確認
3 grpc: context canceled gRPC呼び出し中にContextがキャンセル クライアントタイムアウトとサーバー処理時間を確認
4 net/http: request canceled HTTPリクエストが転送中にキャンセル Client.TimeoutとContextタイムアウトを確認
5 driver: bad connection DB接続がクエリ中にタイムアウト切断 ConnMaxLifetimeを増加、QueryContextタイムアウトを確認
6 i/o timeout ネットワークI/O操作がタイムアウト DialTimeoutを増加、ネットワーク接続性を確認
7 TLS handshake timeout TLSハンドシェイクがタイムアウト TLSHandshakeTimeoutを増加、証明書チェーンを確認
8 connection reset by peer ピアがタイムアウト後に接続を閉じた ピアのタイムアウト設定を確認、双方の合意を確保
9 goroutine leak detected Contextキャンセル後goroutineが終了しない goroutineでctx.Done()を確認、速やかな終了を確保
10 queue is full リクエストキューが満杯で送信失敗 キュー容量を増加、ワーカーを追加、バックプレッシャーを追加

高度な最適化

1. 適応型タイムアウト制御

package adaptive

import (
    "context"
    "sort"
    "sync"
    "time"
)

type AdaptiveTimeout struct {
    mu           sync.Mutex
    history      []time.Duration
    maxHistory   int
    percentile   float64
    minTimeout   time.Duration
    maxTimeout   time.Duration
    safetyMargin float64
}

func NewAdaptiveTimeout(percentile float64, minTimeout, maxTimeout time.Duration) *AdaptiveTimeout {
    return &AdaptiveTimeout{
        history:      make([]time.Duration, 0, 100),
        maxHistory:   100,
        percentile:   percentile,
        minTimeout:   minTimeout,
        maxTimeout:   maxTimeout,
        safetyMargin: 1.5,
    }
}

func (a *AdaptiveTimeout) Record(duration time.Duration) {
    a.mu.Lock()
    defer a.mu.Unlock()

    a.history = append(a.history, duration)
    if len(a.history) > a.maxHistory {
        a.history = a.history[1:]
    }
}

func (a *AdaptiveTimeout) Timeout() time.Duration {
    a.mu.Lock()
    defer a.mu.Unlock()

    if len(a.history) == 0 {
        return a.maxTimeout
    }

    sorted := make([]time.Duration, len(a.history))
    copy(sorted, a.history)
    sort.Slice(sorted, func(i, j int) bool { return sorted[i] < sorted[j] })

    idx := int(float64(len(sorted)) * a.percentile)
    if idx >= len(sorted) {
        idx = len(sorted) - 1
    }

    timeout := time.Duration(float64(sorted[idx]) * a.safetyMargin)
    if timeout < a.minTimeout {
        timeout = a.minTimeout
    }
    if timeout > a.maxTimeout {
        timeout = a.maxTimeout
    }

    return timeout
}

func (a *AdaptiveTimeout) Context(ctx context.Context) (context.Context, context.CancelFunc) {
    return context.WithTimeout(ctx, a.Timeout())
}

2. タイムアウト伝播ミドルウェア

package middleware

import (
    "context"
    "fmt"
    "strconv"
    "time"

    "google.golang.org/grpc"
    "google.golang.org/grpc/metadata"
)

const timeoutMetadataKey = "x-request-timeout-ms"

func TimeoutPropagationInterceptor() grpc.UnaryClientInterceptor {
    return func(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
        if deadline, ok := ctx.Deadline(); ok {
            remainingMs := time.Until(deadline).Milliseconds()
            if remainingMs > 0 {
                md, _ := metadata.FromOutgoingContext(ctx)
                md = md.Copy()
                md.Set(timeoutMetadataKey, fmt.Sprintf("%d", remainingMs))
                ctx = metadata.NewOutgoingContext(ctx, md)
            }
        }
        return invoker(ctx, method, req, reply, cc, opts...)
    }
}

func TimeoutPropagationServerInterceptor() grpc.UnaryServerInterceptor {
    return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
        md, ok := metadata.FromIncomingContext(ctx)
        if !ok {
            return handler(ctx, req)
        }

        values := md.Get(timeoutMetadataKey)
        if len(values) == 0 {
            return handler(ctx, req)
        }

        remainingMs, err := strconv.ParseInt(values[0], 10, 64)
        if err != nil || remainingMs <= 0 {
            return handler(ctx, req)
        }

        remaining := time.Duration(remainingMs) * time.Millisecond
        if deadline, ok := ctx.Deadline(); ok {
            if time.Until(deadline) < remaining {
                remaining = time.Until(deadline)
            }
        }

        ctx, cancel := context.WithTimeout(ctx, remaining)
        defer cancel()

        return handler(ctx, req)
    }
}

3. goroutineリーク検出

package leak

import (
    "context"
    "log"
    "runtime"
    "time"
)

type LeakDetector struct {
    checkInterval time.Duration
    threshold     int
}

func NewLeakDetector(checkInterval time.Duration, threshold int) *LeakDetector {
    return &LeakDetector{
        checkInterval: checkInterval,
        threshold:     threshold,
    }
}

func (d *LeakDetector) Start(ctx context.Context) {
    ticker := time.NewTicker(d.checkInterval)
    defer ticker.Stop()

    var prevGoroutines int
    var growthCount int

    for {
        select {
        case <-ctx.Done():
            return
        case <-ticker.C:
            current := runtime.NumGoroutine()
            if current > d.threshold {
                log.Printf("[LEAK WARNING] goroutine count %d exceeds threshold %d", current, d.threshold)
            }

            growth := current - prevGoroutines
            if growth > 10 {
                growthCount++
                if growthCount >= 3 {
                    log.Printf("[LEAK ALERT] goroutine count growing continuously: %d -> %d (%d consecutive growths)", prevGoroutines, current, growthCount)
                }
            } else {
                growthCount = 0
            }

            prevGoroutines = current
        }
    }
}

比較分析

次元 context.WithTimeout context.WithDeadline context.WithCancel time.After select+timer
タイムアウト精度 ミリ秒 絶対時刻 タイムアウトなし ミリ秒 ミリ秒
キャンセル伝播 ✅自動 ✅自動 ✅手動 ❌なし ❌なし
goroutine安全 ⚠️リーリクスクリスク ⚠️リーリクスクリスク
マイクロサービスカスケード
値伝播
リソースオーバーヘッド 高(リーク)
ユースケース 汎用タイムアウト スケジュールタスク 手動キャンセル 単純待機 ローカルタイムアウト

まとめcontext deadline exceededは「タイムアウトを延ばす」だけでは解決しない。7つの根本原因の中で最も危険なのはgoroutineリークとContext上書き——前者はサービスをゆっくり「失血」させOOMに至らしめ、後者はタイムアウト制御を形骸化させる。2026年のGoマイクロサービスタイムアウトプラクティス:1)ゲートウェイでグローバルタイムアウトを設定、metadataで残り時間を伝播;2)各サービス層でDeriveServiceTimeoutを使用しmin(自タイムアウト,残り時間)を取得;3)全goroutineでctx.Done()をチェック;4)適応型タイムアウトで静的タイムアウトを置き換え;5)goroutineリーク検出をデプロイ。覚えておこう:タイムアウトは長いほど良いのではなく、精密であるほど良い。


オンラインツール推奨

ブラウザローカルツールを無料で試す →

#Go#Context#超时控制#goroutine#微服务#2026#并发编程