RUN --mount=type=bind の動きを調べて COPY のオーバーヘッドを無くす
はじめに
この記事は、株式会社エス・エム・エス Advent Calendar 2024 シリーズ2の12/6の記事です。
Docker において、ビルド時にファイルマウントを行うことができる RUN --mount=type=bind
を使ってみたところ
-
docker run --mount type=bind
と混同して理解に詰まったり - 後続のステップで参照できないため、実際に使うには工夫が必要だったり
といったことがありました。
このエントリでは細かいな動き等を試して理解を進めながら、実際に Dockerfile でどう利用していくか等を考えてみようと思います。
docker container run --mount type=bind
おさらい: 本題の RUN --mount=type=bind
を見ていく前に、よく知られた docker run の mount bind をおさらいしておきます。
docker run するときに --mount type=bind
オプションを指定することで、ホストマシンのファイルやディレクトリをコンテナ内にマウントすることができます。
マウントしていると、コンテナの中からホストマシンからバインドしたファイルを参照でき、ホストマシン側の変更がコンテナ側にも反映されます:
$ echo 'dummy' >> ./dummy.txt
$ docker container run --mount type=bind,source=./,target=/run-mount ubuntu:latest bash -c 'cat /run-mount/dummy.txt'
dummy
また、コンテナの中でファイルを編集した場合に変更がホストマシン側にも反映されます:
$ docker container run -it --mount type=bind,source=./,target=/run-mount ubuntu:latest bash -c 'echo "dummy from container" >> /run-mount/dummy.txt'
$ cat ./dummy.txt
dummy
dummy from container
(ビルド時ではなく)起動してから必要な設定ファイルを流し込んだり、on Docker で開発したいときにコンテナ内のファイルとホストマシン側のファイルを同期したり等幅広く利用されています。
RUN --mount-type=bind
: ビルド時にコンテキストとバインドする
さて、本題の RUN --mount-type=bind
について見ていきます。
まずは公式ドキュメントの説明を見てみます。
Dockerfile reference | Docker Docs
(--mount の説明)
RUN --mount allows you to create filesystem mounts that the build can access. This can be used to:(type=bind の説明)
This mount type allows binding files or directories to the build container. A bind mount is read-only by default.(from オプションの説明)
Build stage, context, or image name for the root of the source. Defaults to the build context.
つまり、--mount=type=bind, ...
を指定すること
- ビルド時にアクセスできる read-only なマウントを作成できる
- マウント元は特に from を明示しなければビルドコンテキスト(ホストマシンではない)になる
- 指定をすれば任意のステージ・任意のイメージからマウントすることもできる
と読めます。
したがって
WORKDIR /app
COPY . .
RUN pnpm build
のようにソースコードをコピーしてきてビルドするような処理を
WORKDIR /app
RUN \
pnpm build
のようにコピーなしでソースコードはマウントしてビルド処理を書いたりと言った使い方ができます。
実際に動かしながら挙動を確かめる
実際に触りながら動きを確かめていきます。
RUN 内でマウントを参照できるが、後のステップでは揮発する
FROM ubuntu:latest
WORKDIR /bind-mount
RUN \
find . -type f
RUN find . -type f
以上の Dockerfile を用意してビルドしてみます。
$ touch sample1.txt sample2.txt sample3.txt
$ docker build ./ -f ./Dockerfile -t debug-bind-mount --no-cache --progress plain
#6 [stage-0 2/5] WORKDIR /bind-mount
#6 DONE 0.0s
#7 [stage-0 3/5] RUN --mount=type=bind,source=.,target=/bind-mount find . -type f
#7 0.075 ./Dockerfile
#7 0.075 ./sample2.txt
#7 0.075 ./sample1.txt
#7 0.075 ./sample3.txt
#7 DONE 0.1s
#8 [stage-0 4/5] RUN find . -type f
#8 DONE 0.1s
RUN 時のマウントはステップの間だけ有効で、ステップを抜けた時点で参照できなくなります。
マウント対象のパスに存在したファイルに取り扱いが気になるので、sample4.txt
を事前に作ってからマウントしてみます。
FROM ubuntu:latest
WORKDIR /bind-mount
RUN touch sample4.txt
RUN \
find . -type f
RUN find . -type f
$ docker build ./ -f ./Dockerfile -t debug-bind-mount --no-cache --progress plain
#7 [stage-0 3/6] RUN touch sample4.txt
#7 DONE 0.1s
#8 [stage-0 4/6] RUN --mount=type=bind,source=.,target=/bind-mount find . -type f
#8 0.094 ./Dockerfile
#8 0.094 ./sample2.txt
#8 0.094 ./sample1.txt
#8 0.094 ./sample3.txt
#8 DONE 0.1s
#9 [stage-0 5/6] RUN find . -type f
#9 0.079 ./sample4.txt
#9 DONE 0.1s
既存のファイルがあるディレクトリに bind すると既存のファイルは見えなくなり、Step を抜けると復活しているように見えます。
.dockerignore の影響を受ける
docker run
時の bind とは異なり、RUN --mount=type=bind
ではビルド時にビルドコンテキストに対してマウントを行います。
すなわち、.dockerignore
によってビルドコンテキストに渡されていないファイルは参照できないことになるはず。一応確認してみます。
sample1.txt
.dockerignore
FROM ubuntu:latest
# debug build-context
WORKDIR /build-context
COPY . .
RUN find . -type f
# debug bind-mount
WORKDIR /bind-mount
RUN \
find . -type f
$ docker build ./ -f ./Dockerfile -t debug-bind-mount --no-cache --progress plain
#5 [stage-0 2/7] WORKDIR /build-context
#5 CACHED
#7 [stage-0 3/7] COPY . .
#7 DONE 0.0s
#8 [stage-0 4/7] RUN find . -type f
#8 0.081 ./Dockerfile
#8 0.081 ./sample2.txt
#8 0.081 ./sample3.txt
#8 DONE 0.1s
#9 [stage-0 5/7] WORKDIR /bind-mount
#9 DONE 0.0s
#10 [stage-0 6/7] RUN --mount=type=bind,source=.,target=/bind-mount find . -type f
#10 0.081 ./Dockerfile
#10 0.081 ./sample2.txt
#10 0.081 ./sample3.txt
#10 DONE 0.1s
期待どおり sample1.txt を除いた sample2.txt と sample3.txt のみ表示されるようになりました。
readwrite オプション で吐き出した成果物の扱い
冒頭でも触れたように RUN --mount=type=bind
では read-only なマウントを作成するので、書き込みができません。
FROM ubuntu:latest
WORKDIR /bind-mount
RUN \
touch sample4.txt
$ docker build ./ -f ./Dockerfile -t debug-bind-mount --no-cache --progress plain
#7 [stage-0 3/4] RUN --mount=type=bind,source=.,target=/bind-mount touch sample4.txt
#7 0.070 touch: cannot touch 'sample4.txt': Read-only file system
#7 ERROR: process "/bin/sh -c touch sample4.txt" did not complete successfully: exit code: 1
以上のようにエラーが発生します。
--mount=type=bind
には readwrite オプションが存在するのでこちらを利用してみます。
ドキュメントには
Allow writes on the mount. Written data will be discarded.
との記載があるので、一旦書き込み自体はできるようにするが書き込んだ変更内容は消えるようです。
FROM ubuntu:latest
# before build-context
WORKDIR /build-context/before
COPY . .
RUN find . -type f
WORKDIR /bind-mount
RUN \
touch sample4.txt && \
find . -type f
# after build-context
WORKDIR /build-context/after
COPY . .
RUN find . -type f
$ docker build ./ -f ./Dockerfile -t debug-bind-mount --no-cache --progress plain
#6 [stage-0 2/10] WORKDIR /build-context/before
#6 DONE 0.0s
#8 [stage-0 4/10] RUN find . -type f
#8 0.079 ./Dockerfile
#8 0.079 ./sample2.txt
#8 0.079 ./sample3.txt
#8 DONE 0.1s
#9 [stage-0 5/10] WORKDIR /bind-mount
#9 DONE 0.0s
#10 [stage-0 6/10] RUN --mount=type=bind,source=.,target=/bind-mount,readwrite touch sample4.txt && find . -type f
#10 0.103 ./Dockerfile
#10 0.103 ./sample2.txt
#10 0.103 ./sample3.txt
#10 0.103 ./sample4.txt
#10 DONE 0.1s
#11 [stage-0 7/10] WORKDIR /build-context/after
#11 DONE 0.0s
#13 [stage-0 9/10] RUN find . -type f
#13 0.079 ./Dockerfile
#13 0.079 ./sample2.txt
#13 0.079 ./sample3.txt
#13 DONE 0.1s
$ find . -type f
./Dockerfile
./.dockerignore
./sample1.txt
./sample2.txt
./sample3.txt
マウントしながら readwrite オプションで sample4.txt を書き出してみましたが、実行後にビルドコンテキスト・ホストマシン側いずれにも変更は反映されていませんでした。
すなわち、マウント中にそのディレクトリ以下になにか書き出してもSTEPを抜けた時点でどこにも残りません。
したがって、ホストマシン側に変更が反映されてしまったり、後続の作業に副作用があったりはしないので、書き込み自体は気軽に行ってしまって良さそうです。
一方、後で利用したいファイルも残らないので
- マウントしていないディレクトリに退避する
- そもそも吐き出す先をマウント外のディレクトリにする
等の必要がありそうです。
マウントは重ねられる
マウントしたディレクトリのサブパスに別のディレクトリをマウントしてみます。
FROM ubuntu:latest as before
WORKDIR /tmp
RUN touch sample4.txt
FROM ubuntu:latest
WORKDIR /bind-mount
RUN \
find . -type f
ビルドコンテキスト以外の mount 元が欲しかったので、before ステージを使ってそちらのディレクトリを重ねてマウントしてみます。
$ docker build ./ -f ./Dockerfile -t debug-bind-mount --no-cache --progress plain
#9 [stage-1 3/3] RUN --mount=type=bind,source=.,target=/bind-mount,readwrite --mount=type=bind,from=before,source=/tmp,target=/bind-mount/tmp find . -type f
#9 0.077 ./Dockerfile
#9 0.077 ./sample2.txt
#9 0.077 ./sample3.txt
#9 0.077 ./tmp/sample4.txt
#9 DONE 0.1s
重ねてマウントもできることがわかりました。
活用: ソースコードを bind マウントして成果物を作る
概ね動きを把握できたので、実行したら console.log
するだけの適当な Node.js アプリを用意して、実際にソースコードを bind mount して build してみます。
➜ lsd --tree . -a --ignore-glob node_modules
.
├── .dockerignore
├── .gitignore
├── dist
│ └── index.js
├── Dockerfile
├── package.json
├── pnpm-lock.yaml
└── src
└── index.ts
console.log('this is index.ts')
{
"name": "docker-run-mount-bind",
"scripts": {
"build": "esbuild ./src/index.ts --format=esm --platform=node --bundle --outdir=dist"
},
"devDependencies": {
"esbuild": "^0.24.0"
}
}
単一ステージでビルドする
まずは、単一ステージでビルドして起動もするミニマムなパターンを作ってみます。
FROM node:22-bullseye
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
WORKDIR /builder
RUN \
pnpm i --frozen-lockfile && \
pnpm build && \
cp -r dist /app/
WORKDIR /app
CMD ["node", "./dist/index.js"]
$ docker build ./ -f ./Dockerfile -t mount-sample-app --progress plain
$ docker run mount-sample-app
this is index.ts
実行できました。
成果物(./builder/dist
) がステップを抜けたら参照できなくなってしまうので、マウントしていない /app ディレクトリにコピーする必要がある点に注意が必要です。
ソースコードや node_modules 等ビルドに利用したファイル群が残らないので、ステージを分けなくても割と実用的に見えます。
builder のステージを分けてみる
単一ステージだとソースコード等は残りませんでしたが諸々インストールしたツールが残りがちなのと、最終イメージのベースは軽量なものが望ましいのでステージを分けてみます。
FROM node:22-bullseye as builder
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
WORKDIR /builder
RUN \
pnpm i --frozen-lockfile && \
pnpm build && \
cp -r dist /tmp/
FROM node:22-bullseye-slim
WORKDIR /app
COPY /tmp /app
CMD ["node", "./dist/index.js"]
/builder
以外のパスに成果物を退避する必要があるのは変わらず、最終ステージで退避した dist をコピーしてくれば OK です。
レイヤーキャッシュが効きやすいように install を分割する
Docker のレイヤーキャッシュを効きやすくするために、ロックファイルのみ先にコピーしてインストールするプラクティスがあります。
COPY package.json pnpm-lock.yaml ./
RUN pnpm i --frozen-lockfile
COPY . .
RUN pnpm build
このようにインストールに必要なファイルだけ先に COPY して実行すると、コピーしたファイルに変更がなければレイヤーキャッシュが利用されるので、ソースコードに変更があってもインストールをスキップできます。
素直に bind で同じことをやろうとすると
WORKDIR /builder
RUN \
pnpm i --frozen-lockfile
RUN \
pnpm build && \
cp -r dist /tmp/
のようになりますが、RUN 後のステップではディレクトリが揮発する で確認したようにマウント先に元あったディレクトリ(node_modules)はマウントした時点で参照できなくなるので、ビルドができなくなります。素直には実現できません。
mount に ignore 的なことはできないので、回避するには node_modules を別のマウントから持ってきて重ねるしかなさそうです。
手軽なのはキャッシュマウントによる上書きです。
FROM node:22-bullseye as base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
FROM base as builder
WORKDIR /builder
RUN \
pnpm i --frozen-lockfile
RUN \
pnpm build && \
cp -r dist /tmp/
FROM node:22-bullseye-slim
WORKDIR /app
COPY /tmp /app
CMD ["node", "./dist/index.js"]
これでレイヤーキャッシュが効きやすい形にインストールを分割できました。
node_modules を bind cache しているのが気持ち悪ければ install のステージを分割して、install するステージから bind マウントで持ってくることもできそうです。
FROM node:22-bullseye as base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
FROM base as installer
WORKDIR /installer
RUN \
pnpm i --frozen-lockfile && \
cp -r /installer/node_modules /tmp/
FROM base as builder
WORKDIR /builder
RUN \
pnpm build && \
cp -r dist /tmp/
FROM node:22-bullseye-slim
WORKDIR /app
COPY /tmp /app
CMD ["node", "./dist/index.js"]
COPY と違って少し工夫が必要ですが、一応基本的なパターンを bind マウントを使って書き直すことができそうです。
COPY に対する利点は?
ここまで試して、COPY に対して RUN --mount=type=bind
を使ってソースコードを参照すると
- COPY と違って「ステップを抜けると書き出したファイルも消えてしまう」、「別のステップで生成されたファイルが存在する前提で処理を行うことはできない」という制約がある
- とはいえ、それぞれマウント外に待避したり、cache や別ステージの bind マウントを重ねたりするといった工夫で回避はできる
- 場合によっては COPY より複雑化しがち
となることがわかりました。
STEPを抜けるとファイルが残らないのでクリーンアップが不要な一方、ビルド処理に失敗した際にデバッグがしづらくなると言ったつらさもあります。
一方、bind マウントを使うメリットはなにがあるでしょうか。
大きめのファイルを COPY vs bind マウントでそれぞれ参照してみます。
$ mkdir big_files
$ dd if=/dev/zero of=big_files/big.txt bs=10M count=1000 # 10GB のファイルを用意する
FROM ubuntu:latest
WORKDIR /app
COPY ./big_files /big_files
RUN ls -al
FROM ubuntu:latest
WORKDIR /app
COPY ./big_files /big_files
RUN \
ls -al
それぞれ copy, bind の Dockerfile を用意してビルドする
$ docker build ./ -f ./Dockerfile.copy -t test-copy --no-cache --progress plain
#6 [internal] load build context
#6 transferring context: 1.26GB 5.1s
#6 transferring context: 2.24GB 10.2s
#6 transferring context: 3.35GB 15.2s
#6 transferring context: 4.55GB 20.4s
#6 transferring context: 5.95GB 25.4s
#6 transferring context: 7.26GB 30.5s
#6 transferring context: 8.25GB 35.5s
#6 transferring context: 9.17GB 40.6s
#6 transferring context: 10.31GB 45.6s
#6 transferring context: 10.49GB 46.4s done
#6 DONE 47.0s
#7 [3/4] COPY ./big_files /big_files
#7 DONE 11.0s
$ touch ./big_files/additional.txt
$ docker build ./ -f ./Dockerfile.copy -t test-copy --progress plain
#7 [3/4] COPY ./big_files /big_files
#7 DONE 10.7s
$ docker build ./ -f ./Dockerfile.bind -t test-bind --no-cache --progress plain
#7 [stage-0 3/3] RUN --mount=type=bind,source=./big_files,target=/app/big_files ls -al
#7 0.085 total 12
#7 0.085 drwxr-xr-x 1 root root 4096 Dec 1 05:24 .
#7 0.085 drwxr-xr-x 1 root root 4096 Dec 1 05:24 ..
#7 0.085 drwxr-xr-x 2 root root 4096 Dec 1 05:21 big_files
#7 DONE 0.1s
結果は
- COPY するファイルのサイズが大きくなると、COPY ステップの実行時間は大きくなる
- COPY 対象に変更がなければレイヤーキャッシュが使えるが、1 ファイルでも変更があればコピーを再実行されて、キャッシュなしのときと同程度の時間がかかる
- mount の場合は RUN ステップの実行時間の増加は認められなかった
でした。
レイヤーの情報も見てみます。
$ docker history test-copy
IMAGE CREATED CREATED BY SIZE COMMENT
eed4b2e39ed1 46 seconds ago RUN /bin/sh -c ls -al # buildkit 0B buildkit.dockerfile.v0
<missing> 47 seconds ago COPY ./big_files /big_files # buildkit 10.5GB buildkit.dockerfile.v0
<missing> 37 minutes ago WORKDIR /app 0B buildkit.dockerfile.v0
<missing> 6 weeks ago /bin/sh -c #(nop) CMD ["/bin/bash"] 0B
<missing> 6 weeks ago /bin/sh -c #(nop) ADD file:f45100f0b1cac298f… 101MB
<missing> 6 weeks ago /bin/sh -c #(nop) LABEL org.opencontainers.… 0B
<missing> 6 weeks ago /bin/sh -c #(nop) LABEL org.opencontainers.… 0B
<missing> 6 weeks ago /bin/sh -c #(nop) ARG LAUNCHPAD_BUILD_ARCH 0B
<missing> 6 weeks ago /bin/sh -c #(nop) ARG RELEASE 0B
$ docker history test-bind
IMAGE CREATED CREATED BY SIZE COMMENT
9d82df1eff23 45 seconds ago RUN /bin/sh -c ls -al # buildkit 0B buildkit.dockerfile.v0
<missing> 38 minutes ago WORKDIR /app 0B buildkit.dockerfile.v0
<missing> 6 weeks ago /bin/sh -c #(nop) CMD ["/bin/bash"] 0B
<missing> 6 weeks ago /bin/sh -c #(nop) ADD file:f45100f0b1cac298f… 101MB
<missing> 6 weeks ago /bin/sh -c #(nop) LABEL org.opencontainers.… 0B
<missing> 6 weeks ago /bin/sh -c #(nop) LABEL org.opencontainers.… 0B
<missing> 6 weeks ago /bin/sh -c #(nop) ARG LAUNCHPAD_BUILD_ARCH 0B
<missing> 6 weeks ago /bin/sh -c #(nop) ARG RELEASE 0B
COPY をする場合はレイヤーに実行結果が乗るので、COPY ステップに 10GB のレイヤーが残りました。一方、mount した側は RUN するときに 10GB のファイルを利用はしましたが STEP 結果には乗らないのでレイヤーにこれらのファイルは残りません。
特にビルドに必要なソースコードが大きい場合には
- build 後に残るレイヤーのストレージが節約できる
- COPY ステップの実行時間が減少する
と言った利点がありそうです。
まとめ
- ランタイムでソースコードが不要なパターンで、COPYの代替として利用できる
RUN --mount=type=bind
の動きを確認しました - COPY ではなくビルド時バインドマウントを利用することで、特に大きなソースコードではレイヤーに不要なファイルが残らないこと・ファイル転送が不要になることで速度的な利点があります
- キャッシュの最適化等の理由であえて RUN を分割したいケースや、マウントしているディレクトリにビルドファイルを吐き出す場合は素直には実現できず、マウントを重ねたり、ビルドファイルを別ディレクトリに退避したりといった工夫が必要になります
Discussion