Go言語のAPIサーバをマルチステージビルドでAWS ECSにデプロイしたら、ヘルスチェックが通らなかった

## 要約
はじめは、ヘルスチェックに`curl`コマンドを用い、Go言語のバイナリ実行のステージに`distroless`イメージを利用する設定にしていました。

ですが、`distroless`イメージは極力コマンドなどを削った環境であり、`curl`コマンドもインストールされていないようで、ヘルスチェックが行えませんでした。

対処法の候補はいくらかあるかと思いますが、私は`distroless`イメージの使用を辞め、**`alpine`イメージを使用する**ようにし、ヘルスチェックのコマンドも`curl`ではなく`wget`を使うように変更しました。

```Dockerfile
HEALTHCHECK CMD wget --quiet --spider http://localhost:1323/ || exit 1
```

## 背景
### 元々のコンテナイメージ
普段Go言語のAPIサーバを構築する際、実行環境はDockerで構築しています。いままでは以下のようなDockerfileでGo言語のプログラムを実行していました。

```Dockerfile:Dockerfile
FROM golang:1.22

WORKDIR /app

COPY go.mod go.sum ./
RUN go mod download

COPY . .
RUN go build -o main main.go

HEALTHCHECK CMD curl -f http://localhost:1323/ || exit 1

CMD [ "./main" ]
```

サーバが起動しているのかを確認するために、ヘルスチェックを`HEALTHCHECK CMD curl -f http://localhost:1323/ || exit 1`の行で設定しています。やっていることは、実際にcurlでリクエストを投げレスポンスが来るかを見ています。

このDockerfileの内容でビルドしたイメージをAWSのECRにプッシュし、ECSでデプロイするという方法を行なっていました。その**ECSのタスク定義**にも上記と同様のヘルスチェックコマンドを設定していました。

```json:ECSのタスク定義（抜粋）
"healthCheck": {
    "command": [
        "CMD-SHELL",
        "curl -f http://localhost:1323/ || exit 1"
    ]
}
```

### この段階での問題点
この方法で問題視していたのは、**コンテナイメージのサイズ**です。

先ほどもいったようにビルドしたコンテナイメージは**AWSのECR上にプッシュ**しています。このECRですが、[課金体系](https://aws.amazon.com/jp/ecr/pricing/)を見ると**ストレージ容量に応じた課金**があります。したがって、イメージサイズが小さければそれだけコスト面で嬉しいです。

そこで、**マルチステージビルド**という手法を採用することで、最終的な**イメージサイズを小さくしよう**ということになりました。

### マルチステージビルド
マルチステージビルドの概要について簡単にまとめます。

Go言語で言えば、プログラムをソースコードから**ビルドする段階**と、ビルドした**バイナリを実行する段階**をそれぞれ分け、ECRにはバイナリの実行環境のみアップロードするという方法です。

実行環境には最低限実行バイナリがあればよく、依存パッケージのソースコードなどは不要になります。それらを省くことで、その分だけイメージのサイズを小さくすることができます。

## マルチステージビルドに変更してみた
マルチステージビルドを実装するために、参考にあげている記事なども参照しながら、以下のようにDockerfileを変更しました。

```Dockerfile:Dockerfile
################## ビルドステージ ##################
FROM golang:1.22-alpine as builder

WORKDIR /app

COPY go.mod go.sum ./
RUN go mod download

COPY . .
RUN go build -o main main.go

################## 実行ステージ ##################
FROM gcr.io/distroless/static-debian11
WORKDIR /app

# ビルドしたバイナリをコピー
COPY --from=builder /app/main .

HEALTHCHECK CMD curl -f http://localhost:1323/ || exit 1

CMD [ "./main" ]

```

ここで実行ステージに使用しているベースイメージはGoogleの手がける`distroless`イメージというものです。**必要最低限の依存パッケージやコマンド**しか入っておらず、そのサイズが非常に小さくなっています。この`distroless`イメージの**必要最低限のコマンド**というのが、追々の問題につながりました。

https://zenn.dev/yoshii0110/articles/21ddb58c6f6bfa

このイメージで、ローカルで正常にコンテナを起動することはできました。Docker Desktopで見た時のイメージのサイズは、以前のイメージで`999.98 MB`となっていたものが、マルチステージビルドを利用したイメージで`10.03 MB`まで減りました。圧倒的です。ローカルで起動もできているので問題ないと思いECSにデプロイをしました。

ただデプロイしてみると、タスクの実行が止まってしまいます。色々ログを見ていたりすると、どうやら**statusがunhealthy**となっていることに気付きました。

### なぜECSでのヘルスチェックが失敗したのか、ローカルでは動作したのか
statusがunhealthyとなっているので、やはり問題があるのはヘルスチェックの箇所です。ヘルスチェックを振り返ってみると、DockerfileでもECSのタスク定義でも、`curl -f http://localhost:1323/ || exit 1`のような`curl`を用いたコマンドをヘルスチェックに指定しています。

結論、**原因はこの`curl`コマンドが、`distroless`イメージにはインストールされていない**ということでした。

ローカルの環境で正常に動作していたのは、**ローカルの`docker-compose`が`status: unhealthy`の場合でも停止するわけではない**からです。`docker container ls`で見てみると、`STATUS`が`Unhealthy`のままでもコンテナが動作しています。逆に**ECSでは`status: unhealthy`なら異常だと判断**して、このタスクを止めてしまっていました。

### 対処法を考える
対処法としてはいくつか考えられるのかと思いますが、私たちの中では以下のような選択肢を考えていました。

- ヘルスチェックの方法を変更する
  - そもそもヘルスチェックを行わない
  - `curl`以外のコマンドを候補に入れる
- `distroless`イメージにcurlをインストールする
- `distroless`イメージ以外を使用する

ここから最終的に取った手段は、「`distroless`イメージ以外を使用する」というものでした。厳密に言えばそれにプラスして、`curl`以外のコマンドとして`wget`コマンドを利用するようにもしています。

### なぜその対処法にしたか
まず「ヘルスチェックをそもそもしないようにする」というのは最終手段と考え、一旦は考慮から省いていました。

そして`curl`以外の方法を使うについては、より具体的に次の2つの方法がありました。**`wget`のような類似のコマンドを利用する**ものと、**Go言語やその他言語でヘルスチェック用のスクリプトを実装する**方法です。

そのうち`wget`は、`curl`と同様にデフォルトでは`distroless`イメージにインストールされていませんでしたので無理です。ヘルスチェック用のスクリプトを作るのは、できなくは無いけどヘルスチェックのためだけには新たにコードを書いて、ビルドなどの環境を整えるのはしんどいなぁといった気持ちでした。

`distroless`イメージに`curl`をインストールするのは、例えば前段のビルドステージで`curl`のバイナリを用意しておき、それを実行ステージにコピーしてくるといった方法が考えられます。ただ、その用意してインストールするという手順がDockerfileに増えるので、内容が複雑になり好ましくは無いなぁーといった気持ちでした。

### 対処法
最終的には、まず`distroless`イメージを使用するのを諦めました。そもそも`distroless`イメージを選んだのも**軽量だから**であって、別の軽量イメージがあればそれで問題ありません。

そこで代わりに採用したのが[`Alpine Linux`のイメージ](https://hub.docker.com/_/alpine)です。`distroless`イメージのように搭載パッケージを削り軽量化したLinux環境ですが、ある程度のコマンドは利用ができました。使えたもののうちの1つが[`wget`のコマンド](https://www.gnu.org/software/wget/manual/wget.html)です。`curl`は**このalpineの中でも使えません**でした。

`wget`コマンドは、Web上のコンテンツをダウンロードするようなコマンドです。ただ、オプションの指定によって`curl`のような疎通確認にも利用できるようでした。この`wget`コマンドを用いてヘルスチェックするように設定を変更しました。

```diff_docker:Dockerfile(抜粋)
################## 実行ステージ ##################
- FROM gcr.io/distroless/static-debian11
+ FROM alpine:3.19
WORKDIR /app

# ビルドしたバイナリをコピー
COPY --from=builder /app/main .

- HEALTHCHECK CMD curl -f http://localhost:1323/ || exit 1
+ HEALTHCHECK CMD wget --quiet --spider http://localhost:1323/ || exit 1

CMD [ "./main" ]
```

同様に**ECSのタスク定義**も次のように書き換えました。

```diff_json:ECSのタスク定義（抜粋）
"healthCheck": {
    "command": [
        "CMD-SHELL",
-       "curl -f http://localhost:1323/ || exit 1"
+       "wget --quiet --spider http://localhost:1323/ || exit 1"
    ]
}
```

`wget`コマンドのオプションについて、[`--quiet`オプション](https://www.gnu.org/software/wget/manual/wget.html#index-quiet)は`wget`コマンドの出力を省くもの、[`--spider`オプション](https://www.gnu.org/software/wget/manual/wget.html#index-Wget-as-spider)はダウンロードを行わないというオプションです。

これらの変更でECS上でも正常にタスクが動作し、ステータスも`HEALTHY`の状態になることが確認できました。

ちなみに、`alpine`イメージを使用した際のイメージサイズは`15.19 MB`でした。`distroless`イメージより大きくはなりますが、元のイメージサイズから大きく減少していて、本来の目的は果たせただろうという判断になりました。

## 終わりに
この記事を書くために改めて情報を整理していたら、**`alpine`はオススメしない**という記事も見つけました。なんでもパフォーマンスが落ちたりするのだとか…。

詳細まで見られていませんが、内容を詳しく見てみて私自身も対応は考えたいなと思っています。この記事にも参考情報として載せておきます。

https://blog.inductor.me/entry/alpine-not-recommended

最後に、ここまでで作成してきたソースコードは、以下のリポジトリにあります。必要に応じて参照してください。

https://github.com/mochi-yu/qiita-material/tree/main/golang-multi-stage-build

## 参考
https://zenn.dev/farstep/articles/a7819ec705dcc0

https://note.com/jtamas_engineer/n/n28cc15e61a1f

https://zenn.dev/yoshii0110/articles/21ddb58c6f6bfa

https://tech.excite.co.jp/entry/2023/05/08/151417

https://github.com/GoogleContainerTools/distroless/issues/183