Docker ComposeでBuildxを使いGitHub Actionsの実行時間を半分にする

AvatarPosted by

この記事は GRIPHONE Advent Calendar 2021 18日目の記事です。

本記事はGitHub Actions上で動作するDocker Compose上のPHPUnitを、actions/cache@v2とBuildxの組み合わせで高速化することを狙った記事です。

PHPerの皆さまなら誰もがお世話になっているだろうDocker、便利ですがイメージのプルやビルドに時間がかかりますよね。開発環境であれば大きな問題にはなりませんが、GitHub ActionsのようなCIでは早く結果を見れるようにしたいものです。

私たちのプロジェクトのGitHub Actions上で動いているPHPUnitも、結果が出るまでに約6分間もかかってしまっていました。これではいかんと、さまざまな仕組みを使い結果的に2分弱まで短縮することができましたので、そのやり方を公開したいと思います。

従来のワークフロー

まず初めに、実行から終了までに約6分間かかるワークフローを見てみます。本筋と関係のないところは一部カットしてあり、今後の画像と一致しない部分があるところはご了承ください。

name: PHPUnit

on:
  push:

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v2
    - name: docker-compose up
      run: docker-compose up -d
    - name: composer install
      run: |
        docker-compose exec -T php-fpm bash -c "cd /usr/share/nginx/codeigniter/application/ && php composer.phar install"
    - name: phpunit
      run: |
        docker-compose exec -T php-fpm bash -c "cd codeigniter/tests && ../application/vendor/bin/phpunit --stop-on-failure"

ビルドも兼ねた docker-compose up -d を利用し、コンテナを立ち上げ内部でコマンドを実行するシンプルなワークフローになっています。これが実行された時の画像を見てみましょう。

docker-compose up で4分30秒もかかっており、合計で6分以上かかっていることがわかります。これは、GitHub Actionsの実行環境はそれぞれのジョブごとに別の環境として立ち上げられており、毎回インターネット上から実行に必要なものをダウンロード、インストールしているためこれだけの時間が必要になってしまっているためです。

このワークフローをもとに高速化を模索していきます。

高速化のキモ: Buildxとactions/cache@v2

高速化を模索するにあたり、避けては通れないのがBuildxとBuildKit、actions/cache@v2の理解です。

BuildxとBuildKit

Buildxとは、Moby BuildKitにより提供されているツールキットをDockerコマンドから直接使用できるようになるCLIプラグインです。Buildxを利用することで、BuildKitが提供している効率的なビルドやキャッシュのインポート・エクスポートの機能を普段のDockerコマンドと同じやり方で使用することができるようになります。次世代のビルドシステムであるBuildKitを、Buildxというインターフェースを通して利用しているイメージです。

そのままBuildxを利用するだけでも並列ビルドなどによりある程度の高速化が見込めますが、GitHub Actions上でBuildKitを利用するにあたり、一番注目したい機能がビルドキャッシュのインポート・エクスポートです。

後述しますが、GitHub Actionsの実行されるワークフロー間でデータをやり取りするためには、actions/cache@v2を利用し、決められた場所にキャッシュしたいファイルを格納する必要があります。Buildxを利用することで、Dockerのキャッシュをactions/cache@v2に橋渡しすることが可能になるのです。

actions/cache@v2

actions/cache@v2は、GitHubが提供するワークフロー間でデータをやり取りしキャッシュを実現するアクションです。

これを利用すると、アクションの開始時にキャッシュがあった場合は指定されたディレクトリにキャッシュを展開し、完了時に保存することができるようになります。ネットワークアクセスに比べはるかに高速に動作します。

今回は、BuildKitのキャッシュをactions/cache@v2を通して保存し、イメージのプルやビルドに関わる時間を短縮することを試みます。

BuildxとBuildKit、actions/cache@v2を組み合わせることで、時間のかかるインターネットからのダウンロードやインストールを初回実行時のみ行い、二回目以降はインターネットより高速なキャッシュを参照することでCIの実行をすることができるようになります。

CIが実行されるごとにダウンロード&インストールすると遅いが
キャッシュを保存して実行間で使いまわせば高速化できる

高速化の手順

ここからは、実際に高速化をしていく中で必要な手順をまとめます。

docker-compose.yaml の修正

実際にワークフローの改善に取り掛かる前に、Composeが自前でコンテナをビルドしないように変更する必要があります。

最新のDocker Composeでは、特に環境変数などを指定しなくてもビルドの際にデフォルトでBuildxを利用してくれます。しかしながら、2021年12月現在、Docker Composeがコンテナをビルドする際に、Buildxへビルド時に利用する引数を渡すことができません。

そのため、Docker Composeで利用する前にBuildxを用いてイメージをビルドしローカルのリポジトリへ保存、その後イメージ名を用いて起動するという手法をとる必要があります。

具体的には、Docker Composeファイル内からbuildの指定を消し、すべてのサービスがimageの指定のみで立ち上げられるように変更する必要があります。

私たちのプロジェクトでは、docker-compose.yamlを下記のように修正しGitHub Actions用として用いることにしました。

version: '3'

services:
  api:
    image: nginx
    ports:
    - "80:80"
    volumes:
    - ./:/usr/share/nginx
    environment:
    - TZ=Asia/Tokyo

  php-fpm:
    # build: docker/php-fpm
    image: php-fpm-test # <-- ワークフローで作成するイメージ名
    volumes:
    - ./:/usr/share/nginx
    working_dir: /usr/share/nginx
    environment:
    - TZ=Asia/Tokyo

上記の変更で指定したイメージ名は後にイメージのビルド時に利用することになります。

ワークフローの編集

ここで、あらかじめ高速化したワークフローを記載します。追加した中で重視したいポイントをコメントとして追加してあります。こちらも本筋と関係のないところは一部カットしてあります。

name: PHPUnit

on:
  push:

jobs:
  testing:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v2

    # ポイント 1 Buildxの利用準備を行う
    - name: Set up Docker Buildx
      id: buildx
      uses: docker/setup-buildx-action@v1

    # ポイント 2 キャッシュを利用する
    - name: Cache Docker layers
      uses: actions/cache@v2
      with:
        path: /tmp/.buildx-cache
        key: ${{ github.ref }}-${{ github.sha }}
        restore-keys: |
          ${{ github.ref }}
          refs/head/main
    - name: Cache Composer layers
      uses: actions/cache@v2
      with:
        path: /tmp/.composer-cache
        key: ${{ runner.os }}-composer-${{ hashFiles('./codeigniter/application/composer.lock') }}
        restore-keys: |
          ${{ runner.os }}-composer-

    # ポイント 3 イメージをビルドする
    - name: Build images
      uses: docker/build-push-action@v2
      with:
        push: false
        builder: ${{ steps.buildx.outputs.name }}
        tags: php-fpm-test:latest
        load: true
        context: docker/php-fpm
        cache-from: type=local,src=/tmp/.buildx-cache
        cache-to: type=local,dest=/tmp/.buildx-cache-new
    - name: docker compose up
      run: |
        mkdir -p /tmp/.composer-cache
        docker compose -f docker-compose.gha.phpunit.yaml build
        docker compose -f docker-compose.gha.phpunit.yaml up -d
    - name: composer install
      run: |
        docker-compose exec -T php-fpm bash -c "cd /usr/share/nginx/codeigniter/application/ && php composer.phar install"
    - name: phpunit
      run: |
        docker-compose exec -T php-fpm bash -c "cd codeigniter/tests && ../application/vendor/bin/phpunit  --stop-on-failure"

    # ポイント 4 肥大化対策を行う
    # 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

各ポイントについて解説していきます。

ポイント 1 Buildxの利用準備を行う
    - name: Set up Docker Buildx
      id: buildx
      uses: docker/setup-buildx-action@v1

後にイメージのビルドに使用するBuildxをセットアップしています。これをワークフローに設定し、アウトプットされるビルダー名をdocker/build-push-action@v2に渡してあげることでBuildxを利用したイメージのビルドが可能になります。

ポイント 2 キャッシュを利用する
    - name: Cache Docker layers
      uses: actions/cache@v2
      with:
        path: /tmp/.buildx-cache
        key: ${{ github.ref }}-${{ github.sha }}
        restore-keys: |
          ${{ github.ref }}
          refs/head/main

actions/cache@v2を利用し、キャッシュの復元を行います。各キーについて簡単に解説します。

path (必須)

キャッシュの保存場所を指定します。この場所に保存されたものがワークフローの終了時に自動的に保存され、次回以降の実行時に復元されることとなります。

key (必須)

このアクションのキャッシュのキーを指定します。キャッシュとキーは一対一で対応しており、このキーが一致した場合に対応したキャッシュが復元されます。

restore-keys (オプショナル)

前述のKeyが一致しなかった場合に復元対象とするキーのパターンを指定します。restore-keysは配列として複数の値を指定可能で、配列の先頭に近いものから判定を行い復元対象を決めます。キーは前方一致で判定されます。

上記の設定では、まずKeyに指定されたキャッシュがないかを判定します。前回の実行からコミットがない状態で実行された場合などにヒットします。手動再実行を行なった場合が考えられます。

Keyでヒットしなかった場合、次は現在実行されているブランチの中で実行されたキャッシュの中で最新のものがヒットします。コミットのプッシュ単位で実行している場合は、前のコミットで実行されたキャッシュが取り出されます。

ブランチでもヒットしなかった場合、最終的にmainブランチのキャッシュが取り出されます。ブランチを作成して最初のコミットの場合、そのブランチではワークフローが実行されていないのでmainブランチのキャッシュが取り出されることになります。

この辺りの詳しい仕様は公式ドキュメントを参照してください。

Caching dependencies to speed up workflows – GitHub Docs

NPM, PIP, Composerなどのパッケージマネージャを利用している場合は、公式ドキュメントに使いやすい例が載っていますのでそちらを参照するのも良いでしょう。

https://github.com/actions/cache/blob/main/examples.md

ポイント 3 イメージをビルドする
    - name: Build images
      uses: docker/build-push-action@v2
      with:
        push: false
        builder: ${{ steps.buildx.outputs.name }}
        tags: php-fpm-test:latest
        load: true
        context: docker/php-fpm
        cache-from: type=local,src=/tmp/.buildx-cache
        cache-to: type=local,dest=/tmp/.buildx-cache-new

Docker公式が提供しているbuild-push-action@v2を利用し、コンテナをビルドします。チェックが必要なキーについて、簡単に説明します。それ以外のキーについては公式ドキュメントを参照してください。

https://github.com/docker/build-push-action#inputs

builder

ビルドするために利用するビルダーを指定します。ポイント1の項で作成したビルダー名を指定します。

tags

イメージにつけるタグを指定します。Docker Composeで呼び出すイメージ名と合わせましょう。

load

trueを指定した場合、ローカルへビルドしたイメージをロードします。後にDocker Composeから呼び出す際に利用するため、trueを指定しましょう。

tagsとloadはイメージビルド後にDocker Composeからイメージを呼び出し利用するのに必須のキーになります。指定し忘れや不備があると、Docker Composeから読み出すことができなくなってしまいますので注意が必要です。

cache-from / cache-to

キャッシュ先を指定します。現在、Buildxにはキャッシュディレクトリが肥大化する不具合があり、キャッシュの読み込み元と書き出し先は分けておく必要があります。

ポイント 4 肥大化対策を行う
    # 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

前述のとおり、現在Buildxを利用したビルドには不具合があります。キャッシュの読み込み元と書き出し先が同じであると、実行のたびにディレクトリサイズが肥大化するため、新しく生成したキャッシュ以外を過去のキャッシュと置き換えるような処理を入れてあります。

結果

このワークフローを利用することで、テストにかかる時間を1回につき2分半程度に短縮することができました。

まとめ

シンプルなワークフローにキャッシュの概念を取り込むことで、結果として実行時間を半分にすることができました。

単一のDockerのイメージビルドを高速化する手法は情報量も多かったのですが、私たちのプロジェクトのようにDocker Composeと組み合わせている方は少なかったので、この記事が誰かの助けになることを望んでいます。