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-2condition: service_healthy が今回の本題そのもの。Compose の depends_on ready 待ちは HEALTHCHECK の上に成り立つ。


大前提: 「コンテナが立ち上がってる」と「使える」は違う

例えば Go API が docker compose psrunning と出ていても、実際にリクエストを受け付けられているとは限らない。

  • 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.log

1. ヘルスチェックの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チェック間隔30s10-30s(短すぎると負荷、長すぎると検知遅延)
--timeout1回の最大待ち時間30s3-5s(短く)
--retries連続失敗で unhealthy に33-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 の livenessProbereadinessProbe がこの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())
}

グレースフルシャットダウンとヘルスチェックの連携

アプリ終了時の正しい順序:

  1. SIGTERM 受信
  2. /readyz を 503 にする(LB がリクエストを止める)
  3. 数秒待つ(LB が外すまで)
  4. 新規接続を拒否、既存接続を完了させる
  5. プロセス終了

この間 /healthz200 を返し続ける(プロセスは生きてるので、再起動されては困る)。

Kubernetes の preStop hook、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
  1. Go アプリで /healthz/readyz を実装
  2. healthcheck サブコマンド(./api healthcheck)を実装
  3. Dockerfile で HEALTHCHECK を設定(curl 無しで動くよう自前コマンド)
  4. compose で MySQL の healthcheck + API の healthcheck + depends_on: service_healthy を組み立て
  5. docker compose up -d --build で起動 → docker compose ps(healthy) 表示を確認
  6. docker compose stop db で DB だけ止める → API の /readyz が 503 を返すか確認
  7. docker compose start db で復活 → API の /readyz が 200 に戻るか確認
  8. restart: unless-stopped の動作確認: API コンテナを docker kill → 自動再起動を確認
  9. 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 pingmysqladmin ping -h 127.0.0.1 -uroot -p$$PASS
Postgres pingpg_isready -U $USER -d $DB
Redis pingredis-cli ping

やらかし事例: ヘルスチェック設計の落とし穴

事例1: /healthz で DB に SELECT

liveness で 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 に変換するなら何になるかをイメージできた

さらに深掘るなら


次のレッスン

2-6_本番Compose.md: マルチステージビルド、compose override の階層化、本番運用の resource limits / read_only / ログ設計、CI からのレジストリ運用を学ぶ。