今までなんとなくで済ませてきたDockerfile
の設定ですが、あらためて公式のベストプラクティス1や公式のリファレンス2を読み解いていきたいと思います。
Dockerfile
の各命令の意味や、キャッシュを有効活用するための注意点などについて触れていきます。
※ベストプラクティスにある「stdinからdocker build
する方法」に関する項目は省略しました。
2022年9月2日追記
- レイヤーの数に関する方針について追記しました。
-
apt-get clean
について追記しました。 - Alpineの使用について追記しました。
Dockerfileとは?
Dockerイメージを作る際の指示が書かれたファイルです。
Dockerfile
を元にイメージが作られ、このイメージを元にコンテナが作られます。
だからすでにイメージがある場合はDockerfile
は不要なんですね。
Dockerfile
を作る時の注意点
では、早速Dockerfile
を作りましょう。
各命令の意味を調べていく前に、Dockerfile
を作成するにあたって気を付けた方が良い点について整理してみます。
キャッシュを有効活用
Dockerfile
は1行目から順番に実行されていきます。キャッシュの中に既存のイメージがあればそれを再利用します。キャッシュの運用ルールをDockerfile
のベストプラクティス3から引用します。
※イメージのビルド時にキャッシュを使用したくない場合はdocker build --no-cache=true
というようにオプションを付けましょう。
Starting with a parent image that is already in the cache, the next instruction is compared against all child images derived from that base image to see if one of them was built using the exact same instruction. If not, the cache is invalidated.
In most cases, simply comparing the instruction in the
Dockerfile
with one of the child images is sufficient. However, certain instructions require more examination and explanation.For the
ADD
andCOPY
instructions, the contents of the file(s) in the image are examined and a checksum is calculated for each file. The last-modified and last-accessed times of the file(s) are not considered in these checksums. During the cache lookup, the checksum is compared against the checksum in the existing images. If anything has changed in the file(s), such as the contents and metadata, then the cache is invalidated.Aside from the
ADD
andCOPY
commands, cache checking does not look at the files in the container to determine a cache match. For example, when processing aRUN apt-get -y update
command the files updated in the container are not examined to determine if a cache hit exists. In that case just the command string itself is used to find a match.
翻訳
既にキャッシュ化されている親イメージから取り掛かります。次に、すべての子イメージと比較して、まったく同じ命令でビルトされたものかどうか確認します。もし違う命令からビルトされていた場合、キャッシュは無効になります。
大抵の場合、
Dockerfile
内の命令と子イメージを比べるだけでキャッシュの確認は十分なのですが、ある命令については詳しく確認し説明する必要があるでしょう。
ADD
とCOPY
については、イメージ内のファイルの内容が確認され、それぞれのファイルに対してチェックサムが計算されます。その際に、ファイルの最終更新日時や最終アクセス日時は考慮されません。キャッシュを調べていきながら、チェックサムを既存のイメージのチェックサムと比較します。もしファイルの内容やメタデータに変更があれば、キャッシュは無効になります。
ADD
とCOPY
以外の命令については、キャッシュの確認はコンテナ内のファイルを見ずにキャッシュがマッチするかどうか判定されます。例えば、RUN apt-get -y update
を実行する場合、アップデートされたコンテナ内のファイルはキャッシュヒットの判定に使われません。この場合には、ただ命令の文字列のみによって判定されます。
キャッシュが一度無効化されると、続く命令はすべて新しいイメージを作って実行され、再度キャッシュが使われることはありません。従って、キャッシュを有効活用するためには変更される頻度が少ない命令をDockerfile
の上の行に書くと良いでしょう。
RUN apt-get update -qq
COPY . /myapp
COPY . /myapp
RUN apt-get update -qq
マルチステージビルド
可能であればマルチステージビルドをどんどん活用しましょう。最終的なイメージファイルの容量を劇的に小さくすることができます。例を見てみましょう4。
# syntax=docker/dockerfile:1
FROM golang:1.16-alpine AS build
# プロジェクトに必要なツールをインストール
# `docker build --no-cache .`を実行して依存関係を更新
RUN apk add --no-cache git
RUN go get github.com/golang/dep/cmd/dep
# Gopkg.tomlとGopkg.lockで依存関係にあるプロジェクトを列挙
# これらのレイヤーはGopkgファイルが更新されたときのみリビルドされる
COPY Gopkg.lock Gopkg.toml /go/src/project/
WORKDIR /go/src/project/
# 依存関係にあるライブラリをインストール
RUN dep ensure -vendor-only
# プロジェクト全体をコピーしてビルド
# このレイヤーはprojectディレクトリにあるファイルが変更された場合にリビルドされる
COPY . /go/src/project/
RUN go build -o /bin/project
# ここを単一のレイヤーイメージで済ませることが可能
FROM scratch
COPY --from=build /bin/project /bin/project
ENTRYPOINT ["/bin/project"]
CMD ["--help"]
ビルド環境を分けることができて便利ですね。余計なファイルを最終イメージから取り除くことができます。
不要なものはインストールしない
イメージの複雑さやファイルサイズを減らすために「あると便利」レベルのものはインストールしないようにしましょう。例えば、データベースイメージにVimをインストールする必要はありません。
.dockerignore
COPY . /myapp
などとする際に、追加しなくてもよいファイルはここで指定しておきます。私がRailsアプリを構築していた時は次のように設定しました。
# Docker
**/.dockerignore
**/*Dockerfile
# Git
.git
.gitignore
# Vim
**/.*.sw[po]
# Node.js
/node_modules
# Rails
/log/*
!/log/.keep
/tmp/*
!/tmp/.keep
/.bundle
/vendor/bundle
レイヤーの数を最小に
公式によると5、古いバージョンのDockerでは性能を上げるためにレイヤーの数を最小化することが重要でした。
※「古いバージョンの」ということは新しいDockerではレイヤーの数を減らすことに心血を注ぐ必要はないのでしょうか? ご存じの方がいたらご意見ください!
2022年9月2日追記
無理に&&
でつないでレイヤーを減らそうとするとキャッシュ効率が下がったり、途中でエラーがあった際にデバッグ時間が伸びたりしてしまうので、今ではデメリットの方が今は大きいとのことです。
特に、マルチステージビルドの際は、最終イメージ以外はレイヤーの数を気にする必要はありません。
コメントにてご指摘くださった@shibukawaさん、ありがとうございました!
RUN
、COPY
、ADD
の3つの命令によってのみレイヤーが新たに作られます。他の命令では中間イメージが作られるのみでありビルドサイズが増えることはありません。可能ならマルチステージビルドも活用しどんどん容量を減らしましょう。
RUN apt-get update -qq \
&& apt-get install -y nodejs postgresql-client \
&& rm -rf /var/lib/apt/lists/* \
&& apt-get clean
COPY foo bar /dest/path
RUN apt-get update -qq
RUN apt-get install -y nodejs postgresql-client
RUN rm -rf /var/lib/apt/lists/*
RUN apt-get clean
COPY foo /dest/path
COPY bar /dest/path
Dockerfile
の命令一覧
では、Dockerfile
で使用する命令(+α)を見ていきましょう。
# syntax
シンタックスのバージョンを指定します。公式では1.x.x
の最新のバージョンを使うよう推奨しています。
# syntax=docker/dockerfile:1
FROM
ベースとなるDockerイメージを指定します。可能であれば現行の公式イメージを使用しましょう。公式ではAlpine imageを推奨しています。musl libcとBusyBoxがついてなんと5MB! 大変お買い得です。(無料です)
Alpine imageの使用に関して;
Ruby、Python、Node.jsなどでNativeモジュールをバンドルしているアプリケーションの場合、パフォーマンスの劣化や互換性の問題にぶち当たる場合
があるとのことです。
Alpine imageを採用する前に、各々のユースケースに適しているか確認を取った方が良いようです。
FROM ruby:2.6.3
LABEL
イメージにラベル情報を付け足すことができます。コメントのようなものですね。ライセンス情報やプロジェクトのバージョンを書いておいたりします。Docker 1.10以前は、レイヤーの数を減らすために複数のLABEL
を1行にまとめて書くことが推奨されていましたが、今はその必要はありません。
LABEL com.example.version="0.0.1-beta"
LABEL vendor1="ACME Incorporated"
LABEL vendor2=ZENITH\ Incorporated
LABEL com.example.release-date="2015-02-12"
RUN
RUNでは任意のコマンドを実行します。デフォルトでは、Linuxでは/bin/sh -c
、Windowsではcmd /S /C
で実行されます。長かったり複雑だったりするときは\
で複数行に分けて書くと管理しやすくなります。
RUN apt-get update -qq \
&& apt-get install -y nodejs postgresql-client \
&& rm -rf /var/lib/apt/lists/*
RUN apt-get update -qq && apt-get install -y nodejs postgresql-client && rm -rf /var/lib/apt/lists/*
apt-get
RUN
と共によく使われるのがapt-get
です。
Dockerfile
でよく使われる-qq
オプションについても確認してみましょう。
apt-get
には-q
オプションがあり、これはquietのqです。プログレスバーなどを非表示にします。
-q=2
または-qq
を指定すると、そこにさらに-y
オプションの機能を兼ねることができます。
また、イメージファイルの容量を減らすためにrm -rf /var/lib/apt/lists/*
でパッケージリストのキャッシュを、apt-get clean
でローカルリポジトリのキャッシュを削除しておきましょう。なお、DebianとUbuntuの公式イメージは自動的にapt-get clean
するようになっています。便利ですね!
コメントでのご指摘を受けてapt-get clean
に関する箇所を訂正しました。
@eduidlさんありがとうございます!
apt-get install
するものが増えた時には、アルファベット順に並べ替え、1個ごとに改行しておくと重複を防ぎ管理しやすくなるのでおすすめです。
RUN apt-get update && apt-get install -y \
aufs-tools \
automake \
build-essential \
curl \
dpkg-sig \
libcap-dev \
libsqlite3-dev \
mercurial \
reprepro \
ruby1.9.1 \
ruby1.9.1-dev \
s3cmd=1.1.* \
&& rm -rf /var/lib/apt/lists/* \
&& apt-get clean
aptじゃなくてapt-get?
ここでちょっと気になることがありました。
Ubuntuではaptの使用が推奨されていると思っていたのですが、Dockerfile
では普通apt-getを使います。
何故でしょう? aptのmanに答えがありました。
SCRIPT USAGE AND DIFFERENCES FROM OTHER APT TOOLS
The apt(8) commandline is designed as an end-user tool and it may change
behavior between versions. While it tries not to break backward compatibility
this is not guaranteed either if a change seems beneficial for interactive use.
All features of apt(8) are available in dedicated APT tools like apt-get(8) and
apt-cache(8) as well. apt(8) just changes the default value of some options
(see apt.conf(5) and specifically the Binary scope). So you should prefer using
these commands (potentially with some additional options enabled) in your
scripts as they keep backward compatibility as much as possible.
使用法と他のaptツールとの違い
apt(8)はエンドユーザー向けに作られており、
インタラクティブな使用に有益ならば後方互換性が保証されない場合があります。
apt(8)の機能はapt-get(8)やapt-cache(8)などのツールで全て利用できます。
従って、できるだけ後方互換性を維持したスクリプトファイルで使うなら、
apt-getやapt-cacheなどのツールを使った方が良いでしょう。
パイプ|
の注意点🚬
RUN
ではコマンドをパイプでつなぐことがよくあります。
例えばこんな感じに。
RUN wget -O - https://some.site | wc -l > /number
しかし、気を付けなければならないのが、上の例ではwget
に失敗してもwc
が成功すれば全体としてコマンドが成功した扱いになり、新しいイメージが作られてしまいます。もしそれが意図していない現象ならば、pipefail
を使用することでこの問題は解決します。次の例ではちゃんとwget
のエラーを拾ってくれます。
RUN set -o pipefail && wget -O - https://some.site | wc -l > /number
WORKDIR
ワーキング・ディレクトリを設定します。ディレクトリが存在しない場合は新たに作られるのでRUN mkdir /app/root
などとする必要はありません。既存のWORKDIRに対して相対パスで指定することも可能ですが、公式では絶対パスでの使用を推奨しています。
COPY
コンテナのファイルシステムへファイルやディレクトリをコピーします。
COPY *.txt /dist/path
の様にワイルドカードも使えます。
スペースを含む文字列を指定したい場合は、次のように書きます。
COPY ["/src1", "/src2", ..., "/path/containing/one space"]
JSONの配列としてパースされるのでクオーテーションには'
ではなく"
を使用しましょう。
COPY or ADD?
ほぼ似た機能のADD
というコマンドがあります。どう使い分けたらいいのか、公式に記載があります。6
Although
ADD
andCOPY
are functionally similar, generally speaking,COPY
is preferred. That’s because it’s more transparent thanADD
.COPY
only supports the basic copying of local files into the container, whileADD
has some features (like local-only tar extraction and remote URL support) that are not immediately obvious. Consequently, the best use forADD
is local tar file auto-extraction into the image, as inADD rootfs.tar.xz /
.
[...]
For other items (files, directories) that do not requireADD
’s tar auto-extraction capability, you should always useCOPY
.
翻訳
ADD
とCOPY
は似たような機能のコマンドですが、大体の場合、COPY
の使用が推奨されます。というのもADD
よりもコマンドが透過的だからです。COPY
はローカルファイルをコンテナにコピーする基本的な機能しかサポートしていませんが、ADD
にはいくつかの特徴があります。例えば、ローカルにあるtarにADD
を使用すると展開してコピーされることや、リモートURLをサポートしていることなどが挙げられます。これらの機能は一見して明瞭ではありません。従って、ADD
を使用するのに適しているのは、ADD rootfs.tar.xz /
の様に、ローカルのtarファイルをDockerイメージへ自動で展開したい場合です。
[中略]
この機能が必要ない場合は、常にCOPY
を使用したほうが良いでしょう。
EXPOSE
コンテナがリッスンするポートを指定します。コンテナ同士をつなぐために、Dockerは受け手側のコンテナからソースに戻る道筋を環境変数に設定しています(例: MYSQL_PORT_3306_TCP)。
ENV
ENV <key>=<value>
、または、ENV <key> <value>
の形で、環境変数を設定することができます。
PATH
の設定の他に、次のようにバージョンの数字だけを指定してコマンドを管理するために使われることもよくあります。
ENV PG_MAJOR=9.3
ENV PG_VERSION=9.3.4
RUN curl -SL https://example.com/postgres-$PG_VERSION.tar.xz | tar -xJC /usr/src/postgres && …
ENV PATH=/usr/local/postgres-$PG_MAJOR/bin:$PATH
ENVでパスワードは設定しないように
ENV
を実行する度に中間イメージが作られるため、後から環境変数をunset
してもdocker history
などで参照できてしまいます。従って、パスワードなどのクレデンシャルな情報は記入しないようにしましょう。
ARG
ARG <name>[=<default value>]
で、ビルド時にのみ必要な変数を設定することができます。個人的にはARG APP_ROOT=/myapp
のように使うことが多いです。ENV
と同様の理由により、ARG
でクレデンシャルな情報は設定しないようにしましょう。
VOLUME
マウントするディレクトリを決め、それに特定の名前を付け、ネイティブホストや他のコンテナからマウントされた外部のボリュームとして使用することができます。データベースや設定ファイルのストレージ、自分のコンテナによって作られたファイルやディレクトリをマウントする時にだけ使うようにしましょう。
FROM ubuntu
RUN mkdir /myvol
RUN echo "hello world" > /myvol/greeting
VOLUME /myvol
例えば上の例では、docker run
すると/myvol
に新しいマウントポイントを作り、greeting
ファイルを新しくできたボリュームにコピーするイメージが出来上がります。
USER
USER <user>[:<group>]
またはUSER <UID>[:<GID>]
で、デフォルトのユーザーとグループを指定することができます。イメージ内のユーザーとグループは非自明なUIDとGIDが割り当てられます。そのことが問題になる場合は明示的にUIDとGIDを指定しましょう。
rootを得たい場合は、sudo
を使用するのは避けましょう。TTYやシグナル送信の予期せぬ問題が引き起こされる可能性があります。どうしてもsudo
のような機能が必要になった場合はgosuの使用を検討してみてください。
また、レイヤーを減らしイメージを複雑にしないためにもUSER
をいたずらに切り替えるようなことは避けましょう。
ONBUILD
Dockerfile
のビルドが完了した後に実行する命令を指定できます。ONBUILD
で指定した命令は、ONBUILD
が書かれたイメージの任意の子イメージに対して、子イメージの他の命令が実行される前に実行されます。
ONBUILD
は指定したイメージに対してビルドを行いたいという場合に便利です。ただし、ONBUILD
の中でADD
やCOPY
を使用する際は、新しいビルドのコンテキストでファイルなどが存在していないと元のイメージのビルドまで失敗してしまうので気を付けましょう。
ENTRYPOINT と CMD
コンテナ実行時のデフォルトの挙動を指定します。CMD
はDockerfile
内に複数記述しても最後の1つしか実行されません。挙動がやや複雑ですので、ENTRYPOINT
とCMD
をどのように使えばいいかについての公式の指針7を引用しておきます。
- Dockerfile should specify at least one of
CMD
orENTRYPOINT
commands.ENTRYPOINT
should be defined when using the container as an executable.CMD
should be used as a way of defining default arguments for anENTRYPOINT
command or for executing an ad-hoc command in a container.CMD
will be overridden when running the container with alternative arguments.
翻訳
- Dockerfileには
CMD
かENTRYPOINT
のうち少なくとも1つを指定しましょう。ENTRYPOINT
はコンテナを実行ファイルとして使用するときに定義しましょう。CMD
はENTRYPOINT
へのデフォルトの引数として使うか、コンテナでアドホックなコマンドを実行するために使用しましょう。- 代わりとなる引数を付けてコンテナを起動すると、
CMD
の引数は上書きされます。
ENTRYPOINT
ではそのイメージのメインとなるコマンドを設定しましょう。例えば、Railsのイメージでは、ENTRYPOINT
内でexec
を実行し、それにCMD
で引数を渡してRailsサーバを起動することが多い印象です。
おわり
以上です。ここまでご覧くださりありがとうございました!
参考
Dockerfile reference | Docker Documentation
Best practices for writing Dockerfiles | Docker Documentation
Manage sensitive data with Docker secrets | Docker Documentation
-
Best practices for writing Dockerfiles | Docker Documentation ↩
-
#leverage-build-cache Best practices for writing Dockerfiles | Docker Documentation ↩
-
#use-multi-stage-builds Best practices for writing Dockerfiles | Docker Documentation ↩
-
#minimize-the-number-of-layers Best practices for writing Dockerfiles | Docker Documentation ↩
-
#add-or-copy Best practices for writing Dockerfiles | Docker Documentation ↩
-
#understand-how-cmd-and-entrypoint-interact Dockerfile reference | Docker Documentation ↩