Docker build を GitHub Actions に最適化する

この記事は [GLOBIS Advent Calendar 2022 - Qiita](https://qiita.com/advent-calendar/2022/globis) の 15 日目です。

Docker build を継続的に行う環境として、2022 年末の現時点では GitHub Actions (GHA)が有力な選択肢の 1 つだと思います。グロービスでも従来は AWS CodeBuild を用いてビルドすることが多かったのですが、最近は GHA を使う場合がほとんどです。

GHA 上で愚直に `docker build` コマンドを実行することももちろん可能ですが、Docker 社が GHA をサポートするツールや機能をかなり打ち出しており、これを適格に利用したほうが開発体験をより向上できます。

## 公式の Docker Docs を読む

とりあえず公式のドキュメントを読みましょう。これさえ読めばむしろこの記事は要らないぐらいかもしれません。

2022 年 11 月頃から [build: rework ci/gha docs by dvdksn · Pull Request #15898 · docker/docs](https://github.com/docker/docs/pull/15898) などの PR によって、GHA 関連のドキュメントがさらに充実し、まとまった形で読みやすくなりました。 [Introduction to GitHub Actions | Docker Documentation](https://docs.docker.com/build/ci/github-actions/) からの 3 ページをすべて読むことをおすすめします。

Introdoction の冒頭に書かれている通り、Docker 社では GHA で便利に使える Action を複数用意しており、これらを組み合わせることで、多くのユースケースに合わせたビルドフローを GHA 上で構築できます。基本的には Buildx を有効化し、image を push する先のコンテナレジストリへログインし、 `docker build && push` を行うという流れになります。弊社では ECR をレジストリに用いているので、簡単に示すと以下のような YAML になります。

```yaml
name: Docker Build and Push

on:
  push:
    branches:
      - main

env:
  ENV: dev
  ECR_REGISTRY_ID: XXXXXXXXXXXX
  ROLE_TO_ASSUME: arn:aws:iam::XXXXXXXXXXXX:role/github-actions-role

permissions:
  id-token: write
  contents: read
  actions: read

jobs:
  build-and-push:
    runs-on: ubuntu-22.04
    steps:
      - uses: actions/checkout@v3

      - name: Set up buildx
        uses: docker/setup-buildx-action@v2

      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v2
        with:
          role-to-assume: ${{ env.ROLE_TO_ASSUME }}
          aws-region: ap-northeast-1

      - name: Login to Amazon ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v1
        with:
          registries: ${{ env.ECR_REGISTRY_ID }}

      - name: Build and push images
        uses: docker/build-push-action@v3
        with:
          context: .
          file: ./Dockerfile
          target: ${{ env.REPOSITORY }}
          push: true
```

## GitHub 上の情報を image tag に活用する

せっかく GitHub 上でビルドしていることですし、GitHub から取得できるブランチ、commit SHA などの情報を image tag に活用したいところです。

[Docker Metadata action · Actions · GitHub Marketplace](https://github.com/marketplace/actions/docker-metadata-action) を使うと、これらの情報を組み合わせた tag を簡単に生成できます。

```yaml
- name: Docker meta
  id: meta
  uses: docker/metadata-action@v4
  with:
    images: |
      name/app
    tags: |
      type=semver,pattern={{version}}
      type=sha,format=long
      type=schedule,pattern={{date 'YYYYMMDD-hhmmss'}}
- name: Build and push
  uses: docker/build-push-action@v3
  with:
    context: .
    push: true
    tags: ${{ steps.meta.outputs.tags }}
```

`docker/metadata-action` の `with.tags` に指定した内容に従ってタグが自動生成されます。少々癖もありますし、ここではすべて説明はしませんが、上記の例で言えば `type=semver,pattern={{version}}` は、 git tag の push でビルドが発火した際、tag がセマンティックバージョンに従ったものになっていれば、それが image tag として使われます。 `type=sha,format=long` は長い形式の commit SHA、 `type=schedule` はスケジュール（cron）実行された際に、実行時刻を活用するものです。このように複数行を用いて 3 パターンを指定すれば 3 つのタグが改行区切りで `steps.meta.outputs.tags` にセットされるので、あとは `build-push-action` にそのまま渡せば OK です。

日付と commit SHA など、複数の要素を複合させて使いたい場合は `type=raw` で実現できます。例えば `type=raw,value={{date 'YYYYMMDD-hhmmss'}}-{{sha}}` とすると、日付と SHA をハイフン区切りで結びつけたタグを生成できます。

## `type=gha` のキャッシュを使う

Buildkit にはキャッシュのエクスポート先を指定する `--export-cache type=` というオプションがあり、この中に GitHub Actions のキャッシュ API を利用する `type=gha` が存在します。

[moby/buildkit: concurrent, cache-efficient, and Dockerfile-agnostic builder toolkit](https://github.com/moby/buildkit#export-cache)

注意点としては、2022 年 12 月時点ではまだ experimental の機能だという点ですが、GHA のキャッシュを手軽に扱うことができて非常に便利です。不具合が起きる可能性のリスクを織り込みつつ活用するのは手ではあるでしょう。GHA 上では、以下のような設定でこの機能を有効化できます。 `mode` はデフォルトだと最終イメージのレイヤーしかキャッシュされない `min` に設定されているので、すべての中間レイヤーもキャッシュしてくれる `mode=max` がほぼ必須になります。

```yaml
- name: Build and push images
  uses: docker/build-push-action@v3
  with:
    context: .
    file: ./Dockerfile
    push: true
    tags: ${{ steps.tags.outputs.tags }}
    cache-from: type=gha
    cache-to: type=gha,mode=max
```

### `type=local` のキャッシュは無限に肥大化する

experimental であることが気になるのであれば、 `type=inline` などその他のキャッシュ方式も当然ながら扱えます。その場合、 `mode=max` を指定可能でかつ、 GitHub Actions のキャッシュ機能と併用しやすい点で `type=local` が有力な選択肢になりそうです。

注意点として `type=local` を `actions/cache` と組み合わせて使う場合、キャッシュが無尽蔵に肥大化してしまうという点があります。これは GitHub Actions 固有の問題では無く、Buildx 自体がそのようなつくりになっています（詳細は [Local cache | Docker Documentation](https://docs.docker.com/build/building/cache/backends/local/) ）。回避策として、GHA に保存したキャッシュをそのまま上書きして使うのでは無く、古いキャッシュを削除して入れ替えていくようなフローが推奨されています（cf: [Pretty big cache · Issue #252 · docker/build-push-action](https://github.com/docker/build-push-action/issues/252) ）。

```yaml
- name: Cache Docker layers
  uses: actions/cache@v3
  with:
    path: /tmp/.buildx-cache
    key: ${{ runner.os }}-buildx-${{ github.sha }}
    restore-keys: |
      ${{ runner.os }}-buildx-
- name: Build and push
  uses: docker/build-push-action@v3
  with:
    context: .
    push: true
    tags: user/app:latest
    cache-from: type=local,src=/tmp/.buildx-cache
    cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max
- name: Move cache
  run: |
    rm -rf /tmp/.buildx-cache
    mv /tmp/.buildx-cache-new /tmp/.buildx-cache
```

## キャッシュを無効化できるようにしておく

ビルドの失敗など、何らかの理由によりキャッシュを無効にしてビルドし直したい場合も出てきます。GHA では 2022 年にキャッシュを削除する機能も設けられましたが、現状、あるワークフローに結びついたキャッシュを一括で削除するような UI は無く、キャッシュを手で削除して再実行するのは困難です。

`docker/build-push-action` には、キャッシュを使わずにビルドする `no-cache` というオプションがあるので、Workflow Dispatch からこれを無効化できるようにしておくと便利です。

```yaml
name: Docker Build and Push

on:
  workflow_dispatch:
    inputs:
      no-cache:
        description: "Build docker images with no cache"
        default: false
        required: false
        type: boolean

jobs:
  build-and-push:
    steps:
      - name: Build and push images
        uses: docker/build-push-action@v2
        with:
          context: .
          file: ./Dockerfile
          push: true
          no-cache: ${{ inputs.no-cache == true }}
          # workflow_dispatchを使わなかった場合はno-cacheがNaNになるため、
          # 明示的な比較が必要になります。
```

このように設定しておくと、Workflow Dispatch を実行する際に、チェックボックスでキャッシュの無効化を指定することができるようになります。

[![Image from Gyazo](https://i.gyazo.com/34b3b3547e6020024031dbd1cf95cf27.png)](https://gyazo.com/34b3b3547e6020024031dbd1cf95cf27)

## matrix を活用する

Docker build においてはマルチプラットフォームのビルド、マルチステージの各ステージのビルドなど、1 回のフローの中で複数のイメージビルドを行いたい場合が多々あります。GHA で複数イメージをビルドする際には [matrix](https://docs.github.com/en/actions/using-jobs/using-a-matrix-for-your-jobs) を活用し、並行でビルドを走らせることで全体のビルド時間を短縮できます。

matrix を使う場合、各 matrix を示す識別子を用いて、キャッシュを明確に分離する必要がある点には注意が必要です。 local cache を用いながら、非常に多くのジョブを並行させている例として、 [fullstaq-ruby-docker/build-push.yml at 9deabb1f7181718cdfc3d18d52cea8eb817c8e65 · evilmartians/fullstaq-ruby-docker · GitHub](https://github.com/evilmartians/fullstaq-ruby-docker/blob/9deabb1f7181718cdfc3d18d52cea8eb817c8e65/.github/workflows/build-push.yml) などが参考になります。

## おまけ：Syntax Highlight を効かせる

最後に GitHub Actions とは直接関係ありませんが、GitHub 上で Dockerfile を扱う際のおまけの tips を載せておきます。

GitHub 上のファイルは拡張子で識別されて自動的に syntax highlight されます。Dockerfile の場合はファイル名が `Dockerfile` であるか、拡張子として `.Dockerfile` が使われている場合に正しく highlight されますが、複数 Dockerfile を同じディレクトリに配置する際、 `Dockerfile.hoge` のようなファイル名として、highlight が効かなくなってしまっている、ということもままあります。 `hoge.Dockerfile` にすればもちろん解決しますが、ファイル名をソートしたときに `Dockerfile.hoge` と `Dockerfile.fuga` を並べておきたい、という需要も理解できます。

この解決策として Vim のモードラインを使うという手があります。モードラインとは、ファイルにコメントで Vim の設定コマンドを書いておくと、Vim で当該ファイルを開いたとき、その設定を読み込んでくれるという機能です。例えば `# vim: fenc=cp932 ft=markdown` というモードラインは、文字コード（fenc, fileencoding）が cp932、ファイル形式（ft, filetype）が Markdown であることを意味します。

このモードラインで `filetype` を指定すると、GitHub の Syntax Highlight に反映させることができます。

[![Image from Gyazo](https://i.gyazo.com/554a538c4628e8a5088a8c6136659c7e.png)](https://gyazo.com/554a538c4628e8a5088a8c6136659c7e)

なお Vim の場合は最上行にモードラインを書くことが多く、Vim の設定にも「最上行から何行目までモードラインとして読み込むか」というものがあるのですが、GitHub で軽く実験した範囲ですと、十数行目に書いてもモードラインは有効化されたので、邪魔にならないよう最下に書くのもありかもしれません。