社内の手動テストを Docker で自動化して CodeBuild 上で動くようにしたときの備忘録

はじめに
========

社内で行われていた DB サーバ、 アプリケーションサーバ、バッチサーバ等の複数ホストを連携させる手動テストを一部自動化しました。連携する各サーバを Docker コンテナとして動かすことで、 複数ホストを連携させる large テストではなく、単一ホスト内で完結する medium テストにしました。また、 CI として使用するために、ローカルだけではなく AWS CodeBuild 上で動かせるようにしました。

このときの実装の要点や、ハマった問題に対する解決方針をいくつか共有します。


BuildKit によるビルドの高速化
============================

テストを自動化する上で、まずは Docker 化されていない各サーバを Docker 化しました。各サーバのための Dockerfile を用意し、 `docker build` によってソースコードからコンパイルして Docker イメージを作成できるようにしました。

Docker はイメージのビルド時に Dockerfile に記述された各命令行ごとにキャッシュを行います[^1]。しかし、Docker の各行ごとのキャッシュだけでは、コンパイラ側のキャッシュの機能をうまく活用できません。コンパイルを行う行の前の時点でのコンテナ状態のキャッシュは行われますが、コンパイル前なので当然コンパイル時に生成される中間ファイル等がキャッシュに含まれていないからです。これではプログラムの一部分を変更しただけでも `docker build` を行うとフルでコンパイルが行われ、毎回時間がかかってしまいます。

``` dockerfile
HOGEHOGE       # src 内を書き換えた場合、この行完了時点のキャッシュから
               # ビルド再開されるため、フルコンパイルになってしまう
COPY src/ src/
RUN compile
```

この問題は BuildKit[^2] を用いることで解決できます。BuildKit とは Docker のビルド機能の拡張です。Docker ではコンテナ実行時にホスト側のディレクトリをマウントする機能がありますが、 BuildKit を利用すれば、コンテナ実行時だけではなく Docker イメージのビルド時にホスト側ディレクトリをマウントできます。また、それを行うための Dockerfile の拡張構文が追加されます。 Docker Desktop を利用していれば BuildKit はデフォルトで有効、それ以外の Docker (ただし18.09以上) では `DOCKER_BUILDKIT` 環境変数を `1` にセットすることで有効にできます。

今回はこの機能を用いて、以下のように `--mount` という BuildKit で追加された `RUN` コマンドのオプション[^3]を用いて、コンパイルの中間ファイルのキャッシュを行いました。

``` dockerfile
RUN --mount=type=cache,target=/hoge/fuga \
    compile
```

BuildKit を用いるとログが省略されてしまう問題
===========================================

BuildKit を利用すると、 `docker build` を行ったときのログ出力も高機能になります。例えば、ログ出力のサイズが巨大だったり、ログ出力が一定よりも高速だったりすると出力を省略する機能があります。デフォルトではログ出力が 1MiB に到達する、あるいは、ログ出力速度が 100KiB/s に達すると以下のメッセージが出力され、それ以上の出力が行われなくなります。

* 1MiB に到達した場合

    ```
    [output clipped, log limit 1MiB reached]
    ```

* 100KiB/s に到達した場合

    ```
    [output clipped, log limit 100KiB/s reached]
    ```

しかし、この機能は、意図せずログが見えなくなってしまい、不便なこともあります。

ログが省略されるサイズの閾値は、設定により引き上げることができます。**Docker デーモン起動時 (`docker build` 実行時ではない)** に `BUILDKIT_STEP_LOG_MAX_SIZE` と `BUILDKIT_STEP_LOG_MAX_SPEED` 環境変数を渡すことで設定できます[^4]。

具体的には、 例えば systemd を利用している場合は `systemctl edit docker.service` コマンドを実行し、
開かれるファイルに以下のような記述をすることで閾値を設定できます。

```
[Service]
Environment="BUILDKIT_STEP_LOG_MAX_SIZE=1073741824"
Environment="BUILDKIT_STEP_LOG_MAX_SPEED=20971520"
```

以上のように、ローカルで動作する Docker に対しては、この設定は簡単に行えます。しかし、 AWS CodeBuild のような環境では Docker デーモン起動前に環境変数を設定しておくことが難しいため工夫が必要です。 AWS CodeBuild でこれら設定を行う方法として以下の 2 方法を考えました。

* CodeBuild で利用される DockerImage を差し替える
* Buildx を用いてビルダーインスタンスを作成し、その際に環境変数を渡す

前者の方法は仕組み的には簡単で、 Docker デーモンが起動する前に上述の環境変数を設定するようにした Docker イメージを作成し、 CodeBuild の環境設定でカスタムイメージとしてその Docker Image を指定します。ただし、この方法は、環境変数を渡す方法自体は簡単ですが、 Docker 内で Docker を動作させる (Docker in Docker) 必要があり、そのために少し特殊なことを行う必要があります。 Docker in Docker のための Docker イメージを作成する方法は後述します。

後者の方法では、 Buildx[^5] という BuildKit のための Docker CLI の拡張を用います。 Buildx を用いることで BuildKit のより様々な機能にアクセスできるようになります。今回は、 Buildx を用いて `BUILDKIT_STEP_LOG_MAX_SIZE` を設定したビルダインスタンスを作成し、そのインスタンスの上でビルドを行うことで、先ほどの問題を解決します。

Buildx でビルダインスタンスを作成し、そのインスタンスを使用するように設定し、そのインスタンスに環境変数を渡すには以下のコマンドを実行します。

``` sh
docker buildx create --use --driver-opt env.BUILDKIT_STEP_LOG_MAX_SIZE=1073741824
```

このコマンドを実行した後で、 `docker build` コマンドと互換の `docker buildx build` コマンド[^6]を使用すれば、 Docker イメージのビルドを行うことができます。 `docker compose build` を使用してビルドしている場合は、それと互換の `docker buildx bake` コマンド[^7]が代わりに使えます。(ただし Buildx v0.8.2 時点では compose ファイルのオーバーライドには対応していませんでした。)



Docker in Docker のイメージ作成
==============================

Docker コンテナとして Docker デーモンを動かすイメージを作成するためには、Docker を実行できるよう、通常通り Docker をインストールした Docker イメージを作成します。

問題になるのがストレージドライバーです。ストレージドライバとは、 Docker でイメージをビルドしたときなどに、それを保存する方式です。通常 Docker は、ストレージドライバとして、 overlay2 でイメージを保存します。しかし、 overlay2 は、 xfs や ext4 の上でしか動作せず、 overlay2 の上で overlay2 は動作しません[^8]。

この問題への対処として、どのようなファイルシステムの上でも動作する vfs を利用することが考えられます。ストレージドライバとして vfs を使用するには Docker デーモン起動時のオプションで `--storage-driver=vfs` を指定します[^9]。

しかし vfs を使用するとまた別の問題が発生します。vfs は overlay2 を使用する場合よりもストレージ消費量が100倍以上多く、私が使用したときにはストレージを使い果たしてしまいました。

最終的にこの問題は、 Docker in Docker を行うイメージでホスト側のファイルシステムをマウントすることで解決しました。 Docker の動作に使われるディレクトリだけ、ホスト側のファイルシステム (ext4) をマウントすることで、その上で overlay2 を動作させることができます。それを行うために、 Docker in Docker を行う Dockerfile には以下の行を記述しました。

``` dockerfile
VOLUME /var/lib/docker
```


Docker コンテナ内から他のコンテナを操作する
=========================================

テストを実装するにあたって、テスト用のコンテナ内から他のコンテナを操作する必要がありました。そのためには、コンテナ内の Docker クライアントから、ホスト側の Docker デーモンにアクセスする必要があります。

Docker では、 Docker デーモンと Docker CLI の通信は、標準的には `/var/run/docker.sock` という UNIX ソケットを経由して行われます。

今回は、ホスト側の `/var/run/docker.sock` をコンテナ内の `/var/run/docker.sock` にマウントすることで、 Docker コンテナ内の Docker クライアントから、ホスト側の Docker サーバを利用して、他の Docker コンテナを制御できるようにしました。


おわりに
========

社内の手動テストを Docker で自動化して CodeBuild 上で動くようにする上で難しかった点を何点か書きました。

どのような問題に対しどのような考えでどのような解決方針を取ったかを中心に述べたため、各技術の詳細については深く言及しませんでした。今回言及した各技術の詳細については以下リンクを参照してください。


[^1]: <https://docs.docker.jp/develop/develop-images/dockerfile_best-practices.html#leverage-build-cache>
[^2]: <https://docs.docker.jp/develop/develop-images/build_enhancements.html>
[^3]: <https://github.com/moby/buildkit/blob/master/frontend/dockerfile/docs/reference.md#run---mounttypecache>
[^4]: <https://github.com/docker/buildx/issues/484>
[^5]: <https://github.com/docker/buildx>
[^6]: <https://github.com/docker/buildx/blob/master/docs/reference/buildx_build.md>
[^7]: <https://github.com/docker/buildx/blob/master/docs/reference/buildx_bake.md>
[^8]: <https://docs.docker.jp/storage/storagedriver/select-storage-driver.html#id16>
[^9]: <https://docs.docker.jp/engine/userguide/storagedriver/selectadriver.html#a-pluggable-storage-driver-architecture>