Dockerfile ベストプラクティス／2022夏

今までなんとなくで済ませてきた`Dockerfile`の設定ですが、あらためて公式のベストプラクティス[^1]や公式のリファレンス[^2]を読み解いていきたいと思います。
`Dockerfile`の各命令の意味や、キャッシュを有効活用するための注意点などについて触れていきます。
※ベストプラクティスにある「stdinから`docker build`する方法」に関する項目は省略しました。

:::note
2022年9月2日追記
- [レイヤーの数に関する方針について](#レイヤーの数を最小に)追記しました。
- [`apt-get clean`について](#apt-get)追記しました。
- [Alpineの使用について](#from)追記しました。

:::

## 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` and `COPY` 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` and `COPY` commands, cache checking does not look at the files in the container to determine a cache match. For example, when processing a `RUN 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`の上の行に書くと良いでしょう。
```Dockerfile:good
RUN apt-get update -qq
COPY . /myapp
```
```Dockerfile:bad
COPY . /myapp
RUN apt-get update -qq
```

### マルチステージビルド
可能であればマルチステージビルドをどんどん活用しましょう。最終的なイメージファイルの容量を劇的に小さくすることができます。例を見てみましょう[^4]。
```Dockerfile:multil-stage builds
# 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アプリを構築していた時は次のように設定しました。
```.dockerignore: .dockerignore
# 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ではレイヤーの数を減らすことに心血を注ぐ必要はないのでしょうか？　ご存じの方がいたらご意見ください！

:::note
2022年9月2日追記
無理に`&&`でつないでレイヤーを減らそうとするとキャッシュ効率が下がったり、途中でエラーがあった際にデバッグ時間が伸びたりしてしまうので、今ではデメリットの方が今は大きいとのことです。
特に、マルチステージビルドの際は、最終イメージ以外はレイヤーの数を気にする必要はありません。

コメントにてご指摘くださった@shibukawaさん、ありがとうございました！
:::
`RUN`、`COPY`、`ADD`の3つの命令によってのみレイヤーが新たに作られます。他の命令では中間イメージが作られるのみでありビルドサイズが増えることはありません。可能ならマルチステージビルドも活用しどんどん容量を減らしましょう。
```Dockerfile:good
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
```
```Dockerfile:bad
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`の最新のバージョンを使うよう推奨しています。
```Docker:# syntax
# syntax=docker/dockerfile:1
```

### FROM
ベースとなるDockerイメージを指定します。可能であれば現行の公式イメージを使用しましょう。公式では[Alpine image](https://hub.docker.com/_/alpine/)を推奨しています。musl libcとBusyBoxがついてなんと5MB！　大変お買い得です。(無料です)

:::note
Alpine imageの使用に関して；
> Ruby、Python、Node.jsなどでNativeモジュールをバンドルしているアプリケーションの場合、パフォーマンスの劣化や互換性の問題にぶち当たる場合

があるとのことです。
Alpine imageを採用する前に、各々のユースケースに適しているか確認を取った方が良いようです。

[軽量Dockerイメージに安易にAlpineを使うのはやめたほうがいいという話 - inductor's blog](https://blog.inductor.me/entry/alpine-not-recommended)
:::
```Docker:# FROM
FROM ruby:2.6.3
```

### LABEL
イメージにラベル情報を付け足すことができます。コメントのようなものですね。ライセンス情報やプロジェクトのバージョンを書いておいたりします。Docker 1.10以前は、レイヤーの数を減らすために複数の`LABEL`を1行にまとめて書くことが推奨されていましたが、今はその必要はありません。
```Docker:# LABEL
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`で実行されます。長かったり複雑だったりするときは`\`で複数行に分けて書くと管理しやすくなります。
```Dockerfile:good
RUN apt-get update -qq \
  && apt-get install -y nodejs postgresql-client \
  && rm -rf /var/lib/apt/lists/*
```
```Dockerfile:bad
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`するようになっています。便利ですね！

:::note
コメントでのご指摘を受けて`apt-get clean`に関する箇所を訂正しました。
@eduidlさんありがとうございます！
:::
`apt-get install`するものが増えた時には、アルファベット順に並べ替え、1個ごとに改行しておくと重複を防ぎ管理しやすくなるのでおすすめです。
```Dockerfile: apt-get
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に答えがありました。
```:man apt(抜粋)
    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`ではコマンドをパイプでつなぐことがよくあります。
例えばこんな感じに。
```Dockerfile: pipe
RUN wget -O - https://some.site | wc -l > /number
```
しかし、気を付けなければならないのが、上の例では`wget`に失敗しても`wc`が成功すれば全体としてコマンドが成功した扱いになり、新しいイメージが作られてしまいます。もしそれが意図していない現象ならば、`pipefail`を使用することでこの問題は解決します。次の例ではちゃんと`wget`のエラーを拾ってくれます。
```Dockerfile: pipefail
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` and `COPY` are functionally similar, generally speaking, `COPY` is preferred. That’s because it’s more transparent than `ADD`. `COPY` only supports the basic copying of local files into the container, while `ADD` has some features (like local-only tar extraction and remote URL support) that are not immediately obvious. Consequently, the best use for `ADD` is local tar file auto-extraction into the image, as in `ADD rootfs.tar.xz /`.
> [...]
> For other items (files, directories) that do not require `ADD`’s tar auto-extraction capability, you should always use `COPY`.

翻訳
> `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`の設定の他に、次のようにバージョンの数字だけを指定してコマンドを管理するために使われることもよくあります。
```Dockerfile: ENV
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
マウントするディレクトリを決め、それに特定の名前を付け、ネイティブホストや他のコンテナからマウントされた外部のボリュームとして使用することができます。データベースや設定ファイルのストレージ、自分のコンテナによって作られたファイルやディレクトリをマウントする時にだけ使うようにしましょう。
```Dockerfile: 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](https://github.com/tianon/gosu)の使用を検討してみてください。
また、レイヤーを減らしイメージを複雑にしないためにも`USER`をいたずらに切り替えるようなことは避けましょう。

### ONBUILD
`Dockerfile`のビルドが完了した後に実行する命令を指定できます。`ONBUILD`で指定した命令は、`ONBUILD`が書かれたイメージの任意の子イメージに対して、子イメージの他の命令が実行される前に実行されます。
`ONBUILD`は指定したイメージに対してビルドを行いたいという場合に便利です。ただし、`ONBUILD`の中で`ADD`や`COPY`を使用する際は、新しいビルドのコンテキストでファイルなどが存在していないと元のイメージのビルドまで失敗してしまうので気を付けましょう。

### ENTRYPOINT と CMD
コンテナ実行時のデフォルトの挙動を指定します。`CMD`は`Dockerfile`内に複数記述しても最後の1つしか実行されません。挙動がやや複雑ですので、`ENTRYPOINT`と`CMD`をどのように使えばいいかについての公式の指針[^7]を引用しておきます。
> 1. Dockerfile should specify at least one of `CMD` or `ENTRYPOINT` commands.
> 2. `ENTRYPOINT` should be defined when using the container as an executable.
> 3. `CMD` should be used as a way of defining default arguments for an `ENTRYPOINT` command or for executing an ad-hoc command in a container.
> 4. `CMD` will be overridden when running the container with alternative arguments.

翻訳
> 1. Dockerfileには`CMD`か`ENTRYPOINT`のうち少なくとも1つを指定しましょう。
> 2. `ENTRYPOINT`はコンテナを実行ファイルとして使用するときに定義しましょう。
> 3. `CMD`は`ENTRYPOINT`へのデフォルトの引数として使うか、コンテナでアドホックなコマンドを実行するために使用しましょう。
> 4. 代わりとなる引数を付けてコンテナを起動すると、`CMD`の引数は上書きされます。

`ENTRYPOINT`ではそのイメージのメインとなるコマンドを設定しましょう。例えば、Railsのイメージでは、`ENTRYPOINT`内で`exec`を実行し、それに`CMD`で引数を渡してRailsサーバを起動することが多い印象です。

## おわり
以上です。ここまでご覧くださりありがとうございました！

## 参考
[Dockerfile reference | Docker Documentation](https://docs.docker.com/engine/reference/builder/)
[Best practices for writing Dockerfiles | Docker Documentation](https://docs.docker.com/develop/develop-images/dockerfile_best-practices/)
[Manage sensitive data with Docker secrets | Docker Documentation](https://docs.docker.com/engine/swarm/secrets/)


[^1]: [Best practices for writing Dockerfiles | Docker Documentation](https://docs.docker.com/develop/develop-images/dockerfile_best-practices/)
[^2]: [Dockerfile reference | Docker Documentation](https://docs.docker.com/engine/reference/builder/)
[^3]: [#leverage-build-cache Best practices for writing Dockerfiles | Docker Documentation](https://docs.docker.com/develop/develop-images/dockerfile_best-practices/#leverage-build-cache)
[^4]: [#use-multi-stage-builds Best practices for writing Dockerfiles | Docker Documentation](https://docs.docker.com/develop/develop-images/dockerfile_best-practices/#use-multi-stage-builds)
[^5]: [#minimize-the-number-of-layers Best practices for writing Dockerfiles | Docker Documentation](https://docs.docker.com/develop/develop-images/dockerfile_best-practices/#minimize-the-number-of-layers)
[^6]: [#add-or-copy Best practices for writing Dockerfiles | Docker Documentation](https://docs.docker.com/develop/develop-images/dockerfile_best-practices/#add-or-copy)
[^7]: [#understand-how-cmd-and-entrypoint-interact Dockerfile reference | Docker Documentation](https://docs.docker.com/engine/reference/builder/#understand-how-cmd-and-entrypoint-interact)