この記事は @fuwasegu くんの記事と合わせて二本立てとなっています。こちらもどうぞご覧ください!
【裏技】別ファイルに切り出した Job 間で Docker イメージを共有し,高速に GitHub Actions をぶん回す
docker compose on GitHub Actions
昨今ではDocker(コンテナ)を使った環境整備が主流になってきています。アプリケーションの実行環境自体をコード化できるため、開発環境間の差異や、本番環境の差異を吸収し、アプリケーションの開発に集中することができます。
一方、CIとDockerの相性はなかなかに良くないです。Dockerの肝はイメージやレイヤーのキャッシュにより、初回のダウンロード以降は爆速に使えることですが、環境がある程度リセットされてしまうCI環境で愚直にDockerを動かすコードを書くと数百MB単位のイメージのダウンロード、ビルドが毎回走ることになり、Dockerを準備する処理でCIの処理の大半が使われてしまうこともままあります。
今回はDockerによる環境のカプセル化の恩恵を受けつつ、GitHub Actionsでdocker composeを動かしながら極限までCacheを生かす方法についてご紹介します。
【追記】この記事のテクニックをまとめた GitHub Action を Marketplace で公開しました!こちらもご利用いただければと思います!!
まずはこれをみてくれ
キャッシュヒット時
イメージのプル、ビルド、プッシュ込みで 35s
package毎の個別のjobのdocker compose up -d が完了するまで ~20s

キャッシュミス時
イメージのプル、ビルド、プッシュ込みで 2m 45s
package毎の個別のjobのdocker compose up -d が完了するまで ~20s

全体でパッケージが20個ほどあるレポジトリに対してinstall, cs, cs-fixer, phpstan, test, coverageのアップロードを全てやって、全体にかかった時間はわずか 4m 3s (キャッシュヒット時)でした。
どうやって実現したか気になりませんか・・?この記事で解説します。
今回解説すること・解説しないこと
解説すること
- CIでdocker composeを使う時にネックになる、イメージ、レイヤーのビルド戦略
 - キャッシュヒットの方針
 - buildxの各種オプションの説明
 - レジストリの運用方法(compose.ci.ymlの書き方)
 
解説しないこと
- 各パッケージ毎に、dockerのimageを利用しつつCIを回す方法(弊社の @fuwasegu くんが実装したのでもしかしたら書いてくれるかも・・?チラッチラッ) → 書いてくれました!!是非こちらもご覧ください!
 
ネタ元
試した環境
- Laravel (マルチパッケージ構成)
 - docker compose
- nginx
 - postgresql
 - php-fpm (laravel) ← 今回ビルドしてローカルにプッシュされるのはこれ
 - localstack
 
 - Dockerのイメージをビルドするジョブ → 各パッケージ毎のCI処理を並列で処理
 
戦略
- キャッシュを2段階に分ける
- レジストリの共有用のキャッシュ
 - ビルドキャッシュ
 
 - ローカル(GitHub Actions内)でレジストリを立てる
 - buildxを使う
- CI用のcompose.ci.ymlをビルドする
 
 - CI用のcompose.ci.ymlでは、localhost:5000をレジストリとして明示的に指定し、ここに対してPush, Pullを行う
 
ざっくり概要図
成果物
on:
    [ push ]
env:
    TERM: xterm-256color
    COMPOSE_FILE: compose.ci.yaml
name: CI
jobs:
    docker-build:
        runs-on: ubuntu-latest
        outputs:
            docker_image_tag_ci: ${{ steps.generate_docker_image_tag.outputs.docker_image_tag_ci }}
        steps:
            -   name: Git Checkout
                uses: actions/checkout@v3
            -   name: Cache Docker Build Cache
                uses: actions/cache@v3
                with:
                    path: /tmp/.buildx-cache
                    key: docker-build-cache-${{ github.ref }}-${{ github.sha }}
                    restore-keys: |
                        docker-build-cache-${{ github.ref }}
                        docker-build-cache-
            -   name: Set up Docker Buildx
                id: buildx
                uses: docker/setup-buildx-action@v1
                with:
                    driver-opts: network=host
            -   name: Generate Docker Image Tag
                id: generate_docker_image_tag
                run: |
                    SHA=${{ github.sha }}
                    TAG=$(TZ=UTC-9 date '+%Y%m')-${SHA:0:7}
                    echo "DOCKER_IMAGE_TAG_CI=$TAG" >> $GITHUB_ENV
                    echo TAG $TAG
                    echo "::set-output name=docker_image_tag_ci::$TAG"
            -   name: Cache Docker Registry
                uses: actions/cache@v3
                with:
                    path: /tmp/docker-registry
                    key: docker-registry-${{ github.ref }}-${{ github.sha }}
                    restore-keys: |
                        docker-registry-${{ github.ref }}
                        docker-registry-
            -   name: Boot-up Local Docker Registry
                run: docker run -d -p 5000:5000 --restart=always --name registry -v /tmp/docker-registry:/var/lib/registry registry:2
            -   name: Wait for Docker Registry
                run: npx wait-on tcp:5000
            -   name: Build Docker Image
                env:
                    GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
                run: |
                    cd docker &&
                    docker buildx bake \
                        --builder="${{ steps.buildx.outputs.name }}" \
                        --set='*.cache-from=type=local,src=/tmp/.buildx-cache' \
                        --set='*.cache-to=type=local,dest=/tmp/.buildx-cache' \
                        --push \
                        -f "${COMPOSE_FILE}"
そして今回の肝となる compose.ci.yml です。レポジトリ直下ではなくdocker/ディレクトリを切ってその配下に、ローカル開発用のcompose.ymlと一緒に置いています。
version: 'Compose-Spec'
services:
    # レジストリにプッシュしない他のイメージ
    postgres:
        image: postgres:13-alpine
        environment:
            POSTGRES_DB: zeus
            POSTGRES_USER: user
            POSTGRES_PASSWORD: password
            PGDATA: /data/postgres
            TZ: Asia/Tokyo
        volumes:
            - ./postgres/docker-entrypoint-initdb.d:/docker-entrypoint-initdb.d:ro
    # レジストリにプッシュするイメージ
    php:
        image: localhost:5000/php-ci:${DOCKER_IMAGE_TAG_CI}
        build:
            context: .
            dockerfile: php/Dockerfile
            # Dockerfileの内部で定義されているターゲット
            target: laravel_application_mounted
            args:
                PHP_COVERAGE_DRIVER: pcov
        # オプションはある程度省略してます
        volumes:
            - ../src:/project:cached
            - ${HOME}/.composer:/composer:delegated
        environment:
            DB_USERNAME: user
            DB_PASSWORD: password
            TZ: Asia/Tokyo
        tty: true
細かい部分の解説
基本的には上のymlを改変していただく形で使っていただけるといいと思います。(一応MITライセンスと明示しておきます)
基本戦略について
まず、目的としては、各package毎のjobでphp+postgresqlなど、自動テストやフォーマッタが完全に動く環境をdocker compose経由で用意する、というものになります。
愚直な書き方としては
- 各package毎にjobを定義
 - 最初に docker compose up -d --build を実行
 - docker compose exec 経由で各種ステップを実行
 
という手順になります。
この場合、
- 全てのpackageを直列で走らせるか
 - package毎にイメージのダウンロード、ビルド、立ち上げが必要になる
 
という形になるため、
前者は独立しているはずのpackage毎の処理を直列で走らせなければならない部分、後者はdockerの環境立ち上げがボトルネックになります。
今回はdockerの処理を共通の親jobとして持たせたあと、並列でpackage毎のjobを走らせる、という方針をとることになりました。なので自分が担当したdockerの処理部分の要件としては
- build時のキャッシュができる
 - dockerビルド部分のjobを単体に切り出し、その結果を他のjobから利用できる
 
というものがありました。これらを踏まえて、
- buildx のキャッシュ (ビルド高速化)
 - Local Registryのキャッシュ (他のジョブとのイメージ共有)
 
を利用することになりました。
なお、他のジョブとのイメージ共有とその利用方法についてはこの記事では触れるだけにとどめておきます。
キャッシュヒットの方針
基本的には再利用できるレイヤー、イメージはできる限り再利用したいので、ヒットする範囲がなるべく広くなるように設定します。ただし、restore-keysの優先順は上から下なので、よりHEADの状態に近いであろうキャッシュが優先的に使われるように注意します。この時、キャッシュはブランチ毎に保持されており、ベースブランチとデフォルトブランチ以外の他のブランチのキャッシュにはヒットしないことに触れておきます。
-   name: Cache Docker Build Cache
    uses: actions/cache@v3
    with:
        path: /tmp/.buildx-cache
        key: docker-build-cache-${{ github.ref }}-${{ github.sha }}
        restore-keys: |
            docker-build-cache-${{ github.ref }}
            docker-build-cache-
上から順に
- prefixが同じで、全く同じブランチ、コミットハッシュのキャッシュ (key:の部分)
 - prefixが同じで、全く同じブランチのキャッシュ
 - prefixが同じキャッシュ
 
を見つかったものから順に使用する、という形になっています。例えばmainから切り出したfeat/hogeブランチからさらにトピックブランチpatch-1を切り出した場合、キャッシュの検索順序は以下の通りになります
- patch-1のshaが一致するもの(ルール1)
 - patch-1のもの(ルール2)
 - feat/hogeのもの(ルール3)
 - mainのもの(ルール3)
 
buildxの各種オプション
参考記事にもあるように、高速化のためにbuildxを使っています。
なお、実装当時は見つけられなかったものの、執筆記事現在では、buildxを使う専用のgithub-actionのテンプレートが用意されています。単一のDockerfileをビルドし、Artifactをpushする場合などはこちらのアクションにキャッシュの機構などがすでに用意されているため、こちらのアクションを利用してもらった方が良いかと思います。
今回はcompose.ci.ymlを丸ごとbuild+pushするにあたって細かい調整が必要になったため、自前でbuildxを利用してビルドするコマンドをオプションとともに書き下します。
-   name: Set up Docker Buildx
    id: buildx
    uses: docker/setup-buildx-action@v1
    with:
        driver-opts: network=host
# ... 省略 ...
-   name: Build Docker Image
    env:
        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
    run: |
        cd docker &&
        docker buildx bake \
            --builder="${{ steps.buildx.outputs.name }}" \
            --set='*.cache-from=type=local,src=/tmp/.buildx-cache' \
            --set='*.cache-to=type=local,dest=/tmp/.buildx-cache' \
            --push \
            -f "${COMPOSE_FILE}"
まず、buildkit(buildx) をインストールするstepを用意します。driver-optsではhost(GitHub Actionsのコンテキスト)内でのネットワーク通信を許可するオプションを指定しています。後述するローカルのレジストリへのプッシュに必要です。
次に実際にビルドするstepです。
env はビルドには直接必要ありませんが、compose.ymlでビルドするイメージ、コンテナ内でGITHUB_TOKENが必要になる場合(composerやcargoなど、GitHubからソースコードをpullしてくるようなステップがある場合など)には、このように外部からアクション毎のトークンを渡してあげることでRate Limitなどの制限を回避することができます。
docker buildx bake の各種オプションは下記のページを参考にしてください。
それぞれ、
- 
--builder="${{ steps.buildx.outputs.name }}": 別のstep (id: buildx) でセットアップしたbuildxを使うために明示的にオプションでしていしています。 - 
--set='*.cache-from=type=local,src=/tmp/.buildx-cache': ビルドキャッシュの読み込み先を指定します。キャッシュのステップでターゲットになっていた/tmp/.buildx-cacheをビルド時のキャッシュのターゲットにも指定することで、Action間でのキャッシュを行うことができます。 - 
--set='*.cache-from=type=local,src=/tmp/.buildx-cache': ビルドキャッシュの書き込み先を指定します - 
--push: 出来上がったimageをプッシュすることを明示します。プッシュ先はcompose.ci.ymlに従います。 
プッシュ先をGitHub Actions内(ci.yml)で指定できた方がよかったのですが、うまくいく方法がなかったため、やむなくcompose.ci.ymlで上書きするような形で妥協しています。こちらについてはより良い書き方があればご教示いただきたいです。
レジストリの運用方法(compose.ci.ymlの書き方)
公式から出ている registry:2 をダウンロードし立ち上げます。ボリュームはキャッシュで指定したディレクトリにします。
docker run -d -p 5000:5000 --restart=always --name registry -v /tmp/docker-registry:/var/lib/registry registry:2
buildxの項で説明した通り、localhost:5000に直接イメージをプッシュする方針が取れなかったため、基本的には開発環境で使うcompose.ymlのimage:にlocalhost:5000をprefixとして割り振ってあげます。
こうすることでプッシュとプルの宛先を示すことができます。
# レジストリにプッシュするイメージ
php:
    #      ____この部分_____
    image: localhost:5000/php-ci:${DOCKER_IMAGE_TAG_CI}
    build:
        context: .
        dockerfile: php/Dockerfile
        # Dockerfileの内部で定義されているターゲット
        target: laravel_application_mounted
        args:
            PHP_COVERAGE_DRIVER: pcov
まとめ
以上のように設定することでGitHub Actions上でも快適なdocker compose環境を用意することができました。
みなさんも良いCI on docker-compose on GitHub Actions ライフをお送りください〜
FAQ
Q. キャッシュサイズは大きくならないの?
A. 今の所サイズは一定です。ただし、元の記事のリンク先にはサイズが肥大化していくバグを避けるために、以下のように書くことが推奨されていました
          cache-from: type=local,src=/tmp/.buildx-cache
          cache-to: type=local,dest=/tmp/.buildx-cache-new
        # This ugly bit is necessary if you don't want your cache to grow forever
        # until it hits GitHub's limit of 5GB.
        # Temp fix
        # https://github.com/docker/build-push-action/issues/252
        # https://github.com/moby/buildkit/issues/1896
      - name: Move cache
        run: |
          rm -rf /tmp/.buildx-cache
          mv /tmp/.buildx-cache-new /tmp/.buildx-cache
Q. なんでGitHub Actionsでdocker-composeを動かすの?直にstepに書き下せば良くない?
A. 自分も実はそう思っていました。(現在もdocker composeのオーバーヘッドを入れてどのくらい効果があるのかと考えたりします。)ただ、インフラについての知識が二重管理にならず、アプリケーション毎のチェックに集中できる点はある程度評価できるのかな、と思っています。
Q. Local RegistryよりGitHub Container Registryを使った方が早くない?
A. 早いです。が、参考にした記事ではBuildxが動かないこと、build-cacheというCI用のイメージがレポジトリにパブリッシュされてしまうことが懸念点として挙げられていて、今回のプロジェクトではLocal Registryを採用しました。
Although we cannot take advantage of BuildKit, using an external registry means that builds can be cached on a layer-by-layer basis. Out of all approaches I’ve tried, this approach gives the best result. 🏆
However, you also get a “build-cache” package listed in your repository’s published packages. 😂
参照
Q. build-push-actionで良くない?
A. 単一イメージをビルドする場合にはそれで問題ないと思います。今回はbuildx bakeを使い複数イメージをまとめてビルドする必要があったため、おそらく該当のアクションが内部で行っているであろうコマンドの実行を自前で行う必要がありました。
Q. キャッシュはいつも適用される?
A. 基本的にはヒットしますが、有効期限があるようでmainに最後にプッシュされたのが16日前のキャッシュはヒットしませんでした。
(追記) 7日間アクセスがないと削除されるようです https://docs.github.com/ja/actions/using-workflows/caching-dependencies-to-speed-up-workflows#usage-limits-and-eviction-policy
Q. どのくらい課金される?
A. ビルドに時間が掛からなくなるため、そっち方面ではコストを抑えられているのではないか、と推測しています。サイズについてはあまり把握できていません。弊社では金額については一括で管理されているため詳しい額を当方で把握できていません・・申し訳ないです。
Q. Jobの並列処理の方に興味があるんだけど?
A. @fuwasegu くんの記事に乞うご期待 → 出ました!



