Docker Buildにおけるリードタイム短縮のための3つの改善ポイント

こんにちは、エンジニアの oga です。2019/12に入社してプロダクト開発業務に携わっています。

プレイドでは、KARTE の開発及び実行環境としてDockerやk8sを活用していていますが、私自身はこれまでPaaSやFaaSなどのコンテナ管理が隠蔽されている様な形やそもそもコンテナを使わない開発が多かったので、Dockerfileに構築のコマンドを書き連ねてdocker build でバシッとイメージができる便利!ぐらいの理解度でした。

今回はそんな中から基礎として学んだDockerfileのベストプラクティスを、

リードタイムCI/CDの実行時間を短縮し開発生産性を向上させる為に行うべき事

という観点でまとめました。

大筋は 公式のベストプラクティス に挙がっている手法を理解し易く整理し、重要だけど分かり辛い部分を解説する内容になります。

3つの改善ポイント

Docker Imageのサイズが肥大化してしまうと、PCのディスク圧迫・転送速度の悪化(ビルド実行したホスト→レジストリ、レジストリ→デプロイ先サーバなど)・ビルド時間の増大といった問題につながります。

つまりデプロイメントパイプライン全体の所用時間へ悪影響となり得るもので、ボトルネックとして大きな存在感を持たないかもしれませんが日々少しずつ時間を浪費してしまう事もあるかもしれません。

また、Imageのサイズ削減に加え、さらに内部処理を理解する事でビルド時間に直接大きな改善ができるポイントもあります。

それを踏まえて、下記の3つを改善ポイントとして整理します。

  1. Docker Daemonへの転送ファイル削減
  2. Docker Imageのサイズ削減
  3. cacheの有効活用

1. Docker Daemonへの転送ファイル削減

  1. Build Contextはビルドに必要なファイルを含む最も深い階層(ディレクトリ)に指定する
  2. Build Contextとするディレクトリに.dockerignoreファイルを設置して.gitやnode_modules、テスト用ファイルやビルド結果ファイル群などを記述して転送対象から除外する

docker build実行時にClientからDaemonにBuild Contextとして指定したディレクトリをtarでまとめて転送する、という動作があります。
Build Contextは、dockerコマンド実行位置か引数で指定したディレクトリ、docker-composeなら context で指定したディレクトリになります。

architecture

https://docs.docker.com/engine/docker-overview/#docker-architecture より抜粋

Docker Daemonではそのtarを展開した状態でビルド処理を進めます。
.dockerignoreファイル に記述されたファイル群はこのtarに含まれない事になり、無用なファイルのDaemonへの転送をしないというメリットがあります。

また、ビルドでもランタイムでも不要とするものを定義する事でDocker Imageに不要なファイルが混入する事を未然に防ぐ事もできます。

2. Docker Imageのサイズ削減

  1. scratchやalpineなど軽量なベースイメージを選択する
  2. ランタイムに必要なファイルのみCOPYする(ビルド処理にも使わないものは.dockerignoreでそもそもDaemonに転送しないのがベスト)
  3. apkやapt-get、npm等で不要な依存モジュールのインストールを行わない
  4. Layer数自体を可能な限り少なくする

Docker Imageのサイズの増大は、CI/CDの実行時間には最も広く範囲で影響します。
ここでは、Node.jsのサンプルを交えて必要最低限のファイルを最小Layer構成で作成する流れを説明します。

Layerの概要

の前に、前提となるLayerの基本的な概念も紹介します。

layer

https://docs.docker.com/storage/storagedriver/ より抜粋

Docker ImageはUnionFS という複数のFS(Layer)にあるファイルを透過的に一つのものとして扱う技術が使われ、Dockerfileの命令によって作成された全ての中間Layerをマージして最終的に一つのFSとなります。

Layerは RUN / COPY / ADD でのみ作成され、それ以外は一時的なLayerとして作成されます。(つまり無闇にこれらのステートメントを小分けにせず可能な限りまとめて記述 && で接続する 事も重要です)

Docker Imageの解析方法

改善の前に、今の問題点と改善されたかを確認する術が必要です。

その為のDocker Imageの解析には、dive というOSSを使用します。

ImageのLayer毎のサイズや含まれるファイルはもちろん、Layerで変更したファイルの確認もできる為、アプリケーションに必要なファイルのみが設置されている事と無駄なものが含まれていないか、無駄なものはどこで追加されたのか等を確認する際に非常に便利です。

以降でアンチパターンとベストプラクティスのdiveでの解析結果の一部を見ながら、問題点とそれが解消されたかを確認していきます。
※ここでは割愛していますが、ファイル差分をコンソールでツリー表示できるので細かな解析も可能です

サイズを肥大化させるアンチパターン と ベストプラクティスのサンプル(Node.js)

TypeScriptで記述したコードをJSにコンパイルして実行するアプリケーションを例にDockerfileとそのLayerの状況を確認してきます。
このサンプルでは、コンパイルの成果物であるJSファイル・その依存モジュール・package.jsonのみがランタイムに必要なもので、それ以外のソースコードやビルドプロセスで使用するモジュールは必要ありません。この構成は、ビルド成果物であるバイナリのみがランタイムに必要なJavaやGoなどのコンパイル型言語を利用する場合でも同じ考え方が適用できます。

┣━ package-lock.json
┣━ package.json
┣━ src
┃  ┗━ index.ts
┣━ Dockerfile
┗━ tsconfig.json

サンプルのファイル構成

"scripts": {
  "build": "tsc -p .",
  "start": "node dist/index.js"
},
"dependencies": {
  "express": "4.17.1"
},
"devDependencies": {
  "@types/express": "4.17.3",
  "@types/node": "13.7.1",
  "typescript": "3.7.5"
}

package.jsonの中身の抜粋

Dockerfileアンチパターン

FROM node:10.19.0-alpine

WORKDIR /usr/src/app
COPY . .
RUN npm install
RUN npm run build

CMD [ "npm", "start" ]

このDockerfileで作成されるイメージの解析をしてみます。

$ dive build .

この様にdockerコマンドの代わりにdiveコマンドでbuildを実行するとdocker buildで作成したImageをそのまま解析にかける事ができます。

Total Image size: 146 MB

Layers
5.6 MB  FROM 48ab3fa2c689ed1                                                        
 70 MB  addgroup -g 1000 node     && adduser -u 1000 -G node -s /bin/sh -D node     
8.3 MB  apk add --no-cache --virtual .build-deps-yarn curl gnupg tar   && for key in...
 116 B  #(nop) COPY file:238737301d47304174e4d24f4def935b29b3069c03c72ae8de97d946243...
# === ↑ここまではFROMで指定したnode:10.19.0-alpineのLayer ===
   0 B  #(nop) WORKDIR /usr/src/app                                                 
 30 kB  #(nop) COPY dir:aaf293439ab5760b443d467609327c967b800cf3974065e9f206cee9ec37...
 63 MB  npm install                                                                 
 162 B  npm run build                                                               

RUN npm install のLayerサイズは63MBとなっていますが、 npm run build に必要なdevDependenciesの依存パッケージも含まれている為、本来ランタイムには不要なファイルのせいで肥大化してしまっています。

ついでに、Dockerfile内で不要なファイルを削除するステートメントを入れた場合にどうなるかも見てみましょう。

FROM node:10.19.0-alpine

WORKDIR /usr/src/app
COPY . .
RUN npm install
RUN npm run build
RUN rm -rf ./node_modules
RUN npm install --production

CMD [ "npm", "start" ]

Total Image size: 148 MB

Layers
5.6 MB  FROM 48ab3fa2c689ed1                                                        
 70 MB  addgroup -g 1000 node     && adduser -u 1000 -G node -s /bin/sh -D node     
8.3 MB  apk add --no-cache --virtual .build-deps-yarn curl gnupg tar   && for key in...
 116 B  #(nop) COPY file:238737301d47304174e4d24f4def935b29b3069c03c72ae8de97d946243...
# === ↑ここまではFROMで指定したnode:10.19.0-alpineのLayer ===
   0 B  #(nop) WORKDIR /usr/src/app                                                 
 30 kB  #(nop) COPY dir:aaf293439ab5760b443d467609327c967b800cf3974065e9f206cee9ec37...
 63 MB  npm install                                                                 
 162 B  npm run build                                                               
   0 B  rm -rf ./node_modules                                                       
1.9 MB  npm install --production

最終的なImageサイズはむしろ2MBほど増加しました。
rm -rf ./node_modules でファイルを削除した為UnionFSで一つのFSとしてマージされれば無駄なファイルは含まれていない様に見えますが、Image自体にはファイルが存在するLayerも含まれる為最終的なサイズは変化しない事になります。

また、ここで npm install --production のLayerが1.9MBなので、61MB程度がdevDependenciesの不要なファイルという事が分かります。

Dockerfileベストプラクティス

この問題に対処するには、ビルド処理を行うステートメントを最終的なイメージと分離するマルチステージビルド(multi-stage builds)を使用する事で解決する事ができます。

# ----------
# build(tscを実行してjsを作る)用のstage
# ----------
FROM node:10.19.0-alpine AS build

WORKDIR /build
COPY . .
RUN npm install
RUN npm run build

# ----------
# runtime用のstage
# 必要なファイルは
#   dist/
#   node_modules/
#   package.json
#   package-lock.json
# のみ
# ----------
FROM node:10.19.0-alpine

WORKDIR /usr/src/app

# src下のtsファイルなど不要なものは含めない
COPY package*.json ./

# ランタイムに必要な依存パッケージのみインストールし、同時にnpmのcacheファイルを削除する
RUN npm install --production --cache /tmp/empty-cache && rm -rf /tmp/empty-cache

# --fromで前半で記述したAS buildと命名した中間イメージから必要なファイルのみ抽出する事ができる(distディレクトリにtsコンパイル結果であるjsファイルが出力されている)
COPY --from=build /build/dist ./dist

CMD [ "npm", "start" ]

Total Image size: 86 MB

5.6 MB  FROM 48ab3fa2c689ed1                                                        
 70 MB  addgroup -g 1000 node     && adduser -u 1000 -G node -s /bin/sh -D node     
8.3 MB  apk add --no-cache --virtual .build-deps-yarn curl gnupg tar   && for key in...
 116 B  #(nop) COPY file:238737301d47304174e4d24f4def935b29b3069c03c72ae8de97d946243...
   0 B  #(nop) WORKDIR /usr/src/app                                                 
 19 kB  #(nop) COPY multi:d2d4eea7823e50333a93f9365997744bdab5bef53f00471ca0dd93457d...
2.5 MB  npm install --production                                                    
 107 B  #(nop) COPY dir:4006974365c2adf58b31daec4c65c5423683e966436eaf409eff0b294f64...

最終的なImageにはDockerfileの前半に記述したbuildステージのLayerは一切含まれず、不要なnode_modulesのファイルと(今回はサンプルなので極小サイズですが)コンパイルの為のソースコードが排除されたものが作成されました。

3. cacheの有効活用

  1. Layer Cacheの仕組み理解してDockerfileを書く
  2. CIではそれぞれのサービスでサポートされているLayer Cache機構を活用する

Docker Daemonでは、Docker Imageのビルド時に作成したLayerをcacheとして保持しており、Layer毎にcache busted条件に当てはまらない場合はcacheされたLayerが使われそのLayerのビルド処理自体はスキップされます。

cache busted条件

COPY,ADD 以外のステートメントではDockerfileの記述が変わらない限り変更無しとして扱われます。冪等では無いRUNである場合でも、コマンド自体に変更が無ければ2度目以降は実行はスキップされ結果のcacheが使用されます。

ファイルを扱う COPY,ADD については、対象となる全ファイルのタイムスタンプを除く要素(中身やパーミッション)の変更が検知されcache bustedとなり、以降のステートメントは全てcacheが無効となります。

Dockerfileアンチパターン

FROM node:10.19.0-alpine
WORKDIR /usr/src/app
COPY . .
RUN npm install
CMD [ "npm", "start" ]

この場合、 package*.json 以外のアプリケーション部分(最も頻繁に変更する部分)を含むCOPYの後に npm install をする為、 package*.json に変更が無くても以下の様にCOPYステートメントでcacheが使われず、 npm install も実行されてしまいます。

# cacheが無い状態(1度目のビルド)
$ docker build -f Dockerfile .                         
Sending build context to Docker daemon  36.86kB
Step 1/5 : FROM node:10.19.0-alpine
10.19.0-alpine: Pulling from library/node
c9b1b535fdd9: Pull complete 
514d128a791d: Pull complete 
ab9dddf2630f: Pull complete 
acb767e231ef: Pull complete 
Digest: sha256:e8d05985dd93c380a83da00d676b081dad9cce148cb4ecdf26ed684fcff1449c
Status: Downloaded newer image for node:10.19.0-alpine
 ---> 29fc59abc5de
Step 2/5 : WORKDIR /usr/src/app
 ---> Running in 3553ad2992b5
Removing intermediate container 3553ad2992b5
 ---> 74ad5e46291f
Step 3/5 : COPY . .
 ---> 31b3836d301c
Step 4/5 : RUN npm install
 ---> Running in 3ef03d67d060
added 63 packages from 103 contributors and audited 141 packages in 3.267s
found 0 vulnerabilities

Removing intermediate container 3ef03d67d060
 ---> 053faf5803ad
Step 5/5 : CMD [ "npm", "start" ]
 ---> Running in 87a59e056a75
Removing intermediate container 87a59e056a75
 ---> 137e9163caa5
Successfully built 137e9163caa5

$ echo "changes..." >> src/index.ts

# cacheがある状態(2度目のビルド)
$ docker build -f Dockerfile .
Sending build context to Docker daemon  36.86kB
Step 1/5 : FROM node:10.19.0-alpine
 ---> 29fc59abc5de
Step 2/5 : WORKDIR /usr/src/app
 ---> Using cache
 ---> 74ad5e46291f
Step 3/5 : COPY . .
 ---> 9d82e700d43a
Step 4/5 : RUN npm install
 ---> Running in 880c4cdb4a77
added 63 packages from 103 contributors and audited 141 packages in 3.225s
found 0 vulnerabilities

Removing intermediate container 880c4cdb4a77
 ---> ca173febc39a
Step 5/5 : CMD [ "npm", "start" ]
 ---> Running in be0789f174d5
Removing intermediate container be0789f174d5
 ---> 6d96c96deb67
Successfully built 6d96c96deb67

Dockerfileベストプラクティス

FROM node:10.19.0-alpine
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm install
COPY . .
CMD [ "npm", "start" ]

こちらは、 npm install は直前のCOPYの変更がなければcacheを使う事ができ、 package*.json の更新があった場合に限定する事ができます。以下の様にsrc/index.tsの変更であればcacheが適用されます。

# cacheが無い状態(1度目のビルド)
$ docker build -f Dockerfile .                         
Sending build context to Docker daemon  36.86kB
Step 1/6 : FROM node:10.19.0-alpine
10.19.0-alpine: Pulling from library/node
c9b1b535fdd9: Pull complete 
514d128a791d: Pull complete 
ab9dddf2630f: Pull complete 
acb767e231ef: Pull complete 
Digest: sha256:e8d05985dd93c380a83da00d676b081dad9cce148cb4ecdf26ed684fcff1449c
Status: Downloaded newer image for node:10.19.0-alpine
 ---> 29fc59abc5de
Step 2/6 : WORKDIR /usr/src/app
 ---> Running in e830ef841e6a
Removing intermediate container e830ef841e6a
 ---> afa8e56cfa87
Step 3/6 : COPY package*.json ./
 ---> 75c3f6213e37
Step 4/6 : RUN npm install
 ---> Running in f40616adb12b
added 63 packages from 103 contributors and audited 141 packages in 3.037s
found 0 vulnerabilities

Removing intermediate container f40616adb12b
 ---> bb049dacf487
Step 5/6 : COPY . .
 ---> ed9a51dd72c4
Step 6/6 : CMD [ "npm", "start" ]
 ---> Running in 3afb9dc12b13
Removing intermediate container 3afb9dc12b13
 ---> 0da0c9442549
Successfully built 0da0c9442549

$ echo "changes..." >> src/index.ts

# cacheがある状態(2度目のビルド)
$ docker build -f Dockerfile .
Sending build context to Docker daemon  36.86kB
Step 1/6 : FROM node:10.19.0-alpine
 ---> 29fc59abc5de
Step 2/6 : WORKDIR /usr/src/app
 ---> Using cache
 ---> afa8e56cfa87
Step 3/6 : COPY package*.json ./
 ---> Using cache
 ---> 75c3f6213e37
Step 4/6 : RUN npm install
 ---> Using cache
 ---> bb049dacf487
Step 5/6 : COPY . .
 ---> a71fed6250cc
Step 6/6 : CMD [ "npm", "start" ]
 ---> Running in f6439e6dce92
Removing intermediate container f6439e6dce92
 ---> 6aa3a753e2e0
Successfully built 6aa3a753e2e0

CIサービスでのcache利用

cacheはDaemonで保持しているので通常異なるホストで実行する場合はcacheが効かないですが、メジャーなCIサービスではcache利用の為の機能を用意しているのでdocker buildをCIサービスで実行する場合は、それぞれで必要な設定を行えばcacheが活用できます。

例えばプレイドでよく使われているCircleCIとCloudBuildでは以下の様になっています。

さらなる効率化の為に発展しているツール・機能

ここまではDockerの基礎的なベストプラクティスでしたが、さらに改善に利用できるツールや機能を簡単に紹介します。

Buildkit

新たな docker build として開発されたツールである、 Buildkit を利用する事でさらなる高速化が行える可能性があります。

  • stage間の依存関係を解析しビルド処理を並列化
  • cacheやsecret用のmount機能があり、各種パッケージマネージャのキャッシュや秘密鍵等をLayerに含めずにビルドに組み込む事ができる
  • build contextが差分転送になる
  • Rootless mode (Experimental)

など様々な面で docker build からパワーアップしたツールです。

Buildkitのみをインストールして使用する事もできますが、 Dockerでも version 18.09からBuildkitが統合されていて 、環境変数( DOCKER_BUILDKIT=1 )を設定する事ですぐに使用する事も可能です。

$ DOCKER_BUILDKIT=1 docker build .

mount機能等新しいDockerfileの記法を活用するには、 experimental syntaxを指定 する必要がありますが、従来のDockerfileと互換性がある為まずはBuildkitを使うだけで高速化するか是非検証してみてください。

BuildX

上記のBuildkitと関連するものですが、 BuildX というDocker CLI PluginがDocker version 19.03以降のexperimental modeで使用できます。

Docker Buildx | Docker Documentation

Buildkitのマルチプラットフォームビルド機能などいくつかの機能は、 DOCKER_BUILDKIT=1 を設定した docker build では利用する事ができません。本来の機能をフル活用するには複雑なBuildkitのCLIを使用する必要があります。この問題に対して、BuildXは従来の docker build に近いIFでバックエンドをBuildkitとするビルドを実行できる様になります。

docker build --squash

experimental optionですが、squash オプションを使用すると最終的なImageを単一Layerで作成する変換処理を追加させる事ができます。

これにより、マルチステージビルドを使用せず不要ファイルの削除を行う様なステートメントで構成しても最終的なImageサイズはマルチステージビルドを使用した場合と同等の結果になります。

ただし、

  • ビルドキャッシュは利用できる様統合前のLayerもディスク上に配置される為、デ ィスク領域を圧迫しやすくなる
  • Layer統合のステップが追加される為ビルド時間が増加する
  • Layerが必ず1つになる為並列転送ができなくなる

など多くのデメリットがあり、とにかくセンシティブなファイルの除去・サイズ削減ができればデメリットは問題ないと言い切れるケース以外はマルチステージビルドを使用すべきです。

まとめ

  1. Docker Daemonへの転送ファイル削減
  2. Docker Imageのサイズ削減
  3. cacheの有効活用

基本であるこの3点をベストに近づけていく事で、リードタイムを短縮し生産性を高める事ができます。
冒頭に書いた通り、ボトルネックとなりそれが認知されるまでは時間がかかるし優先度も低くなりがちな問題ですが、なるべく初期の段階から効率的な方法を学び最適化しそれが継続される状態を目指す事で、より本質的な事柄に集中する時間を増やす助けになると思います。

CX(顧客体験)プラットフォーム「KARTE」を運営するプレイドでは、KARTEを使ってこんなアプリケーションが作りたい! KARTE自体の開発に興味がある!というエンジニア(インターンも!)を募集しています。
詳しくは弊社採用ページまたはWantedlyをご覧ください。 もしくはお気軽に、下記の「話を聞きに行きたい」ボタンを押してください!