Docker Composeのhealthcheckを正しく設定する方法
Docker Composeのhealthcheckの判定方法とdepends_onだけでは待機できない理由を解説します。Postgresとアプリのreadiness checkの書き方も紹介します。
Docker Compose の healthcheck が実際に行うこと
Docker Compose の healthcheck は、Docker が一定間隔でコンテナ内で実行する 1 つのコマンドです。Docker はログを読み取ったり、ポートを監視したり、プロセス一覧を調べたりしません。コマンドを実行し、終了コードを読み取り、コンテナに starting、healthy、unhealthy のいずれか 1 つの状態を保存します。終了コード 0 は healthy を示します。それ以外の終了コードは unhealthy を示します。終了コード 2 は Docker 用に予約されているため、意図的に返してはいけません。
仕組みはこれだけです。healthcheck の問題のほとんどは、同じ原因で発生します。つまり、作成したコマンドが、確認したかったこととは別の質問に答えています。このガイドでは、すでに VPS で compose ファイルを作成する方法を理解しているものとし、スタックが誤った順序で起動する段階から説明します。
services:
api:
image: ghcr.io/example/api:1.4.0
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8080/healthz"]
interval: 10s
timeout: 3s
retries: 5
start_period: 30stest の値には、2 つの便利な形式があります。CMD で始まるリストは、シェルを介さずにコマンドを直接実行します。そのため、パイプ、&&、変数展開は機能しません。CMD-SHELL で始まるリストは、残りの部分を 1 つの文字列としてコンテナ内の /bin/sh -c に渡します。チェックでシェル構文が必要な場合は、この形式を使用します。単純な文字列は CMD-SHELL として扱われます。["NONE"] だけで構成されたリストは、イメージの Dockerfile に組み込まれた healthcheck を削除します。
チェックはコンテナ内で実行されるため、指定するすべてのバイナリがそのイメージに存在している必要があります。まずこれを確認してください。curl のない軽量イメージでは、アプリケーションログに現れない理由でコンテナが恒久的に unhealthy になることがあります。手動でテストします。
docker compose exec api curl --version存在しないバイナリは OCI runtime exec failed: exec: "curl": executable file not found in $PATH: unknown を返します。Alpine ベースのイメージには通常、代わりに BusyBox の wget が含まれているため、チェックは ["CMD", "wget", "-q", "-O", "-", "http://localhost:8080/healthz"] になります。
interval、retries、start_period の組み合わせ
タイミングを制御する設定は 5 つあります。デフォルト値は Compose ではなく Docker Engine によって決まります。
interval: コンテナが start period を過ぎた後、2 回のチェックの間隔です。デフォルトは 30s です。timeout: 1 回のチェックにかけられる時間です。この時間を超えると Docker はチェックを強制終了し、その実行を失敗として数えます。デフォルトは 30s です。retries: 状態がunhealthyに変わるまでに必要な連続失敗回数です。デフォルトは 3 です。start_period: コンテナ起動後の猶予期間です。デフォルトは 0s です。start_interval: start period 中にチェックを実行する間隔です。デフォルトは 5s で、Docker Engine 25.0 以降が必要です。
重要なルールは、start period 中にチェックが失敗しても retries に数えられず、コンテナは starting のままであることです。チェックが初めて成功すると、コンテナは healthy になり、start period の残り時間があっても直ちに終了します。チェックが失敗したまま start period を使い切ると、通常のカウントダウンが始まります。その後、コンテナが unhealthy と判定されるには、retries 回連続で失敗する必要があります。
したがって、コンテナの起動から unhealthy になるまでの最悪時間は、start_period に retries と interval の積を加え、さらに timeout を加えた時間です。上記のファイルの値では、30 に 5 と 13 の積を加えるため、95 秒です。デプロイのタイムアウトを設定する前に、この数値を書き留めてください。60 秒後に処理を中止するロールアウトでは、このコンテナが最終状態に到達するのを確認できません。
ここでよくある誤りは、遅い起動を補うために retries を増やすことです。この方法は最初は機能しますが、その後の運用に悪影響を与えます。起動に 8 回の再試行が必要だったサービスが、本番環境で 8 回連続して失敗しても、何も検知されなくなるためです。代わりに start_period を使用してください。これは最初に成功するまでの期間だけ適用されます。
depends_on だけでは何も保証されません
depends_on の短縮形式が、混乱の主な原因です。
api:
depends_on:
- dbこれは 1 つのことだけを意味します。api コンテナより先に db コンテナを起動する、ということです。Compose はコンテナの作成と起動が完了するまで待機します。しかし、PostgreSQL の初回初期化が完了するまで待機するわけではありません。また、ポート 5432 で接続を受け付けるまで待機するわけでもありません。アプリは約 1 秒後に起動し、まだ何も待ち受けていないポートへ接続して終了します。ログには Connection refused、またはサーバーは起動しているもののリカバリ中の場合は FATAL: the database system is starting up と表示されます。
実際に必要になるのは長い形式です。
api:
depends_on:
db:
condition: service_healthy
restart: true
migrate:
condition: service_completed_successfullycondition には 3 つの値があります。service_started は短縮形式と同じです。service_healthy を指定すると、依存先が healthy と報告するまで依存元サービスの起動を保留します。これは、依存先が compose file またはそのイメージで healthcheck を定義している場合にのみ意味があります。service_completed_successfully は、データベースマイグレーションなどの one-shot コンテナがステータス 0 で終了するまで待機します。
condition の隣には、追加のフィールドが 2 つあります。restart: true を指定すると、依存先サービスの更新後にこのサービスを再起動するよう Compose に指示します。required: false を指定すると、依存先がない場合のエラーを警告に変更します。
ここで、見落とされやすい制限があります。これらの条件が評価されるのは、stack の起動時だけです。これは起動順序の指定であり、監視ルールではありません。午前 3 時にデータベースが再起動しても、service_healthy が再評価されることはなく、その条件を再び満たすためにアプリが再起動されることもありません。アプリケーションコードは、自身で再接続する必要があります。docker compose up --no-deps api は設計上、この仕組み全体を回避します。docker start でコンテナを直接起動した場合も同様です。
プロセスの存在ではなく、準備状態を確認するチェックを作成する
pgrep nginxのようなチェックで確認できるのは、プロセステーブルにエントリが存在することだけです。サービスがリクエストに応答できることは確認できません。Web アプリケーションは、データベース接続プールが停止した後も、待ち受けソケットを開いたままにすることがあります。その場合、障害中もプロセスチェックは成功し続けます。
コンテナが本来提供する処理を実行するようにします。
- HTTP サービスでは、実際のエンドポイントにリクエストします。
curl -fsSは-fにより、400 以上のステータスで non zero を返します。そのため、アプリケーションの障害による 500 もチェック失敗になります。 - PostgreSQL では、
pg_isreadyを使用します。サーバーが接続を受け付けている場合は 0、拒否している場合は 1、まったく応答しない場合は 2、渡したパラメーターが誤っている場合は 3 を返します。 - Redis では、
redis-cli pingを使用します。PONGを出力し、0 で終了します。 - MariaDB では、公式イメージに
healthcheck.shスクリプトが含まれています。healthcheck.sh --connect --innodb_initializedが、そのメンテナーがドキュメントで示している形式です。
pg_isreadyには、知っておくべき注意点があります。空のデータディレクトリで初回起動する場合、公式のpostgresイメージは、Unix ソケットだけで待ち受ける一時サーバーに対して初期化を実行します。ホスト引数なしのpg_isreadyはこのソケットを使用します。そのため、アプリケーションから接続する TCP ポート 5432 がまだ閉じていても、「接続を受け付けている」と応答できます。チェックの接続先を明示的に TCP に指定すれば、この問題は解消します。一時サーバーは TCP では応答しないためです。
healthcheck:
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -p 5432 -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 10
start_period: 30sドル記号を 2 つ重ねているのは誤記ではありません。Compose はファイルの読み込み時に$VAR自体を展開します。その場合、ホスト環境の値がチェックに埋め込まれます。$$はこれを単一の$にエスケープします。そのため、コンテナ内のシェルがコンテナ自身の環境に対して展開します。
正しい順序で起動する postgres と app のスタック
services:
db:
image: postgres:17.5
environment:
POSTGRES_USER: appuser
POSTGRES_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD in .env}
POSTGRES_DB: appdb
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -p 5432 -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 10
start_period: 30s
restart: unless-stopped
api:
image: ghcr.io/example/api:1.4.0
environment:
DATABASE_URL: postgres://appuser:${DB_PASSWORD}@db:5432/appdb
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8080/healthz"]
interval: 10s
timeout: 3s
retries: 5
start_period: 30s
ports:
- "127.0.0.1:8080:8080"
restart: unless-stopped
volumes:
pgdata:起動して、状態の変化を監視します。
docker compose up -d
docker compose psSTATUS 列には、角括弧内にヘルス状態が表示されます。正常な状態では、両方の行に Up 41 seconds (healthy) と表示されます。データベースの初期化中は、db に Up 4 seconds (health: starting) と表示され、api は一覧にありません。Compose がまだ作成していないためです。
チェックが成功または失敗した理由を確認するには、ヘルスログを読みます。
docker inspect --format '{{json .State.Health}}' "$(docker compose ps -q db)"Docker は直近の結果をいくつか保持します。各結果には、開始時刻、終了時刻、ExitCode、およびコマンドの Output が含まれます。保存される出力は切り詰められます。そのため、大きなページ本文を出力するチェックでは、役に立たないログエントリになります。チェックの出力は少なくしてください。
コンテナが unhealthy になったときに Docker が行うこと
何もしません。これが最も意外に思われる点です。
単一ホスト上の Docker Engine は、unhealthy になったコンテナを再起動しません。restart: unless-stopped ポリシーが反応するのはメインプロセスが終了した場合であり、unhealthy のコンテナは終了していないためです。コンテナは unhealthy の状態のまま 1 週間動作し続けることがあり、その間も Compose は何もしません。Swarm mode は unhealthy なタスクを置き換えますが、1 台のサーバー上で動作する通常の Compose スタックは置き換えません。
そのため、現実的な選択肢は 2 つです。プロセスが自身の異常を検知したときに終了するようにし、restart policy が処理できる状態にします。または、外部から状態を監視してアラートを発生させます。ヘルスチェックが呼び出すエンドポイントと同じエンドポイントを Uptime Kuma のモニターで監視すると、依存先の障害が両方に現れます。ユーザーからではなく、モニターから障害を検知できます。トラフィックが Traefik のリバースプロキシを経由してアプリに到達する場合は、プロキシがバックエンドを判断する状態と Docker の health state は別であることに注意してください。一方だけでは、もう一方をカバーできません。
正常にならない healthcheck のデバッグ
同じコンテナ内で、正確なコマンドを自分で実行し、終了コードを確認します。
docker compose exec api curl -fsS http://localhost:8080/healthz; echo "exit=$?"コンテナが依然として unhealthy と報告している状態で exit=0 になる場合、compose test が実際に入力した内容と異なっています。通常は、シェル構文が必要な箇所で CMD が使用されています。
残りの大半は、2 つの間違いで説明できます。1 つ目は、ポートの指定が誤っていることです。healthcheck はコンテナ内で実行されるため、公開されたホスト側のポートではなく、必ずコンテナ側のポートを使用します。ports: - "8080:3000" ではアプリケーションが 3000 で待ち受けるため、http://localhost:8080 に対するチェックは、ブラウザーではサイトが正常に表示されていても永遠に失敗します。2 つ目は、ホストの指定が誤っていることです。チェック内の localhost は同じコンテナを指します。自分自身を確認する場合は正しい指定ですが、別のコンテナを確認する場合は誤りです。その場合は、たとえば db のようにサービス名を指定します。
最後に、名前を付けておくべきケースが 1 つあります。healthcheck は成功しているのに、ユーザーにはエラーが表示されるケースです。これは、エンドポイントが実際の依存先にアクセスせず、固定の 200 を返す場合に起こります。データベースに一度も問い合わせない readiness エンドポイントでは、データベースが停止していることを検出できません。低コストの実際のクエリを 1 つ実行するようにします。
FAQ
depends_on でデータベースが healthy と示されているのに、アプリが接続に失敗し続けるのはなぜですか?
condition: service_healthy はスタックの起動時に 1 回だけ評価されるためです。その後の状態を監視するものではありません。後からデータベースコンテナが再起動しても、Compose は条件を再度満たすためにアプリケーションを再起動しません。そのため、アプリケーションコード側に再接続とリトライの処理が必要です。また、docker start または docker compose up --no-deps で単一のコンテナを起動した場合、条件は機能しません。
イメージに healthcheck が定義されている場合、自分で追加する必要はありますか?
通常は必要ありません。上書きすると、かえって後退することもあります。そのソフトウェアで何を readiness とするかは、イメージのメンテナーが把握しているためです。イメージのチェックが環境に適していない場合に限り、自分で追加してください。たとえば、移動したポートを検査している場合です。イメージの healthcheck を無効にするには、サービスに test: ["NONE"] または disable: true を設定します。
healthcheck では curl と wget のどちらを使うべきですか?
イメージにすでに存在する方を使い、利用する前に docker compose exec <service> curl --version で確認してください。Debian ベースのイメージには、どちらも存在しないことがよくあります。Alpine ベースのイメージには BusyBox wget があります。ソフトウェアが pg_isready や redis-cli などの独自クライアントを提供している場合、healthcheck を実行するだけのためにイメージへパッケージを追加しないでください。
unhealthy のコンテナは自動的に再起動されますか?
単一ホスト上の Docker Engine では、自動的に再起動されません。再起動ポリシーが反応するのはプロセスの終了であり、health 状態ではありません。そのため、unhealthy のコンテナは起動したまま壊れた状態で残り、別の処理が行われるまで復旧しません。障害を検出したらプロセスを終了させるか、状態を監視してアラートを送る外部モニターを実行してください。
start_period はどの程度に設定すべきですか?
実測した正当な初回起動時間のうち、最も遅いものに余裕を加えた長さにしてください。空のボリュームに対して docker compose up で計測します。データベースの初回起動は、その後の起動より大幅に遅いためです。start_period が長すぎると、最初の unhealthy 判定が遅れるだけです。リトライ回数が多すぎると、コンテナの稼働期間全体でチェックが甘くなり、より深刻な障害につながります。