こんにちは、エムスリーエンジニアリンググループのコンシューマチームに所属している園田(@ryoryoryohei)です。今回は 15 年以上続いている弊社の C 向けサービスである AskDoctors の AWS 移行で苦労した点や工夫した点などをお伝えしたいと思います。
はじめに
弊社では to C のサービスとして AskDoctors という医師に直接相談できる Rails のサービスを 15 年以上前から運営しています。
こちらの AskDoctors ですが、先日 1 年以上の時間をかけてフロントエンドサービスの AWS 移行が完了しました。
AskDoctors のクラウド移行に関する背景については以前のポストをご覧ください。
先のポストに記載されている移行したサブシステムは、AskDoctors サービス全体のクラウド移行における 4 フェーズ目です。
今回は上記に加え、AskDoctors サービス全体のクラウド移行 (未完) について書いてみたいと思います。
移行フェーズ
クラウド移行開始前のサービス全体構成としてはざっくりと以下のような構成でした。
この構成を、以下のようにフェーズ分割して徐々にクラウド移行しています。
- リバースプロキシ
- 社内管理画面
- バッチ
- 投稿監視 / CMS <= 前回ポスト
- Redis
- フロントエンド
- レガシー API <= イマココ
- データベース
- 医師側画面 (構成図にはありません)
それぞれのフェーズの説明は割愛しますが、最初にリバースプロキシをクラウド移行したのは、ぶら下がっている各サブシステムを分割してクラウド移行させるためです。
リバースプロキシを変更管理が容易な ALB にすることで、その後のサブシステムの移行を柔軟に行うというねらいがありました。*1
苦労したポイント
苦労したポイントの説明の前に、このポストの体裁は以下の面白法人カヤック様のブログポストを参考にさせていただいてます。
こちらのポストで「苦労したポイント」に挙げられている内容は、そっくりそのまま AskDoctors で大変だったポイントにも当てはまりました。デプロイ
、バッチ
、泥臭い修正
の 3 つです。当ポストではその 3 つに加え 待ち時間
と 定型外のリリースフロー
も追加したいと思います。
上記以外にも(説明は割愛しますが)とくに個人的に大変だったことを羅列して供養しておきます。*2
個人的に大変だったこと
- 性能評価の制限
- 現行システムのメトリクスがない
- アクセスログの Kibana だけある
- OS が古すぎて node_exporter をインストールできない
- 現行システムでどれだけリソースを消費しているかがわからない
- オンプレ <=> AWS 間のネットワーク通信
- DirectConnect に負荷をかけるわけにはいかないのでその部分の性能は試験しない
- 現行システムのメトリクスがない
- アーキテクチャが二転三転した
- ドメイン知識不足
- AWS 機能知識不足
- AWS の進化 (ポジティブな変更)
- 機能改善などと並行して作業しないといけない
- 横断的変更が多いのでコンフリクトが起きやすい
- フルリグレッションの必要な変更が多い
- QA リソースが足りない
- ログ集約方法の変更
- 現状のレポート系処理との相互運用性を維持
- S3 to BigQuery が一筋縄ではいかない
- ログ転送処理の切り替えによるログロストの懸念
- 現状のレポート系処理との相互運用性を維持
- 外部からの接続
- Stripe など外部システムからの Webhook 呼び出し
- ドメイン名のハードコーディングをやめる
- メール記載ドメインなどの環境変数化
- Open Redirect 脆弱性対策の戻り先 URL ホワイトリスト対応
- OAuth2 Redirect URI, Webhook のドメイン
- Trusted Proxies 問題
- 当初、サービス全体を CloudFront で覆うという案があった
- Trusted Proxies 問題があるためやめた
- 当初、サービス全体を CloudFront で覆うという案があった
- 動的言語というリファクタリングの障害
- Java や .NET では当たり前の Find Usage ができない
- とくに concern や before_action などが非常に厄介
- 検出漏れによる仕切り直し多数
- Java や .NET では当たり前の Find Usage ができない
- オンプレサーバー上の運用スクリプト類
- ウイルススキャン用の perl スクリプト
- ログ転送用の shell スクリプト
デプロイ方法の変更
オンプレでのデプロイでは Capistrano を利用してフロントエンドサーバー上で bundle install や assets:precompile を実行していました。 ECS Fargate のデプロイは Capistrano で実行していた処理を docker build で行い、docker push して ECS Service を再起動するのが主なオペレーションとなります。 幸いにも Fargate については実績と知見があったのでデプロイの手法自体に大きな障害はありませんでした。
ただ、Docker 化したことにより Rails の webpacker:compile に非常に時間がかかるようになり、デプロイにかかる時間が大幅に増大しました。
Rails の場合、マルチステージビルドにして compile した assets をイメージに COPY するのが定石だと思いますが、CI ノード上での docker build で node_modules
や node_modules/.cache
を参照させるには工夫が必要です。
docker export した tarball を GitLab でキャッシュしたり、中間イメージを push したりでデプロイ時間の大幅な短縮は成功してはいましたが、かなり複雑な仕組みになってしまったのと、変更量が多くて移行時の障害切り分けなどが大変になるのを懸念して、最終的に初回 AWS リリースでのデプロイ高速化は諦めました。
バッチのアーキテクチャ
移行前は Digdag から SSH で Rails サーバにログインし、 rails runner コマンドでバッチ用の Ruby クラスを直接実行していました。 以下は Digdag タスク定義のイメージです。
sh>: ssh batch-user@batch-server bundle exec rails runner 'XXXXXXXX'
移行の検討を始めた当初はバッチの移行先アーキテクチャでも AWS 上での Digdag 構築を検討していたのですが、2020 の re:Invent で
- Amazon Managed Workflows for Apache Airflow (MWAA)
- AWS Batch が Fargate の実行環境をサポート
が登場し、選択肢が増えました。2021年の4月ごろに Terraform の AWS Provider で Fargate のバッチ実行環境がサポートされたことをきっかけに、AWS Batch を移行先アーキテクチャとして採用しました。
採用理由としては、バッチのソースコードが社内管理画面の Rails アプリケーションに内包されていたため、管理画面を ECS Fargate に移行すれば必然的にバッチのソースも Docker イメージとして ECR に登録されるので、AWS Batch と相性が良いと考えたためです。
もともとオンプレで熟成されてきたという経緯から、すべてのバッチが定期実行の単発バッチのため AWS Batch にスムーズに移行できました。並列実行や複雑なフローのバッチがあれば Step Functions と AWS Batch を組み合わせて使うか、まったく別のアーキテクチャを選定していたと思います。
泥臭い修正
前述のカヤック様のブログポストに以下の記載があるのですが、
「この環境変数、本来の使われ方をしていないぞ?」「このconfig、すごく場当たり的な書き方をしてるぞ?」といったような、長年積もってなかなか見直されにくい負債が次々と見つかりました。これらを見直し、アプリケーションとその動作環境との繋がり方をあるべき姿へとリファクタリングする作業にかなりの労力と時間を割きました。
まさしくこの通りでした。
とくにインフラに関わるような修正って修正内容自体は大したことない修正なんですけど、影響範囲が広くなりやすいのと、ローカルでの動作確認がしづらいため、ほとんどの時間が影響調査と言っても過言ではありませんでした。
AskDoctors ではたとえば以下のようなコードを修正をしました。
def remote_ip_address request.headers['HTTP_X_FORWARDED_FOR'] || request.remote_ip end
はい、オンプレだから顕在化していないだけで潜在バグです。
- X-Forwarded-For は配列
- Trusted Proxies が指定されていない
- そもそも request.remote_ip は X-Forwarded-For が考慮されてるんだが・・・
弊社では取締りが厳しいのでこのままだとダメです。
修正したのがこちら。修正量は 0.5 行(+ trusted_proxies の設定)だけです。
def remote_ip_address request.remote_ip end
このメソッド、いろんなところから呼ばれていたので影響調査は大変でした。動作確認も難しかったです。docker-compose で apache を噛ませて docker の network を変更したり、QA 環境にデプロイして動作確認したりなど。*3
待ち時間
移行作業のほとんどの時間は Docker と Terraform をいじってる時間なのですが、どちらも実装している時間よりもコマンド実行の待ち時間の方が圧倒的に長いです。 リモートワークなので、自宅の細い回線で docker push や bundle install が何度も実行されるわけです。
かなり時間のかかる docker build はともかく、Terraform は休憩するには短いし、待機するには長いという絶妙な時間になることが多いです。しかも plan コマンドを何度も実行するので、待ち時間の頻度も多いです。しんどいです。
また、前回のポストにもありますが、QA 環境の調整でも待ち時間が発生します。*4 もちろん環境には限りがあるので通常の事業開発でも待ちは発生するのですが、インフラの変更はよりまとまった時間が必要です。インフラのロールバックはアプリケーションのロールバックよりも大変であることがほとんどです。確認作業も横断的な変更が多いため時間がかかります。
定型外のリリースフロー
これは移行あるあるですが、通常リリースとは全く異なる作業のため、当然のことですがリリースの決められたフローなどはありません。都度、チーム内外に確認しながら進めていくことになります。大きな変更を伴う変更の場合は移行手順書を作成して、レビューを受けて、移行手順のテストをして、とかなり時間をかけます。
AskDoctors のクラウド移行はほぼワンオペでやっているので*5、例えば「えいや」で適用しちゃっても良さそうな変更とかの判断に不安になります。
特に 15 年以上稼働しているレガシーな AskDoctors は「えいや」でやらないと進まない修正が結構ありました。そういった場合は Slack で「◯◯はXXなので、えいやで問題ないですよね?」みたいな相談をするんですが、「◯◯やXXなので」の導出にかなりの時間と精神を消耗します。
なので、リリースのときは常にハラハラドキドキしていましたし、何か異常が起きていないか監視ダッシュボードや Slack の関連チャンネルなどは常に Watch していました。
前回ポストにあるように、フェーズ 4 の移行に関しては弊社松原(@ma2ge)がほぼ全部やってくれました。感謝カンゲキ雨嵐です。
AWS 移行後のこと
無事に移行も済んだわけですが、移行後にいくつかの問題が発生しました。
End-to-End のレイテンシー悪化
Web レスポンスレイテンシーの 90 %tile が約 10 倍に悪化し、体感でも明らかに遅くなっていました。 単純に CPU リソースの不足が原因だったので、すぐに必要起動数(ECS Service の DesiredCount)を 3 倍に増やしました。
もともとスケーリングや Auto Scaling 対応は移行してから実施する予定だったのですが、ここまで大幅な性能劣化は想定外だったので、移行したその週のうちには Auto Scaling 対応しました。なので、クラウド移行して早々にクラウドの恩恵(迅速なスケーリング)を享受できたことになります。クラウド万歳。
バッチ起動エラー
AWS Batch を利用する場合の注意点として AWS のエラー起因によるバッチジョブのタスク起動エラーが割と発生します。一番よくあるのは ENI の作成タイムアウト、次によくあるのは ECR からの Pull のタイムアウトや失敗、FARGATE_SPOT の起動失敗、珍しいところだと Security Group の API が Internal Error になったこともありました。ソースコードや構成に変更がなくてもこれらは発生します。そのため、どんなジョブでもリトライを必ず 1 回は実行するようにしました。
この場合、バッチの実装はリトライされてもいいように冪等に作られていないといけません。幸いなことに AskDoctors のバッチはもともと複数台あるサーバー上の cron で実行されていた経緯もあり冪等性は確保されていました。
なお、AskDoctors では短い間隔で定期実行するジョブや失敗しても次のスケジュールで成功すれば問題がないようなジョブは FARGATE_SPOT で実行しています。AWS Batch にはタスク起動時に FARGATE か FARGATE_SPOT かを指定するインタフェースがないため、バッチ実行環境とジョブキューを FARGATE と FARGATE_SPOT とで 2 つずつ作成しています。
Redis メモリ逼迫
フロントエンド移行の直前に Redis もオンプレから ElastiCache に移行したのですが、ピークタイムが来るとすぐにメモリが枯渇して Eviction が増大するようになりました。 これは単にアプリケーションのキャッシュの使い方の問題だったのですが、移行直後だったので ElastiCache の設定に問題があるのか?などハラハラしました。
レイテンシーの低下もあったので、ダッシュボードを作りました。これも AWS だからすぐできたことで、移行して良かったです。
オンプレの API に対する Connection Failed
AskDoctors の一部の処理でオンプレの API に対して HTTP リクエストを実行しているのですが、AWS 移行した直後から高頻度で Connection Failed エラーを発生するようになりました。
高頻度とはいえ全体の 0.2 % 程度なのですが、それでも 1 日 300 回くらい Sentry からのエラー通知が Slack に来るため他のエラーが埋もれてしまいよろしくありません。また、Sentry の Rate Limit にも引っかかるようになりました。
これに関しては再現性がなく、あらゆる API のあらゆるタイミングで発生するため、今のところ原因は不明です。また、この API はレガシーでそこまで重要な API ではないため、原因調査に時間をかけるよりも AWS に持ってきてしまう方がエラー対策や調査のためにもいいと思い AWS への移行を実施しています。
余談ですが、この API は Java 製のため、Fargate (しかも Linux/ARM) で動かすまでわずか 2 時間 *6 でできました。Write once, run anywhere の真骨頂で、Java がいかに移行フレンドリーか思い知らされました。
おわりに
歴史のあるレガシーシステムのクラウド移行は一筋縄では行かないことが多く、ほとんどが地道な作業の繰り返しということがわかりました。 AWS 知識やドメイン知識が必要な難易度の高い作業も少なからずありましたが、その多くはワークアラウンドが存在したり、地道に紐解くことが可能でした。レガシーシステムの移行に絶望感を感じている方の助けに少しでもなれば幸いです。
We are hiring!
クラウド移行やクラウドネイティブに興味がある方、ぜひ一緒に働きませんか?
*1:最終的にサブシステムはサブドメインにしたのでこの方針は無意味になりました
*2:掘り下げて欲しい内容がありましたらブコメやTwitterでつぶやいてもらえれば書きます
*3:その都度 docker build したり CI パイプライン実行したりで準備時間も相当かかります
*4:移行前なので AWS 側は誰も触っていませんが、移行のための環境変数を追加したりなど、変更した箇所は既存の環境でも動作することを確認しないといけません。
*5:もちろん設計やコードのレビューは複数人で行いますが、Docker や Terraform の実装・適用・確認などはすべて自分が担当しています。これはチーム的に事業開発を優先しているからというのもありますが、そもそもインフラ作業って分散しづらいですよね。Terraform の state が競合することもありますし、他のところではどう分担しているのかとか気になります。
*6:ruby の場合は nokogiri がビルドできなくて躓いたり node がインストールできなかったりで相当時間かかった