コンテンツにスキップ

ヘルスチェックと readiness

オーケストレータが知りたいことは 2 つあり、それは別々の問いです。このプロセスは 生きているか、殺して置き換えるべきか。 そしてこのプロセスはいまトラフィックを 受けられるか。 ローリングデプロイの最中には答えが食い違います。だからエンドポイントも 2 つです。

[server]
health = "/healthz"
readiness = "/readyz"

どちらのキーにも既定値がありません。/healthz で応答するアプリケーションは、その事実を 設定を読む運用者の目に入る場所に書くべきです。既定値があると、リポジトリのどのファイルにも 書かれていないエンドポイントが動き続けることになります。キーを設定しなければ、ルートは まったく登録されません。

pw init は両方を config.dev.toml に書きます。生成直後のプロジェクトは開発環境では これらを持ち、他の環境については明示的に宣言することになります。

health は liveness です。 何も検査せず、何にも依存しません。プロセスが接続を受け付けて ハンドラを実行できるなら 200 を返します。これが正しい定義です。依存先の障害はアプリケーションを 殺して再起動する理由にはなりませんし、データベースが落ちたときに失敗する liveness プローブは、 ひとつの障害を再起動ループに変えてしまいます。

readiness は設定されたデータベースプールすべてに ping します。 グループ集合の各接続、 あるいは単一のプールに対して、検査全体で 1 秒の上限を設けて実行します。応答しないプールが ひとつでもあればこのエンドポイントは 503 になり、オーケストレータは回復するまでこの インスタンスへのルーティングをやめます。

それ以外の挙動は共通です。

メソッド GETHEAD。それ以外は Allow 付きの 405
成功 200、ボディは ok
失敗 503、ボディは unavailable
Content-Type text/plain; charset=utf-8
Cache-Control no-store

ボディが一語なのは意図的です。これらのエンドポイントはセッションと認証を迂回するので、 ポートに到達できるものなら何でも到達できます。だから返すのは状態だけであり、DSN も、 バックエンド名も、スタックトレースも、設定値も返しません。認証されていない相手にインフラの 形を漏らすプローブは、それが検出するために追加された問題より悪い問題です。

シェルの無いコンテナからのプローブ

Section titled “シェルの無いコンテナからのプローブ”

Docker の HEALTHCHECK はコンテナ内でコマンドを実行します。よく使われる コマンドは curl です。ところが distroless や scratch のイメージ——Go バイナリの 正しい配布形——には curl も、それを起動するシェルもありません。命令に呼ぶものが 無いのです。その役はアプリケーションのバイナリ自身が務めます。

HEALTHCHECK CMD ["/myapp", "healthcheck"]

このサブコマンドはサーバと同じ設定ソース——TOML ファイル、環境変数、PORT——を 読みます。だからポートと health のパスを Dockerfile に書き直す必要はありません。 ループバックに GET を 1 回発行し、2xx なら終了コード 0。それ以外——別の ステータス、接続拒否、タイムアウト——は 1 で終了し、Docker はこれを unhealthy と 数えます。終了コード 2 は使いません。Docker が予約しているからです。

プローブを調整するオプションは 2 つです。--ready は代わりに readiness のパスを 叩きます。判定にデータベースプールが含まれるようになるので、依存サービスに データベース接続済みのインスタンスを待たせたい場面には適切で、再起動ポリシーには 厳しすぎます。データベースの障害が再起動ループに変わってしまうからです。--timeout は プローブ全体を制限し、既定は 3s。Docker 自身の既定である 30 秒より十分内側なので、 固まったリスナは「プローブごと殺されて何も報告されない」のではなく unhealthy として 報告されます。

プローブには、その環境の設定に server.health(--ready なら server.readiness)と 固定の server.port が必要です。パスが未設定、あるいはポートが 0 なら、キーの名前を 挙げたメッセージとともに失敗します。サブコマンド名は予約されています。アプリケーションが healthcheck という名前を独自コマンドに使おうとすると、 pw.RegisterSubCommand は起動時に パニックします。

プローブはイメージがすでに載せているバイナリそのものです。だから命令は exec 形式の まま書けて、シェルを要求しません。

HEALTHCHECK --interval=30s --timeout=5s --start-period=5s \
CMD ["/app/myapp", "healthcheck"]

Docker の --timeout=5s はコマンド全体を待つ時間です。プローブ自身の既定 3s は その内側で終わるので、判定はつねにプローブの終了コードであり、kill されることは ありません。その環境の設定には server.health が必要です。キーが無ければ プローブはその名前を挙げて失敗するので、設定ミスは「永遠に気づかない」のではなく 最初のインターバルで表面化します。

pw init はこの行を含む Dockerfile を、キーを設定した config.prod.toml と 一緒に書きます。そのファイルの残りの部分 — Popcorn Web のイメージが COPYgo build では作れない理由を含めて — は コンテナイメージにあります。

Compose の healthcheck.test にも同じ exec 形式を書きます。そして --ready が 本領を発揮するのはここです。depends_onservice_healthy は依存サービスを 待たせますが、「背後のデータベースが応答する」ことを意味すべきゲートは liveness では なく readiness のパスです。

services:
app:
image: myapp:latest
environment:
PORT: "8080"
healthcheck:
test: ["CMD", "/myapp", "healthcheck", "--ready"]
interval: 10s
timeout: 5s
retries: 3
start_period: 10s
importer:
image: myapp:latest
command: ["import", "/data/users.csv"]
depends_on:
app:
condition: service_healthy

ここで宣言した healthcheck はイメージの HEALTHCHECK 行を上書きします。イメージは docker run 向けに素の liveness プローブを持ったまま、Compose は依存グラフが必要と する、より厳しい問いを投げられます。

Kubernetes は HEALTHCHECK を完全に無視し、コンテナの外側から HTTP でプローブ します。だからマニフェストにサブコマンドは登場しません。プローブをエンドポイント そのものに向けてください。

containers:
- name: app
image: myapp:latest
ports:
- containerPort: 8080
env:
- name: APP_ENV
value: prod
livenessProbe:
httpGet:
path: /healthz
port: 8080
readinessProbe:
httpGet:
path: /readyz
port: 8080

対応は 1 対 1 です。health に向けた livenessProbe はプロセス自身が応答しなく なったときだけ Pod を再起動し、readiness に向けた readinessProbe はプールが 応答するまで Pod を Service から外します。HEALTHCHECK 行を持つイメージをここで 使っても害はありません。単に実行されないだけです。

[server]
openapi = "/openapi.json"
api_doc = "scalar"
api_doc_path = "/docs"

同じリスナが、生成された OpenAPI 文書とその上の UI も配信できます。どちらもプローブと 同じ規則に従います——既定のパスは無く、キーを設定しなければ何も配信しません。api_doc は さらに openapi を必要とします。誰も配信していない文書の UI には、描くものがありません。

違うのはアクセスです。プローブはあらゆる拡張より上で応答します。だから構造上、認証を 通りません。一方でドキュメントのエンドポイントは拡張チェインの内側にあり、セッションと 認証ガードは、アプリケーションのルートに届くのとまったく同じようにそこへ届きます。

[auth.protection]
include = ["/openapi.json", "/docs"]

保護はオプトインなので、列挙していないパスは公開のままです。ただし認証するテストは、 自分のルートを読むのと同じやり方でこの文書を読めるようになりますし、閉じたデプロイは API の記述をプライマリリスナから動かすことなくログインの後ろに置けます。

それでも環境ごとに設定する価値はあります。pw initapi_doc = "scalar" を書くのは config.dev.toml だけで、既定値は空です。ステージングや本番の設定が意図的に有効化するまで、 リファレンスは非公開のままになります。API ドキュメントと、 公開されたドキュメントエンドポイントをデプロイ環境向けの readiness の指摘として報告する pw doctor を参照してください。

すべての運用エンドポイントのパスは、リスナが開く前にアプリケーションのルートと突き合わせて 検証されます。有効なエンドポイントと衝突するルートは、黙って覆い隠されるのではなく起動 エラーになります。誤ったハンドラを見ていたプローブから知るのではなく、デプロイの時点で 分かります。

SIGINT または SIGTERM を受けると、サーバは新しい接続の受け付けをやめ、処理中の リクエストを終わらせます。上限はこれです。

[server]
shutdown_timeout = "10s"

その後、ランタイムのリソース——データベースプールと、クリーンアップに登録されたその他の もの——が同じ上限のもとで閉じられます。

タイムアウト時点でまだ処理中のリクエストは打ち切られます。値は通常のリクエストのうち 最も遅いものに合わせてください。そしてストリームは、それより はるかに長く開いたままでいるよう設計されていることを思い出してください。長時間のストリームは 停止によって終了させられ、クライアント側が再接続する前提です。

readiness エンドポイントは、停止が始まっても失敗し始めるわけではありません。ドレインは オーケストレーターが、トラフィックを止めてからシグナルを送ります。シグナルが 届く頃には、リスナはすでに閉じ始めています。