GoでgRPCをDocker化:イメージサイズを800MBから20MBに削減し、実践的なロードバランシングの秘訣を解説

Docker tutorial - IT technology blog
Docker tutorial - IT technology blog

Go gRPCアプリケーションをDocker化する3つのアプローチ

初めてgRPCをDockerにデプロイしたとき、私は単なるHTTP/2だと思い込み、通常のREST APIと同じようにパッケージングしてしまいました。その結果、システムは重く、コンテナはGB単位のサイズになり、ロードバランシングは運任せのような状態に。実際のプロジェクトを通じて学んだ、3つの一般的なアプローチを紹介します。

  • Single-stage Build(「インスタント」形式): golang:latestイメージにソースコードをすべてコピーしてgo runを実行します。イメージサイズは通常800MBを超え、不要なツールが多く含まれるため、ストレージを消費するだけでなく、セキュリティの脆弱性も生じやすくなります。
  • Multi-stage Build with Alpine: ステージ1でバイナリをビルドし、ステージ2でalpineベースの環境にコピーします。サイズは約50MBまで削減され、多くのエンジニアに採用されているバランスの良い選択肢です。
  • Multi-stage Build with Distroless: 本番環境における「至高」の選択肢です。イメージにはバイナリファイルと最小限のランタイムライブラリのみが含まります。非常に軽量(約20MB)で、シェルが含まれていないためハッカーの侵入を許さず、極めて安全です。

なぜ本番環境でDistrolessを優先するのか?

秒間5,000リクエストを処理するプロジェクトで、メモリリークに悩まされたことがありました。2日間のデバッグの末、原因は従来のOSイメージに含まれる不要なバックグラウンドプロセスにあることが判明しました。Distrolessに切り替えたところ、すべてが軽量化され、コンテナの起動速度は30%向上し、攻撃対象領域(アタックサーフェス)を最小限に抑えることができました。

以下は、実際に測定した比較表です。

項目 Single-stage Alpine-based Distroless (推奨)
実際のサイズ ~850MB ~48MB ~19MB
セキュリティ 非常に低い 非常に高い
Pull/Push速度 非常に遅い 速い 爆速

実践的な実装:コードからコンテナへ

1. gRPC Goに最適化されたDockerfileの作成

ここでの秘訣は、Dockerfileを**Build**と**Run**の2つのステージに分けることです。ソースコード全体をコピーする前にgo.modファイルをコピーするのを忘れないでください。これにより、Dockerのレイヤーキャッシュを活用でき、ロジックを数行修正しただけの際の待ち時間を大幅に短縮できます。

# ステージ1: バイナリのビルド
FROM golang:1.21-alpine AS builder
WORKDIR /app

# 依存関係のキャッシュを最適化
COPY go.mod go.sum ./
RUN go mod download

COPY . .

# -s -w フラグによりデバッグ情報を削除し、バイナリサイズをさらに約20%削減
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o grpc-app ./cmd/server/main.go

# ステージ2: 最小限のランタイム
FROM gcr.io/distroless/static:nonroot
WORKDIR /
COPY --from=builder /app/grpc-app .
COPY --from=builder /app/certs ./certs

USER nonroot:nonroot
EXPOSE 50051
ENTRYPOINT ["./grpc-app"]

2. 内部TLS(Internal TLS)の設定

内部ネットワーク内のマイクロサービス間の通信も、暗号化されていなければリスクが伴います。Docker環境で最も手っ取り早い方法は、自己署名証明書(self-signed certificate)を使用することです。Go’のコードでは、セキュアなチャネルを確立するために認証情報を読み込む必要があります。

// サーバー側
creds, _ := credentials.NewServerTLSFromFile("certs/server.crt", "certs/server.key")
s := grpc.NewServer(grpc.Creds(creds))

// クライアント側
creds, _ := credentials.NewClientTLSFromFile("certs/ca.crt", "")
conn, _ := grpc.Dial("server-service:50051", grpc.WithTransportCredentials(creds))

3. gRPCのロードバランシング:Docker Composeに騙されるな

これは多くの人が陥る罠です。gRPCはHTTP/2上で長時間持続するコネクション(long-lived connections)を維持します。Docker Composeでサーバーを3つのレプリカにスケールさせても、デフォルトではLayer 4でロードバランシングが行われます。その結果、クライアントは特定の1つのコンテナに固執し続け、残りの2つは完全にアイドル状態になってしまいます。

解決策は**クライアントサイド・ロードバランシング**を使用することです。クライアントコード内で直接dns:///スキームを使用することで、gRPCがリクエストを分散する方法を自律的に判断できるようになります。

// 解決策: gRPCのDNSリゾルバを使用する
conn, err := grpc.Dial(
    "dns:///server-service:50051",
    grpc.WithDefaultServiceConfig(`{"loadBalancingConfig": [{"round_robin":{}}]}`),
    grpc.WithTransportCredentials(insecure.NewCredentials()),
)

本番環境での「コネクション切れ」への対処

システムが15分稼働するごとにgRPC接続が自動的に切断される事象に遭遇したことがあります。サーバーは正常に動作しているにもかかわらず、クライアントにはUnavailableエラーが表示されました。原因は、クラウドのロードバランサーがアイドル状態の接続(idle connections)を自動的に切断していたことにありました。

アドバイス: 常にKeepaliveParamsを設定しましょう。これは、クライアントが定期的にサーバーの「肩を叩く」ことで、接続を維持する必要があることを知らせるようなものです。

var kasp = keepalive.ServerParameters{
    MaxConnectionIdle: 15 * time.Second,
    Time:              20 * time.Second, // 20秒ごとにpingを送信
    Timeout:           5 * time.Second,
}
s := grpc.NewServer(grpc.KeepaliveParams(kasp))

gRPCのDocker化は、単にコードをコンテナに詰め込むことではありません。イメージサイズの最適化からサービス間の通信方法に至るまでの「最適化の技術」です。これらの実践的な経験が、不要な徹夜のデバッグを避ける助けになれば幸いです!

Share: