1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

42日でバックエンドエンジニアの基礎を完全に理解する #23 - docker-composeで複数サービスを動かす

1
Posted at

この記事で学ぶこと

前回、マルチステージビルドで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 の下の appdb が、さっき手で起動した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

dbports が無いことに注目してください。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

出力に WaitingHealthy が現れました。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_onappdb に依存するよう書いたのに、app がDB接続エラーで起動直後に落ちました。dbコンテナは正常に動いています。原因と、compose.yamlでの解決方法を説明してください。

Q2. composeで立てたアプリコンテナからDBへの接続は、DB_HOST=localhost では失敗し、DB_HOST=db では成功します。それぞれの理由を、名前が何を指しているかに触れながら説明してください。

Q3. docker compose downdocker 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 に、-eenvironment: に、-pports: にそのまま対応する
  • 起動順対策は 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 の直接覗きまで、障害調査の道具箱を作ります。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?