🐳

社内のDockerfileのベストプラクティスを公開します

2021/07/16に公開

はじめに

第1旅行プラットフォーム部エンジニアの六車と申します。
大手旅行代理店の検索サイトの構築をメイン業務としつつ、社内のコンテナ・クラウド活用推進活動も行っています。
この記事では、社内コンテナ推進活動の一環で行ったDockerfileの書き方のベストプラクティスのまとめを紹介します。

この記事のゴール:効率的かつ保守性の高いDockerfileの書き方を知る。

Dockerfileとはなんぞや

Dockerfileはずばり 「docker imageを作るための設計図」 のようなものです。

Dockerfileはdocker imageを自動構築する際に必要となるファイルで、1image = 1Dockerfileと対応します。
以下がDockerfileの例です。

FROM ubuntu:20.04
COPY . /app
RUN make app
CMD python /app/app.py

DockerfileはFROM, COPY, RUNなどの命令文で構成されています。何のimageをもとに何を実行してどのようなimageを構築するか、といった内容が書かれています。実行時は上から順番に読み込まれます。Dockerfileを見ればimageの構築過程を確認することができます。

Dockerfileのカレントディレクトリでdocker build -t <IMAGE:TAG> .とすれば、Dockerfileが読み込まれ、imageが構築されます。例えばforciaというimageを2021というタグを付けて作りたいときdocker build -t forcia:2021 .とします。tagをつけない場合自動で:latestというタグが付与されますが、必ずtagをつけたほうが良いとされています。誰が見てもversionを確認できるためです。

Dockerfileをきちんと書くべき理由

DockerfileはDocker imageの設計図なわけですが、意外と簡単にかけます。localのファイルをCOPYで持ってきたり、RUNで通常の環境構築と同じように実行すれば、とりあえず動かすことができます。

しかしながら、Docker imageを商用環境で利用する際には以下の点を考慮する必要があります。

  • 再現性
    • buildするタイミングによってimageの中身が変わらないようにする
  • セキュリティ
    • コンテナに不正に侵入された際の影響を限定するために、最小限の権限しか持たないようにする
  • 可搬性
    • imageサイズはなるべく小さい方がbuild時間、配布時間の短縮になる

とりわけ、再現性とセキュリティは重要です。例えば同じDockerfileでも、ある環境、あるタイミングではbuildできなかったりすると困ります。またコンテナに入れたらroot権限を得ることができる状態であるのは危険です。したがってコンテナを商用環境で使用することを考えているならば、Dockerfileをきちんと書く必要があるわけです。そのためのBest practiceを以下で紹介します。

Best practices for writing Dockerfile

本題です。

フォルシアにおいてのDockerfileのガイドラインとアドバイスを列挙します。このベストプラクティスには、Docker社公式のベストプラクティス、世の中一般的によく言われているもの、フォルシア社内特有のルールが混合しています。ですので絶対的に正しいものとしてではなく、あくまで参考として読んでいただけると幸いです。

とりわけ推奨したいものに★をつけています。

  • 不要な特権を避ける★
  • ビルドコンテキストについて理解しよう
  • .dockerignoreを使ったファイル除外の指定
  • マルチステージビルドの利用★
  • 不要なパッケージをインストールしない
  • アプリケーションの分割
  • ビルドキャッシュの利用
  • レイヤー数は最小に、並び順も意識
  • 信頼できるベースイメージを使用する★
  • Linterを使う★

不要な特権を避ける

rootlessコンテナ

コンテナでプログラムをroot (UID 0) として実行することはやめましょう。

これは一般のLinux環境と同様ですが、root権限が与えられるとすべてのファイルが丸見え、操作可能となるからです。コンテナ内のプログラムがrootとして実行されていると、コンテナを単に実行するだけで、ホストや他のコンテナがのっとられます。**非常に危険です。**またDocker側で生成したファイルの権限がrootになる問題もあり面倒です。

非root 権限として実行するには、Dockerfile にいくつかの追加ステップが必要になります。以下にそのステップを記載します。

FROM alpine:3.12
*# Create user and set ownership and permissions as required*
RUN adduser -D myuser && chown -R myuser /myapp-data
*# ... copy application files*
USER myuser
ENTRYPOINT ["/myapp"]

特定の UID にバインドしない

Dockerfile内で一般ユーザーを作成する際に困るのが、コンテナ内の実行ユーザーとホストのユーザーのUID/GIDが一致しない問題です。特に開発環境でローカルのファイルをコンテナにマウントする場合によく発生します。

対策として以下のようにDockerfile内でuid/gidを指定する方法があるのですが、ローカルマシンの環境依存するので良くないです。

FROM ubuntu:20.04
ARG UID=1001
ARG GID=1001
RUN useradd -u $UID -o -m myuser
RUN groupmod -g $GID -o myuser
...

コンテナ内の実行ユーザーとローカルのユーザーのUID/GIDを合わせるのはコンテナ実行時(docker run時)にしましょう。以下の記事がとても参考になります。

dockerでvolumeをマウントしたときのファイルのowner問題

ビルドコンテキストについて理解しよう

ビルドコンテキストとはdocker build実行時に指定するディレクトリのこと。

例えばdocker build -t forcia:2020 .では最後の.がビルドコンテキストです。構築時、ビルドコンテキストとして現在のディレクトリ以下にある全てのファイルやディレクトリをDocker deamonに送信してしまいます。ビルドコンテキストに余分なディレクトリ・ファイルがあると、build時に時間がかかる、メモリを消費する原因となります。例えば、ビルドコンテキストに100MBのファイルがあるとimageのサイズが100MBプラスとなってしまいます。このような事態を防ぐためにもDockerfile用のディレクトリを作成し、そのディレクトリには無駄なファイルは配置しないようにすべきです。

.dockerignoreを使ったファイル除外の指定

Dockerfile用のディレクトリを作ったがそのディレクトリ内にビルドコンテキストとして含みたくないファイルが存在する、もしくはDockerfileをアプリのソースファイルが配置されているディレクトリと同じにしたいということもあると思います。

そんなときに .dockerignore を用いると、ビルドコンテキストとして無視します。

記述ルールについてはこちら

マルチステージビルドの利用

マルチステージビルドを利用すると、複数のFROM命令をDockerfileに記述できます。

Docker 17.05以降で使えます。構築手順(プロセス)の最終段階(ステージ)でイメージをビルドするため、ビルド・キャッシュの効果によってイメージ・レイヤを最小化できます。

FROM golang:1.14 as builder
 
WORKDIR /app
COPY . /app/
 
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -ldflags="-s -w" -installsuffix cgo -o main main.go
 
FROM alpine:3.12
 
COPY --from=builder /app/main /bin/main
COPY . .
 
EXPOSE 8080
 
CMD ["/bin/main"]

マルチステージビルドを使うことで、成果物だけ最終ステージにCOPYすればよいため、build用の中間イメージにおいてはRUNをまとめる必要もなくなり可読性がupします。

参考: https://future-architect.github.io/articles/20210121/

不要なパッケージをインストールしない

複雑さ、依存関係、ファイル容量、構築回数を減らすために「あったほうがいいだろう」くらいの必要性のパッケージのインストールは避けるべきです。例えば、データベースのコンテナにテキストエディタは必要ありません。軽量のベースイメージを使うのもいいです。

また軽量ベースイメージとしてはdistroless, alpineなどが有名です。(ただし軽量Dockerイメージに安易にAlpineを使うのはやめたほうがいいという話というのもあったりします。)

Tip: Debian及びUbuntu系では --no-install-recommendsをつけると、不必要なパッケージのインストールを防ぐことができます。

FROM ubuntu
RUN apt-get update && apt-get -y install python
FROM ubuntu
RUN apt-get update && apt-get -y install --no-install-recommends python

アプリケーションの分割

各コンテナは「一つの役割」のみ持つべきです。

アプリケーションを複数のコンテナに分離すると、水平スケールやコンテナの再利用が行いやすくなります。例えば、ウェブ・アプリケーションのスタックであれば、ウェブ・アプリケーション、データベース、インメモリのキャッシュを分離した状態で管理するために、それぞれが自身のユニークなイメージを持つ、3つのコンテナで構成することができます。

ただし、厳密に「1コンテナ1プロセス」にこだわる必要もありません。どの方法が最善かは都度判断が必要です。コンテナが相互に依存する場合、Dockerネットワークの活用も有効です。

ビルドキャッシュの利用

imageの構築は、Dockerfile行の命令順(上から順)にしたがって実施されます。Docker は各命令で既存のイメージにキャッシュがあるかどうか検査します。もしキャッシュあれば、新しい(重複する)イメージを作成するのではなく、再利用します。キャッシュ利用のルールは以下の通り。

  • FROMのimageが既にある場合、Dockerfileの命令と親イメージから派生した子イメージの一致を確認し、一致するものがなければキャッシュを破棄して構築する。
  • ADDCOPY命令はチェックサムのみ比較対象。アクセス時間・更新時間は無関係。それ以外の内容変更があればキャッシュ破棄
  • 構築時、Dockerfileの命令行しか見ない(コンテナ内の比較はない)
    • 例えば、 RUN apt-get -y updateの実行が古くてもDockerは判断しない。

もしもキャッシュを一切使わないのであれば、docker buildコマンドで--no-cache=trueオプションが使えます。

レイヤ数は最小に、順番も意識する

Docker の古いバージョンでは、確実に性能を出すために、イメージ・レイヤ数の最小化が非常に重要でした。この制限を減らすため、以下の機能が追加されました。

  • RUNCOPYADD命令のみレイヤを作成します。他の命令では一時的な中間イメージ(temporary intermediate image)を作成し、ビルド容量(サイズ)の増加はありません。
  • 可能であれば マルチステージビルド を使い、最終イメージの中に必要な成果物のみコピーします。これにより、最終イメージの容量は増えずに、中間構築ステージにツールやデバッグ情報を入れられるようになります。

簡単には、RUNCOPYADD命令はなるべく少なくすることが重要です。

またビルドキャッシュを有効活用するために、レイヤ(命令)の実行順番を意識することが重要です。

FROM ubuntu:20.04
 
COPY . /usr/local/src # よく差分が生じやすいレイヤ
RUN apt-get update
RUN apt-get -y install vim

以上のDockerfileのように記述すると、Dockerfileのあるディレクトリに差分があるとそれ以降のレイヤもキャッシュは破棄されてやり直しとなります。

頻繁に変更するレイヤはなるべく後ろの方に書くとビルドキャッシュの有効利用ができ、build時間の短縮につながります。

FROM ubuntu:20.04
 
RUN apt-get update
RUN apt-get -y install vim
 
COPY . /usr/local/src # よく差分が生じやすいレイヤは後ろの方に書く

信頼できるベースイメージを使用する

ベースイメージには中身が不明なイメージは使わないようにしてください。

Docker Hubにある公式イメージでも、tagを一意に決めていたとしても、中身は変わりうるので突然動かなくなる場合があります。

よってベースイメージは信頼できるものを使用したほうがよいです。例えば社内のインフラチームが管理していて、定期的にupdateを行っており、中身が明確にわかって、問い合わせもできるようなものです。

またベースイメージとしてdistrolessイメージを使うのもよいかもしれません。distrolessは商用環境に特化したimageでランタイムに必要ないということでシェルすらありません。なので軽量でセキュアです。

Linterを使う

Dockerfileを書く際はLint toolを使いましょう。

以下のhadolintとdockleを使うことを推奨します。Linterに怒られながらDockerfileを作ればよいので快適です。

hadolint

DockerfileのLinterとしてhadolintというものがデファクトスタンダードとして存在します。

対象のDockerfileと同じディレクトリに配置した上でdocker run --rm -v "$PWD:/work" -w /work hadolint/hadolint hadolint Dockerfileを実行すればよいです。

例えば以下のDockerfileを対象としてhadolintを実行してみます。

FROM node:14
 
RUN apt-get update
RUN apt-get install -y python3
WORKDIR /app
COPY . .
RUN yarn install --production
 
# RUN adduser nodejs && chown -R nodejs /app
# USER nodejs
 
CMD node src/index.js
❯ docker run --rm -v "$PWD:/work" -w /work hadolint/hadolint hadolint Dockerfile
Dockerfile:3 DL3009 info: Delete the apt-get lists after installing something
Dockerfile:4 DL3008 warning: Pin versions in apt get install. Instead of `apt-get install <package>` use `apt-get install <package>=<version>`
Dockerfile:4 DL3015 info: Avoid additional packages by specifying `--no-install-recommends`
Dockerfile:12 DL3025 warning: Use arguments JSON notation for CMD and ENTRYPOINT arguments

このように丁寧にアドバイスしてくれます。

VSCodeのextensionもあるそうです。こちらを使えばリアルタイムにエディタがメッセージをだしてくれます。

またhadolintのうちどのルールを無視するかなどの設定を.hadolint.yamlとしてas codeで管理できます。

ignored:
  - DL3000 # Use absolute WORKDIR.
  - DL3005 # Do not use apt-get upgrade or dist-upgrade.
 
trustedRegistries:
  - hoge.dkr.ecr.ap-northeast-1.amazonaws.com # 特定のレジストリのベースイメージしか使えなくする

たとえばフォルシアではベースイメージはprivate ECRにある、社内のコンテナチームが管理しているものを使用することを推奨しており、hadolintのruletrustedRegistriesで使えるコンテナレジストリをあえて制限しています。

❯ hadolint --config ./.hadolint.yaml Dockerfile

dockle

dockleはビルドしたDockerImageをスキャンして、セキュリティ上の問題が無いかチェックしてくれるツールです。

Dockerfileからbuildしたimageを、dockleでスキャンした結果を以下に示しています。rootユーザでないことや、不必要なファイルを追加していないかなども注意してくれます。

❯ docker build -t lint:test .

❯ dockle lint:test
WARN    - CIS-DI-0001: Create a user for the container
        * Last user should not be root
INFO    - CIS-DI-0005: Enable Content trust for Docker
        * export DOCKER_CONTENT_TRUST=1 before docker pull/build
INFO    - CIS-DI-0006: Add HEALTHCHECK instruction to the container image
        * not found HEALTHCHECK statement
INFO    - CIS-DI-0008: Confirm safety of setuid/setgid files
        * setuid file: urwxr-xr-x usr/bin/chfn
        * setuid file: urwxr-xr-x usr/bin/newgrp
        * setgid file: grwxr-xr-x sbin/unix_chkpwd
        * setuid file: urwxr-xr-x bin/mount
        * setuid file: urwxr-xr-x usr/bin/gpasswd
        * setgid file: grwxr-xr-x usr/bin/ssh-agent
        * setgid file: grwxr-xr-x usr/bin/expiry
        * setgid file: grwxr-xr-x usr/bin/wall
        * setuid file: urwxr-xr-x usr/lib/openssh/ssh-keysign
        * setuid file: urwxr-xr-x bin/su
        * setuid file: urwxr-xr-x bin/umount
        * setuid file: urwxr-xr-x bin/ping
        * setuid file: urwxr-xr-x usr/bin/passwd
        * setgid file: grwxr-xr-x usr/bin/chage
        * setuid file: urwxr-xr-x usr/bin/chsh
INFO    - DKL-LI-0003: Only put necessary files
        * unnecessary file : app/Dockerfile

hadolintと同様に適用しないruleを.dockleignoreファイルで管理することもできます。dockleコマンド実行時のカレントディレクトリ下に配置するだけで適応されます。

# Avoid empty password
DKL-LI-0001
# Avoid apt-get upgrade, apk upgrade, dist-upgrade
DKL-DI-0003

CIで動かす

hadolint, dockleともにCIで動かすこともできます。

Dockerfileの差分があればlintを実行することでよりクリーンなDockerfileが維持されることが期待されます。

以下はGitLab CIでの例です。

stages:
  - dockerfile_test
  - docker_image_test
 
docker-hadolint:
  image: hadolint/hadolint:latest-debian
  stage: dockerfile_test
  script:
    - hadolint Dockerfile
  rules:
    - changes:
        - Dockerfile
 
dockle_test:
  variables:
    DOCKER_HOST: tcp://docker:2375
    DOCKER_TLS_CERTDIR: ""
    DOCKER_DRIVER: overlay2
  services:
    - docker:dind
  stage: docker_image_test
  before_script:
    - apk -Uuv add bash git curl tar sed grep
  script:
    - docker build -t dockle-ci-test:${CI_COMMIT_SHORT_SHA} .
    - docker save dockle-ci-test:${CI_COMMIT_SHORT_SHA} > dockle-ci-test.tar # 何故かdockle実行時にdokcer.ioを見てしまうので一旦tarに固める
    - |
      VERSION=$(
      curl --silent "<https://api.github.com/repos/goodwithtech/dockle/releases/latest>" | \\
      grep '"tag_name":' | \\
      sed -E 's/.*"v([^"]+)".*/\\1/' \\
      ) && curl -L -o dockle.tar.gz <https://github.com/goodwithtech/dockle/releases/download/v${VERSION}/dockle_${VERSION}_Linux-64bit.tar.gz> &&  \\
      tar zxvf dockle.tar.gz
    - ./dockle --exit-code 1 --input dockle-ci-test.tar
  rules:
    - changes:
        - Dockerfile

おわりに

Dockerfileのベストプラクティスを社内向けにまとめたものを公開させていただきました。

Dockerは使うだけならば、各フレームワーク公式がDocker Imageを公開していますので簡単に使うことができます。しかし、特に商用環境で利用することを考慮し、セキュアで再現性を高く、かつ可搬性も上げるためには様々な考慮が必要だよね、という話が社内であがったため今回の記事を書きました。

今回紹介したベストプラクティスは、Docker社公式であるものとフォルシアで特に重視しているものが混ざり合っています。特にLinterの使用はベストプラクティスをAS Codeで管理することができるので定着推進がしやすいため推しているところです。

この記事を読んでくださったかたの一助となりましたら幸いです。

おまけ: 参考になりそうなdocker-compose

社外の参考になりそうなdocker-compose

参考

FORCIA Tech Blog

Discussion