GoReleaser でマルチプラットフォーム対応の Docker イメージを作る

https://goreleaser.com/

Go のクロスコンパイルやらリリースやらの面倒を見てくれる便利な [GoReleaser](https://goreleaser.com/) ですが、Docker イメージのビルドにも対応しています。

この記事では、GoReleaser を使用してマルチプラットフォーム (マルチ CPU アーキテクチャ) 対応の Docker イメージを作成する方法について紹介します。

記事中で使用する Go のバージョンは 1.19.1、GoReleaser のバージョンは 1.11.4 です。

## サンプルプロジェクト

次のようなサンプルプロジェクトを例として扱います。

```console
$ tree
.
├── go.mod
└── main.go
```

```go:go.mod
module github.com/frozenbonito/helloworld

go 1.19
```

```go
package main

import "fmt"

func main() {
	fmt.Println("Hello, world!")
}
```

このサンプルプロジェクトから `linux/amd64`, `linux/arm64` 両対応の Docker イメージを作成します。

## Dockerfile を作る

まずは Dockerfile を作成します。
Go のビルド自体は GoReleaser がやってくれるので、ビルド後のバイナリを COPY する感じで書けば OK です。

今回のサンプルプロジェクトの場合、module 名が `helloworld` なのでデフォルトでは `helloworld` というバイナリが生成されるはずです。

そのため Dockerfile は次のようになります。

```docker:Dockerfile
FROM gcr.io/distroless/static-debian11
COPY helloworld /
ENTRYPOINT [ "/helloworld" ]
```

重要なのは `COPY` 命令の部分だけで、他は自由にカスタマイズ可能です。

## GoReleaser の設定ファイルを作る

次に GoReleaser の設定ファイル `.goreleaser.yaml` を作成します。

今回書く設定は主に Go のビルドに関する設定、Docker イメージのビルドに関する設定、Docker マニフェスト (マルチプラットフォーム対応イメージを実現するもの) に関する設定の 3 つに分けられます。

```yaml:.goreleaser.yaml
project_name: helloworld
builds:
  # Go のビルドに関する設定
  # ...
dockers:
  # Docker イメージのビルドに関する設定
  # ...
docker_manifests:
  # Docker マニフェストのビルドに関する設定
  # ...
```

### Go のビルドに関する設定

次のように `linux/amd64`, `linux/arm64` 向けのビルドを行うように設定します。

```yaml:.goreleaser.yaml
builds:
  - env:
      - CGO_ENABLED=0
    goos:
      - linux
    goarch:
      - amd64
      - arm64
```

### Docker イメージのビルドに関する設定

`linux/amd64` 用と `linux/arm64` 用の 2 つの設定を用意します。

今回は DockerHub に push することを想定して、`frozenbonito/helloworld` というイメージ名を使うことにします。
また、セマンティックバージョニングに従ってイメージにタグを付けることにします。

```yaml:.goreleaser.yaml
dockers:
  - image_templates:
      - frozenbonito/helloworld:latest-amd64
      - frozenbonito/helloworld:{{ .Version }}-amd64
      - frozenbonito/helloworld:{{ .Major }}-amd64
      - frozenbonito/helloworld:{{ .Major }}.{{ .Minor }}-amd64
    use: buildx
    goos: linux
    goarch: amd64
    build_flag_templates:
      - --platform=linux/amd64
  - image_templates:
      - frozenbonito/helloworld:latest-arm64
      - frozenbonito/helloworld:{{ .Version }}-arm64
      - frozenbonito/helloworld:{{ .Major }}-arm64
      - frozenbonito/helloworld:{{ .Major }}.{{ .Minor }}-arm64
    use: buildx
    goos: linux
    goarch: arm64
    build_flag_templates:
      - --platform=linux/arm64
```

この設定により、タグ `v1.0.0` をリリースした場合に次のようなイメージが作成されます。

- `linux/amd64` 向けイメージ
  - `frozenbonito/helloworld:latest-amd64`
  - `frozenbonito/helloworld:1.0.0-amd64`
  - `frozenbonito/helloworld:1-amd64`
  - `frozenbonito/helloworld:1.0-amd64`
- `linux/arm64` 向けイメージ
  - `frozenbonito/helloworld:latest-arm64`
  - `frozenbonito/helloworld:1.0.0-arm64`
  - `frozenbonito/helloworld:1-arm64`
  - `frozenbonito/helloworld:1.0-arm64`

いくつかポイントがあります。

- Docker でクロスプラットフォームビルドを行うには実験的機能である `buildx` を使用する必要があるため、`use: buildx` を指定する
- CPU アーキテクチャ毎に適切な `goarch` を指定する
- CPU アーキテクチャ毎に適切な `--platform` フラグを指定する
- 各タグに `-amd64`, `-arm64` などの分かりやすいサフィックスをつけておく

### Docker マニフェストに関する設定

[Docker イメージのビルドに関する設定](#docker-イメージのビルドに関する設定) で `linux/amd64` 用と `linux/arm64` 用のイメージを作ることはできていますが、アーキテクチャごとに別のタグとなってしまっています。
これではマルチプラットフォーム対応イメージとは言えないので Docker マニフェストを作成する設定を追加します。

```yaml:.goreleaser.yaml
docker_manifests:
  - name_template: frozenbonito/helloworld:latest
    image_templates:
      - frozenbonito/helloworld:latest-amd64
      - frozenbonito/helloworld:latest-arm64
  - name_template: frozenbonito/helloworld:{{ .Version }}
    image_templates:
      - frozenbonito/helloworld:{{ .Version }}-amd64
      - frozenbonito/helloworld:{{ .Version }}-arm64
  - name_template: frozenbonito/helloworld:{{ .Major }}
    image_templates:
      - frozenbonito/helloworld:{{ .Major }}-amd64
      - frozenbonito/helloworld:{{ .Major }}-arm64
  - name_template: frozenbonito/helloworld:{{ .Major }}.{{ .Minor }}
    image_templates:
      - frozenbonito/helloworld:{{ .Major }}.{{ .Minor }}-amd64
      - frozenbonito/helloworld:{{ .Major }}.{{ .Minor }}-arm64
```

この設定により次のようなマニフェストが作成され、DockerHub からマルチプラットフォーム対応イメージとして利用することができるようになります。

- `frozenbonito/helloworld:latest`
- `frozenbonito/helloworld:1.0.0`
- `frozenbonito/helloworld:1`
- `frozenbonito/helloworld:1.0`

## ローカルで動作を確認する

`.goreleaser.yaml` が作成できたらローカルで動作を確認しておきます。

GoReleaser の CLI をインストールします。

```console
$ go install github.com/goreleaser/goreleaser@latest
```

Docker の `buildx` コマンドを使用するので、実験的機能を有効にしておく必要があります。

例えば Docker Desktop for Windows (v4.12.0) の場合は次のように右上の歯車アイコンから設定を開き、Docker Engine の `experimental` を `true` にして Apply & Restart します。

![dockerdesktop.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/586035/5e5c11c6-12ee-f745-ae5b-737a5110f2a4.png)

Docker イメージを push せずにビルドするには `--snapshot` をつけて `goreleaser release` を実行します。

```console
$ goreleaser release --rm-dist --snapshot
```

想定通りのイメージが作成されているかを確認します。

```console
$ docker image ls
REPOSITORY                TAG                         IMAGE ID       CREATED          SIZE
frozenbonito/helloworld   0-arm64                     7a445338dc93   5 seconds ago    3.6MB
frozenbonito/helloworld   0.0-arm64                   7a445338dc93   5 seconds ago    3.6MB
frozenbonito/helloworld   0.0.0-SNAPSHOT-none-arm64   7a445338dc93   5 seconds ago    3.6MB
frozenbonito/helloworld   latest-arm64                7a445338dc93   5 seconds ago    3.6MB
frozenbonito/helloworld   0-amd64                     8408192f6a68   5 seconds ago    3.57MB
frozenbonito/helloworld   0.0-amd64                   8408192f6a68   5 seconds ago    3.57MB
frozenbonito/helloworld   0.0.0-SNAPSHOT-none-amd64   8408192f6a68   5 seconds ago    3.57MB
frozenbonito/helloworld   latest-amd64                8408192f6a68   5 seconds ago    3.57MB
```

ただしこの時点では Docker マニフェストは作成されません。
これは [docker manifest create](https://docs.docker.com/engine/reference/commandline/manifest/) の制限としてマニフェストに追加するイメージが push されている必要があるためです。

また、`--snapshot` をつけて実行した場合スナップショット用のバージョンが振られます。
このバージョン名は [snapshot](https://goreleaser.com/customization/snapshots/) 設定でカスタイマイズ可能です。

https://goreleaser.com/customization/snapshots/

## GitHub Actions で DockerHub に push する

ここまでの手順で、リリースを実行すればマルチプラットフォーム対応の Docker イメージが DockerHub に push されるようになっています。

リリースの実行方法は色々とありますが、ここでは GitHub Actions で自動化する方法を紹介します。

次のような workflow ファイルを作成します。

```yaml:.github/workflows/release.yaml
name: Release

on:
  push:
    tags:
      - v**

permissions:
  contents: write

jobs:
  build:
    name: Build
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v3
        with:
          fetch-depth: 0
      - name: Setup go
        uses: actions/setup-go@v3
        with:
          go-version: "1.19"
          cache: true
      - name: Login to Docker Hub
        uses: docker/login-action@v2
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}
      - name: Run goreleaser
        uses: goreleaser/goreleaser-action@v3
        with:
          args: release --rm-dist
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
```

[GoReleaser Action](https://github.com/goreleaser/goreleaser-action) の一般的な使い方に従えば OK ですが、イメージを push するために事前に DockerHub にログインしておく必要があります。
これは [Docker 公式の Login Action](https://github.com/docker/login-action) で簡単に実現可能です。

ログインに必要な情報は GitHub リポジトリの Secret に保存します。

- `DOCKERHUB_USERNAME`
  - DockerHub のユーザー名
- `DOCKERHUB_TOKEN`
  - DockerHub のアクセストークン

アクセストークンは次の手順に従って `Read & Write` 権限を持つものを作成します。

https://docs.docker.com/docker-hub/access-tokens/

これで GitHub への tag push をトリガーに自動でリリースが実行され、マルチプラットフォーム対応の Docker イメージが作成されるようになるはずです。