Railsで作られたWebサービスをElasticBeanstalkからECS on Fargateに移行した話し（全体概要）

# はじめに
医療系クラウドサービスを提供しているレイヤードという会社で働いています。

弊社で提供しているWeb問診システムSymviewはRuby on Railsに構築しており、2017年からElasticBeanstalk（以下EB）を利用してオーケストレーション及びDeploymentを管理しておりました。
EBの機能的にはそれなりに満足していたし、長年使っていて運用ノウハウも溜まっていたのですが、主に下記に記載の理由からECSに乗り換えることにしました。

:::note warn
本記事では完成形の移行にフォーカスして書いております。
アーキテクチャの選定や実装の試行錯誤、設定等の細かい話しは省いていますのでご了承ください。
:::

## 課題
- EBがサポートする **プラットフォームの更新が遅く、最新バージョンのRubyをなかなか利用出来ない** 
- オートスケールやデプロイ時のEC2のプロビジョニングでライブラリのインストールが毎回実施されるため、ライブラリの配布サイトの状態（サイトダウン等）によってはダウンロード失敗等でプロビジョニングエラーが発生する
  - これが原因で **オートスケール出来なくなる** という事態が直近2年で2度も起きた（ちなみに問題となったライブラリは2度ともNode.js）
  - **プロビジョニングが遅い** （20分くらいかかる）
- Workerの **スケールイン時にjobが殺されてしまう** 
  - SQSのリトライでリカバリされるので事実上は問題ないが、Sentryが飛んでくるなど気持ちの良いものではない

## アーキテクチャ検討
上記課題を解決するのに以下３つのアプローチを検討しましたが、享受できるメリットの多さからCを選択しました。
- A. EBプラットフォームをDockerにする
- B. EBでカスタムAMIを利用するようにする
- C. ECSに乗り越える

### Cのメリット
- 環境の構築・運用の情報量が多い
- 環境の柔軟性が高い
- プロビジョニングが早い
  - 負荷に合わせた柔軟な伸縮が可能だとインフラコストを抑える事ができる
  - 現状EBだとスパイクにプロビジョニングが間に合わないため、スケジュールでスケールアウト/インを組んでいるためスケール前後で余剰なリソースを抱えこまざるを得ない
- エンジニアのモチベーション（EB < ECS）

# 環境
### 環境概要
アーキ図だけ見ても分かり難いので少し説明します。
Symviewは主に下記4つのアプリ（サービス）で成り立っています。
- Webアプリ
  - MVCで書かれており、サーバサイドでレンダリングするごく普通のWebアプリです
- Workerアプリ
  - 1秒以上 **120秒以内で終わる** 時間のかかる（実際には5秒以内くらいで終わっています）処理を捌きます
  - WebアプリとはSQSキューを介してメッセージをやり取りします
  - 「120秒以内」とは[SQSの可視性タイムアウト](https://docs.aws.amazon.com/ja_jp/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-visibility-timeout.html)に合わせています
  - Symviewではメール送信、httpリクエスト送信、画像生成、PDF生成等を処理しています
- Taskアプリ
  - 120秒以上**30分以内で終わる**時間のかかる処理を捌きます
  - 当然ですがWorkerとは別のSQSキューを使ってメッセージをやり取りします 
  - 30分以上かかりそうな時は途中で処理を中断して再帰（自身で再度キューイング）し、続きから処理を再開するように実装側で工夫しています
  - SymviewではCSVエクスポート等を処理しています
- 定時処理（cronタスク）
  - 言葉の通り、定時で起動して特定のバッチ処理を実行します
  - Symviewではデータメンテナンスや、メッセージ送信等を処理しています
  - EBではTaskアプリのEC2インスタンスにcronを登録して処理していました

:::note info
ECS環境では「Web」「Worker」「Task+定時処理」「migration用（Deploy時にCodePipelineから`db:migration` を実行するために起動される）」の4つの括りでタスク定義を作成し、1つのクラスタ内に「Web」「Worker」「Task」のサービスと、スケジュールされたタスクを作成しました。
:::

### 旧EB環境
[![Image from Gyazo](https://i.gyazo.com/0fb5d3dca036d1c2f16f7b622e8ade37.png)](https://gyazo.com/0fb5d3dca036d1c2f16f7b622e8ade37)

### 新ECS環境
[![Image from Gyazo](https://i.gyazo.com/3b38671feefb430b5607ab35e2d08105.png)](https://gyazo.com/3b38671feefb430b5607ab35e2d08105)

### 新ECS環境 CI/CD構成
[![Image from Gyazo](https://i.gyazo.com/250db079ad0df2ec0b535328fbdab99b.png)](https://gyazo.com/250db079ad0df2ec0b535328fbdab99b)


# 本番移行
- 実運用に影響がでないように、5ステップに分けて（アプリ部分はタスク定義単位で移行）1ヵ月程かけて段階的に移行しました。
- 段階的に移行するデメリットとして、環境が共存する事でデプロイ周りが複雑になり、作業ミスが発生する可能性が高くなると判断し、機能開発及びリリースはストップする事にしました。


1. production環境の準備
2. 定期実行のバッチ処理+Taskアプリの移行
3. Workerアプリの移行
4. Webアプリの移行
5. CI/CDの移行

## 1.production環境の準備
- 最初にproductionのAWSアカウントにECS環境をCI/CD含めてフルセットで構築しました（この時点ではRDS、ElastiCache、SQS等全て稼働中の本番環境とは分けて構築しています）

- この状態でDeploy含めて全体的な動作確認をマニュアルで行いました。
全ての動作確認が済んだら、RDS等の接続先を本番に向けていく事で入れ替えを実施していきます。

- スケジュールタスクは、動作確認が済んだらcron式のスケジュールに未来の西暦を設定してスケジュールが動かないようにしました。

## 2.定期実行のバッチ処理+Taskアプリの移行
:::note info
EBの時はRailsのWheneverというgemを使ってcronタスクで動かしていた部分を、ECSでは「スケジュールされたタスク」に移行します。
基本的にバッチ処理のロジック内で排他制御ないしは冪等性を保つように実装していますので、あまり考えることはありませんでした。
:::
- スケジュールタスクのRDS等の接続先を本番に向けました
- 前述のcron式のスケジュールの未来の西暦を`*`に戻しました
- TaskアプリのEBのオーケストレーションの設定でEC2を0台に変更しました

## 3. workerアプリの移行
- 役割の性質上EBもECSも共存できるはずですが、EBのWorkerはスケールインする時にプロセスを殺すので、サービスを停止してworkerの処理が無くなってから切り替えました。
- ここでEBとECSでActiveJobを起動するアダプタを変更（EBのWorker　→　aws_sqs_active_job）した影響がでましたので、一旦ECSの移行を止めました（作業直後に気付けて良かった。。。）。
- 詳しくは一緒にECS移行に尽力してくれた[une marcottée](https://zenn.dev/a_layered/articles/cd205763cd9516)さんが記事にしてくれていますので、EBのworkerをECSに移行する人は読んでみてください。
- 問題を解決した後、改めて同じ手順で無事移行できました

## 4. Webアプリの移行
:::note info
ちょうどCloudFrontのStaging Distributionのリリース情報を目にしていたので、本機能を利用したカナリアデプロイメントに挑戦してみました。

ざっくり言うと全体のリクエストの15%（max）をECSに振り向ける事ができます）。

メチャメチャ便利な機能なので是非皆さんも頭の片隅に入れておいて損は無いと思います。
:::

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/165742/c16390bc-5129-fbcd-9764-7e7afda5ae95.png)

- CloudFrontのStaging Distributionの設定についてはダウンタイムはありませんので、日中の業務時間に作業しました。
- Webアプリはリクエストが来なければ動く事はありませんので、CloudFront同様に日中に本番のデータベース等諸々に接続しておきました
- Staging Distributionの有効化はダウンタイムはありませんが念のため利用者の少ない夜間に行いました
- このまま1週間ほど並行稼働して様子をみて、1週間後にpromoteして100%切り替えを実施しました（これもダウンタイムありません）

:::note warn
2023年11月時点でStaging Distributionで1点だけ注意点があります。
日本時間の**16時～23時の間Staging Distributionは寝る**という事です:sleeping:
ただしstagingが機能しないだけでリクエストを失う訳ではありません。
CloudWatchでリクエスト数をモニタリングするとこうなります:grinning:
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/165742/627706b3-7971-91cd-1851-e7817140cf97.png)
promoteして完全に切り替えるとこの挙動は無くなりますのでStaging Distribution特有の挙動のようです。
:::

## 5. CI/CDの移行
弊社ではproduction環境は手動でパイプラインを実行するようにしていますので、特に移行というほどの事は作業はありませんでしたが、強いて言えばmigration用のタスク定義を用意していましたので、RDS等の接続先を本番にむけたのと、Deployフローがちゃんと最後まで実行されるよねというのを再確認した程度でした。

# おわりに
参考サイトで統一感が無かったり、情報が無かったりで苦労した（苦労をかけた）以下2点の実装をご紹介したいと思います。
- DeployフローでどのようにMigrationを実行するか
  - Deployが終わった後にSSHしてMigrationとかマニュアル作業を入れたくない
- スケジュールされたタスクのタスク定義をどのように更新するか
  - CodePipelineからはDeployできない（と、思う。たぶん）

:::note warn
CodePipelineという高機能なモノがありながら果たしてこのようなオレオレな実装をして良いのか？と、当初は不安もありましたが、いざ運用が始まって様子みた感じだと、これは間違いないなと自負しております（提案も構築もインフラ担当岩崎様なので私が自負するのは、おこがましいにも程があるのですが笑）。
:::

## Migrationの実行
- CodePipeline上で、`Migration`と、`WaitMigration`の2つのステージを作成しました
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/165742/760658b9-7dca-b474-1d7a-4af24c818a9a.png)

- `Migration`ステージではlambdaを起動します
    - ①タスク定義を更新します（ECRから最新のイメージのURLを取得して更新する）
    - ②タスクの起動（`bundle exec rake db:migrate`）を行います。
    - タスク起動は非同期で行われるのでココで待つことは出来ません（仮に待てたとしてもlambdaの時間制限もあるので好ましくありません）
- `WaitMigration`ステージはCodeBuildeプロジェクトで作成し、Buildspec(yml)内のコマンドで、先のステージで起動したECSタスクが終了するのを待ち続けます
```
phases:
  pre_build:
    on-failure: ABORT
    commands:
      - if [ -z $CLUSTER_NAME ]; then echo "env var CLUSTER_NAME required in codebuild config"; exit -1; fi
      - if [ -z $TASKDEF_MIGRATION_FAMILY ]; then echo "env var TASKDEF_MIGRATION_FAMILY required in codebuild config"; exit -1; fi
  build:
    commands:
      # 起動中のタスク一覧からマイグレーション用タスク定義のタスクARNを取得する
      - echo list migration tasks
      - MIGRATION_TASK=`aws ecs list-tasks --cluster $CLUSTER_NAME --family $TASKDEF_MIGRATION_FAMILY --query "taskArns[0]"`
      - echo "[`date "+%Y-%m-%d %H:%M:%S"`] migration task arn=${MIGRATION_TASK}"
      # ステータスがPENDING、RUNNING以外になるまでループ
      - |
        if [ "$MIGRATION_TASK" != "null" ]; then
          while true; do
            STATUS=`aws ecs describe-tasks --cluster $CLUSTER_NAME --tasks $MIGRATION_TASK --query "tasks[0].desiredStatus"`
            echo "[`date "+%Y-%m-%d %H:%M:%S"`] status is ${STATUS}"
            if [ "$STATUS" != "PENDING" ] && [ "$STATUS" != "RUNNING" ]; then
              break
            fi
            sleep 20
          done
        else
          echo "[`date "+%Y-%m-%d %H:%M:%S"`] migration task not found."
        fi

```
## スケジュールされたタスクのタスク定義の更新
- EventBridgeで定期的にlambdaを起動して、Taskアプリのタスク定義とスケジュールされたタスクのタスク定義に相違が無いかをチェックします
- TaskアプリのタスクはCodePipelineで更新されるので、時差はあるものの自動的に追従してスケジュールのタスク定義も更新するようにしています
- 「しています」と書くのは簡単ですが、コードは結構長くて大変だったと思います。岩崎さんいつもありがとう。

# ホントのおわりに
あるあるなのか分かりませんが、AWS特有の色々で凄く苦労しました。
- 新UIでしか設定できない
- 旧UIでしか設定できない
- 普通に不具合
- 油断する（編集モードにすると設定が初期化される）と設定がクリアされる
- 別のサービスに飛んだ後（Codepipelineで作ってEventBridgeにいくとか）なら設定できる

AWSを触る以上、こういう事はあるものだと思って取り組まないといけないですね。
いきなりブチ当ってブチ当たってブチ当たると、いい加減心が折れそうになる(笑)
皆様もお気をつけくださいまし。

最後に、インフラ担当岩崎さん、アプリ担当采本さん、本当にありがとうございました。
お二人のおかげでゴールまで辿り着けました。

# 参考
- [AWS FargateにRailsアプリケーションをデプロイする手順](https://qiita.com/hatsu/items/22e11e94a0a981d78efa)
- [aws-sdk-railsでSQSをActive Jobのアダプターとして使う](https://techblog.lclco.com/entry/2021/12/04/000000)
- [AWS CodePipelineを使用してgit pushでAmazon ECSをデプロイする](https://www.seeds-std.co.jp/blog/creators/2020-05-27-153520/)
- [EB から ECS にワーカーを移行したら、RequestStore が想定外の動作をしたので調査した](https://zenn.dev/a_layered/articles/cd205763cd9516)