この記事で学ぶこと
前回、マルチステージビルドで7.93MBの実行イメージを作りました。ただ、ここまでアプリのコンテナはずっと単体で動かしてきました。実際のバックエンドはアプリとDBのセットです。そしてDBの方は、#4からずっと docker run の長いコマンドを手で打って立ててきました。
今日はこの「手で立てる」をやめます。アプリとDBの構成をファイル1枚に書き下ろし、docker compose up 一発で立ち上がるようにします。途中で「DBコンテナはUpなのにアプリが即死する」という、composeを使い始めた人がほぼ全員踏む問題をわざと踏んで、原因から解決します。
この記事を終えると以下ができるようになります。
- compose.yaml を読み書きし、コンテナ同士がサービス名で通信できる仕組みをDNSまで含めて説明できる
- 「depends_on を書いたのにアプリがDB接続で死ぬ」問題の原因を説明し、healthcheckで解決できる
- コンテナを消してもデータが残る仕組み(volume)と、
down -vで何が起きるかを説明できる
この回は言語に依存しません。題材はGoですが、composeの書き方も、起動順の問題も、名前解決の仕組みも、どの言語のアプリでも同じです。
見出しの 🔴🟡⚪ は学習の優先度です。🔴は確実に覚える、🟡は理屈を理解する、⚪は今は流してOK。迷ったら🔴だけ確実に押さえてください。
準備
前回の hello-container の続きです。前回HTTPSの検証用に足した /check は役目を終えたので外し、代わりにDBから読む /users を足します。#10 以来の database/sql + lib/pq の出番です。
package main
import (
"database/sql"
"fmt"
"log"
"net/http"
"os"
"github.com/google/uuid"
_ "github.com/lib/pq"
)
func main() {
dsn := fmt.Sprintf(
"host=%s port=5432 user=postgres password=postgres dbname=testdb sslmode=disable",
os.Getenv("DB_HOST"))
db, err := sql.Open("postgres", dsn)
if err != nil {
log.Fatal("接続設定に失敗:", err)
}
defer db.Close()
if err := db.Ping(); err != nil {
log.Fatal("DBに接続できない: ", err)
}
log.Println("DB接続OK")
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
host, _ := os.Hostname()
fmt.Fprintf(w, "hello from %s (request-id: %s)\n", host, uuid.NewString())
})
http.HandleFunc("/users", func(w http.ResponseWriter, r *http.Request) {
rows, err := db.Query("SELECT id, name FROM users ORDER BY id")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer rows.Close()
for rows.Next() {
var id int
var name string
if err := rows.Scan(&id, &name); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
fmt.Fprintf(w, "%d: %s\n", id, name)
}
})
log.Println("listening on :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
ポイントは2つです。
- 接続先ホストを環境変数
DB_HOSTから受け取る。同じイメージのまま、動かす場所に応じて接続先を差し替えるためです。今日はここが主役になります - 起動時に
db.Ping()して、繋がらなければlog.Fatalで即終了する。この潔い設計が後半で事件を起こします
Dockerfileは前回の完成形(builder + scratch + 非root)をそのまま使います。DBの初期データはSQLファイルで用意しておきます。
-- init.sql
CREATE TABLE users (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL
);
INSERT INTO users (name) VALUES ('alice'), ('bob');
環境はこれまでと同じ、WSL2 上の Linux と Docker 29(Compose v2.37)です。
🔴 composeの無い世界を先に見る
いきなり道具を使わず、まず docker run だけでアプリとDBを繋いでみます。道具のありがたみは、無い世界を知らないとわかりません。
素朴に考えると「ホストの5432には #4 から使っている pg-practice がいるから、localhostに繋げばいい」となりそうです。やってみます。
docker build -t hello:db .
docker run --rm -e DB_HOST=localhost hello:db
2026/09/01 19:46:53 DBに接続できない: dial tcp [::1]:5432: connect: connection refused
即失敗です。#20 を思い出してください。コンテナは network namespace で隔離されていて、コンテナの中の localhost は、ホストではなくコンテナ自身を指します。自分の中に5432で待つプロセスはいないので、接続拒否になります。composeの話に入る前の、コンテナネットワークの大原則です。
正攻法は、コンテナ同士を同じDockerネットワークに入れることです。
# コンテナ同士を繋ぐための専用ネットワークを作る
docker network create hello-net
# DBを起動する(初期化SQLをpostgresイメージの所定の場所にマウント)
docker run -d --name db --network hello-net \
-e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=testdb \
-v "$PWD/init.sql":/docker-entrypoint-initdb.d/init.sql \
postgres:16
# 数秒待ってから、アプリを起動する(接続先はコンテナ名の db)
docker run -d --name app --network hello-net \
-e DB_HOST=db -p 8080:8080 hello:db
curl localhost:8080/users
1: alice
2: bob
動きました。「数秒待ってから」とさらっと書きましたが、これが後で効いてきます。
さて、動きはしたものの、この構成を再現するにはネットワーク作成、DB起動(オプション5個)、アプリ起動(オプション4個)を正しい順番で打つ必要があります。私たちも #4 からずっと、pg-practice の起動コマンドをメモからコピペしてきました。サービスが3つ4つに増えると、この手順メモは秘伝のタレ化します。新しく入ったメンバーが環境を作れない、作れたけれど微妙に違う、が現実に起きます。
🔴 構成をファイルに書き下ろす
composeの発想は単純で、いま手で打った内容をレシピとして1枚のファイルに書いておくことです。プロジェクト直下に compose.yaml を作ります。
services:
app:
build: .
ports:
- "8080:8080"
environment:
DB_HOST: db
depends_on:
- db
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: testdb
volumes:
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
services の下の app と db が、さっき手で起動した2つのコンテナに対応します。手打ちコマンドとの対応はこうです。
| 手打ちでやっていたこと | compose.yaml では |
|---|---|
docker network create hello-net |
書かない。プロジェクト専用のネットワークが自動で作られる |
docker build -t hello:db . |
build: .(イメージが無ければupのときにビルドされる) |
--name db |
サービス名がそのまま名前になる |
-e POSTGRES_PASSWORD=... |
environment: |
-v "$PWD/init.sql":... |
volumes: |
-p 8080:8080 |
ports: |
| DB→アプリの順に手で起動 | depends_on |
db に ports が無いことに注目してください。DBと話す必要があるのはアプリだけで、アプリとは内部ネットワークで繋がっています。ホストに公開する理由がないので書きません(この判断の意味は後でまとめます)。副産物として、ホストの5432を使っている pg-practice とも衝突しません。
新顔は pgdata という名前付きvolumeです。これは後半で扱うので、いまは「DBのデータ置き場」とだけ思っておいてください。では、手打ちで作った app db hello-net を全部消してから、一発で立ち上げます。
docker compose up -d
Container hello-container-app-1 Created
Container hello-container-db-1 Starting
Container hello-container-db-1 Started
Container hello-container-app-1 Starting
Container hello-container-app-1 Started
【手を動かす①】dbはUpなのに、appが死んでいる
立ち上がったように見えます。状態を確認します。
docker compose ps -a --format '{{.Name}} {{.Service}} {{.Status}}'
hello-container-app-1 app Exited (1) 4 seconds ago
hello-container-db-1 db Up 4 seconds
dbは元気に動いているのに、appは終了コード1で死んでいます。ログを見ます。
docker compose logs app
app-1 | 2026/09/01 19:47:45 DBに接続できない: dial tcp 172.18.0.2:5432: connect: connection refused
接続拒否です。「depends_onを書いたのに順番がおかしいのでは」と思うところですが、さっきのupの出力を見返すと、dbが Started になった後にappが Starting しています。順番は約束どおりです。
問題は、「コンテナが起動した」と「中のプロセスが仕事できる」は別物だということです。postgresのコンテナは、プロセスが立ち上がってからデータディレクトリの初期化や設定の読み込みを済ませて、接続を受け付けるまでに数秒かかります。素の depends_on が保証するのは「コンテナの起動順」だけで、中身の準備完了は関知しません。開店に喩えるなら、シャッターは開いたけれど店員はまだ仕込み中の状態で、客(app)が入ってきてしまったわけです。
手作業のときに動いたのは、私が「数秒待ってから」アプリを起動したからです。人間がコマンドを打ち直すもたつきの間に、DBの準備が偶然終わっていた。composeはこの「人間の遅さ」という偶然の同期装置を取り除いて、2つをほぼ同時に立ち上げます。それで一気に露呈しました。この種の問題は「手元では動くのにCIだと時々落ちる」という一番嫌な形で現れることも多く、原因を知らないと相当時間を溶かします。
healthcheckで「仕事できるか」を待つ
解決策は、dbに「準備完了の確認方法」を教え、appの依存条件を「起動した」から「健康である」に格上げすることです。compose.yaml を2箇所直します。
app:
# ...(略)...
depends_on:
db:
condition: service_healthy
db:
# ...(略)...
healthcheck:
test: ["CMD", "pg_isready", "-U", "postgres", "-d", "testdb"]
interval: 2s
timeout: 3s
retries: 15
pg_isready はPostgreSQLに付属する、接続を受け付けられる状態かを確認するコマンドです。composeが2秒おきにこれをdbコンテナ内で実行し、成功するとdbは healthy 扱いになります。appは condition: service_healthy によって、それまで起動を待ちます。
作り直して確かめます。
docker compose down
docker compose up -d
Container hello-container-db-1 Waiting
Container hello-container-db-1 Healthy
Container hello-container-app-1 Starting
Container hello-container-app-1 Started
出力に Waiting と Healthy が現れました。composeがdbの準備完了を待ってからappを起動しています。
docker compose ps --format '{{.Name}} {{.Status}} {{.Ports}}'
curl localhost:8080/users
hello-container-app-1 Up 10 seconds 0.0.0.0:8080->8080/tcp, [::]:8080->8080/tcp
hello-container-db-1 Up 12 seconds (healthy) 5432/tcp
1: alice
2: bob
今度は一発で通りました。
🟡 本命はアプリ側のリトライ
これで解決、としたいところですが、実務ではもう一段深く考えます。今日のアプリは「起動時に一度だけPingして、ダメなら死ぬ」という設計です。DBが一時的にいなくなるのは起動時だけではありません。再起動、フェイルオーバー、ネットワークの瞬断。本番で長く動くアプリは、接続に失敗したら間隔を空けてリトライするのが本命の対策です。healthcheckによる起動順制御は「ローカル開発の立ち上げを快適にする補助」と位置づけてください。両方やるのが実務の答えです。リトライ処理自体は #17 のcontextを使えば「合計30秒までリトライ」のような形で書けます。今日は位置づけだけ押さえておけば十分です。
【手を動かす②】なぜ「db」という名前で繋がるのか
compose.yaml では接続先を DB_HOST: db と書きました。IPアドレスではなくサービス名です。なぜ名前で届くのか、実測で確かめます。dbコンテナの /etc/resolv.conf(名前解決の問い合わせ先の設定ファイル)を見ます。
docker compose exec db cat /etc/resolv.conf
nameserver 127.0.0.11
options ndots:0
(コメント行は省略しています。)nameserverが 127.0.0.11 という見慣れないアドレスになっています。これはDockerが各コンテナの中に用意している内蔵DNSサーバーで、同じネットワークにいるコンテナの名前を問い合わせるとIPアドレスを返します。実際に引いてみます。
docker compose exec db getent hosts db app
172.18.0.2 db
172.18.0.3 app
composeは起動時にプロジェクト専用のネットワーク(今回は hello-container_default)を作り、全サービスをそこに繋ぎます。サービス名がそのままDNS名になるので、アプリは「dbに繋ぐ」とだけ書けばよく、コンテナを作り直してIPが変わっても影響を受けません。#20 で見た network namespace による隔離に、内蔵DNSによる名前解決を足したもの。コンテナ間通信の正体はこれだけです。
これで冒頭の失敗も整理できます。
- コンテナの中の
localhostは自分自身。他のコンテナには届かない - サービス名(
db)は内蔵DNSが解決して、同じネットワークの相手に届く - ホスト側の5432(
pg-practice)は今日のネットワークとは別世界。衝突もしないが、名前でも届かない
🟡 データはどこに残るのか
残しておいた pgdata の話をします。#20 で学んだとおり、コンテナ内の書き込みはコンテナを消すと一緒に消えます。DBのデータが消えては困るので、postgresのデータディレクトリだけをコンテナの外にあるvolume(Dockerが管理する保存領域)に載せているのが、この行でした。
volumes:
- pgdata:/var/lib/postgresql/data
本当に残るのか実測します。行を1つ足してから、全部消して作り直します。
docker compose exec db psql -U postgres -d testdb \
-c "INSERT INTO users (name) VALUES ('carol');"
docker compose down
docker compose up -d
curl localhost:8080/users
1: alice
2: bob
3: carol
down はコンテナとネットワークを削除しますが、volumeは残します。だからcarolが生きています。もう1つ、静かに起きていることがあります。もし2回目のupで init.sql が再実行されていたら、aliceとbobが重複しているはずです。していません。postgresイメージの初期化スクリプトはデータディレクトリが空の初回だけ実行される仕組みだからです。「initスクリプトを直したのに反映されない」という定番の悩みの答えがこれで、反映したければvolumeごと消します。それが down -v です。
docker compose down -v
docker compose up -d
curl localhost:8080/users
Volume hello-container_pgdata Removing
Volume hello-container_pgdata Removed
(略)
1: alice
2: bob
carolが消え、初期化スクリプトが再実行されてaliceとbobだけに戻りました。「DBをまっさらにしてやり直す」ための定番操作です。裏を返すと、-v を付けた瞬間にデータは消えます。ローカル開発では便利なリセットボタン、それ以外の場所では事故です。打つ前にどこの環境にいるか確認する癖をつけてください。
実務での判断基準
🔴 composeの守備範囲はローカルまで、しかし価値はそれ以上
composeが受け持つのは、ローカル開発環境とCI、せいぜい小規模な検証環境までです。本番は複数サーバーへの分散や自動復旧が必要になるため、ECSやKubernetesといったオーケストレータの仕事になります(#35 で扱います)。ただし compose.yaml の価値は起動の自動化だけではありません。「このシステムは何のサービスで構成され、何に依存し、どんな設定で動くのか」がコードとして残ります。手順書のコピペで作られた環境と違い、git cloneして up すれば誰のマシンでも同じ構成が再現されます。構成がファイルに書いてあること自体が、チームにとってのドキュメントです。
🔴 ポートの公開は「必要になったときに、必要な分だけ」
今日の構成でホストに公開したのはappの8080だけです。dbはアプリとだけ話せればよいので公開しません。GUIのDBクライアントでホストから覗きたくなったら、そのとき初めて "5433:5432" のように公開します(ホストの5432は pg-practice が使用中なので、ホスト側の番号をずらします)。#22 で「実行イメージに置くものを最小に」と学びました。これはそのネットワーク版で、外から届く口が少ないほど攻撃面は小さくなります。
🟡 コードを直したのに反映されない
docker compose up -d は、ビルド済みイメージが既にあればそれを使い回します。コードを変更したら docker compose up -d --build で再ビルドが必要です。「直したはずのバグが直っていない」と感じたら、コードを疑う前にまずこれを疑ってください。#21 で学んだとおりイメージは不変で、コンテナを再起動しても中身は変わりません。
⚪ 今は流してよいもの
環境ごとに起動サービスを切り替える profiles、複数のcomposeファイルを重ねる -f、環境変数をまとめる .env ファイルなどは、必要になったときに調べれば追いつけます。古い資料の先頭にある version: キーは現在は不要です。また、ハイフン付きの docker-compose コマンドは旧世代(v1)のもので、現在は docker compose が標準です。
確認テスト
知識問題
Q1. depends_on で app が db に依存するよう書いたのに、app がDB接続エラーで起動直後に落ちました。dbコンテナは正常に動いています。原因と、compose.yamlでの解決方法を説明してください。
Q2. composeで立てたアプリコンテナからDBへの接続は、DB_HOST=localhost では失敗し、DB_HOST=db では成功します。それぞれの理由を、名前が何を指しているかに触れながら説明してください。
Q3. docker compose down と docker compose down -v の違いを、DBのデータの行方と初期化スクリプト(/docker-entrypoint-initdb.d/ のSQL)が実行される条件に触れながら説明してください。
Q4. 今日のcompose.yamlの db サービスには ports がありませんが、app からは接続できます。なぜですか。また、ports を書くべきなのはどんなときですか。
実技問題
Q5. 次の手打ちコマンド一式を compose.yaml に翻訳してください。apiがcacheの準備完了を待ってから起動するよう、起動順の対策も入れること(redisの疎通確認には redis-cli ping が使えます)。
docker network create shop-net
docker run -d --name cache --network shop-net redis:7
docker run -d --name api --network shop-net \
-e REDIS_HOST=cache -p 3000:3000 shop-api:latest
確認テスト 解答・解説
Q1
原因は、素の depends_on が保証するのが「コンテナの起動順」だけだからです。dbコンテナが起動しても、中のPostgreSQLが接続を受け付けるまでには数秒かかります。composeはdbコンテナの起動直後にappを立ち上げるので、appの接続がその準備時間に突っ込んで接続拒否になります。「コンテナが起動した」と「中のプロセスが仕事できる」は別物、が今日の核心です。
解決は、dbに healthcheck(例: pg_isready)を定義し、appの依存を condition: service_healthy にすることです。これでcomposeは「dbが健康になるまで」appの起動を待ちます。加えて実務では、起動時の1回きりの接続で諦めず、アプリ自身がリトライする設計にしておくのが本命の対策です。
Q2
localhost は「この通信スタック上の自分自身」を指します。コンテナは network namespace で隔離されているため、アプリコンテナの中のlocalhostはアプリコンテナ自身で、そこに5432で待つプロセスはいません。だから接続拒否になります。
db はcomposeのサービス名です。composeは全サービスをプロジェクト専用ネットワークに繋ぎ、各コンテナ内の /etc/resolv.conf はDockerの内蔵DNS(127.0.0.11)を向いています。この内蔵DNSがサービス名を同じネットワーク上のdbコンテナのIPに解決するため、名前だけで届きます。IPを直書きしないので、コンテナを作り直してIPが変わっても壊れません。
Q3
down はコンテナとネットワークを削除しますが、名前付きvolumeは残します。DBのデータディレクトリはvolume上にあるので、次の up でデータはそのまま戻ります。このときデータディレクトリは空ではないため、初期化スクリプトは実行されません。
down -v はvolumeも一緒に削除します。データは失われ、次の up ではデータディレクトリが空なので初期化スクリプトが再実行されます。つまり初期化スクリプトが動くのは「データディレクトリが空の初回だけ」です。initスクリプトの変更を反映したいときは down -v、データを守りたいときは -v を付けない。向きを間違えると、片や「反映されない」、片や「データが消えた」という逆方向の事故になります。
Q4
appとdbは同じ内部ネットワーク上にいて、コンテナ間の通信はそのネットワーク内で完結するからです。ports は「ホストの外の世界にコンテナの口を公開する」ための設定であり、コンテナ同士の通信には必要ありません。
書くべきなのは、ホスト側からその口に届く必要があるときだけです。今日ならブラウザやcurlで叩くappの8080がそれにあたります。dbに書くとしたら、ホストのGUIクライアントで中を覗きたい場合などで、その場合も公開は必要な間だけに留めます。公開する口が少ないほど攻撃面が小さくなる、という #22 と同じ発想です。
Q5
services:
api:
image: shop-api:latest
ports:
- "3000:3000"
environment:
REDIS_HOST: cache
depends_on:
cache:
condition: service_healthy
cache:
image: redis:7
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 2s
timeout: 3s
retries: 15
ポイント
- ネットワークの定義は不要。composeが専用ネットワークを自動で作り、
apiからはcacheという名前で届く -
--name cacheはサービス名cacheに、-eはenvironment:に、-pはports:にそのまま対応する - 起動順対策は
depends_on: - cacheだけでは不十分。redisにredis-cli pingのhealthcheckを持たせ、condition: service_healthyで「準備完了」を待つ
まとめ
- composeは「手で打っていたdocker runの構成」をレシピとして1枚のファイルに書き下ろす道具。
up一発で同じ構成が誰のマシンでも再現でき、compose.yaml自体が構成のドキュメントになる - 素の
depends_onが保証するのはコンテナの起動順だけ。「起動した」と「仕事できる」は別物で、healthcheck+condition: service_healthyで準備完了を待つ。実務ではアプリ側のリトライが本命 - サービス名で繋がるのは、専用ネットワーク + 内蔵DNS(127.0.0.11)のおかげ。コンテナ内のlocalhostは自分自身を指すので、他コンテナには届かない
- DBのデータはvolumeに載せる。
downでは残り、down -vで消える。初期化スクリプトが動くのはデータディレクトリが空の初回だけ - ホストへのポート公開は必要な口だけ。dbに
portsは書かない
次回は #24 コンテナの調査テクニック です。#22 で作ったscratchイメージにはシェルが無く、docker exec で中に入れませんでした。では動いているコンテナの中で何が起きているか、どう調べるのか。#20 の「コンテナはホストから見えるただのプロセス」を武器に、logs・inspect・stats から /proc の直接覗きまで、障害調査の道具箱を作ります。