😷

RUN --mount=type=bind の動きを調べて COPY のオーバーヘッドを無くす

2024/12/06に公開

はじめに

この記事は、株式会社エス・エム・エス Advent Calendar 2024 シリーズ2の12/6の記事です。

https://qiita.com/advent-calendar/2024/bm-sms

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 を明示しなければビルドコンテキスト(ホストマシンではない)になる
    • 指定をすれば任意のステージ・任意のイメージからマウントすることもできる

と読めます。

したがって

Dockerfile
WORKDIR /app
COPY . .
RUN pnpm build

のようにソースコードをコピーしてきてビルドするような処理を

Dockerfile
WORKDIR /app
RUN --mount=type=bind,source=.,target=/app \
  pnpm build

のようにコピーなしでソースコードはマウントしてビルド処理を書いたりと言った使い方ができます。

実際に動かしながら挙動を確かめる

実際に触りながら動きを確かめていきます。

RUN 内でマウントを参照できるが、後のステップでは揮発する

Dockerfile
FROM ubuntu:latest

WORKDIR /bind-mount
RUN --mount=type=bind,source=.,target=/bind-mount \
  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 を事前に作ってからマウントしてみます。

Dockerfile
FROM ubuntu:latest

WORKDIR /bind-mount
RUN touch sample4.txt

RUN --mount=type=bind,source=.,target=/bind-mount \
  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 によってビルドコンテキストに渡されていないファイルは参照できないことになるはず。一応確認してみます。

.dockerignore
sample1.txt
.dockerignore
Dockerfile
FROM ubuntu:latest

# debug build-context
WORKDIR /build-context
COPY . .
RUN find . -type f

# debug bind-mount
WORKDIR /bind-mount
RUN --mount=type=bind,source=.,target=/bind-mount \
  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 なマウントを作成するので、書き込みができません。

Dockerfile
FROM ubuntu:latest

WORKDIR /bind-mount
RUN --mount=type=bind,source=.,target=/bind-mount \
  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.

との記載があるので、一旦書き込み自体はできるようにするが書き込んだ変更内容は消えるようです。

Dockerfile
FROM ubuntu:latest

# before build-context
WORKDIR /build-context/before
COPY . .
RUN find . -type f

WORKDIR /bind-mount
RUN --mount=type=bind,source=.,target=/bind-mount,readwrite \
  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を抜けた時点でどこにも残りません。

したがって、ホストマシン側に変更が反映されてしまったり、後続の作業に副作用があったりはしないので、書き込み自体は気軽に行ってしまって良さそうです。

一方、後で利用したいファイルも残らないので

  • マウントしていないディレクトリに退避する
  • そもそも吐き出す先をマウント外のディレクトリにする

等の必要がありそうです。

マウントは重ねられる

マウントしたディレクトリのサブパスに別のディレクトリをマウントしてみます。

Dockerfile
FROM ubuntu:latest as before

WORKDIR /tmp
RUN touch sample4.txt

FROM ubuntu:latest

WORKDIR /bind-mount
RUN --mount=type=bind,source=.,target=/bind-mount,readwrite \
    --mount=type=bind,from=before,source=/tmp,target=/bind-mount/tmp \
  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
/src/index.ts
console.log('this is index.ts')
/package.json
{
  "name": "docker-run-mount-bind",
  "scripts": {
    "build": "esbuild ./src/index.ts --format=esm --platform=node --bundle --outdir=dist"
  },
  "devDependencies": {
    "esbuild": "^0.24.0"
  }
}

単一ステージでビルドする

まずは、単一ステージでビルドして起動もするミニマムなパターンを作ってみます。

Dockerfile
FROM node:22-bullseye

ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable

WORKDIR /builder

RUN --mount=type=bind,source=.,target=/builder,readwrite \
    --mount=type=cache,id=pnpm,target=/pnpm/store \
    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 のステージを分けてみる

単一ステージだとソースコード等は残りませんでしたが諸々インストールしたツールが残りがちなのと、最終イメージのベースは軽量なものが望ましいのでステージを分けてみます。

Dockerfile
FROM node:22-bullseye as builder

ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable

WORKDIR /builder
RUN --mount=type=bind,source=.,target=/builder,rw=true \
    --mount=type=cache,id=pnpm,target=/pnpm/store \
    pnpm i --frozen-lockfile && \
    pnpm build && \
    cp -r dist /tmp/

FROM node:22-bullseye-slim

WORKDIR /app
COPY --from=builder /tmp /app
CMD ["node", "./dist/index.js"]

/builder 以外のパスに成果物を退避する必要があるのは変わらず、最終ステージで退避した dist をコピーしてくれば OK です。

レイヤーキャッシュが効きやすいように install を分割する

Docker のレイヤーキャッシュを効きやすくするために、ロックファイルのみ先にコピーしてインストールするプラクティスがあります。

Dockerfile
COPY package.json pnpm-lock.yaml ./
RUN pnpm i --frozen-lockfile

COPY . .
RUN pnpm build

このようにインストールに必要なファイルだけ先に COPY して実行すると、コピーしたファイルに変更がなければレイヤーキャッシュが利用されるので、ソースコードに変更があってもインストールをスキップできます。

素直に bind で同じことをやろうとすると

Dockerfile
WORKDIR /builder
RUN --mount=type=bind,source=./package.json,target=/installer/package.json \
    --mount=type=bind,source=./pnpm-lock.yaml,target=/installer/pnpm-lock.yaml \
    --mount=type=cache,id=pnpm,target=/pnpm/store \
    pnpm i --frozen-lockfile

RUN --mount=type=bind,source=.,target=/builder,rw=true \
    pnpm build && \
    cp -r dist /tmp/

のようになりますが、RUN 後のステップではディレクトリが揮発する で確認したようにマウント先に元あったディレクトリ(node_modules)はマウントした時点で参照できなくなるので、ビルドができなくなります。素直には実現できません。

mount に ignore 的なことはできないので、回避するには node_modules を別のマウントから持ってきて重ねるしかなさそうです。

手軽なのはキャッシュマウントによる上書きです。

Dockerfile
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 --mount=type=bind,source=./package.json,target=/builder/package.json \
    --mount=type=bind,source=./pnpm-lock.yaml,target=/builder/pnpm-lock.yaml \
    --mount=type=cache,id=pnpm,target=/pnpm/store \
    --mount=type=cache,target=/builder/node_modules \
    pnpm i --frozen-lockfile

RUN --mount=type=bind,source=.,target=/builder,rw=true \
    --mount=type=cache,id=pnpm,target=/pnpm/store \
    --mount=type=cache,target=/builder/node_modules \
    pnpm build && \
    cp -r dist /tmp/

FROM node:22-bullseye-slim

WORKDIR /app
COPY --from=builder /tmp /app
CMD ["node", "./dist/index.js"]

これでレイヤーキャッシュが効きやすい形にインストールを分割できました。

node_modules を bind cache しているのが気持ち悪ければ install のステージを分割して、install するステージから bind マウントで持ってくることもできそうです。

Dockerfile
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 --mount=type=bind,source=./package.json,target=/installer/package.json \
    --mount=type=bind,source=./pnpm-lock.yaml,target=/installer/pnpm-lock.yaml \
    --mount=type=cache,id=pnpm,target=/pnpm/store \
    pnpm i --frozen-lockfile && \
    cp -r /installer/node_modules /tmp/

FROM base as builder
WORKDIR /builder

RUN --mount=type=bind,source=.,target=/builder,readwrite \
    --mount=type=cache,id=pnpm,target=/pnpm/store \
    --mount=type=bind,from=installer,source=/tmp/node_modules,target=/builder/node_modules \
    pnpm build && \
    cp -r dist /tmp/

FROM node:22-bullseye-slim

WORKDIR /app
COPY --from=builder /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 のファイルを用意する
Dockerfile.copy
FROM ubuntu:latest

WORKDIR /app
COPY ./big_files /big_files
RUN ls -al
Dockerfile.bind
FROM ubuntu:latest

WORKDIR /app
COPY ./big_files /big_files
RUN --mount=type=bind,source=./big_files,target=/app/big_files \
  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