GitHub Actions と GitHub Packages で Docker image を配布する

## Docker Hub から GitHub Packages へ

Git tag やブランチをトリガーに Docker image を自動でビルドして Docker registry で公開・配布したいとなると、まず [Docker Hub](https://hub.docker.com/) の利用が候補として考えられます。

あれだけの機能を無料で提供してくれている Docker Hub 開発チームには本当に感謝しているのですが、一点だけ不満があります。実行時間です。Docker Hub はビルドの開始が遅く、実行時間も結構かかりがちです。

これの解決策として外部サービスで Docker image をビルドして、そこから Registry に image を Push することが考えられます。外部サービスには色々な選択肢がありますが、今回は [GitHub Actions] を選びました。ビルド行程を GitHub Actions で行えば Docker Hub よりは速そう(速かった)です。おそらく CircleCI とかであればもっと速いでしょうが、ビルド行程の管理のしやすさも合わせて総合的に考えると GitHub Actions が便利だと思います。

この記事では GitHub Actions と [GitHub Packages] で Docker image をビルド・配布する方法について紹介します。(ついでに GitHub Actions から Docker Hub にも Push します。)

ちなみに GitHub Packages は旧名 GitHub Package Registry です。

[GitHub Actions]: https://github.com/features/actions
[GitHub Packages]: https://github.com/features/packages



## GitHub Packages for Containers から GitHub Container Registry へ

最近になって GitHub Packages for Containers の後継となる GitHub Container Registry というサービスが登場しました。

GitHub Packages では `docker pull` の実行前に `docker login` が必要ですが GitHub Container Registry では Docker Hub と同様に `docker pull` に関しては `docker login` は必須ではありません。image の所属先も GitHub Packages for Containers と GitHub Container Registry では異なります。

| 比較項目 | GitHub Packages for Containers | GitHub Container Registry |
|---|---|---|
| `docker pull` | `docker login` 必須 | `docker login` 不要 |
| URL | `docker.pkg.github.com/owner/repository/image` | `ghcr.io/owner/image` |
| `GITHUB_TOKEN` | 利用可能 | 利用不可 |

現時点では GitHub Packages for Containers では `docker login` に `secrets.GITHUB_TOKEN` を利用できるが GitHub Container Registry (GHCR) ではそうではないため Personal Access Token を発行して利用しなければならない点にも注意してください。これに関しては将来的に GHCR でも `secrets.GITHUB_TOKEN` を login に利用できるよう改善されると嬉しいですね。

この記事で GitHub Actions workflow を紹介しているリポジトリ peaceiris/docker-mdbook では GitHub Container Registry に移行しました。この記事には古い workflow YAML file を残しておくので、最新の workflow を参考にしたい場合はリポジトリの方をみてください。

移行時の作業内容: [Migrate from docker.pkg.github.com to ghcr.io by peaceiris · Pull Request #12 · peaceiris/docker-mdbook](https://github.com/peaceiris/docker-mdbook/pull/12)



## mdBook Docker image

[rust-lang/mdBook] の alpine base Docker image を以下のリポジトリで管理しており GitHub Actions で image をビルドして Docker Hub と GitHub Packages へ push しています。

[rust-lang/mdBook]: https://github.com/rust-lang/mdBook

- [peaceiris/docker-mdbook: mdBook Alpine Base Docker Image](https://github.com/peaceiris/docker-mdbook)

以下のようなリリースフローとなっています。

- master branch へ push すると latest tag のイメージを公開
- Release を作成すると release tag のイメージを公開
- Pull Request ではビルドはするが push はしない

`alpine:3.10` base のイメージと `rust:alpine3.10` ベースのイメージ、この2種類を並列にビルドして配布しています。



## リポジトリの概要

### ファイル一覧

リポジトリのファイル一覧です。(記事に必要なものだけを抜粋)

```txt
.github/
└── workflows
    └── push.yml
.hadolint.yaml
Dockerfile
```

`Dockerfile` の中身です。

- [docker-mdbook/Dockerfile at master · peaceiris/docker-mdbook](https://github.com/peaceiris/docker-mdbook/blob/master/Dockerfile)

```Dockerfile
ARG BASE_IMAGE

FROM rust:slim-buster AS builder

ARG MDBOOK_VERSION
ENV MDBOOK_VERSION ${MDBOOK_VERSION:-0.3.5}
ENV ARC="x86_64-unknown-linux-musl"
RUN apt-get update && \
    apt-get install --no-install-recommends -y \
    musl-tools && \
    rustup target add "${ARC}" && \
    cargo install mdbook --version "${MDBOOK_VERSION}" --target "${ARC}"

FROM ${BASE_IMAGE}

SHELL ["/bin/ash", "-eo", "pipefail", "-c"]
COPY --from=builder /usr/local/cargo/bin/mdbook /usr/bin/mdbook
WORKDIR /book
ENTRYPOINT [ "/usr/bin/mdbook" ]
```

GitHub Actions workflow です。

- [docker-mdbook/push.yml at master · peaceiris/docker-mdbook](https://github.com/peaceiris/docker-mdbook/blob/master/.github/workflows/push.yml)

```yaml
name: Push Docker Image

on:
  push:
    branches:
    - master
    paths-ignore:
    - '**.md'
  release:
    types: [published]
  pull_request:
    types: [opened, synchronize]
    paths-ignore:
    - '**.md'

env:
  DOCKER_BASE_NAME: docker.pkg.github.com/${{ github.repository }}/mdbook
  DOCKER_HUB_BASE_NAME: peaceiris/mdbook

jobs:
  hadolint:
    runs-on: macos-latest
    steps:
    - uses: actions/checkout@v1
      with:
        fetch-depth: 1
    - run: brew install hadolint
    - run: hadolint ./Dockerfile

  push:
    runs-on: ubuntu-18.04
    needs: hadolint
    strategy:
      matrix:
        baseimage: ['alpine:3.10', 'rust:alpine3.10']
    steps:

    - uses: actions/checkout@v1
      with:
        fetch-depth: 1

    - name: Set env
      run: |
        if [ "${{ github.event_name }}" = 'release' ]; then
            export TAG_NAME="${{ github.event.release.tag_name }}"
        else
            export TAG_NAME="latest"
        fi
        if [ "${{ startsWith( matrix.baseimage, 'rust:alpine') }}" = "true" ]; then
            export TAG_NAME="${TAG_NAME}-rust"
        fi
        echo "PKG_TAG=${DOCKER_BASE_NAME}:${TAG_NAME}" >> ${GITHUB_ENV}
        echo "HUB_TAG=${DOCKER_HUB_BASE_NAME}:${TAG_NAME}" >> ${GITHUB_ENV}

    - name: Build ${{ matrix.baseimage }} base image
      run: |
        docker build . -t "${PKG_TAG}" --build-arg BASE_IMAGE="${{ matrix.baseimage }}"
        docker tag "${PKG_TAG}" "${HUB_TAG}"

    - name: Print mdBook version
      run: |
        docker run --rm ${PKG_TAG} --version

    - name: Scan image
      run: |
        docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
          -v ${HOME}/.cache:/root/.cache aquasec/trivy:latest --exit-code 1 ${PKG_TAG}

    - name: Login to Registries
      if: github.event_name != 'pull_request'
      env:
        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        DOCKER_HUB_TOKEN: ${{ secrets.DOCKER_HUB_TOKEN }}
      run: |
        echo "${GITHUB_TOKEN}" | docker login docker.pkg.github.com -u peaceiris --password-stdin
        echo "${DOCKER_HUB_TOKEN}" | docker login -u peaceiris --password-stdin

    - name: Push to GitHub Packages
      if: github.event_name != 'pull_request'
      run: docker push "${PKG_TAG}"

    - name: Push to Docker Hub
      if: github.event_name != 'pull_request'
      run: docker push "${HUB_TAG}"
```

### GitHub Packages

- [Packages · peaceiris/docker-mdbook](https://github.com/peaceiris/docker-mdbook/packages)
- [イメージのタグ一覧 · peaceiris/docker-mdbook](https://github.com/peaceiris/docker-mdbook/packages/66741/versions)

```sh
# Pull alpine:3.10 base image
docker pull docker.pkg.github.com/peaceiris/docker-mdbook/mdbook:latest
docker pull docker.pkg.github.com/peaceiris/docker-mdbook/mdbook:v0.3.5

# Pull rust:alpine3.10 base image
docker pull docker.pkg.github.com/peaceiris/docker-mdbook/mdbook:latest-rust
docker pull docker.pkg.github.com/peaceiris/docker-mdbook/mdbook:v0.3.5-rust
```

### Docker Hub

- [peaceiris/mdbook - Docker Hub](https://hub.docker.com/r/peaceiris/mdbook)

Autobuild 機能を OFF にしています。リポジトリをリンクしているだけです。

```sh
# Pull alpine:3.10 base image
docker pull peaceiris/mdbook:latest
docker pull peaceiris/mdbook:v0.3.5

# Pull rust:alpine3.10 base image
docker pull peaceiris/mdbook:latest-rust
docker pull peaceiris/mdbook:v0.3.5-rust
```



## GitHub Actions Workflow の解説

### トリガー

- master branch から `latest` tag
- Release event から `vx.x.x` tag
- Pull Request はビルドテスト用 (Push しない)

master へ push すると `latest` tag のイメージが公開されます。`v0.3.5` タグでリリースを作成すると `v0.3.5` tag のイメージが公開されます。Pull Request ではビルドだけを行います。

```yaml
on:
  push:
    branches:
    - master
    paths-ignore:
    - '**.md'
  release:
    types: [published]
  pull_request:
    types: [opened, synchronize]
    paths-ignore:
    - '**.md'
```

### workflow 全体で使う環境変数

Workflow 全体で使う環境変数を定義しており、各 Job で共有できます。

```yaml
env:
  DOCKER_BASE_NAME: docker.pkg.github.com/${{ github.repository }}/mdbook
  DOCKER_HUB_BASE_NAME: peaceiris/mdbook
```

### hadolint job

```yaml
hadolint:
  runs-on: macos-latest
  steps:
  - uses: actions/checkout@v1
    with:
      fetch-depth: 1
  - run: brew install hadolint
  - run: hadolint ./Dockerfile
```

[hadolint] で `Dockerfile` をチェックしています。hadolint の Docker image も公開されていますが `.hadolint.yaml` を上手く読み込むことができなかったので macOS 仮想環境に homebrew でインストールした hadolint を使っています。

[hadolint]: https://github.com/hadolint/hadolint

### Push job

```yaml
push:
  runs-on: ubuntu-18.04
  needs: hadolint
  strategy:
    matrix:
      baseimage: ['alpine:3.10', 'rust:alpine3.10']
  steps:
```

hadolint job が正常終了した場合に push job を並列に実行しています。

mdBook は `mdbook test` というサブコマンドで Book 中に記述された Rust コードの実行テストができます。そのテストには Rust の実行環境が必要なので `alpine:3.10` base のイメージだけでなく `rust:alpine3.10` base のイメージもビルド・公開するようにしています。それぞれのイメージを順番にビルドするとビルド時間が2倍になってしまうので matrix で記述することにより並列実行しています。

```yaml
- name: Set env
  run: |
    if [ "${{ github.event_name }}" = 'release' ]; then
        export TAG_NAME="${{ github.event.release.tag_name }}"
    else
        export TAG_NAME="latest"
    fi
    if [ "${{ startsWith( matrix.baseimage, 'rust:alpine') }}" = "true" ]; then
        export TAG_NAME="${TAG_NAME}-rust"
    fi
    echo "PKG_TAG=${DOCKER_BASE_NAME}:${TAG_NAME}" >> ${GITHUB_ENV}
    echo "HUB_TAG=${DOCKER_HUB_BASE_NAME}:${TAG_NAME}" >> ${GITHUB_ENV}
```

イメージのタグを設定しています。~~`set-env` で定義した環境変数は以降の step で利用できるようになります。~~ 今日現在 (2020-10-23) `set-env` を workflow で利用しているとログに warning が表示されます。将来の GitHub Actions runner において `set-env` および `add-path` の廃止が決定しているのでそれぞれ `GITHUB_ENV`, `GITHUB_PATH` を使うように workflow を更新する必要があります。

```diff
- echo "::set-env name=PKG_TAG::${DOCKER_BASE_NAME}:${TAG_NAME}"
+ echo "PKG_TAG=${DOCKER_BASE_NAME}:${TAG_NAME}" >> ${GITHUB_ENV}
```

cf. [GitHub Actions: Deprecating set-env and add-path commands - GitHub Changelog](https://github.blog/changelog/2020-10-01-github-actions-deprecating-set-env-and-add-path-commands/)

```yaml
- name: Build ${{ matrix.baseimage }} base image
  run: |
    docker build . -t "${PKG_TAG}" --build-arg BASE_IMAGE="${{ matrix.baseimage }}"
    docker tag "${PKG_TAG}" "${HUB_TAG}"
```

イメージのビルド step です。 `matrix.baseimage` からベースイメージを取得しています。

```yaml
- name: Print mdBook version
  run: |
    docker run --rm ${PKG_TAG} --version
```

なにか実行テストが可能であれば push する前にしておくと良いでしょう。ここでは単純に mdbook のバージョンを出力しています。

```yaml
- name: Scan image
  run: |
    docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
      -v ${HOME}/.cache:/root/.cache aquasec/trivy:latest --exit-code 1 ${PKG_TAG}
```

[aquasecurity/trivy] で Docker image の脆弱性を検査しています。終了コードを設定すれば検出時に Job を停止させることができます。

[aquasecurity/trivy]: https://github.com/aquasecurity/trivy

```yaml
- name: Login to Registries
  if: github.event_name != 'pull_request'
  env:
    GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
    DOCKER_HUB_TOKEN: ${{ secrets.DOCKER_HUB_TOKEN }}
  run: |
    echo "${GITHUB_TOKEN}" | docker login docker.pkg.github.com -u peaceiris --password-stdin
    echo "${DOCKER_HUB_TOKEN}" | docker login -u peaceiris --password-stdin
```

GitHub Packages と Docker Hub へログインします。GitHub Actions beta v2 のリリース当初は Packages への Push に Personal access token が必要でしたが、コミュニティの意見が反映され、今では自動生成される `GITHUB_TOKEN` で Packages への Push ができるようになりました。

`DOCKER_HUB_TOKEN` は Docker Hub で生成した Personal access token です。Secret 変数として事前に設定する必要があります。

Pull Request の時はスキップしたいので `if: github.event_name != 'pull_request'` としています。

```yaml
- name: Push to GitHub Packages
  if: github.event_name != 'pull_request'
  run: docker push "${PKG_TAG}"

- name: Push to Docker Hub
  if: github.event_name != 'pull_request'
  run: docker push "${HUB_TAG}"
```

最後に GitHub Packages と Docker Hub へイメージを Push しています。ひとつの step にまとめてもいいと思いますが、それぞれの実行時間を計測するためにあえて二つの step に分けています。(どちらも同じ実行時間でした。)

Pull Request の時はスキップしたいので `if: github.event_name != 'pull_request'` としています。



## 定期的にイメージをアップデートする

`alpine:3.10.x` の更新を反映するために定期的にイメージをアップデートする Workflow を別に作りました。合計12個の Job が並列に実行されて Job 1個の時間で完了します。

- [docker-mdbook/update-image.yml at master · peaceiris/docker-mdbook](https://github.com/peaceiris/docker-mdbook/blob/master/.github/workflows/update-image.yml)

今は単純に version を列挙していますが何か上手いやり方があるかもしれません。

```yaml
name: Update Docker Image

on:
  schedule:
    - cron: '11 11 */31 * *'

env:
  DOCKER_BASE_NAME: docker.pkg.github.com/${{ github.repository }}/mdbook
  DOCKER_HUB_BASE_NAME: peaceiris/mdbook

jobs:

  update:
    runs-on: ubuntu-18.04
    strategy:
      matrix:
        baseimage: ['alpine:3.10', 'rust:alpine3.10']
        version: ['0.3.0', '0.3.1', '0.3.2', '0.3.3', '0.3.4', '0.3.5']
    steps:

    - uses: actions/checkout@v1
      with:
        fetch-depth: 1
        ref: "v${{ matrix.version }}"

    - name: Set env
      run: |
        export TAG_NAME="v${{ matrix.version }}"
        if [ "${{ startsWith( matrix.baseimage, 'rust:alpine') }}" = "true" ]; then
            export TAG_NAME="${TAG_NAME}-rust"
        fi
        echo "PKG_TAG=${DOCKER_BASE_NAME}:${TAG_NAME}" >> ${GITHUB_ENV}
        echo "HUB_TAG=${DOCKER_HUB_BASE_NAME}:${TAG_NAME}" >> ${GITHUB_ENV}

    - name: Build ${{ matrix.baseimage }} base image
      run: |
        docker build . -t "${PKG_TAG}" \
            --build-arg BASE_IMAGE="${{ matrix.baseimage }}"
        docker tag "${PKG_TAG}" "${HUB_TAG}"

    - run: docker run --rm ${PKG_TAG} --version
    - run: docker images

    - name: Login to Registries
      env:
        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        DOCKER_HUB_TOKEN: ${{ secrets.DOCKER_HUB_TOKEN }}
      run: |
        echo "${GITHUB_TOKEN}" | docker login docker.pkg.github.com -u peaceiris --password-stdin
        echo "${DOCKER_HUB_TOKEN}" | docker login -u peaceiris --password-stdin

    - name: Push to GitHub Packages
      run: docker push "${PKG_TAG}"

    - name: Push to Docker Hub
      run: docker push "${HUB_TAG}"
```



## まとめ

GitHub Packages と GitHub Actions の組み合わせは Docker Hub 以上に高速で柔軟性がありました。ビルド行程だけ GitHub Actions に任せてもいいですし、もう Docker Hub を使わずに GitHub Packages と GitHub Actions だけで完結させても問題ないでしょう。

以上です。ありがとうございました。