Dockerイメージのビルドを速く、軽くする

本記事は QualiArts Advent Calendar 2023 の8日目の記事です。

23新卒バックエンドエンジニアのfoltです。
プライベートでは、Rustを使いセキュアでパフォーマンスの高いソフトウェアを追求しています。
この記事では、Dockerイメージのビルド時間を短縮しイメージサイズを削減する方法をまとめます。

# 動機

コンテナを立てる際には信頼できるイメージを使うことが重要です。
しかし、複数のランタイムを含んだ実行・開発環境（Devcontainer）等が必要となる場合には、自身でイメージをビルドすることがあります。
このような場面では、速くビルドが終わり、小さなイメージが得られることが嬉しいでしょう。
小さなイメージサイズならばストレージやネットワーク料金、そしてイメージを利用する際の時間を削減できます。
この記事はこれらの知見を共有する目的で執筆しました。

# 想定する読者

- これからDockerイメージを作ろうと思っている方
- 最近Dockerイメージを作りはじめた方

またコンテナイメージを作っている際に、
- Dockerイメージのビルドに時間がかかりすぎてつらい方
- Dockerイメージのサイズが大きく苦しんでいる方

この記事では、これらの問題に対処する知見を共有しますが、お時間がある方は以下のリンク先のベストプラクティスを一読してみてください。
https://docs.docker.jp/develop/develop-images/dockerfile_best-practices.html

# ビルドを速くする

ビルドを速くするためにできることは限られていますが、これらの考慮がされてない環境・設定でビルドしていることがよくあります。

## 方針

- キャッシュを使う
- データの転送速度を最大化する

### Dockerのビルド設定を見直す

ビルド設定にはさまざまなものがありますが、目的に適していない使い方をしていることはよくあります。

#### --no-cache

no-cacheオプションはビルド処理の際にキャッシュを無視するフラグオプションです。
当たり前かもしれませんが、過去のビルド結果を利用したい場合には外す必要があります。

#### --platform {PLATFORM}

platformオプションは、特定のターゲットプラットフォームのためのイメージをビルドするためのオプションで、指定するとターゲット環境のイメージがビルドされます。
現在の環境とは異なるplatformオプションが指定されていると、エミュレーション機能が使われる場合があります。
この際、ビルド時間が大きく伸びる可能性があるため異なるプラットフォームの指定は必要な場合のみにとどめることが必要です。

### Dockerのネットワーク設定を見直す

多くのパッケージ管理ソフトウェアはリモートからファイルを取得します。
そのためネットワーク下り帯域の最大化やより効率的なプロトコルを使用することでビルドは速くなります。
ネットワーク帯域は環境依存であるため、ここではホストで可能なネットワーク設定を説明します。

#### 異なるネットワークモードを使ってみる

Dockerのバージョンによりますが、ビルド時のネットワークモードを指定できます。
ネットワークモードの切り替えによって速度が改善する場合があります。

```shell
docker build --network host .
```

#### IPv6を使わない

Dockerはバージョンや構成によってホストOSのIPv6や通信プロトコルに完全に対応していない場合があり、IPv4のみに制限する設定でダウンロード速度が改善することがあります。
遭遇した事例では、IPv6を使った場合にIPv4の時と比べて数十倍の時間がかかることがありました。
IPv6の制限が場合によっては有効な解決策となることがあります。
注意点として、IPv6を無効化しIPv4のみを有効にするオプションはコンテナの実行時のオプションにしか用意されていません。
そのため、ビルド時にIPv4のみに制限するにはホストOSのネットワーク設定を変更する必要があります。

### Dockerで利用するファイルシステムを見直す

「ネットワーク設定をしっかりやったのになぜか他の環境よりも遅い」という場合には、ファイルシステムを確認しましょう。

#### ファイルシステムの問題

仮想化領域で利用されているファイルシステムでは、仮想化のオーバヘッドが生じていることがあります。
特にホストOSと同居する仮想OSの内部でDockerを使う場合にはこの問題に遭遇することがよくあります。
代表的なものとしてWSL（Windows Subsystem for Linux）環境があり、Linux側からWindows側のファイルを参照するとファイル変換のオーバヘッドが生じます。
ビルド環境は、可能な限りシステムコールのオーバヘッドが少なくアーキテクチャの性能を活かすことができる環境にしておきたいです。

### ビルドを並列化する

後述するマルチステージビルド化をした上で、BulidKitを有効にすることでビルドを並列化できます。
DockerDesktopの環境の場合には、v3.2以降ではデフォルトで有効化されているようです。

環境変数によって有効化する場合は以下のコマンドを実行してください。
```shell
export DOCKER_BUILDKIT=1
```


# イメージサイズを小さくする

この記事ではわかりやすさを優先したおおまかなイメージサイズの削減をやってみます。
ここではPythonイメージを例にして説明します。
以下が説明に用いるDockerfileです。

:::note warn
Pythonを使いたい場合には公式のイメージを含む信頼できるイメージを利用することを推奨します
:::

```dockerfile
WORKDIR /tmp

# Install build dependencies
RUN apt-get update && apt-get install -y \
    build-essential \
    wget \
    libffi-dev \
    libssl-dev \
    zlib1g-dev \
    liblzma-dev \
    libbz2-dev \
    libreadline-dev \
    libsqlite3-dev

# Download Python source
RUN wget https://www.python.org/ftp/python/3.9.7/Python-3.9.7.tgz \
    && tar -xvf Python-3.9.7.tgz 

# Comile Python
RUN cd Python-3.9.7 \
    && ./configure --enable-optimizations --with-ensurepip=install \
    && make -j `nproc` \
    && make altinstall

RUN ln -s /usr/local/bin/python3.9 /usr/local/bin/python
RUN ln -s /usr/local/bin/pip3.9 /usr/local/bin/pip
```

## レイヤー

Dockerのイメージサイズの削減にはレイヤーの理解が必要となります。
ここでは具体的な例を挙げて説明します。

上述したDockerfileをビルドした結果を確認してみます。

```shell
docker images
```

```shell
REPOSITORY                               TAG             IMAGE ID       CREATED          SIZE
dockerfile-example                       latest          eaa6a83aaf21   1 minutes ago   
 916MB
```

historyサブコマンドで詳細な構成を確かめることができます。
```shell
docker history <image-name: dockerfile-example>
```


```shell
IMAGE          CREATED          CREATED BY                                       SIZE      COMMENT
eaa6a83aaf21   35 minutes ago   RUN /bin/sh -c ln -s /usr/local/bin/pip3.9 /…   0B        buildkit.dockerfile.v0
<missing>      35 minutes ago   RUN /bin/sh -c ln -s /usr/local/bin/python3.…   0B        buildkit.dockerfile.v0
<missing>      35 minutes ago   RUN /bin/sh -c cd Python-3.9.7     && ./conf…   437MB     buildkit.dockerfile.v0
<missing>      38 minutes ago   RUN /bin/sh -c wget https://www.python.org/f…   118MB     buildkit.dockerfile.v0
<missing>      38 minutes ago   RUN /bin/sh -c apt-get update && apt-get ins…   286MB     buildkit.dockerfile.v0
<missing>      38 minutes ago   WORKDIR /tmp                                     0B        buildkit.dockerfile.v0
<missing>      2 weeks ago      /bin/sh -c #(nop)  CMD ["bash"]                  0B        
<missing>      2 weeks ago      /bin/sh -c #(nop) ADD file:7b5bbc3b85f671aaf…   74.4MB
```

`CREATED BY`と`SIZE`を確認してみると、Dockerfileの各行が`CREATED BY`に対応しています。
Dockerfileの各行がレイヤーであり、レイヤー毎にサイズを持っていることが確認できます。
また`SIZE`の合計がイメージサイズになっていることがわかります。

つまりレイヤー毎のサイズの削減がイメージサイズの削減につながります。

## 便利なツール
Docker CLIだけでもイメージサイズの削減を進めることはできますが、これらの作業を効率化する便利なツールを紹介します。

### Dive

`docker history <image-name>`はサイズ肥大化の原因を見つけるために有効ですが、このツールはよりわかりやすい情報を提供してくれます。

https://github.com/wagoodman/dive

### Slim

このツールはDockerイメージのサイズを削減する作業を効率化できます。

https://github.com/slimtoolkit/slim

## 方針

- いらないものを見つけて削除する

## 基本的な手法

### キャッシュを削除する

パッケージ管理ソフトウェアやコンパイル時のキャッシュは実行時に必要ないものです。
基本的にこれらのキャッシュを削除することでイメージサイズの削減が期待できます。

#### apt-get

ベストプラクティスにも記載されてますが、apt-getの実行後に `/var/lib/apt/lists` を削除することでキャッシュが削除され、イメージサイズを削減することができます。
`apt-get clean` は公式Debian系イメージで自動で実行されるため省略しています。

以下の例では上のDockerfileでapt-getのキャッシュを削除してみました。
```dockerfile
RUN apt-get update && apt-get install -y \
    build-essential \
    wget \
    libffi-dev \
    libssl-dev \
    zlib1g-dev \
    liblzma-dev \
    libbz2-dev \
    libreadline-dev \
    libsqlite3-dev　\
    && rm -rf /var/lib/apt/lists/*
```

サイズが20MB程度削減できました。
```shell
REPOSITORY                               TAG             IMAGE ID       CREATED             SIZE
dockerfile-example                       latest          18ebfbb9aa07   2 minutes ago       898MB
```

#### 実行キャッシュ

実行時に利用するキャッシュについても削除することは可能で、ビルドしたイメージに含まれている場合があります。
これらのキャッシュは実行時の高速なロードなどのために生成されており、イメージサイズの増加の原因になります。
ユースケースに合わせてこれらのキャッシュを削除することも一つの選択肢となります。

### レイヤーを圧縮する

イメージはレイヤー毎に保持されるため、レイヤーの圧縮によりレイヤー数を減らすことでイメージサイズの削減が期待できます。
一方で、レイヤー圧縮は可読性を低下させることがあるため注意が必要です。

以下は、レイヤーをまとめた方が良い事例です。
パッケージのインストールとキャッシュの削除が異なる`RUN`にあるためレイヤー数・サイズが増加します。
```dockerfile
RUN apt-get update && apt-get install -y \
    build-essential
RUN rm -rf /var/lib/apt/lists/*
```

### そのレイヤー以外で使わないファイルをレイヤー内で削除する

レイヤーで使用したファイルには、以降のレイヤーおよび実行時に利用しないファイルが含まれている場合があります。
以下の例では、レイヤー圧縮をした上でダウンロードしたソースコードとコンパイルに用いたディレクトリを削除しています。
これにより大幅なイメージサイズの削減が期待できます。

```dockerfile
RUN wget https://www.python.org/ftp/python/3.9.7/Python-3.9.7.tgz \
    && tar -xvf Python-3.9.7.tgz \
    && cd Python-3.9.7 \
    && ./configure --enable-optimizations --with-ensurepip=install \
    && make -j `nproc` \
    && make altinstall \
    && rm -rf /tmp/Python-3.9.7*
```

確認してみるとイメージサイズは300MB程度削減できました。

```
REPOSITORY                               TAG             IMAGE ID       CREATED          SIZE
dockerfile-example                       latest          01673c8530c8   27 seconds ago   593MB
```

### 必要のない依存関係のインストールを避ける

パッケージ管理ソフトウェアでインストールされるパッケージの中には必要としない機能が含まれていることがあります。
インストールしないという選択肢以外にも、同じレイヤーでアンインストールすることでレイヤーイメージに含まないことも可能です。
説明に用いているDockerfileでは`build-essential`をインストールしていますが、このパッケージには非常に多くのパッケージが含まれているため必要なパッケージのみインストールすることでイメージサイズの削減が可能です。

### マルチステージビルドする

ステージを分けることで、最終イメージに前のステージの利用しないファイルを持ち越さないことも可能です。
またマルチステージビルドにすることで並列化も可能になるため、マルチステージ化はビルド高速化とイメージサイズ削減の両方に有効です。
以下の例ではマルチステージにし、apt-getでインストールした依存関係のように最終的なイメージで必要のないファイルを持ち越さないようにしています。
次のステージで必要なファイルを前のステージからコピーしないことで実行時にエラーが発生することがあるため注意が必要です。

```dockerfile
# ステージ1
FROM debian:bullseye-slim AS build-python

WORKDIR /tmp

# Install build dependencies
RUN apt-get update && apt-get install -y \
    build-essential \
    wget \
    libffi-dev \
    libssl-dev \
    zlib1g-dev \
    liblzma-dev \
    libbz2-dev \
    libreadline-dev \
    libsqlite3-dev \
    && rm -rf /var/lib/apt/lists/*

# Download Python source and comile Python
RUN wget https://www.python.org/ftp/python/3.9.7/Python-3.9.7.tgz \
    && tar -xvf Python-3.9.7.tgz \
    && cd Python-3.9.7 \
    && ./configure --enable-optimizations --with-ensurepip=install \
    && make -j `nproc` \
    && make altinstall \
    && rm -rf /tmp/Python-3.9.7*

# ステージ2
FROM debian:bullseye-slim AS bin-python
COPY --from=build-python /usr/local/bin/python3.9 /usr/local/bin/python3.9
COPY --from=build-python /usr/local/bin/pip3.9 /usr/local/bin/pip3.9
COPY --from=build-python /usr/local/lib/python3.9 /usr/local/lib/python3.9
RUN ln -s /usr/local/bin/python3.9 /usr/local/bin/python
RUN ln -s /usr/local/bin/pip3.9 /usr/local/bin/pip
```

イメージサイズが320MB程度削減できました。

```shell
REPOSITORY                               TAG             IMAGE ID       CREATED          SIZE
dockerfile-example                       latest          62b82150ffa8   2 minutes ago    277MB
```

### COPYを避ける

COPYは1つのレイヤーとして扱われるため必要のないファイルがイメージに含まれイメージサイズが肥大化することがあります。
ADDやリモートからの取得にすることで回避できる場合があります。

# 場合によってはありな手法

以下で紹介する方法は、ビルドを行う環境への依存が強くなり、様々なデメリットが生じる手法です。
利用する際には十分な検討が必要です。

### パッケージ管理ソフトウェアのためのキャッシュサーバを建てる

パッケージサーバーの転送速度が遅延の原因となっている際には、キャッシュサーバーを自身で建てることで2度目以降のダウンロード速度を速くすることができます。
手間に対して効果が限定的な場合も多いです。

### ストレージサーバ・キャッシュサーバを建てる

前述したCOPYを避ける手法としてストレージサーバを立てる方法があります。
具体的には、RUN内でファイルのダウンロード、（必要に応じて解凍、）利用した処理を行い、ダウロードしたファイルを削除することでイメージサイズに全く影響なくビルドを進めることができます。
筆者はHTTPファイルサーバーを立てており、このサーバーを利用してCOPYを回避することがあります。
環境への依存は強くなりますが、任意のタイミングで更新可能なキャッシュとしての使用も可能な強力な手法です。

### バイナリをスクリプトに置き換える

OSのコマンドによっては他のコマンドをラップして提供されるものが存在しており、それらの機能はスクリプトで置き換え可能な場合があります。
環境への依存が非常に強くなりますが、数MBの依存関係を数バイトに削減することが可能です。

例えばadd-apt-repositoryはAPTのソースリストにソースを追加するために用いるコマンドですが、このソースの追加処理は他のコマンドを組み合わせることでも同様の結果が得られます。

# 最後に

この記事では筆者がよくやっているDockerイメージのビルド高速化とサイズの軽量化についてまとめました。
今回挙げたPythonの例はさらに最適化できるため気になる方はやってみてください！
記事に書かれていない手法を知っている方がいたらぜひ教えていただけると嬉しいです！