2-5. ヘルスチェックと再起動戦略 - readiness / liveness の元祖
所要時間: 40-50分 ゴール: HEALTHCHECK を正しく設計でき、
/healthzと/readyzを使い分けられる コミット内容:~/learn/infra/day12/に Go アプリ + Dockerfile HEALTHCHECK + compose の構成
このレッスンのゴール
- HEALTHCHECK の3状態 (starting / healthy / unhealthy) を理解した
-
/healthz(liveness) と/readyz(readiness) を使い分けて実装できる - interval / timeout / retries / start_period を場面別に設計できる
- restart policy(no / on-failure / unless-stopped / always)を選べる
- 再起動ループに陥らない設計を語れる
なぜ学ぶか(実務悩みベース)
- 「コンテナ起動してるのに応答無い」状態を自動検出したい
- Kubernetes / ECS への布石として「probe の元祖」を理解する
- グレースフルシャットダウンを実装してダウンタイムを減らす
- 「監視からアラート」までの責務分担を学ぶ
前章とのつながり
2-2 の condition: service_healthy が今回の本題そのもの。Compose の depends_on ready 待ちは HEALTHCHECK の上に成り立つ。
大前提: 「コンテナが立ち上がってる」と「使える」は違う
例えば Go API が docker compose ps で running と出ていても、実際にリクエストを受け付けられているとは限らない。
- DB 接続中で起動中(リクエストは 503 を返すべき)
- メモリリークで詰まっていて応答しない
- 起動はしたが panic で標準エラーに吐いて死にかけ
- 起動はしたが間違った設定で動いている(環境変数の typo 等)
これを コンテナの外から判定する仕組み がヘルスチェック。Compose だけでなく Kubernetes(readiness/liveness probe)、AWS ECS(Container Health Check)、ALB の Target Health Check と、現代のオーケストレーションは全部この仕組みに乗っている。
「Docker の HEALTHCHECK は Kubernetes の readiness/liveness probe の元祖」と覚えるとよい。
本日の到達点
- HEALTHCHECK の3つの状態(starting / healthy / unhealthy)
- Dockerfile HEALTHCHECK と compose healthcheck の使い分け
- interval / timeout / retries / start_period の設計
/healthz(生きてる)と/readyz(受付可能)の違い- restart policy の挙動と本番選定
- 再起動ループを起こさない設計
セッション①: HEALTHCHECK の基礎(25-30分)
0. 録画スタート&作業ディレクトリ
mkdir -p ~/log ~/learn/infra/day12
cd ~/learn/infra/day12
script ~/log/infra_day12.log1. ヘルスチェックの3状態
docker compose ps
# NAME IMAGE STATUS PORTS
# api-1 myapi:1.0 Up 5 minutes (healthy) 0.0.0.0:8080->8080/tcp
# db-1 mysql:8.0 Up 5 minutes (healthy) 3306/tcpヘルスチェックの3つの状態
状態 意味 starting起動直後、まだ判定保留中( start_period中)healthy直近のチェックが成功 unhealthy連続して失敗( retriesの回数)状態遷移:
[container start] | v [starting] ←─── start_period 内は失敗してもカウントしない | | start_period 経過 or 1回成功 v [healthy] ←──┐ | | 1回成功で復活 | 連続失敗| v | [unhealthy] ──┘Compose の
depends_on: condition: service_healthyはこの状態を見ている。
2. Dockerfile の HEALTHCHECK
FROM golang:1.23-alpine AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /out/api .
FROM alpine:3.20
RUN apk add --no-cache curl
COPY --from=build /out/api /api
EXPOSE 8080
# ヘルスチェック定義
HEALTHCHECK --interval=10s --timeout=3s --start-period=20s --retries=3 \
CMD curl -fsS http://localhost:8080/healthz || exit 1
USER 65532:65532
ENTRYPOINT ["/api"]HEALTHCHECK のオプション設計
オプション 意味 デフォルト 推奨 --intervalチェック間隔 30s 10-30s(短すぎると負荷、長すぎると検知遅延) --timeout1回の最大待ち時間 30s 3-5s(短く) --retries連続失敗で unhealthy に 3 3-5 --start-period起動猶予期間 0s アプリの起動時間に合わせる
--start-periodの重要性: アプリ起動に 15 秒かかるのに--start-period=0だと、最初の interval で unhealthy になってしまい、depends_onが永遠に待ち続ける。
Dockerfile HEALTHCHECK と compose healthcheck の優先順位
両方書くと compose の healthcheck が勝つ。
services: api: build: . healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8080/healthz"] interval: 10s timeout: 3s retries: 3 start_period: 20s使い分け
- Dockerfile に書く: そのイメージを単独で使う時もチェックが効く(外部の人に渡すイメージ)
- compose に書く: そのプロジェクト固有のヘルスチェックを上書き
両方書くか、片方だけかは設計判断。公式イメージは HEALTHCHECK を持っていないことが多い(汎用性のため)ので compose 側で書くことが多い。
3. compose healthcheck の書き方
services:
api:
image: myapi:1.0
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8080/healthz"]
interval: 10s
timeout: 3s
retries: 3
start_period: 20s
testの3つの書き方# 1. CMD: 配列で書く(推奨) test: ["CMD", "curl", "-f", "http://localhost/healthz"] # 2. CMD-SHELL: シェル経由 test: ["CMD-SHELL", "curl -f http://localhost/healthz || exit 1"] # 3. NONE: 親イメージの HEALTHCHECK を無効化 test: ["NONE"]CMD vs CMD-SHELL: パイプ、リダイレクト、複数コマンドを使うなら CMD-SHELL。単純なコマンドなら CMD(fork が要らないので僅かに速い)。
curl / wget がコンテナに無いことを忘れがち
Distroless や
scratchベースのイメージには curl も wget も無い。FROM gcr.io/distroless/static-debian12 COPY api /api HEALTHCHECK CMD curl -f http://localhost:8080/healthz || exit 1 # NG: curl が無い解決策:
- Go バイナリでヘルスチェックを兼ねさせる:
myapp healthcheckのようなサブコマンドを実装- 専用バイナリを同梱: grpc_health_probe など軽量なヘルスチェック専用ツール
- alpine ベースに切り替え:
RUN apk add --no-cache curlを入れる
Go アプリでヘルスチェック専用サブコマンドを実装
func main() { if len(os.Args) > 1 && os.Args[1] == "healthcheck" { resp, err := http.Get("http://localhost:8080/healthz") if err != nil || resp.StatusCode != 200 { os.Exit(1) } os.Exit(0) } // 通常のサーバー起動 startServer() }Dockerfile:
HEALTHCHECK --interval=10s --timeout=3s --start-period=20s --retries=3 \ CMD ["/api", "healthcheck"]distroless でも動く。Kubernetes の livenessProbe.exec とも互換。
4. ヘルスチェックエンドポイント設計: /healthz vs /readyz
func main() {
db := connectDB(...)
mux := http.NewServeMux()
// /healthz: プロセスが生きてるか(liveness)
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
fmt.Fprintln(w, "ok")
})
// /readyz: 外部依存も含めてリクエストを受けられるか(readiness)
mux.HandleFunc("/readyz", func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
http.Error(w, "db not ready", http.StatusServiceUnavailable)
return
}
// 他の依存(Redis, 外部APIなど)もここで確認
fmt.Fprintln(w, "ready")
})
log.Fatal(http.ListenAndServe(":8080", mux))
}/healthz と /readyz の役割の違い
エンドポイント 質問 失敗時の対応 /healthz(liveness)「プロセスは生きてる?」 コンテナを 再起動 /readyz(readiness)「リクエストを受けられる?」 LB から 外す(再起動はしない)
/healthz失敗 = 再起動
- 例: デッドロック、メモリリーク、無限ループ
- 再起動すれば直る可能性が高い問題
/readyz失敗 = LB から外す
- 例: DB 接続が一時的に切れた、起動中、シャットダウン中
- 再起動しても直らない or 待てば直る問題
Kubernetes の livenessProbe と readinessProbe がこの2つに対応している。Compose では「HEALTHCHECK」しか無いので
/healthzだけが対象。ただし将来 K8s に移行する時のために両方実装しておく。
/healthz の "z" の由来
Kubernetes / Google 由来の慣習。
/healthだと一般的すぎてアプリのルーティングと衝突する可能性がある(例:/health-recordsのようなパス)。“z” を付けることで:
- 「これは内部用のシステムエンドポイント」と即座に分かる
- アプリのビジネスパスと混同しない
- Google の社内慣習が広まったもの
似た慣習:
/varz(変数dump)、/statusz(状態)、/debugz(デバッグ)。
アンチパターン: ヘルスチェックで重い処理
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { // NG: フルクエリで重い集計 var count int db.QueryRow("SELECT COUNT(*) FROM huge_table").Scan(&count) // NG: 外部 API に依存 resp, _ := http.Get("https://api.stripe.com/v1/charges") fmt.Fprintln(w, "ok") })なぜNGか:
- 10秒ごとに重いクエリが走り、DB を圧迫
- 外部 API が落ちると自分のサービスまで unhealthy に
- ヘルスチェックがタイムアウトして再起動ループに陥る
正解:
/healthzは 本当に最小限(メモリ確認程度、即時 200 返す)/readyzは依存を見るが、タイムアウトを必ず付ける- 外部依存は「あれば良い」程度で、必須にしすぎない
アンチパターン: 依存サービスチェックなし
// /readyz でも DB を見ない mux.HandleFunc("/readyz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })なぜNGか:
- DB が落ちててもアプリは「ready」と返す
- LB がリクエストを流す → エラー応答が大量発生
- ユーザーに 500 が返り続ける
正解:
/readyzは クリティカルな依存のみ チェック。DB は必須、外部 API は graceful degradation で運用。
セッション②: restart policy と再起動戦略(25-30分)
5. restart policy の4種類
services:
api:
image: myapi:1.0
restart: unless-stopped # 推奨デフォルトrestart policy 一覧
ポリシー 動作 no再起動しない(デフォルト) always常に 再起動。手動停止後も次回 daemon 起動で立ち上がる on-failure[:N]異常終了(exit code 非0)のみ再起動。N で最大回数指定 unless-stoppedalwaysと似てるが、手動で停止したら復活しない詳細な違い:
状況 | no | always | on-failure | unless-stopped ───────────────────┼──────┼────────┼────────────┼─────────────── 異常終了 | 停止 | 再起動 | 再起動 | 再起動 正常終了 (exit 0) | 停止 | 再起動 | 停止 | 再起動 手動 stop | 停止 | 停止 | 停止 | 停止 dockerd 再起動後 | 停止 | 起動 | 状況による | 起動(手動stopしてなければ)
本番での選定指針
unless-stopped: ほとんどのサービスはこれ。dockerd 再起動でも復活、手動 stop は尊重always: 「絶対動いてて欲しい」サービス。docker stopでも諦めないon-failure: バッチジョブ、cron 系。正常終了したらそのまま終わってほしいno: マイグレーションサービス、1回だけ実行するものCompose で
depends_on: condition: service_completed_successfullyを使う場合、依存先サービスはrestart: "no"で書く(再起動されると永遠に完了しない)。
6. 再起動ループに陥る危険
services:
api:
image: myapi:1.0
restart: always
# 設定が間違っていて起動直後に panic で落ちる
environment:
DATABASE_URL: "broken-url"再起動ループの何が問題か
起動 → panic → 再起動 → panic → … を高速で繰り返す状態。
起きること:
- ログが大量出力 → ディスク埋まる
- DB に接続試行が殺到 → DB の負荷増大
- 外部 API への接続試行も殺到 → レート制限に引っかかる
- CPU が再起動コストで埋まる → 他コンテナにも影響
実際の事故: 設定ミスで起動できないアプリが 1秒に5回再起動 → 数時間で ログだけで 100GB に達してホスト全体が止まった事例。
対策:
on-failure:5のように 回数上限 を付ける- 監視で「再起動回数」をアラート対象に
- アプリ側で 起動失敗時は exponential backoff で sleep してから exit
Backoff 付き再起動の実装
func main() { if err := startApp(); err != nil { log.Printf("startup failed: %v", err) // 即座に終了するのではなく、少し sleep してから exit // → restart 間隔が稼げる time.Sleep(10 * time.Second) os.Exit(1) } }Docker daemon 自身も exponential backoff で再起動間隔を伸ばしてくれる(100ms → 200ms → 400ms … 最大 1分)が、アプリ側でも防御線を張る。
7. depends_on with condition: service_healthy
services:
db:
image: postgres:16
healthcheck:
test: ["CMD", "pg_isready", "-U", "postgres"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10s
cache:
image: redis:7-alpine
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
api:
image: myapi:1.0
depends_on:
db:
condition: service_healthy
cache:
condition: service_healthy
healthcheck:
test: ["CMD", "/api", "healthcheck"]
interval: 10s
timeout: 3s
retries: 3
start_period: 30s
restart: unless-stoppedヘルスチェックチェーンの設計
- DB と Cache のヘルスチェックが healthy になってから API が起動
- API 自身もヘルスチェックを持つ
- すべて
restart: unless-stopped(マイグレーション系は"no")これで「依存先が ready になるまで待つ」「死んだら自動回復」が両立する。
8. グレースフルシャットダウンとヘルスチェック
func main() {
db := connectDB(...)
var ready atomic.Bool
ready.Store(true)
mux := http.NewServeMux()
mux.HandleFunc("/readyz", func(w http.ResponseWriter, r *http.Request) {
if !ready.Load() {
http.Error(w, "shutting down", http.StatusServiceUnavailable)
return
}
if err := db.PingContext(r.Context()); err != nil {
http.Error(w, "db not ready", http.StatusServiceUnavailable)
return
}
fmt.Fprintln(w, "ready")
})
srv := &http.Server{Addr: ":8080", Handler: mux}
// シグナルハンドラ
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGTERM, syscall.SIGINT)
go func() {
<-sigs
log.Println("shutdown signal received, marking not ready")
ready.Store(false)
// LB がこちらを外すまで猶予を取る
time.Sleep(5 * time.Second)
// その後 graceful shutdown
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
srv.Shutdown(ctx)
}()
log.Fatal(srv.ListenAndServe())
}グレースフルシャットダウンとヘルスチェックの連携
アプリ終了時の正しい順序:
- SIGTERM 受信
/readyzを 503 にする(LB がリクエストを止める)- 数秒待つ(LB が外すまで)
- 新規接続を拒否、既存接続を完了させる
- プロセス終了
この間
/healthzは 200 を返し続ける(プロセスは生きてるので、再起動されては困る)。Kubernetes の
preStophook、AWS ALB の deregistration delay と同じ思想。
アンチパターン: いきなり exit する
sigs := make(chan os.Signal, 1) signal.Notify(sigs, syscall.SIGTERM) <-sigs os.Exit(0) // NG: 処理中のリクエストが切断されるなぜNGか:
- 進行中のリクエストが 500 で切断
- DB トランザクションが中途半端に
- LB はまだこのインスタンスにリクエストを送ってる
必ず graceful shutdown を実装。
練習課題
mkdir -p ~/learn/infra/day12/api
cd ~/learn/infra/day12
script ~/log/infra_day12.log- Go アプリで
/healthzと/readyzを実装 - healthcheck サブコマンド(
./api healthcheck)を実装 - Dockerfile で HEALTHCHECK を設定(curl 無しで動くよう自前コマンド)
- compose で MySQL の healthcheck + API の healthcheck +
depends_on: service_healthyを組み立て docker compose up -d --buildで起動 →docker compose psで(healthy)表示を確認docker compose stop dbで DB だけ止める → API の/readyzが 503 を返すか確認docker compose start dbで復活 → API の/readyzが 200 に戻るか確認restart: unless-stoppedの動作確認: API コンテナをdocker kill→ 自動再起動を確認exitで script 終了
考察課題
/readyzが DB のチェックを含んでいるとき、DB がメンテナンスで5分止まると全コンテナが unhealthy になる。これでよい?対策は?- ヘルスチェックの interval を 1 秒にすると何が起きる?逆に 60 秒にすると?
締め: git で証跡を残す
cd ~/learn/infra/day12
git init -q 2>/dev/null || true
git add api/ compose.yml
git commit -m "feat(infra): ヘルスチェックとグレースフルシャットダウン"チェックリスト
- starting / healthy / unhealthy の遷移を口頭で説明できる
- interval / timeout / retries / start_period を意図を持って設計できる
-
/healthzと/readyzの役割の違いを語れる - distroless でヘルスチェックを動かす方法(自前バイナリ)を知っている
- restart policy 4種類の挙動を区別できる
-
depends_on: condition: service_healthyを使える - グレースフルシャットダウンを Go で書ける
- 再起動ループの危険性と対策を説明できる
詰まった時のチートシート
| やりたいこと | 書き方 |
|---|---|
| HEALTHCHECK 定義(Dockerfile) | HEALTHCHECK --interval=10s CMD curl -f .../healthz |
| HEALTHCHECK 無効化 | HEALTHCHECK NONE |
| compose で定義 | healthcheck: { test: [...], interval: 10s, ... } |
| 起動猶予 | start_period: 30s |
| 状態確認 | docker compose ps の STATUS 列 |
| 詳細確認 | docker inspect --format='{{json .State.Health}}' <container> |
| ready 待ち依存 | depends_on: { svc: { condition: service_healthy } } |
| 完了待ち依存 | depends_on: { svc: { condition: service_completed_successfully } } |
| 再起動ポリシー | restart: unless-stopped |
| MySQL ping | mysqladmin ping -h 127.0.0.1 -uroot -p$$PASS |
| Postgres ping | pg_isready -U $USER -d $DB |
| Redis ping | redis-cli ping |
やらかし事例: ヘルスチェック設計の落とし穴
事例1:
/healthzで DB に SELECTliveness で DB を叩くと、DB 一時障害でアプリも巻き込まれて再起動ループ。liveness はアプリ自身のみ、依存先は readiness で。
事例2:
start_period設定忘れで起動中に unhealthy 判定Java/Spring の起動に30秒かかるアプリで
start_period: 5s→ 起動中なのに unhealthy 判定 → 再起動 → 永遠に起動できない。
事例3: グレースフルシャットダウン未実装
SIGTERM 受けて即 exit、進行中のリクエストが 502 を返す。
server.Shutdown(ctx)で在中リクエストを待つ実装が必要。
事例4: 再起動ループの放置
restart: alwaysで1秒ごとに失敗→再起動→失敗のループ。CPU 100%、ログ無限増殖。on-failure+ 上限指定 or 監視アラート。
事例5: HEALTHCHECK 内コマンドが重い
interval: 5sなのに healthcheck 内で重いクエリ実行 → DB 負荷増大。healthcheck は超軽量に。
対比表: liveness vs readiness
| 観点 | liveness (/healthz) | readiness (/readyz) |
|---|---|---|
| 問い | 「生きてる?」 | 「いま受付OK?」 |
| 失敗時の挙動 | コンテナ再起動 | LB から外す(再起動しない) |
| 依存先チェック | しない(自分だけ) | する(DB / cache 等) |
| 起動直後 | 早期に healthy | 依存先 ready まで not ready |
| 典型ロジック | return 200 即返し | DB.Ping() / Redis Ping |
対比表: restart policy
| ポリシー | 挙動 | 用途 |
|---|---|---|
no | 再起動しない | 一回限りのバッチ |
on-failure | 失敗時のみ再起動(exit code != 0) | 通常のサービス |
unless-stopped | 手動停止以外は再起動 | 本番サービス推奨 |
always | 停止しても起動時に必ず再起動 | systemd みたいに動かしたい |
「実務OK」基準
- 「unhealthy になった」ログを見て原因を切り分けられる: healthcheck コマンド、interval、依存先
/healthzと/readyzを使い分けてアプリを書ける: liveness と readiness の役割を意識- 本番でグレースフルシャットダウンが効いている: 0ダウンタイムデプロイの基礎
- 再起動ループのリスクを理解した運用ができる: 監視・上限・backoff
自己評価チェックリスト
知識レベル
- liveness と readiness の責務分離を語れる
-
start_periodの必要性を即答できる - グレースフルシャットダウンの実装手順をイメージできる
実行レベル
- Go アプリに
/healthzと/readyzを実装した - Dockerfile に
HEALTHCHECKを書いた - compose で
condition: service_healthyを使って ready 待ちした
メタ認知
- 自分のアプリのヘルスエンドポイントが「依存先を巻き込む設計」になっていないか確認した
- Kubernetes の probe に変換するなら何になるかをイメージできた
さらに深掘るなら
- Docker docs: HEALTHCHECK - 公式リファレンス
- Kubernetes: Configure Liveness, Readiness and Startup Probes - 次のステップ
- grpc-ecosystem/grpc-health-probe - gRPC ヘルスチェック標準
- 記事: Google SRE Book - Practical Alerting - 監視とアラートの哲学
- 書籍: 「Site Reliability Engineering」(O’Reilly)- ヘルスチェック・再起動戦略の章
- 記事: Kubernetes Probes: An Honest View - liveness probe の罠
次のレッスン
2-6_本番Compose.md: マルチステージビルド、compose override の階層化、本番運用の resource limits / read_only / ログ設計、CI からのレジストリ運用を学ぶ。