docker
dockerfile

Dockerfileを書くときに気をつけていること10選

この文章では、私が個人開発で使用しているDockerサーバの管理や、業務でプロジェクトメンバーに開発環境を配布する際に、Dockerfileを書く上で気をつけていることを整理します。

1. Dockerファイルのフォルダには不要なファイルを置かない

docker buildは開始時にコンテクスト(現在のフォルダの状態)をDockerデーモンに転送します。具体的には、Dockerfileのあるディレクトリの内容をtarで圧縮し送ります。そのため、Dockerfileのディレクトリに不要なファイルがあるとビルドに余計な時間がかかりよくありません。

とはいえ、プロジェクトフォルダでビルドした成果物をイメージ化するためにDockerfileを含める運用はよくあると思いますので、その場合は.dockerignoreファイルを記述して余計なファイルが転送対象にならないようにします。

.dockerignoreについては下記の記事が参考になるかと思います。

2. ENVはなるべくまとめる

docker builddocker run時に使用する環境変数はENV文で設定しますが、ENV文には1つの環境変数を定義する記法と、複数の環境変数をまとめて定義する2つの記法があります。

こうではなく
## スペース区切りで1つの環境変数を定義
# 待ち受けるアドレス
ENV HOST val1
# Listenポート
ENV PORT val2
...
こう書く
## key=val形式で複数の環境変数を定義
# HOST: 待ち受けるアドレス
# PORT: Listenポート
# ...
ENV HOST="val1" \
    PORT="val2" \
    ...

Dockerは文を実行するたびにイメージのレイヤーを作成するため、可能なら下の例のようにまとめることを推奨しています。

[2018-06-02] 追記:
Docker 1.10以降では、イメージのレイヤーが作成されるのは RUN、COPY、ADDの3つのみになっているとのことです。(参考: Best practices for writing Dockerfiles #minimize-the-number-of-layers)
ですので、2についてはむしろ上の書き方のほうが読みやすくていいかもしれません。
@ikemoさん、教えていただきありがとうございます。

3. RUNはなるべくまとめる

RUNでも不要なイメージのレイヤーが作成されることを防ぐため、なるべくひとつのRUN文に必要な処理を書いてしまいます。

こうではなく
RUN apt update
RUN apt upgrade -y
RUN apt install -y --no-install-recommends \
  curl \
  git \
  ...
こう書く
RUN apt update && \
  apt upgrade -y && \
  apt install -y  --no-install-recommends \
    curl \
    git \
    ...

[2018-06-03] 追記:
Docker 1.13から試験的に導入されている--squashオプションを使えば、イメージのレイヤーを最終的にひとつのレイヤーに圧縮してくれるので、可読性重視で書けばいいとはてブでコメント頂きました。(squashオプション: docker build | Docker Documentation #options)
たしかにこれについて書いておくべきでした…squashオプションを付けてビルドすると、現在のDockerfileに書かれた命令文について、一枚のレイヤーにしてくれます。こういうのが欲しかったんだよって感じのオプションですね。(マルチステージビルドではどうなるか試したことなし)
ただ、試験的オプションはdaemon.jsonの設定でexperimentalをtrueにしてあげないといけないので、配布の際にやってもらうにはちょっと面倒で普段使いしづらいという点があり、なるべくならまとめる書き方を継続するのがいいんじゃないかと思います。(参考: Daemon configuration file)

4. RUNにコメントを入れる

前述のRUNをひとつにまとめる記述を行っていると、最終的に長大なワンライナーを書くことになり、全体の目的は分かっても個別に何をやってるのかが分かりづらくなります。

そこでRUNにコメントを入れていくのですが、下記のようなDockerfileのコメントをRUNの途中に挟んでいく書き方は非推奨となっているので、シェルの機能を活用してインラインコメントを記述していきます。

こうではなく
RUN echo 'Hello, ' && \
    # ここでコメントは非推奨
    echo 'world!'
こう書く
RUN echo 'Hello, ' && \
    : "何もしないコマンドをコメントとして利用" && \
    echo 'world!'

各シェルごとのインラインコメントの書き方は、シェルでインラインコメントを書く方法にまとめてあります。参考になれば幸いです。

5. USERを切り替えすぎない

Dockerコンテナ内では基本的に全てのコマンドがrootで実行されます。しかしビルド時はともかく、ビルド後のアプリケーションをrootで実行していると、思わぬ不具合でコンテナごと破壊しかねません。そのため、Dockerfileを書く際はなるべくアプリケーションの実行だけに使用するユーザーを作成するようにしています。

このとき、rootと作成したユーザーを何度も切り替えていると、処理の流れが煩雑で理解が難しいDockerfileが出来てしまう点でよくありません。USER文を多用するのではなく、rootで実行する必要があるコマンドは先にすべて実施しておき、その後作成したユーザーに切り替えるようにコマンドの順序を工夫します。

6. cdではなくWORKDIR

RUN文のなかでcdでフォルダを何度も移動するのではなく、はじめにWORKDIRを設定して、そこからの相対/絶対パスで作業します。

こうではなく
RUN cd /var/lib && \
  curl -L ... -O && \
  unzip app.zip
  cd app && \
  ...
こう書く
# WORKDIRはフォルダがなければ作成する
WORKDIR /var/lib/app
RUN curl -L ...

RUN文は実行のたびに作業ディレクトリがリセットされるので、cdで移動する癖がついているとコマンドを間違えがちです。また、WORKDIRに設定したフォルダがdocker run時の作業ディレクトリになるので、のちのちコンテナ内に入って作業するときにわかりやすいのではないかと思います。

7. 文字列(shell形式)ではなくJSON(exec形式)

CMD, ENTRYPOINT, VOLUMEに値を設定するときは、文字列(shell形式)ではなく、JSON(exec形式)で指定します。

こうではなく
ENTRYPOINT /bin/bash -c
CMD ping 127.0.0.1
VOLUME /root /home
こう書く
ENTRYPOINT [ "/bin/bash", "-c" ]
CMD [ "ping 127.0.0.1" ] # -cの引数なので全体を囲む
VOLUME [ "/root", "/home" ]

上の文字列の例はそもそも動きませんが…動く例で行くとこうでしょうか。

ENTRYPOINT ping 127.0.0.1
CMD -c1
$ docker run --rm <container id>
# 止まらない!
$ docker run --rm <container id> -c4
# やっぱり止まらない!

文字列で指定すると、シグナルが届かなくなるので、Ctrl−Cが効きません。docker stopするしかなくなります。

さらに、常に/bin/sh -c "ENTRYPOINT"で動くようになるので、docker run時にコマンドを与えるとデフォルトの引数としてCMDではなく、コマンドに与えた値を使用する機能が使えなくなります。意図的にやっているならいいですが、CMDの指定が意味をなさなくなるので、分かっていないと混乱します。

CMDがJSONでない場合も同様に、ENTRYPOINTと同時に指定するとCMD側の指定の意味がなくなります。docker run時にコマンドを与えるとデフォルトの引数としてCMDではなく、コマンドに与えた値を使用する機能(この機能に名前欲しいですね)を有効にするには、ENTRYPOINTとCMD両方にJSONで値を指定する必要があります。

VOLUMEは文字列、JSONどちらでもいいですが、記述を揃えたほうがきれいに見えます。リファレンスガイドもJSONを推奨しています。

ちなみにJSONなので囲みはダブルクォートです。

8. VAR=val commandではなくENV val, RUN command

ここからはシェルがshの場合を例に話を進めていきますがご容赦ください。

$ NODE_ENV=production npm ...

のように、シェル上ではVAR=val形式で変数を定義し、コマンドに渡すやり方が一般的ですが、DockerではそれぞれのRUNで定義した変数を共有しないので注意が必要です。

こうではなく
# ここで定義した変数が
RUN PATH=${PATH}:/var/lib/app/bin
# こちらでは反映されていないので動かない!
RUN app conf
RUN app run
こう書く
ENV PATH="${PATH}:/var/lib/app/bin"
# 変数が引き継がれているので動く
RUN app conf
RUN app run

ちなみに、コマンドの実行結果をENVに渡すことは出来ません。なので、環境変数ベースでdocker buildの結果を基にdocker runの挙動を変えるようなことは難しいです。もし、どうしてもそのようなことがやりたい場合には、下記にやり方をまとめたので挑戦してみてください。

9. RUN set -xで始める

RUN set -x && \
  apt update && \
  apt upgrade -y --no-install-recommends &&
  apt install -y \
    curl \
    git \
    ...

上記のように、set -xを実施しておくことで、docker build時にコマンドが出力されるようになるので、ビルドの経過がわかりやすくなります。また、: "コメント"形式の行が出力されるのも便利です。

10. 設定ファイルの作成は { ... } | tee file

RUN : "apache.confを作成する" && { \
  echo "<VirtualHost *:80>";
  echo "  ServerHost example.com";
  echo "  DocumentRoot /var/www/html";
  echo "</VirtualHost>";
} | tee /etc/httpd/conf/apache.conf

上記のように、blockの中でechoを連ねて、その結果をファイルに書き出す方法がわかりやすいかと思います。(php:apacheで実際に使用されています)

このとき、set -xしていると同じような出力が2回起こるので、一時的にset -してもいいかもしれません。

おわりに

いかがだったでしょうか。メンテナンス性、再利用性を重視した書き方を模索しながら必要を感じたものをまとめてみました。
他にも、Dockerfileの仕様で理解しておきたい点はありますが、そちらについてはDockerfile のベストプラクティスが詳しいです。

また、shの書き方について、シェルスクリプトの処理境界が鮮明になる「名前付きブロック記法」なるものを考えてみたに記載された記法を利用するのがわかりやすくて、RUNコマンドと相性いいんじゃないかと個人的に思います。