AWS Copilot で時間のかかるデプロイを実施するときに対応すべきこと

## はじめに

AWS Copilot は AWS 上に Web システムを簡単に構築できるツールです

https://aws.github.io/copilot-cli/ja/

以前、 AWS Copilot を使って Phoenix Framework の Web サイトを構築する記事を書きました

AWS Copilot がコンテナの起動、停止などを自動的に制御してくれるため、デプロイが非常に簡単になっています

https://qiita.com/RyoWakabayashi/items/b831516ca9f012fb56b5

また、 AWS Copilot でデータベースも構築でき、 Phoenix の仕組みでデータベースの初期構築、マイグレーション（テーブル定義の変更等）もデプロイ時に自動実行できます

https://qiita.com/RyoWakabayashi/items/6eeda41be7941bd0b485

しかし、マイグレーションに複雑な更新処理を含み、かつデータが膨大な場合、注意しないといけない場合があります

この記事では AWS Copilot によるデプロイに長時間かかる場合、どのような対応が必要なのか紹介します

## 長時間のデプロイ

例えば Phoenix と Ecto で以下のようなデータベースマイグレーションを実装した場合を考えます

```elixir
defmodule Sample.Repo.Migrations.AddANewColumToTable do
  use Ecto.Migration

  def change do
    # some_table に new_column 列を追加する
    alter table(:some_tabel) do
      add :new_column, :bigint
    end

    # 列の追加をこの時点で実行する    
    flush()

    # new_column の値を refered_tabel の refered_column の値で更新する
    execute """
      UPDATE some_table
      SET new_column = (
        SELECT refered_column
        FROM refered_tabel
        WHERE some_table.id = refered_tabel.some_id
      )
    """
  end
end
```

データ量が多いと UPDATE 文の実行時間が長大になることが懸念されます

この場合、データベースマイグレーションの実行に1分以上時間がかかると、 AWS Copilot の実行中に以下のような状態になることがあります

```
- Updating the infrastructure for stack apb-stage-apb-svc                                              [update in progress]  [1420.4s]
  - An ECS service to run and maintain your tasks in the environment cluster                           [update in progress]  [1407.6s]
    Deployments
               Revision  Rollout      Desired  Running  Failed  Pending
      PRIMARY  242       [completed]  1        0        1       0

   ✘ Latest 2 tasks stopped reason
      - [xxx,xxx]: Task failed ELB health checks in (target-group arn:aws:
        elasticloadbalancing:ap-northeast-1:123:targetgroup/axxx/yyy)

    Troubleshoot task stopped reason
      1. You can run `copilot svc logs --previous` to see the logs of the last stopped task.
      2. You can visit this article: https://repost.aws/knowledge-center/ecs-task-stopped.

    ✘ Latest 2 failure events
      - (service xxx) (port 4000) is unhealthy in (target-group arn:aws:
        elasticloadbalancing:ap-northeast-1:123:targetgroup/xxx/yyy) due to
        (reason Health checks failed).
      - (service xxx) (port 4000) is unhealthy in (target-group arn:aws:
        elasticloadbalancing:ap-northeast-1:123:targetgroup/xxx/yyy) due to
        (reason Health checks failed).
  - An ECS task definition to group your containers and run them on ECS     
```

`Task failed ELB health checks` とあるように、コンテナのヘルスチェック（リクエストに対する応答チェック）に失敗し、デプロイに失敗したとみなされています

データベースマイグレーションが終了してから Web サーバーを起動するため、マイグレーションに時間がかかる場合、ヘルスチェックに間に合わないことがあるのです

## ヘルスチェックの検証

以下のような検証用データベースマイグレーションを用意すると、ヘルスチェックのタイムアウトを再現できます

```elixir
defmodule Sample.Repo.Migrations.Timeout do
  use Ecto.Migration

  def change do
    # マイグレーションを終了させたいUTC時刻
    target_utc = %DateTime{
      year: 2024,
      month: 7,
      day: 5,
      hour: 11,
      minute: 18,
      second: 0,
      microsecond: {0, 0},
      time_zone: "Etc/UTC",
      zone_abbr: "UTC",
      utc_offset: 0,
      std_offset: 0
    }

    # 最大 1000 秒で終了
    1..1000
    |> Enum.reduce_while(%{seconds: 0}, fn _, acc ->
      # 現在のUTC時刻を取得
      current_utc = DateTime.utc_now()

      # 現在時刻を表示
      IO.inspect("current_utc: #{current_utc}")

      # 指定時刻が来たら終了、それまでは繰り返し
      if DateTime.compare(current_utc, target_utc) == :gt do
        {:halt, acc}
      else
        seconds = acc[:seconds] + 1

        # 経過時間を表示
        IO.inspect("seconds: #{seconds}")

        # 1秒待つ
        :timer.sleep(1000)
        {:cont, %{seconds: seconds}}
      end
    end)
  end
end
```

このマイグレーションを含めてデプロイすると、ログは以下のようになります（CloudWatch Logs に出力されます）

```
/copilot/xxx copilot/yyy/abc123 Starting dependencies...
/copilot/xxx copilot/yyy/abc123 create Sample.Repo database if it doesn't exist
/copilot/xxx copilot/yyy/abc123 create_db task done!
/copilot/xxx copilot/yyy/abc123 08:03:30.466 [info] == Running 20240704162000 Sample.Repo.Migrations.Timeout.change/0 forward
/copilot/xxx copilot/yyy/abc123 "current_utc: 2024-07-04 08:03:30.540948Z"
/copilot/xxx copilot/yyy/abc123 "seconds: 1"
/copilot/xxx copilot/yyy/abc123 "current_utc: 2024-07-04 08:03:31.550753Z"
/copilot/xxx copilot/yyy/abc123 "seconds: 2"
/copilot/xxx copilot/yyy/abc123 "current_utc: 2024-07-04 08:03:32.551757Z"
/copilot/xxx copilot/yyy/abc123 "seconds: 3"
...
/copilot/xxx copilot/yyy/abc123 "current_utc: 2024-07-04 08:08:06.831815Z"
/copilot/xxx copilot/yyy/abc123 "seconds: 277"
/copilot/xxx copilot/ecs-service-connect-zzz/abc123 [2024-07-04 08:08:07.392][1][info] [AppNet Agent] Received request to drain listener connections.
/copilot/xxx copilot/yyy/abc123 "current_utc: 2024-07-04 08:08:07.832804Z"
/copilot/xxx copilot/yyy/abc123 "seconds: 278"
...
/copilot/xxx copilot/yyy/abc123 "current_utc: 2024-07-04 08:08:35.865814Z"
/copilot/xxx copilot/yyy/abc123 "seconds: 306"
/copilot/xxx copilot/yyy/abc123 "current_utc: 2024-07-04 08:08:36.866826Z"
/copilot/xxx copilot/yyy/abc123 "seconds: 307"
/copilot/xxx copilot/ecs-service-connect-zzz/abc123 [2024-07-04 08:08:48.524][1][info] [AppNet Agent] Draining Envoy listeners...
/copilot/xxx copilot/ecs-service-connect-zzz/abc123 [2024-07-04 08:08:48.525][1][info] [AppNet Agent] Waiting 20s for Envoy to drain listeners.
```

277 秒時点でコンテナ停止のリクエストを受けて、 307 経過時点で強制終了されています

実際にはもっと早い段階（60秒経過あたり）でヘルスチェック失敗の判定を受けていて、その後もしばらくはコンテナが活かされています

ざっくりとした流れは以下のようなものです

- デプロイ開始
- 新しいコンテナ起動
- マイグレーション開始
- 60秒経過してもマイグレーションが終わらないのでヘルスチェック失敗判定（Copilot の標準出力でコンテナが Failed ステータスに移動）
- しばらくしてコンテナ停止リクエストを受ける（まだマイグレーションは進行中）
- コンテナが停止される（マイグレーションも強制終了）
- 新しいコンテナ起動
- 以下、マイグレーションが完了するまで繰り返し

## ヘルスチェック設定によるタイムアウト回避

公式ドキュメントにヘルスチェックに関する設定が記載されています

https://aws.github.io/copilot-cli/ja/docs/manifest/lb-web-service/#http-healthcheck

`http.healthcheck.grace_period` を変更することで対応可能です

> `http.healthcheck.grace_period` Duration
> コンテナ起動時にターゲットグループのヘルスチェックが失敗した場合の、それを無視する時間を指定します。デフォルトは 60 秒です。これは、healthy であることを担保しながら着信を待機するまでに時間がかかるコンテナのデプロイ時の問題を修正したり、迅速な起動が保証されているコンテナのデプロイを高速化したりするのに役立ちます。

二つの使用例が書かれていますが、本件は前者の例に当てはまります

- healthy であることを担保しながら着信を待機するまでに時間がかかるコンテナのデプロイ時の問題を修正
- 迅速な起動が保証されているコンテナのデプロイを高速化

マニフェストファイル "copilot/<サービス名>/manifest.yml" を以下のように設定します

```yaml
---

name: <サービス名>
type: Load Balanced Web Service

http:
  path: '/'
  healthcheck:
    grace_period: 600s
```

これにより、デプロイ時は 10 分経過するまでヘルスチェックの失敗判定を無視してくれます

前述の検証用データベースマイグレーションを使うと、実際に 600 秒未満の時間経過ではコンテナの状態が Failed に遷移しないことが確認できます

また、逆に短時間でデプロイが完了する場合、ヘルスチェックの成功判定は 600 秒未満でも成立するため、長めに指定しておいても問題はありません

問題のあるケースとしては、そもそもコンテナ自体に問題があって Web サービスが起動できないような場合、失敗判定に時間がかかってなかなかリトライできないケースが考えられます

## まとめ

マニフェストファイルの `http.healthcheck.grace_period` を変更することで長時間のデプロイも問題なく実行できました

リリース作業計画時にはデータベースマイグレーションなど、デプロイに付随する処理の時間も見積り、全体が問題なく実行できるようにしましょう