はじめに
Google Cloud で構築したサーバレスコンテナの Probe 設計を行った際の話です。
「gRPC サービスだから gRPC プローブでいいよね」と設計を始めたら、3 日後には全部作り直していました。😇
システム移行プロジェクトで Cloud Run のプロセス・ポート監視を設計したときの話です。最初は「各リソースにプローブを設定するだけ」のつもりでしたが、実装を掘り下げるたびに何かが出てきました。gRPC Health Check 未実装、Distroless でコマンド実行不可、Node.js Worker Pool は HTTP サーバなし、バッチ Job はポート自体がない。
この記事は、その 4 つの制約にぶつかるたびに何を判断したかの記録です。
先にまとめ
Cloud Run Service → Startup + Liveness Probe(gRPC or TCP Socket or HTTP GET)
Cloud Run Worker Pool → Startup + Liveness Probe(HTTP GET が前提、ポートがなければ実装必要)
Cloud Run Job → Probe なし、ジョブ失敗ログ監視で代替
Compute Engine (MIG) → MIG オートヒーリングヘルスチェック(本記事では詳細割愛)
プローブ種別は以下の基準で選びます。
| 状況 | 選択するプローブ種別 |
|---|---|
| gRPC Health Checking Protocol が実装済み | gRPC プローブ |
| gRPC サーバが動いているが HealthService 未登録 | TCP Socket プローブ(gRPC ポート) |
HTTP エンドポイント(/healthz 等)がある |
HTTP GET プローブ |
| ポートが存在しない(バッチ処理等) | プローブなし → ログ監視で代替 |
| distroless コンテナ | Exec プローブ禁止(上記3択のいずれか) |
設定値の設計ルールは以下の通りです。
Startup Probe: failureThreshold × periodSeconds ≥ アプリの最大起動時間
Liveness Probe: failureThreshold × periodSeconds = 誤検知を避けつつ迅速に検知できる判定時間
Cloud Run のリソース種別と Probe の基礎
3 種類のリソースと Probe の適用可否
Cloud Run には Service / Worker Pool / Job の 3 種類があり、それぞれ Probe の適用方法が異なります。
| リソース種別 | 特徴 | Startup Probe | Liveness Probe |
|---|---|---|---|
| Service | HTTP リクエスト駆動。コンテナが常時稼働 | ○ | ○ |
| Worker Pool | Pub/Sub などのメッセージをプル処理。コンテナが常時稼働 | ○ | ○ |
| Job | 1 回限りの実行。完了後にコンテナが終了 | △(ポートがあれば設定可能) | ✗ |
Startup Probe と Liveness Probe の役割分担
コンテナ起動
│
├─ Startup Probe 開始(failureThreshold に達するまでトラフィック遮断)
│ │
│ ├─ 成功 → Liveness Probe 開始(稼働中の定期チェック)
│ │ │
│ │ ├─ 成功が続く → 正常稼働継続
│ │ └─ failureThreshold 到達 → コンテナ再起動
│ │
│ └─ failureThreshold 到達 → コンテナ終了(FAILED)
│
└─ Startup Probe が通るまで Liveness Probe は開始されない
Startup Probe が通るまで Liveness Probe は動かない、というのは意外と見落とされやすい仕様です。起動に時間のかかる Java アプリケーションに Startup Probe を設定しないと、Liveness Probe が起動中のコンテナを「ハング」と誤判定して再起動ループに陥ります。
Cloud Run Service 編:gRPC 未実装の現実に向き合う
当初の設計
プロジェクトの gRPC バックエンドサービスは gRPC サーバ(port 9090)で公開していました。当然、最初は gRPC プローブを設計しました。
# 当初の設計(動かなかった)
startupProbe:
grpc:
port: 9090
initialDelaySeconds: 10
periodSeconds: 3
failureThreshold: 5
gRPC プローブは、コンテナ内で gRPC Health Checking Protocol(grpc.health.v1.Health/Check RPC)を実装していることが前提です。
実装を確認したら未実装だった
pom.xml と Java ソースを調べた結果:
grep -r "grpc.health" ./app --include="*.java" --include="*.properties"
→ 0 件
grep -r "spring-boot-starter-actuator" ./app --include="pom.xml"
→ 0 件
grpc-services 依存が存在せず、HealthServiceImpl の登録もなし。gRPC プローブを設定しても、gRPC サーバは Health Check RPC に応答できないため、プローブは常に失敗します。
よく誤解されますが、「gRPC サーバが動いている」ことと「gRPC Health Checking Protocol に対応している」は別の話です。後者には明示的な実装が必要です。
TCP Socket プローブへの切り替え
gRPC Health Checking Protocol の実装はアプリ実装する必要があります。リリースまでの時間が限られていたため、アプリ変更なしで即時対応できる TCP Socket プローブに切り替えました。
# 変更後(TCP Socket に切り替え)
startupProbe:
tcpSocket:
port: 9090 # gRPC サーバが listen しているポートをそのまま使用
initialDelaySeconds: 0
periodSeconds: 10
failureThreshold: 12 # 120 秒の起動窓
livenessProbe:
tcpSocket:
port: 9090
initialDelaySeconds: 0
periodSeconds: 15
failureThreshold: 3 # 45 秒で再起動判定
timeoutSeconds: 5
TCP Socket プローブは「ポートに接続できるか」しか確認しません。gRPC サーバプロセスが起動していてポートが開いている状態 = プロセス・ポート監視の要件を満たす、という判断です。
アプリレベルのヘルス(依存サービスへの接続状態など)まで監視したい場合は、将来的に gRPC Health Checking Protocol を実装したうえで gRPC プローブへ移行するのが本筋です。
Distroless コンテナの制約
使用しているコンテナイメージは gcr.io/distroless/java21-debian12:nonroot です。Distroless はシェルや診断ツールを含まないため、Exec プローブ(コマンド実行型)は使用できません。
# これはできない(distroless だとシェルがないので失敗する)
startupProbe:
exec:
command: ["curl", "-f", "http://localhost:9090/health"]
プローブ種別は必ず gRPC / TCP Socket / HTTP GET のいずれかを選んでください。
failureThreshold × periodSeconds の算出ロジック
「起動窓」の考え方
Startup Probe の設計で最も重要なのは、起動窓 = failureThreshold × periodSeconds がアプリの最大起動時間を上回っていることです。
| 項目 | 計算式 |
|---|---|
| 起動窓(秒) | failureThreshold × periodSeconds |
| 最大チェック回数まで | initialDelaySeconds + failureThreshold × periodSeconds |
当初の設定値を見直した際の Before/After です。
| パラメータ | Before | After | 理由 |
|---|---|---|---|
initialDelaySeconds |
10 | 0 | 起動が早ければ即座に通過できるため、不要な待機をなくす |
periodSeconds |
3 | 10 | 細かすぎるチェックはコンテナ高負荷時に誤失敗を増やす |
failureThreshold |
5 | 12 | 起動窓を 25 秒 → 120 秒に拡大 |
| 起動窓 | 25 秒 | 120 秒 | Java の実際の起動時間に合わせる |
Java と Node.js で全然違う起動時間
同じプロジェクトに Java(Spring Boot)と Node.js の両方のサービスがあり、必要な起動窓が大きく異なりました。
Java(Spring Boot)の起動時間の構成要素:
- JVM 起動
- Spring Boot コンテキスト初期化
- OpenTelemetry Agent の初期化
- gRPC サーバの起動
- Cloud Spanner 接続プールの初期化
推定起動時間:60〜120 秒(性能試験前のため実測値ではありません)。これに安全マージンを含めて 10 × 12 = 120 秒 の起動窓を設定しました。
一方、Node.js(monitoring Worker)の起動時間は実測値 300〜600ms。1 秒もかかりません。
# Node.js 系 Worker Pool の Startup Probe
startupProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 5 # Java の数十秒とは桁違いに短い
periodSeconds: 3
failureThreshold: 5 # 15 秒の起動窓で十分
timeoutSeconds: 3
Java と同じ設定を Node.js に適用すると、120 秒以上の起動窓を持つことになり、起動失敗の検知が大幅に遅延します。言語・フレームワークの起動特性に合わせて個別に設計する必要があります。
Liveness Probe は「誤検知との戦い」
Liveness Probe の failureThreshold × periodSeconds は、一時的な高負荷による応答遅延を誤検知として排除しつつ、真の異常(プロセスハング・ポート閉塞)を検知できる時間に設定する必要があります。
GCP のベストプラクティスでは failureThreshold: 3 が推奨されており、periodSeconds を調整して判定時間を決めます。
| サービス | periodSeconds |
判定時間(積) | 採用理由 |
|---|---|---|---|
| API サービス(Java/gRPC) | 15 | 45 秒 | gRPC Health Check は軽量だが、Spanner 高負荷時の一時的遅延を考慮 |
| フロントサービス(Node.js) | 10 | 30 秒 | Node.js はイベントループがフリーズすると即座に無応答になるため短め |
| 長時間バッチ処理系 | 30 | 90 秒 | 処理中の一時的なスレッド占有による誤検知を防ぐ |
Cloud Run Worker Pool 編:Node.js 無ポート問題
Worker Pool の特殊性
Cloud Run Worker Pool は Pub/Sub などのメッセージをプル処理するリソースです。HTTP リクエストを受け付けず、外部からポートを叩かれることを想定していません。
そのため、Probe の設計では「何を監視するか」の整理が必要でした。
Node.js Worker Pool に HTTP サーバがなかった問題
monitoring 系の Worker Pool は Node.js 実装で、Pub/Sub メッセージを受信して処理するだけのシンプルな構成です。ソースを確認したところ、HTTP サーバを起動していませんでした。
# 確認したこと
HTTP サーバ(express 等)の依存: なし
ポートのリスニング: なし
/healthz エンドポイント: なし
HTTP GET プローブを設定しようにも、叩く先がありません。
解決策として、/healthz エンドポイントの実装をアプリチームに依頼しました。要求仕様を以下のように整理して提示しています。
エンドポイント: GET /healthz
ポート: 8080
応答条件: Pub/Sub サブスクリプションへの接続が完了していること
成功レスポンス: HTTP 200(ボディ任意)
失敗レスポンス: HTTP 5xx
タイムアウト: 3 秒以内に応答
Pub/Sub 接続が完了する前に 200 を返すだけでは、「起動したがまだメッセージ処理できない」状態でトラフィックを受け入れることになります。接続完了後に 200 を返すことで、プローブが本当の意味で「使える状態になった」ことを確認できます。
Java 系 Worker Pool との設計の分岐
Java(Spring Boot)で動く Worker Pool はポート 8080 で HTTP サーバが起動していることを確認できました。こちらは Spring Actuator の導入状況次第で、HTTP GET(/actuator/health)か TCP Socket かを選択します。
# Java 系 Worker Pool(Spring Actuator 確認済みの場合)
startupProbe:
httpGet:
path: /actuator/health
port: 8080
initialDelaySeconds: 30
periodSeconds: 10
failureThreshold: 9 # 90 秒の起動窓
livenessProbe:
httpGet:
path: /actuator/health
port: 8080
initialDelaySeconds: 0
periodSeconds: 15
failureThreshold: 3
timeoutSeconds: 5
Cloud Run Job 編:Startup Probe は「使えるが使わない」という判断
バッチ処理系 Job の実態
プロジェクトには数十本の Cloud Run Job があります。実装を確認したところ:
# application.properties
spring.main.web-application-type=none
# gRPC も無効
grpc.server.port=-1
HTTP サーバなし・gRPC サーバなし。これは意図的な設計で、バッチ処理としてメモリを節約するために HTTP サーバを起動しないよう設定されていました。ポートが存在しないため、TCP Socket プローブも HTTP GET プローブも設定できません。
「Startup Probe を使えるようにアプリを変更する」は合理的か
「ではポートを開ければいいのでは」という議論もありましたが、以下の理由から推奨しませんでした。
| 観点 | 内容 |
|---|---|
| Probe でないと補えないか | ジョブ失敗ログ監視(既存)が起動失敗を含む全失敗シナリオを捕捉済み |
| コスト | 全 Job にヘルスエンドポイントを追加 → テスト・デプロイの工数が発生 |
| リスク | 本来不要な HTTP サーバを起動することで、攻撃面が増える |
ログ監視で起動失敗まで監視できる理由
「Startup Probe がないと起動失敗を検知できないのでは?」という懸念がありましたが、以下のシナリオはすべてジョブ失敗ログ監視(タスク失敗ログベースアラート)で捕捉できます。
| 起動失敗シナリオ | コンテナの終了 | ログ監視での検知 |
|---|---|---|
| JVM 起動失敗(OOM、クラスパスエラー等) | 非ゼロ exit | ✅ 検知できる |
| Spring Context 初期化失敗(Bean エラー等) | 非ゼロ exit | ✅ 検知できる |
| イメージ pull 失敗 | コンテナ未起動 | ✅ 検知できる |
| 起動中に OOM Kill | カーネルが kill | ✅ 検知できる |
| 起動後にコンテナがハングして exit しない | タイムアウト後に強制終了 | ✅ timeoutSeconds 後に失敗として記録 |
唯一 Startup Probe でしか補えないケースは「起動後にポートが開いているか確認する」ですが、ポートのないバッチジョブにはそもそも監視対象のポートがありません。
バッチ処理系 Job は Startup Probe / Liveness Probe を設定せず、既存のログ監視で十分です。これは「使えるが使わない」という積極的な判断です。
まとめ
結局、どのリソースも「何のポートが開いているか」を先に確認することが出発点でした。ポートがなければプローブは設定できず、あっても Health Check の実装がなければ gRPC プローブは機能しない。distroless ならコマンドも叩けない。
次に同じ設計をするなら、まず実装を読んでから YAML を書きます。
- gRPC サーバが動いていても Health Check 未実装なら TCP Socket で代替
- distroless では gRPC / TCP Socket / HTTP GET の三択
-
failureThreshold × periodSecondsを起動窓として計算し、Java と Node.js は別設計 - Worker Pool は HTTP サーバの有無を先に確認。なければ
/healthz実装が前提 - バッチ Job はポートなし。ログ監視で起動失敗も含めてカバーできる
- Cloud Run の自動再起動を前提に「2 回目の失敗でアラート」にする
最後に、GMOコネクトではサービス開発支援や技術支援をはじめ、
幅広い支援を行っておりますので、何かありましたらお気軽にお問合せください。