gRPCを本番運用2年、コネクション切断とハングで学んだ本当の使い方

REST APIからgRPCに移行して地獄を見ました。本番で頻発した接続切断・タイムアウト・Keep-Alive問題の実装ノウハウを実例付きで解説します。

REST APIからgRPCへ、予想外の沼に嵌った

3年前、うちのチームはマイクロサービス間の通信をREST APIからgRPCに移行することを決めた。理由は至ってシンプル。「レイテンシを下げたい」「通信量を削減したい」「型安全性が欲しい」——教科書的な理由で、みんなうなずいていた。

だが現実は違った。本番環境に投入して2年、地獄のような試行錯誤が始まったんだ。

最初は開発環境でテストしてる時点で違和感を感じてた。ローカルだと快適なのに、本番に上げた途端に「接続が切れる」「ハングする」「謎のタイムアウト」が頻発。当時の自分たちは正直ナメてた。「gRPCなんてHTTP/2ベースのプロトコルだし、RESTより簡単だろう」って。

実際に痛い目を見るまで、僕たちはgRPCの複雑さを理解してなかったんだ。

コネクション管理の地雷:Keep-Aliveと接続プール

最初にぶち当たったのが、コネクション管理の問題だった。

RESTと違い、gRPCはHTTP/2のストリーム多重化を前提にしてる。つまり、複数のRPCコールが1つのTCP接続を共有する。これは素晴らしい最適化なんだが、プロキシやロードバランサーがこの仕様を理解していないと、接続がぶっ壊れる。

うちの場合、Nginxでリバースプロキシを使ってたんだけど、デフォルト設定だと接続タイムアウトが60秒に設定されてた。長時間アイドル状態の接続は勝手に切られるわけ。すると、クライアント側は「まだ接続有効」だと思い込んでるから、突然のハングが発生。本来は避けられるエラーなのに。

対策として、Keep-Aliveパラメータを調整した。これが本当に効いた。

// クライアント側(Go)
conn, err := grpc.Dial(
    "service:50051",
    grpc.WithKeepaliveParams(keepalive.ClientParameters{
        Time:                10 * time.Second, // 10秒ごとにPING送信
        Timeout:             3 * time.Second,  // 3秒でタイムアウト
        PermitWithoutStream: true,             // ストリームがなくても送信
    }),
)
// サーバー側(Go)
server := grpc.NewServer(
    grpc.KeepaliveParams(keepalive.ServerParameters{
        Time:    20 * time.Second,
        Timeout: 3 * time.Second,
    }),
    grpc.KeepaliveEnforcementPolicy(keepalive.EnforcementPolicy{
        MinTime:             5 * time.Second,
        PermitWithoutStream: true,
    }),
)

そしてNginxの設定も修正。

upstream grpc_backend {
    server backend:50051 max_fails=3 fail_timeout=10s;
}

server {
    listen 50051 http2;
    
    location / {
        grpc_pass grpc://grpc_backend;
        grpc_connect_timeout 5s;
        grpc_send_timeout 10s;
        grpc_read_timeout 10s;
    }
}

これだけで接続切断が大幅に減った。実運用で見えた知見だが、このKeep-Alive設定は本当に重要。本番環境で接続が頻繁に切れるなら、まずここを疑うべき。正直、この部分が全体の安定性を8割くらい決めてる気がする。

エラーハンドリング:gRPCのステータスコードの罠

REST APIなら、HTTPステータスコードで簡潔に表現できた。400は「Bad Request」、500は「Internal Error」。シンプルで分かりやすい。

しかしgRPCのステータスコードは17種類あるし、その使い分けが曖昧だった。

コード名称意味
0OK成功
1CANCELEDキャンセルされた
2UNKNOWN不明なエラー
3INVALID_ARGUMENT無効な引数
4DEADLINE_EXCEEDED期限超過
5NOT_FOUND見つからない
6ALREADY_EXISTS既に存在する
7PERMISSION_DENIED権限なし
8RESOURCE_EXHAUSTEDリソース枯渇
9FAILED_PRECONDITION前提条件失敗
10ABORTED中止
11OUT_OF_RANGE範囲外
12UNIMPLEMENTED未実装
13INTERNAL内部エラー
14UNAVAILABLEサービス利用不可
15DATA_LOSSデータ損失
16UNAUTHENTICATED認証なし

最初、うちのチームは「エラーはINTERNALで返しておけばいいや」って感じで運用してた。当然、リトライ戦略が機能しない。UNAVAILABLEINTERNALを区別しないから、本来ならリトライすべきエラーまで永続的に失敗し続ける。これはホントに危険だった。

実装の失敗の一例がこれだ。

// 悪い例:すべてINTERNALで返す
if err != nil {
    return nil, status.Errorf(codes.Internal, "database error: %v", err)
}

// 良い例:エラーの種類を見分ける
if err == sql.ErrNoRows {
    return nil, status.Errorf(codes.NotFound, "user not found")
}
if errors.Is(err, context.DeadlineExceeded) {
    return nil, status.Errorf(codes.DeadlineExceeded, "database timeout")
}
if strings.Contains(err.Error(), "connection refused") {
    return nil, status.Errorf(codes.Unavailable, "database unavailable")
}
return nil, status.Errorf(codes.Internal, "unexpected error: %v", err)

クライアント側のリトライロジックも重要だ。正確なステータスコードがあれば、ここで「このエラーはリトライすべき」「このエラーは永続的」の判断ができる。

conn, _ := grpc.Dial(
    target,
    grpc.WithDefaultCallOptions(
        grpc.WithRetry(
            grpc.RetryPolicy{
                MaxAttempts: 3,
                Backoff: grpc.BackoffConfig{
                    BaseDelay:  100 * time.Millisecond,
                    MaxDelay:   1 * time.Second,
                    Multiplier: 1.3,
                },
                RetryableStatusCodes: []codes.Code{
                    codes.Unavailable,
                    codes.ResourceExhausted,
                    codes.DeadlineExceeded,
                },
            },
        ),
    ),
)

2年運用してわかったのは、エラーハンドリングの質がサービスの安定性を決めるということ。正確なステータスコードを返さないと、上位のマイクロサービスが適切に対応できない。ここが雑だと、連鎖的に問題が広がる。

パフォーマンスの現実:想像したより複雑だった

gRPCの売りは「高速」だ。確かにペイロードサイズはRESTより小さいし、HTTP/2の多重化で接続効率も良い。だが本番では、単純な速度比較では見えない問題が山積みだった。

最初に検証したベンチマーク結果はこんな感じだった。

xychart-beta
    title REST vs gRPC レイテンシ比較(ローカル環境)
    x-axis [1K, 10K, 100K, 1M]
    y-axis "平均レイテンシ (ms)" 0 --> 250
    line [5, 12, 45, 180] name "REST"
    line [2, 8, 25, 95] name "gRPC"

確かに速い。だが本番環境では同じ傾向が出なかった。原因は複数あったんだけど、代表的なのは以下の通り。

メモリ圧力の想定外の増加

gRPCはメモリ効率が良いと聞いてたが、実運用では別の話。ストリームの多重化が原因で、メモリ上に多くのバッファが存在する。同時接続数が多いと、メモリ使用量が予想外に増える。

// メモリプロファイルの実測値
// REST導入時: Heap 256MB
// gRPC導入直後: Heap 480MB (87%増加)
// 
// 原因: HTTP/2フレームバッファの蓄積と、
//      接続プール内のバッファが全て積み上がる

対策として、接続プールサイズを明示的に制限した。これでメモリ圧力がマシになった。

conn, _ := grpc.Dial(
    target,
    grpc.WithDefaultCallOptions(
        grpc.WithMaxCallRecvMsgSize(10 * 1024 * 1024), // 10MB制限
    ),
    grpc.WithDefaultCallOptions(
        grpc.WithMaxCallSendMsgSize(10 * 1024 * 1024),
    ),
)

デバッグの地獄

RESTなら、curlやPostmanでリクエスト/レスポンスを確認できた。gRPCはそうはいかない。バイナリプロトコルだから、grpcurlのようなツール必須

# 簡単なヘルスチェック
grpcurl -plaintext localhost:50051 grpc.health.v1.Health/Check

# サービス一覧を確認
grpcurl -plaintext localhost:50051 list

# 特定のメソッドを実行
grpcurl -plaintext -d '{"user_id": "123"}' localhost:50051 api.UserService/GetUser

ローカルでテストする時は良いが、本番デバッグはヘビーだった。結局、構造化ログとメトリクスをちゃんと用意するしか道がなかった。この部分で正直かなり時間を使った。

本番運用で見えた2026年時点のベストプラクティス

2年間のドタバタから、以下のアーキテクチャに落ち着いた。

graph TB
    subgraph "Client"
        App["Application"]
    end
    subgraph "Network Layer"
        LB["LoadBalancer<br/>HTTP/2対応"]
        subgraph "Connection Pool"
            CP["ConnPool<br/>キープアライブ設定"]
        end
    end
    subgraph "Service Mesh"
        Istio["Istio Sidecar<br/>gRPC対応"]
    end
    subgraph "gRPC Server"
        Server["gRPC Server<br/>Interceptor装備"]
        Interceptor["Server Interceptors<br/>Auth / Logging / Metrics"]
    end
    subgraph "Observability"
        Prometheus["Prometheus"]
        Jaeger["Jaeger<br/>Distributed Tracing"]
        Log["Structured Logs"]
    end
    
    App -->|gRPC Call| LB
    LB --> CP
    CP -->|HTTP/2| Istio
    Istio --> Server
    Server --> Interceptor
    Interceptor --> Prometheus
    Interceptor --> Jaeger
    Interceptor --> Log

この構成で、本当に安定した。細かいポイントをいくつか挙げるんだけど、どれも重要なんだ。

Interceptorの活用

RESTなら、ミドルウェアで一元的に処理できた。gRPCでもInterceptorを活用することで同じことができる。これがないと、ロギングやメトリクスが散らばって地獄になる。

func loggingInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
    start := time.Now()
    
    // Handler実行前のログ
    log.WithFields(log.Fields{
        "method": info.FullMethod,
        "timestamp": start,
    }).Info("RPC started")
    
    // 実際のハンドラー実行
    resp, err := handler(ctx, req)
    
    // レスポンス後のログ
    duration := time.Since(start)
    if err != nil {
        s := status.FromError(err)
        log.WithFields(log.Fields{
            "method": info.FullMethod,
            "code": s.Code(),
            "duration_ms": duration.Milliseconds(),
        }).Error("RPC failed")
    } else {
        log.WithFields(log.Fields{
            "method": info.FullMethod,
            "duration_ms": duration.Milliseconds(),
        }).Info("RPC completed")
    }
    
    return resp, err
}

server := grpc.NewServer(
    grpc.UnaryInterceptor(loggingInterceptor),
)

メトリクス収集

Prometheus対応のgRPCメトリクスライブラリを使うと、自動で以下が取得される。地味に便利だ。

import "github.com/grpc-ecosystem/go-grpc-prometheus"

grpcMetrics := grpc_prometheus.NewServerMetrics()

server := grpc.NewServer(
    grpc.UnaryInterceptor(grpcMetrics.UnaryServerInterceptor()),
    grpc.StreamInterceptor(grpcMetrics.StreamServerInterceptor()),
)

grpcMetrics.InitializeMetrics(server)

// 自動で取得されるメトリクス例
// grpc_server_handled_total{grpc_code="OK",grpc_method="GetUser",grpc_service="api.UserService"}
// grpc_server_handling_seconds_bucket{grpc_method="GetUser",...}

Deadlineの重要性

RESTではタイムアウトを暗黙的に設定することが多かった。gRPCでは明示的にDeadlineを設定する必要がある。これをやらないと、ハングが発生する。

// クライアント側
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

response, err := client.GetUser(ctx, &api.GetUserRequest{UserId: "123"})

こうすることで、サーバー側はcontext.Deadline()で残り時間を確認できる。レスポンスに間に合わないなら、無駄な処理を止めてさっさとエラーを返す。

// サーバー側
func (s *Server) GetUser(ctx context.Context, req *api.GetUserRequest) (*api.User, error) {
    deadline, ok := ctx.Deadline()
    if ok {
        remainingTime := time.Until(deadline)
        if remainingTime < 100*time.Millisecond {
            // もう間に合わない、短い応答を返す
            return nil, status.Errorf(codes.DeadlineExceeded, "not enough time")
        }
    }
    
    // 重い処理
    return fetchUser(req.UserId), nil
}

REST vs gRPC、本当の使い分け

2年運用して、正直な感想は「銀弾ではない」ということ。正しい場面で使えば最高だが、無理に使うと地獄。個人的には以下の指標で判断してる。

gRPCに向く場面

  • マイクロサービス間通信(内部通信)
  • リアルタイムストリーム処理が必要
  • 低レイテンシが必須
  • 大量の小さなリクエストが頻繁に発生

RESTのままの方が良い場面

  • 外部API(パートナー企業との連携)
  • ブラウザからのアクセスが必要
  • デバッグ・テストのシンプルさが重要
  • プロトコルの柔軟性が必要

うちのチームは結局、ハイブリッド構成に落ち着いた。内部通信はgRPC、外部APIはREST。これが一番安定した。両者の長所を活かす感じだな。

まとめ

運用から学んだ重要なポイントをまとめると、こんな感じだ。

  1. Keep-Aliveは命 — コネクション管理を甘く見るな。本番環境でのタイムアウト設定が不安定さの根本原因になることが多い。これを忘れると詰む。

  2. エラーハンドリングは戦略 — 正確なステータスコードを返すことで、リトライ戦略が機能し、システム全体の安定性が向上する。ここが雑だと上位サービスまで巻き込んで失敗する。

  3. メモリとデバッグに注意 — HTTP/2の多重化はメモリ圧力を高める。本番環境でのリソース見積もりはRESTより大きめに取る必要がある。デバッグもツールが限定される。

  4. Interceptorとメトリクスは必須 — gRPCの「ブラックボックス性」を補うために、構造化ログ・メトリクス・分散トレーシングをちゃんと用意する。これがないと本番運用で死ぬ。

  5. 正しい場面で使う — 内部通信に特化した素晴らしいプロトコルだが、万能ではない。REST + gRPCのハイブリッド構成が現実的で安定してる。

RESTに慣れたエンジニアがgRPCに移行するときは、教科書的な「高速」「効率的」という触れ込みより、運用の地雷を知ることが大事。うちの2年間の失敗と改善が、同じ道を歩むチームの参考になれば幸いだ。

U

Untanbaby

ソフトウェアエンジニア|AWS / クラウドアーキテクチャ / DevOps

10年以上のIT実務経験をもとに、現場で使える技術情報を発信しています。 記事の誤りや改善点があればお問い合わせからお気軽にご連絡ください。

関連記事