search
LoginSignup
331

posted at

updated at

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

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

2022年9月2日追記

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内の命令と子イメージを比べるだけでキャッシュの確認は十分なのですが、ある命令については詳しく確認し説明する必要があるでしょう。

  • ADDCOPYについては、イメージ内のファイルの内容が確認され、それぞれのファイルに対してチェックサムが計算されます。その際に、ファイルの最終更新日時や最終アクセス日時は考慮されません。キャッシュを調べていきながら、チェックサムを既存のイメージのチェックサムと比較します。もしファイルの内容やメタデータに変更があれば、キャッシュは無効になります。

  • ADDCOPY以外の命令については、キャッシュの確認はコンテナ内のファイルを見ずにキャッシュがマッチするかどうか判定されます。例えば、RUN apt-get -y updateを実行する場合、アップデートされたコンテナ内のファイルはキャッシュヒットの判定に使われません。この場合には、ただ命令の文字列のみによって判定されます。

キャッシュが一度無効化されると、続く命令はすべて新しいイメージを作って実行され、再度キャッシュが使われることはありません。従って、キャッシュを有効活用するためには変更される頻度が少ない命令をDockerfileの上の行に書くと良いでしょう。

good
RUN apt-get update -qq
COPY . /myapp
bad
COPY . /myapp
RUN apt-get update -qq

マルチステージビルド

可能であればマルチステージビルドをどんどん活用しましょう。最終的なイメージファイルの容量を劇的に小さくすることができます。例を見てみましょう4

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
# 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さん、ありがとうございました!

RUNCOPYADDの3つの命令によってのみレイヤーが新たに作られます。他の命令では中間イメージが作られるのみでありビルドサイズが増えることはありません。可能ならマルチステージビルドも活用しどんどん容量を減らしましょう。

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
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の最新のバージョンを使うよう推奨しています。

# syntax
# syntax=docker/dockerfile:1

FROM

ベースとなるDockerイメージを指定します。可能であれば現行の公式イメージを使用しましょう。公式ではAlpine imageを推奨しています。musl libcとBusyBoxがついてなんと5MB! 大変お買い得です。(無料です)

Alpine imageの使用に関して;

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

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

軽量Dockerイメージに安易にAlpineを使うのはやめたほうがいいという話 - inductor's blog

# FROM
FROM ruby:2.6.3

LABEL

イメージにラベル情報を付け足すことができます。コメントのようなものですね。ライセンス情報やプロジェクトのバージョンを書いておいたりします。Docker 1.10以前は、レイヤーの数を減らすために複数のLABELを1行にまとめて書くことが推奨されていましたが、今はその必要はありません。

# 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で実行されます。長かったり複雑だったりするときは\で複数行に分けて書くと管理しやすくなります。

good
RUN apt-get update -qq \
  && apt-get install -y nodejs postgresql-client \
  && rm -rf /var/lib/apt/lists/*
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するようになっています。便利ですね!

コメントでのご指摘を受けてapt-get cleanに関する箇所を訂正しました。
@eduidlさんありがとうございます!

apt-get installするものが増えた時には、アルファベット順に並べ替え、1個ごとに改行しておくと重複を防ぎ管理しやすくなるのでおすすめです。

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ではコマンドをパイプでつなぐことがよくあります。
例えばこんな感じに。

pipe
RUN wget -O - https://some.site | wc -l > /number

しかし、気を付けなければならないのが、上の例ではwgetに失敗してもwcが成功すれば全体としてコマンドが成功した扱いになり、新しいイメージが作られてしまいます。もしそれが意図していない現象ならば、pipefailを使用することでこの問題は解決します。次の例ではちゃんとwgetのエラーを拾ってくれます。

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.

翻訳

ADDCOPYは似たような機能のコマンドですが、大体の場合、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
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

マウントするディレクトリを決め、それに特定の名前を付け、ネイティブホストや他のコンテナからマウントされた外部のボリュームとして使用することができます。データベースや設定ファイルのストレージ、自分のコンテナによって作られたファイルやディレクトリをマウントする時にだけ使うようにしましょう。

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の中でADDCOPYを使用する際は、新しいビルドのコンテキストでファイルなどが存在していないと元のイメージのビルドまで失敗してしまうので気を付けましょう。

ENTRYPOINT と CMD

コンテナ実行時のデフォルトの挙動を指定します。CMDDockerfile内に複数記述しても最後の1つしか実行されません。挙動がやや複雑ですので、ENTRYPOINTCMDをどのように使えばいいかについての公式の指針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にはCMDENTRYPOINTのうち少なくとも1つを指定しましょう。
  2. ENTRYPOINTはコンテナを実行ファイルとして使用するときに定義しましょう。
  3. CMDENTRYPOINTへのデフォルトの引数として使うか、コンテナでアドホックなコマンドを実行するために使用しましょう。
  4. 代わりとなる引数を付けてコンテナを起動すると、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

  1. Best practices for writing Dockerfiles | Docker Documentation

  2. Dockerfile reference | Docker Documentation

  3. #leverage-build-cache Best practices for writing Dockerfiles | Docker Documentation

  4. #use-multi-stage-builds Best practices for writing Dockerfiles | Docker Documentation

  5. #minimize-the-number-of-layers Best practices for writing Dockerfiles | Docker Documentation

  6. #add-or-copy Best practices for writing Dockerfiles | Docker Documentation

  7. #understand-how-cmd-and-entrypoint-interact Dockerfile reference | Docker Documentation

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
What you can do with signing up
331