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_ondoes 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流れ:
dbが起動 → healthcheck が通るmigrateが起動 → マイグレーション実行 → exit 0apiが起動(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 0apiは migrate の 正常終了 を待ってから起動api自体も DB 接続リトライを持っている(インフラを信用しすぎない)- イメージは distroless + 非 root(USER 65532:65532)でセキュリティ強化
開発・CI・小規模本番でそのまま流用できる構成。
練習課題
mkdir -p ~/learn/infra/day09
cd ~/learn/infra/day09
script ~/log/infra_day09.log- 上記の構成を再現し、
docker compose up -d --buildで起動。docker compose logs migrateで migrate がexit 0になっているか確認 migrations/0002_add_status.up.sqlを追加(ALTER TABLE users ADD COLUMN status VARCHAR(20) DEFAULT 'active';)→docker compose up -dで再適用されるか確認- 意図的に壊す:
dbの healthcheck のmysqladmin pingのパスワードを間違えて、apiが起動しないことを確認 → 何分待っても上がらないログを見る - 接続リトライの効果を見る:
dbをdocker compose stop dbで止めて、apiのログがリトライしてるか確認 →docker compose start dbで復活するか 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.sh 系 | shell で待機 | レガシー対応 | 古い書き方、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 を専用サービスで分離した
メタ認知
- 自分のチームのマイグレーション運用に問題が無いか棚卸しした
- 「接続リトライをアプリに入れる」設計理由を後輩に説明できるか確認した
さらに深掘るなら
- docker docs: depends_on - 公式の正確な仕様
- 12 Factor App: Config - 環境変数で設定を渡す思想
- golang-migrate/migrate - 言語非依存のマイグレーションツール
- 書籍: 「データベース・リファクタリング」(Scott W. Ambler)- マイグレーション運用の古典
- 記事: Use Compose to develop with databases - 公式ガイド
次のレッスン
2-3_Dockerネットワーク.md: bridge / host / overlay の各ドライバ、ports vs expose、フロント / API / DB のネットワーク分離設計へ進む。