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つの根本原因
- タイムアウトが短すぎる:ネットワークジッタやサービス負荷を考慮せず、P99レイテンシがタイムアウト閾値を超過
- Contextが伝播されていない:関数シグネチャはContextを受け取るが、呼び出し側が
context.TODO()やcontext.Background()を渡す - goroutineリーク:Contextキャンセル後、子goroutineがDoneチャネルをチェックせず実行を継続
- タイムアウトカスケード増幅:マイクロサービスチェーンの各層でタイムアウトを設定、総タイムアウトが層ごとに圧縮
- HTTP Clientにタイムアウト未設定:
http.Client{}はデフォルトでタイムアウトなし、リクエストが永遠にブロックする可能性 - データベースクエリにタイムアウトなし:SQL実行時間が制御不能、長時間クエリがリクエスト全体を引きずる
- 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リーク検出をデプロイ。覚えておこう:タイムアウトは長いほど良いのではなく、精密であるほど良い。
オンラインツール推奨
- JSONフォーマッター:/ja/json/format
- Base64エンコード/デコード:/ja/encode/base64
- Hash計算:/ja/encode/hash
- JWTデコード:/ja/encode/jwt-decode
ブラウザローカルツールを無料で試す →