はじめに
既存プロジェクトのDockerfileとcompose.ymlをコピーして動かしてきました。動いているので困りませんが、どの行が何のためにあるのかは説明できませんでした。
今回は小さな題材を1つ用意して、どちらに何を書くのか、両方に書けるものはどちらが効くのかを上から順に確かめました。
題材はPHPとPostgreSQLの2コンテナ
PHPの組み込みサーバーとPostgreSQLを1つずつ立てるだけです。
FROM php:8.4-cli
RUN apt-get update \
&& apt-get install -y --no-install-recommends libpq-dev \
&& docker-php-ext-install pdo_pgsql \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /work
ENV APP_ENV=local
ENV ONLY_IN_DOCKERFILE=yes
EXPOSE 8080
CMD ["php", "-S", "0.0.0.0:8080", "-t", "public"]
services:
php:
build:
context: .
working_dir: /work
volumes:
- .:/work
ports:
- "8080:8080"
environment:
APP_ENV: docker
depends_on:
db:
condition: service_healthy
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: handson
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d handson"]
interval: 2s
timeout: 3s
retries: 30
volumes:
pgdata:
配信する中身は、環境変数とDBのバージョンを出すだけにしました。
<?php
header('Content-Type: text/plain; charset=utf-8');
echo 'APP_ENV=', getenv('APP_ENV'), PHP_EOL;
$pdo = new PDO('pgsql:host=db;dbname=handson', 'postgres', 'postgres');
echo $pdo->query('select version()')->fetchColumn(), PHP_EOL;
まず動くところまで確かめます。docker compose up -d --buildのあとの結果です。
$ curl -s http://localhost:8080/
APP_ENV=docker
PostgreSQL 16.15 (Debian 16.15-1.pgdg13+2) on aarch64-unknown-linux-gnu, compiled by gcc (Debian 14.2.0-19) 14.2.0, 64-bit
境界線はビルド時と起動時
どちらもコンテナの設定に見えるので、起動のたびに両方が読み直されると認識していました。Dockerfileが読まれるのはビルドのときで、compose.ymlは起動のたびに読まれます。
| Dockerfile | compose.yml | |
|---|---|---|
| 読まれるとき | ビルドのとき |
docker compose upのたび |
| 結果 | イメージに残る | 残らない |
| 決めるもの | イメージの中身 | 起動のしかた |
そもそもDockerfileが作るのはイメージで、コンテナではありません。build:は「このディレクトリのDockerfileからイメージを作れ」という指示で、ここがcompose.ymlとDockerfileの接点です。
compose.yml
services:
php:
build:
context: . ──▶ ./Dockerfile ──▶ イメージ ──▶ php コンテナ
db:
image: postgres:16 ─────────────────▶ イメージ ──▶ db コンテナ
イメージまでの行き方が2通りあります。build:はDockerfileから自分で作り、image:は既製のものを取ってくる。どちらもその先はコンテナになります。題材が2コンテナなのはservicesの下に名前が2つあるからで、Dockerfileを通っているのはphpだけでした。
では、そのビルドはいつ走るのか。docker compose upはイメージが無いときだけビルドします。--buildを付けると、あっても作り直しにいきます。
$ docker compose up -d # イメージがある状態、--build なし
Network dockerfile-compose-lab_default Creating
Network dockerfile-compose-lab_default Created
Container dockerfile-compose-lab-db-1 Creating
Container dockerfile-compose-lab-db-1 Created
$ docker compose up -d --build # Dockerfile は変えていない
Image dockerfile-compose-lab-php Building
#6 [2/3] RUN apt-get update && apt-get install -y --no-install-recommends libpq-dev && docker-php-ext-install pdo_pgsql && rm -rf /var/lib/apt/lists/*
#6 CACHED
#7 [3/3] WORKDIR /work
#7 CACHED
上はCreatingしか出ず、ビルドに入っていません。下は--buildがあるので毎回入りますが、Dockerfileが変わっていないので全部CACHEDで、apt-getは実行されていません。手順を最初から辿り直すのは、Dockerfileのその行から下を書き換えたときだけでした。
$ docker compose ps --format 'table {{.Service}} {{.Image}} {{.State}}'
SERVICE IMAGE STATE
db postgres:16 running
php dockerfile-compose-lab-php running
--formatは出す列を選ぶオプションです。IMAGEの列が、そのコンテナがどのイメージから起動したかを指しています。イメージ自体は動きません。buildで作るかpullで取ってくるかした結果が保存されているもので、それを起動したのがコンテナです。
この線を引いて上から読み直します。
Dockerfileに書いたことはイメージに焼かれる
題材のDockerfileに出てくるのはこの6つです。
| キー | 何をする |
|---|---|
FROM |
土台にするイメージを決める |
RUN |
buildのときだけ走り、結果がイメージに残る |
WORKDIR |
無ければ作って移動する。以降の行とCMDの基準になる |
ENV |
環境変数をイメージに焼く |
EXPOSE |
待ち受けるポートをイメージに書き残す。公開はしない |
CMD |
イメージに焼かれるが、走るのは起動のたび |
土台にしたイメージにWebサーバーごと入っていると考えていましたが、入っていないのでCMDで自分で起動しています。
CMD ["php", "-S", "0.0.0.0:8080", "-t", "public"]
0.0.0.0にしているのは、127.0.0.1だとコンテナの中からしか届かないからです。
EXPOSEは、書けばブラウザから8080番で見えると勘違いしていました。実際にやるのは「このイメージは8080番で待ち受けます」とイメージに書き残すことだけです。ホストから届くようにしているのはcompose.ymlのportsのほうで、公式がどう書いているかは後で引きます。
ENVのAPP_ENVはcompose.ymlのenvironmentにも同じキーがあり、ONLY_IN_DOCKERFILEはこちらにしかありません。わざと片方にだけ置いています。
(補足)RUNを&&で1行に繋いでいる理由
RUNひとつが1レイヤです。updateとinstallを分けると古い一覧のままinstallが走り、rm -rfを分けると消す前のレイヤが残ります。
compose.ymlに書いたことは実行のたびに効く
compose.ymlのほうは、題材に出てくるのがこの8つです。
| キー | 何をする | 対応するDockerfile側 |
|---|---|---|
services |
コンテナを並べる。名前がそのままホスト名になる | なし |
build |
context:のディレクトリにあるDockerfileをビルドする |
なし |
image |
ビルドせず、既製のイメージを取ってくる | なし |
working_dir |
コンテナの作業ディレクトリ | WORKDIR |
volumes |
ホストやDockerの領域をコンテナに重ねる | なし |
ports |
ホストからコンテナのポートに届くようにする | EXPOSE |
environment |
環境変数を渡す | ENV |
depends_on / healthcheck
|
起動順と、条件つきの待ち合わせ | なし |
index.phpがpgsql:host=dbで繋がるのは、composeが同じネットワークにコンテナを並べてサービス名で名前解決するからです。
ports:
- "8080:8080"
左の8080がホスト側、右がコンテナ側です。EXPOSE 8080と数字は同じですが、ホストから届くようにしているのはこちらでした。
DockerfileにはAPP_ENV=localとあるのに、最初のcurlが返したのはenvironmentに書いたdockerのほうです。
volumesは書き方で寿命が変わります。
volumes:
- .:/work
volumes:
- pgdata:/var/lib/postgresql/data
| 書き方 | 実体 |
down -vのあと |
|---|---|---|
.:/work(bind mount) |
ホストのディレクトリ | 消えない |
pgdata(named volume) |
Dockerが管理する領域 | 消える |
DockerfileにCOPYがないので、public/index.phpがコンテナに入るのは.:/workのおかげでした。ホスト側で保存すれば、そのままリロードで反映されます。
depends_on: [db]だけだと、dbが起動した時点でphpが動き出します。Postgresは接続を受け付けるまでに数秒かかるので、その間にリクエストを投げるとConnection refusedで落ちました。condition: service_healthyを足すと、db側のhealthcheck(pg_isreadyを2秒おきに30回まで)が通るまで待ちます。
healthcheckをphp側に書いても効きません。conditionが見るのは待たれる側で、db側に無ければこの条件は使えません。
上から読み終えてみると、Dockerfileで見たものと同じ名前がいくつも出てきました。
両方に書けるものはcompose側が勝つ
出てきた同名のうち、値まで同じなのが/workでした。DockerfileのWORKDIRとcompose.ymlのworking_dirです。同じなので素通りしていましたが、2箇所に書けるならどちらが効くかは決まっているはずです。compose側に違う値を入れます。
| Dockerfile | compose.yml | どっちが効くか |
|---|---|---|
WORKDIR |
working_dir: |
compose |
ENV |
environment: |
compose(同じキーだけ上書き、残りは残る) |
CMD |
command: |
compose |
ENTRYPOINT |
entrypoint: |
compose(このときCMDは消える) |
EXPOSE |
ports: |
役割が違う。EXPOSEは公開しない |
Compose file referenceにはworking_dirもcommandもイメージ側の値を "overrides" するとあります。この2つは仕様です。ENVだけは事情が違います。
WORKDIRとENVはcompose側の値になった
working_dir: /from-composeとAPP_ENV: dockerを与えた側だけ変わりました。
--- 上書きなし ---
APP_ENV=local WD=/work
--- 上書きあり ---
APP_ENV=docker WD=/from-compose
イメージ側は書き換わっていません。ここからは題材のphpに戻って、イメージと、そこから起動したコンテナに同じ変数を聞いた結果です。
$ docker image inspect dockerfile-compose-lab-php --format '{{range .Config.Env}}{{println .}}{{end}}' | grep APP_ENV
APP_ENV=local
$ docker inspect $(docker compose ps -q php) --format '{{range .Config.Env}}{{println .}}{{end}}' | grep APP_ENV
APP_ENV=docker
composeがやっているのはイメージの書き換えではなく、コンテナを起動する瞬間に別の値を渡すことでした。DockerfileのAPP_ENV=localはイメージに焼かれたまま残っています。
environment:はENVを全部は消さなかった
DockerfileにはONLY_IN_DOCKERFILE=yesもあります。APP_ENVだけ上書きしてenvを叩きます(順不同)。
$ docker compose -f compose.override-demo.yml run --rm override env | grep -E "APP_ENV|ONLY_IN_DOCKERFILE"
ONLY_IN_DOCKERFILE=yes
APP_ENV=docker
丸ごと差し替わると思っていましたが、同じキーだけ上書きされ、残りは残りました。ただしCompose file referenceのenvironmentの項に、イメージ側のENVとの関係は書かれていません。仕様と言い切れないので、手元で確かめた挙動として書いておきます。
docker compose configにENVは出てこない
$ docker compose -f compose.override-demo.yml config override
name: verify
services:
override:
build:
context: /Users/yamoto/dev/personal/lab/dockerfile-compose-lab
dockerfile: Dockerfile
command:
- php
- -r
- echo 'APP_ENV=', getenv('APP_ENV'), ' WD=', getcwd(), PHP_EOL;
environment:
APP_ENV: docker
networks:
default: null
working_dir: /from-compose
networks:
default:
name: verify_default
ONLY_IN_DOCKERFILEが出てきません。ここに出ない変数を設定なしと判断したことがありますが、実際はコンテナの中にありました。configが見ているのはcompose.ymlだけで、イメージの中身は追っていないのだと理解しています。最終的な値はdocker inspectかコンテナ内のenvでしか見えません。
EXPOSEはポートを公開しない
題材とは別の検証用ファイルです。command:は同じで、差はports:だけ。
$ docker compose -f compose.expose-demo.yml ps --format 'table {{.Service}} {{.State}} {{.Ports}}'
SERVICE STATE PORTS
only-expose running 8080/tcp
with-ports running 0.0.0.0:18080->8080/tcp, [::]:18080->8080/tcp
--- inspectで見る ---
only-expose: ExposedPorts={"8080/tcp":{}} PortBindings={}
with-ports: ExposedPorts={"8080/tcp":{}} PortBindings={"8080/tcp":[{"HostIp":"","HostPort":"18080"}]}
ExposedPortsは両方同じで、違うのはPortBindingsです。公開しているのはports:の側です。Dockerfile referenceにもこうあります。
The EXPOSE instruction doesn't actually publish the port.
ではports:のないサービスには届かないのか。同じネットワークから叩きました(この検証用ファイルにenvironment:は無いのでlocalのままです)。
$ docker compose -f compose.expose-demo.yml exec -T with-ports php -r "echo file_get_contents('http://only-expose:8080/');"
APP_ENV=local
ホストに出ないだけで、compose networkの中では名前で引けます。題材のdbにports:が無くてもphpから繋がります。
(補足)entrypointを書くとCMDが消える
ENTRYPOINTがある別イメージです。
FROM alpine:3.20
ENTRYPOINT ["echo", "EP:"]
CMD ["cmd-from-dockerfile"]
--- そのまま ---
EP: cmd-from-dockerfile
--- commandだけ上書き ---
EP: cmd-from-compose
--- entrypointだけ上書き ---
EP-from-compose:
command:だけならENTRYPOINTは残り、entrypoint:だとCMDごと消えました。公式にも明記があります。
If entrypoint is non-null, Compose ignores any default command from the image, for example the CMD instruction in the Dockerfile.
https://docs.docker.com/reference/compose-file/services/#entrypoint
おわりに
コピーして動かしていたファイルを、行ごとに説明できるようになりました。両方に書けるものはcompose側が勝つ、という線が引けました。同じようにコピーして動かしていた方の参考になれば嬉しいです。誤っている箇所あれば指摘ください。