2-2. マルチサービス構成 - Go API + MySQL を Compose で動かす

所要時間: 40-50分 ゴール: 複数サービスを連携起動でき、depends_on の本質と「ready 待ち」の正しい方法を語れる コミット内容: ~/learn/infra/day09/ に Go API + MySQL の compose 一式


このレッスンのゴール

  • 「コンテナ起動」と「サービス応答可能」の違いを語れる
  • depends_on の単純条件と condition: service_healthy の違いを使い分けられる
  • アプリ側に接続リトライを実装する設計感覚を持てる
  • マイグレーション戦略(アプリ内 vs 別サービス)を比較できる
  • 接続文字列を環境変数で渡す 12 Factor 原則を腹落ちする

なぜ学ぶか(実務悩みベース)

  • up 直後に「connection refused」で止まる事故を予防する
  • 本番でアプリ再起動時にDB再接続できないバグを設計段階で潰す
  • マイグレーションを手動 SSH でやる運用から脱却する
  • 「接続情報を Dockerfile に書く」アンチパターンを撲滅する

前章とのつながり

2-1 では Compose の宣言的管理を学んだ。今回は 複数サービスの起動順 + ready 待ち という、Compose 入門者が必ずハマる罠の本質と解決策を扱う。depends_on の落とし穴を知らないと、CI が flaky になる。


大前提: なぜ「複数サービスを正しく起動する」が難しいのか

docker compose up で Go API と MySQL を同時に起動すると、ほぼ確実に最初は失敗する。

api_1  | dial tcp 172.18.0.2:3306: connect: connection refused
api_1  | exit status 1

「コンテナは立ち上がってるのに、API が DB に繋がらない」状態。これは Compose を始めた人が必ず1回はハマる王道の罠。

原因は 「コンテナが起動している」と「サービスが応答可能」が違う ことに起因する。

  • コンテナ起動: プロセスが exec された状態(1秒以内)
  • 応答可能: MySQL が初期化スクリプトを実行し、ソケットを開き、コネクションを受け付ける状態(10-30秒かかる)

この差を理解せずに depends_on を書くと、API が DB の準備完了を待たずに突っ込んで死ぬ。今回はこの問題を アプリ側のリトライCompose のヘルスチェック条件依存 の両輪で解決する。

本日の到達点

  • Go API(最小構成)と MySQL を1つの compose で連携起動
  • depends_on の表面的な使い方と本質の理解
  • condition: service_healthy で「DB が ready になってから API を起こす」
  • マイグレーションを別サービスとして1回だけ実行する設計
  • 接続文字列 DATABASE_URL を環境変数で渡す 12 Factor App パターン

セッション①: 最初に詰まる「起動順 != Ready」(25-30分)

1. ナイーブな compose(動くが弱い)

services:
  db:
    image: mysql:8.0
    environment:
      MYSQL_ROOT_PASSWORD: rootpass
      MYSQL_DATABASE: app
      MYSQL_USER: app
      MYSQL_PASSWORD: apppass
    volumes:
      - db_data:/var/lib/mysql
 
  api:
    image: golang:1.23-alpine
    depends_on:
      - db
    command: ["sh", "-c", "echo waiting && sleep 5 && echo ok"]
 
volumes:
  db_data:

depends_on の本質: 起動順を制御するだけ

depends_on: [db]「db のコンテナが Start してから api を Start する」 を意味する。それ以上ではない。

公式 docs の原文:

depends_on does not wait for db to be “ready” before starting - only until it has been started.

つまり、MySQL コンテナの プロセスが起動した瞬間に API のコンテナも起動を開始する。MySQL の初期化(DB作成、ユーザー作成、ソケット開放)は数十秒かかるが、その間 API は無視して走り出す。

多くの初心者が誤解する点: 「depends_on を書けば DB が ready になるのを待ってくれる」←これが幻想。これを知らずにいると、CI でランダムに失敗するテストに悩まされる。

アンチパターン: 接続リトライなし

// main.go - これはNG
func main() {
    db, err := sql.Open("mysql", os.Getenv("DATABASE_URL"))
    if err != nil {
        log.Fatal(err)  // 起動直後にここで死ぬ
    }
    defer db.Close()
 
    if err := db.Ping(); err != nil {
        log.Fatal(err)  // 接続できないとここで終わり
    }
    // ...
}

なぜNGか:

  • Compose 起動直後は DB が ready じゃない → Ping 失敗 → 即終了
  • restart: on-failure を付ければコンテナは再起動するが、依存を再評価しないので何度も死ぬ

本番でも同じ問題が起きる: DB のフェイルオーバー、ネットワーク瞬断時にプロセスが死ぬと、自動復旧できないアプリになる。

2. アプリ側のリトライ(責務はアプリ)

// db_connect.go - 良いパターン
package main
 
import (
    "context"
    "database/sql"
    "fmt"
    "log"
    "time"
 
    _ "github.com/go-sql-driver/mysql"
)
 
// 起動時に DB の ready 待ちをする。
// timeout 内に繋がらなければ諦める。
func connectDB(dsn string, timeout time.Duration) (*sql.DB, error) {
    db, err := sql.Open("mysql", dsn)
    if err != nil {
        return nil, fmt.Errorf("open: %w", err)
    }
 
    ctx, cancel := context.WithTimeout(context.Background(), timeout)
    defer cancel()
 
    // exponential backoff っぽく
    delay := 500 * time.Millisecond
    for {
        if err := db.PingContext(ctx); err == nil {
            log.Println("db connected")
            return db, nil
        }
        select {
        case <-ctx.Done():
            return nil, fmt.Errorf("db not ready within %s", timeout)
        case <-time.After(delay):
        }
        if delay < 5*time.Second {
            delay *= 2
        }
    }
}

なぜアプリ側にリトライを入れるべきか

「DB の準備完了を待つ責務」はインフラ層よりアプリ層の方が 本質的に正しい

  • 本番環境では DB のフェイルオーバー、ネットワーク瞬断、メンテナンス再起動が起きる
  • そのときアプリが死ぬと、運用が壊れる
  • 起動時だけでなく 稼働中のコネクション切断にも備える

Compose の depends_on はあくまで「最初の起動順制御」。アプリ側のリトライは省略不可

Kubernetes でも同じで、init container や readiness probe は補助。「依存先が瞬断しても回復するアプリ」が前提。

接続プールとリトライの設計

database/sql パッケージはコネクションプールを内部で持っている。設定すべき項目:

db.SetMaxOpenConns(25)              // 最大同時接続数
db.SetMaxIdleConns(5)                // アイドル接続数
db.SetConnMaxLifetime(5 * time.Minute) // コネクション最大寿命
db.SetConnMaxIdleTime(5 * time.Minute) // アイドル寿命

ConnMaxLifetime は重要。クラウドの LB が長時間アイドル接続を切ることがあるので、ここを切れる前にローテーション。

3. ヘルスチェック付き depends_on

services:
  db:
    image: mysql:8.0
    environment:
      MYSQL_ROOT_PASSWORD: rootpass
      MYSQL_DATABASE: app
      MYSQL_USER: app
      MYSQL_PASSWORD: apppass
    volumes:
      - db_data:/var/lib/mysql
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-u", "root", "-prootpass"]
      interval: 5s
      timeout: 3s
      retries: 10
      start_period: 20s
 
  api:
    build: ./api
    depends_on:
      db:
        condition: service_healthy
    environment:
      DATABASE_URL: "app:apppass@tcp(db:3306)/app?parseTime=true"
    ports:
      - "8080:8080"
 
volumes:
  db_data:

condition: service_healthy の意味

depends_onオブジェクト形式 で書くと、起動順だけでなく「依存先のヘルスチェックが healthy になるまで待つ」が指定できる。

condition動作
service_started起動したら次へ(デフォルト相当)
service_healthyhealthcheck が成功するまで待つ
service_completed_successfully依存先が exit code 0 で終了するのを待つ(マイグレーション等)

これが使えるのは 依存先に healthcheck が定義されていれば。MySQL なら mysqladmin ping がお作法。

注意: ヘルスチェックの内容は「アプリとして応答可能か」を表すべき。コンテナのプロセス生存だけを見るのは弱い(後の章で詳しく)。

本番では service_healthy だけに頼らない

Compose のヘルスチェック依存は 起動時の一発勝負。アプリが稼働してから DB が落ちた時の自動再接続には関与しない。

よくある事故パターン:

  • 起動はうまくいった
  • 数時間後、DB がメンテで再起動
  • アプリが接続失敗をキャッチせず、エラーをそのままユーザーに返し続ける
  • 監視で気づくまで数十分のサービス断

対策: アプリのリトライ + Connection pool の ConnMaxLifetime + 監視(DB エラー率のアラート)。


セッション②: マイグレーションと完全な構成(25-30分)

4. DB 初期化の3つの方法

services:
  db:
    image: mysql:8.0
    # 方法 A: 公式イメージの init スクリプト機構
    volumes:
      - ./initdb:/docker-entrypoint-initdb.d:ro

公式イメージの /docker-entrypoint-initdb.d/ 慣習

多くの DB 公式イメージ(mysql, postgres, mariadb など)は、初回起動時/docker-entrypoint-initdb.d/ 配下の .sql .sh を実行する仕組みを持つ。

./initdb/
├── 01_schema.sql
├── 02_seed.sql
└── 03_setup.sh

ファイル名でアルファベット順実行。01_ 02_ のような prefix で順序制御するのが慣例。

重要な制約: named volume が空の時しか走らない。一度起動済みのボリュームには再実行されない。スキーマ変更を反映したい時は docker compose down -v でボリュームごと消すか、別のマイグレーション機構を使う。

方法 B: マイグレーション専用サービス(推奨)

services:
  db:
    image: mysql:8.0
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uroot", "-prootpass"]
      interval: 5s
      retries: 10
 
  migrate:
    image: migrate/migrate:4
    volumes:
      - ./migrations:/migrations:ro
    command:
      - "-path=/migrations"
      - "-database=mysql://app:apppass@tcp(db:3306)/app"
      - "up"
    depends_on:
      db:
        condition: service_healthy
    restart: "no"
 
  api:
    build: ./api
    depends_on:
      migrate:
        condition: service_completed_successfully
      db:
        condition: service_healthy

流れ:

  1. db が起動 → healthcheck が通る
  2. migrate が起動 → マイグレーション実行 → exit 0
  3. api が起動(migrate の正常終了を待ってから)

メリット

  • スキーマ変更が CI/CD で安全に流せる(マイグレーションファイルが Git 管理)
  • 本番でも同じ仕組みでスキーマ適用できる
  • up を再実行しても、適用済みは飛ばされる(idempotent)

主要ツール: golang-migrate/migrate, pressly/goose, Atlas

アンチパターン: アプリ起動時にマイグレーション

// main.go
func main() {
    db := connectDB(...)
    runMigrations(db)  // NG: アプリ内でマイグレーション
    startServer(db)
}

なぜNGか:

  • アプリを 複数レプリカ で起動する時、全レプリカが同時にマイグレーションを走らせて競合
  • ロールバック時の制御が困難
  • マイグレーション失敗時の挙動が複雑になる(アプリは起動しちゃう?落とす?)

マイグレーションはアプリの責務ではなく、デプロイパイプラインの責務。アプリは「スキーマが最新であることを前提に動く」。

5. 接続文字列 DATABASE_URL の作法

services:
  api:
    environment:
      DATABASE_URL: "app:apppass@tcp(db:3306)/app?parseTime=true&loc=Local"

DATABASE_URL に環境変数を使う「12 Factor App」のIII

アプリの設定(DB 接続情報、API キー、外部サービス URL)は 環境変数で渡す のが現代のお作法。これを最初に体系化したのが 12 Factor App の Config 原則。

# 開発
DATABASE_URL=app:apppass@tcp(db:3306)/app

# ステージング
DATABASE_URL=app:xxx@tcp(staging-db.internal:3306)/app

# 本番
DATABASE_URL=app:xxx@tcp(prod-db-rds.amazonaws.com:3306)/app

アプリのコードは1つ。環境ごとに異なるのは環境変数だけ。これにより:

  • 本番と同じイメージを開発でも使える(Docker のメリット最大化)
  • 設定変更でビルドし直さなくて済む
  • シークレットをコードに混入しない

コンテナ内 DNS の挙動

tcp(db:3306)dbサービス名による名前解決。Compose が作るネットワーク内で:

api コンテナ → DNS 127.0.0.11 → "db" → 172.18.0.2 (db コンテナの IP)

ホストマシンからは db は引けない。db は Compose ネットワーク内でしか有効な名前。

ホストから繋ぐ場合は: db のポートを公開して localhost:3306 で繋ぐ。ただし本番では DB ポートを外に公開しないこと(後述)。

アンチパターン: ホストから DB に繋ぐためにポート公開を本番でやる

services:
  db:
    image: mysql:8.0
    ports:
      - "3306:3306"   # NG: 本番でこれをやる

なぜNGか:

  • サーバーのファイアウォール設定次第で 3306 が外部からアクセス可能 になる
  • 攻撃者がブルートフォースで侵入する典型ルート
  • DB は他コンテナからしか触らないので、ポート公開は不要

開発でだけポート公開したい 場合は、override ファイルで開発時のみ追加する(後の prod 章で扱う)。

6. 完全な実例: Go API + MySQL + マイグレーション

ディレクトリ構成:

~/learn/infra/day09/
├── compose.yml
├── api/
│   ├── Dockerfile
│   ├── go.mod
│   └── main.go
└── migrations/
    ├── 0001_init.up.sql
    └── 0001_init.down.sql

migrations/0001_init.up.sql:

CREATE TABLE users (
  id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
  email VARCHAR(255) NOT NULL UNIQUE,
  name VARCHAR(100) NOT NULL,
  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

migrations/0001_init.down.sql:

DROP TABLE IF EXISTS users;

api/main.go:

package main
 
import (
    "context"
    "database/sql"
    "encoding/json"
    "fmt"
    "log"
    "net/http"
    "os"
    "time"
 
    _ "github.com/go-sql-driver/mysql"
)
 
type User struct {
    ID    int64  `json:"id"`
    Email string `json:"email"`
    Name  string `json:"name"`
}
 
func main() {
    dsn := os.Getenv("DATABASE_URL")
    if dsn == "" {
        log.Fatal("DATABASE_URL is required")
    }
 
    db, err := connectDB(dsn, 30*time.Second)
    if err != nil {
        log.Fatalf("db: %v", err)
    }
    defer db.Close()
 
    db.SetMaxOpenConns(25)
    db.SetMaxIdleConns(5)
    db.SetConnMaxLifetime(5 * time.Minute)
 
    mux := http.NewServeMux()
    mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusOK)
        fmt.Fprintln(w, "ok")
    })
    mux.HandleFunc("/readyz", func(w http.ResponseWriter, r *http.Request) {
        if err := db.PingContext(r.Context()); err != nil {
            http.Error(w, "db not ready", http.StatusServiceUnavailable)
            return
        }
        fmt.Fprintln(w, "ready")
    })
    mux.HandleFunc("/users", func(w http.ResponseWriter, r *http.Request) {
        rows, err := db.QueryContext(r.Context(), "SELECT id, email, name FROM users")
        if err != nil {
            http.Error(w, err.Error(), 500)
            return
        }
        defer rows.Close()
        var us []User
        for rows.Next() {
            var u User
            if err := rows.Scan(&u.ID, &u.Email, &u.Name); err != nil {
                http.Error(w, err.Error(), 500)
                return
            }
            us = append(us, u)
        }
        w.Header().Set("Content-Type", "application/json")
        _ = json.NewEncoder(w).Encode(us)
    })
 
    log.Println("listening :8080")
    log.Fatal(http.ListenAndServe(":8080", mux))
}
 
func connectDB(dsn string, timeout time.Duration) (*sql.DB, error) {
    db, err := sql.Open("mysql", dsn)
    if err != nil {
        return nil, err
    }
    ctx, cancel := context.WithTimeout(context.Background(), timeout)
    defer cancel()
    delay := 500 * time.Millisecond
    for {
        if err := db.PingContext(ctx); err == nil {
            return db, nil
        }
        select {
        case <-ctx.Done():
            return nil, fmt.Errorf("db not ready within %s", timeout)
        case <-time.After(delay):
        }
        if delay < 5*time.Second {
            delay *= 2
        }
    }
}

api/Dockerfile:

FROM golang:1.23-alpine AS build
WORKDIR /src
COPY go.mod go.sum* ./
RUN go mod download || true
COPY . .
RUN CGO_ENABLED=0 go build -o /out/api .
 
FROM gcr.io/distroless/static-debian12
COPY --from=build /out/api /api
USER 65532:65532
EXPOSE 8080
ENTRYPOINT ["/api"]

compose.yml:

services:
  db:
    image: mysql:8.0
    environment:
      MYSQL_ROOT_PASSWORD: rootpass
      MYSQL_DATABASE: app
      MYSQL_USER: app
      MYSQL_PASSWORD: apppass
    volumes:
      - db_data:/var/lib/mysql
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uroot", "-prootpass"]
      interval: 5s
      timeout: 3s
      retries: 10
      start_period: 20s
    restart: unless-stopped
 
  migrate:
    image: migrate/migrate:v4.17.1
    volumes:
      - ./migrations:/migrations:ro
    command:
      - "-path=/migrations"
      - "-database=mysql://app:apppass@tcp(db:3306)/app"
      - "up"
    depends_on:
      db:
        condition: service_healthy
    restart: "no"
 
  api:
    build: ./api
    environment:
      DATABASE_URL: "app:apppass@tcp(db:3306)/app?parseTime=true"
    ports:
      - "8080:8080"
    depends_on:
      migrate:
        condition: service_completed_successfully
      db:
        condition: service_healthy
    restart: unless-stopped
 
volumes:
  db_data:
cd ~/learn/infra/day09
docker compose up -d --build
 
# 起動順序を観察
docker compose logs -f
 
# 動作確認
curl http://localhost:8080/healthz
curl http://localhost:8080/readyz
curl http://localhost:8080/users

この構成のポイント

  • db が healthcheck で ready を表明
  • migrate が ready 後に1回走って exit 0
  • api は migrate の 正常終了 を待ってから起動
  • api 自体も DB 接続リトライを持っている(インフラを信用しすぎない)
  • イメージは distroless + 非 root(USER 65532:65532)でセキュリティ強化

開発・CI・小規模本番でそのまま流用できる構成。


練習課題

mkdir -p ~/learn/infra/day09
cd ~/learn/infra/day09
script ~/log/infra_day09.log
  1. 上記の構成を再現し、docker compose up -d --build で起動。docker compose logs migrate で migrate が exit 0 になっているか確認
  2. migrations/0002_add_status.up.sql を追加(ALTER TABLE users ADD COLUMN status VARCHAR(20) DEFAULT 'active';)→ docker compose up -d で再適用されるか確認
  3. 意図的に壊す: db の healthcheck の mysqladmin ping のパスワードを間違えて、api が起動しないことを確認 → 何分待っても上がらないログを見る
  4. 接続リトライの効果を見る: dbdocker compose stop db で止めて、api のログがリトライしてるか確認 → docker compose start db で復活するか
  5. exit で script 終了

考察課題

  • 本番環境でこの構成をデプロイするとき、MYSQL_ROOT_PASSWORD を YAML にハードコードしているのはなぜ問題か?
  • マイグレーションが「down」しかない場合(ロールバック)、Compose ではどう運用する?

締め: git で証跡を残す

cd ~/learn/infra/day09
git init -q 2>/dev/null || true
git add compose.yml api/ migrations/
git commit -m "feat(infra): Go API + MySQL の compose 一式(healthcheck, migrate 分離)"

チェックリスト

  • depends_on の本質(起動順だけ、ready ではない)を口頭で説明できる
  • condition: service_healthy を使えるようになった
  • アプリ側に接続リトライを書く意義を理解した
  • マイグレーションを別サービスで実行する設計が分かる
  • condition: service_completed_successfully を使った
  • DATABASE_URL を環境変数で渡し、コンテナ間 DNS でサービス名解決した
  • DB ポートを本番で公開してはいけない理由を説明できる

詰まった時のチートシート

やりたいことコマンド/書き方
起動順依存depends_on: [db]
ready 待ちdepends_on: { db: { condition: service_healthy } }
1回だけ実行サービスrestart: "no" + 依存側で service_completed_successfully
MySQL ヘルスチェックmysqladmin ping -h 127.0.0.1 -uroot -p$PASS
PostgreSQL ヘルスチェックpg_isready -U $USER -d $DB
マイグレーション再実行docker compose run --rm migrate
DB だけ再起動docker compose restart db

やらかし事例: マルチサービスの落とし穴

事例1: depends_on だけで「ready 待ち」したつもり

Compose v3 のドキュメントだけ見て depends_on: [db] のみで API 起動。DB が tcp listen していない瞬間に API が突っ込んで死ぬ。condition: service_healthy が必須

事例2: アプリにリトライ無しで depends_on 任せ

ヘルスチェックで起動順は守れても、本番で アプリ再起動中の一瞬の DB 切断 で永久死。アプリ側にリトライ(指数バックオフ)を必ず入れる。

事例3: マイグレーションを手で SSH 実行

本番デプロイ後に「あれ、テーブル無い」と気づき手で migrate。次のデプロイで同じ事故。別サービス or initContainer 的に自動化

事例4: パスワードを compose.yml に平文書き

Git に commit → 履歴に永遠に残る。.env + .gitignore、本番は secrets。

事例5: DB の ports: 3306:3306 を本番でも公開

開発用に書いた公開設定が本番にそのまま漏れて、インターネットから接続可能に。DB は内部ネットワークのみ。

対比表: 依存解決の選択肢

方式仕組みメリット注意点
depends_on: [db]起動順だけ保証書くのが楽ready 待ちにならない
depends_on: { db: { condition: service_healthy } }DB の healthcheck 成功まで待つ確実healthcheck の設計が必要
アプリ側リトライDB 接続を指数バックオフ本番でも有効アプリ実装コスト
wait-for-it.shshell で待機レガシー対応古い書き方、healthcheck で代替可

「実務OK」基準

  • depends_on を見た瞬間に「起動順だけ vs ready 待ち」を見抜ける
  • アプリ起動コードに接続リトライが入っている: インフラ任せにしない
  • マイグレーション戦略を1分で説明できる: アプリ内 vs 別サービス、idempotent な仕組み
  • 接続文字列を環境変数で扱う: 12 Factor App の原則

自己評価チェックリスト

知識レベル

  • 「起動」と「ready」の違いを言葉で語れる
  • condition: service_healthy の使い所を即答できる
  • マイグレーションの3つの戦略(アプリ内 / 別サービス / 手動)の長短を述べられる

実行レベル

  • Go API + MySQL の compose.yml を 1 から書けた
  • healthcheck で ready 待ちが効くことを実測した
  • migration を専用サービスで分離した

メタ認知

  • 自分のチームのマイグレーション運用に問題が無いか棚卸しした
  • 「接続リトライをアプリに入れる」設計理由を後輩に説明できるか確認した

さらに深掘るなら


次のレッスン

2-3_Dockerネットワーク.md: bridge / host / overlay の各ドライバ、ports vs expose、フロント / API / DB のネットワーク分離設計へ進む。