NuxtJS製のWebサービスをECSに移行したはなし

SREチームの長田です。 Advent Calendar Migration Track 22日目の記事です。

今回は弊社で運用しているLobiというサービスの、Webブラウザ版(Web版)をECSに移行したはなしです。

web.lobi.co

なぜ移行したのか

おなじみ、Amazon Linux1 EoL対応です。 すべてのアプリケーションをEC2から移行するプロジェクトの一環です。

移行前

LobiのWeb版はNuxtJSを使って実装されています *1。 各APIにリクエストし、サーバーサイドレンダリング(SSR)した結果を、Webブラウザに返しています。 NuxtJSアプリは他のアプリケーションも同居するEC2インスタンスで実行していました。

f:id:handlename:20191220165016p:plain
移行前の構成

(実際にはクライアントで動的にコンテンツを更新するためのAPIリクエストも発生しますが、今回の話題には関わってこないので省略しています)

移行

設定値の切り分け

ECSに移行するにあたり、本番環境でも開発環境でも、同じコンテナイメージを使うというルールにしていました。 環境ごとにイメージを用意するのは管理コストがかかりますし、「本番環境と同じものが手元でも動く」(=デバッグしやすい)というコンテナの旨味が半減してしまいます。 どの環境でもイメージは同じものを使い、環境依存の値は実行時に環境変数として与えています。

NuxtJSはデプロイ前にアプリケーションをビルドする必要があります。 ビルド後の成果物をコンテナイメージに含めることになるのですが、nuxt.config.js内に環境変数を参照する設定値を書いてしまうと、 ビルド時点での環境変数が成果物に埋め込まれてしまいます。 EC2インスタンス上で動かしているうちは、

実行環境上でビルド → 成果物を配布してアプリケーション再起動

という手順をとっていたため、問題になっていませんでした。

環境変数を参照するということは、サーバーサイドレンダリング時に実行されるコードということです。 nuxt.config.jsに書く代わりに、Server Middleware内で環境変数を参照することで、アプリケーション実行時に設定値を渡すようにしています。

具体的には、SSR時にAPIリクエストするドメインがこれにあたります。 APIリクエスト先は環境によって異なるので、ビルド時に埋め込まれてしまっては困るというわけです。

デプロイの仕組み

EC2時代の手順は、以下のようになっていました。

  1. Github上でデプロイ対象のPull Requestをmasterにmergeする
  2. デプロイサーバー上でリポジトリをpullする
  3. デプロイサーバー上でNuxtJSアプリをビルドする
  4. アプリケーションサーバーにビルド成果物を配布し、アプリケーションを再起動する

f:id:handlename:20191220170251p:plain
移行前のデプロイフロー

(成果物の配布にはstretcherを使っているので間にS3 Bucketがいたりconsulがいたりするのですが、本筋ではないので省略しています)

ECSに移行することで、以下のように変わりました。

  1. Github上でデプロイ対象のPull Requestをmasterにmergeする
  2. CircleCIがmergeを検知してgit pull
  3. CircleCI上でNuxtJSアプリをビルドする
  4. CircleCI上でコンテナイメージをビルドしECRにpushする
  5. Task Definitionを更新しECS Serviceに反映する

f:id:handlename:20191220172132p:plain
移行後のデプロイフロー

手順が増えたように見えますが、CircleCIが行おう処理は自動で行われるので、人間が行う作業としてはほとんど変わりありません。

canaryデプロイの仕組み

少量のタスクを試験的にデプロイするcanaryデプロイの需要がありました。 EC2時代には、1インスタンスにのみデプロイ操作を行うことで実現していました。

ECSにはcanaryデプロイの仕組みはありません。 ひとつのECS Serviceについて、複数のTask Definitionを混在させることはできません。

そこで、1Taskしか起動しないECS Serviceを別途用意し、 それに対してデプロイを行うことでcanaryデプロイを実現しました。 メインのECS Serviceと同じTarget Groupに入れることで、全体の1/nのリクエストをcanaryタスクに回すことができます。

f:id:handlename:20191220173243p:plain
ひとつのTarget GroupにメインTaskとcanary Task両方を入れる

ちなみに、現在はALBでWeighted Target Groupsが使えるので、 canary用のTarget Groupを別途用意することで任意の割合だけリクエストを流すことができるようになりました。

aws.amazon.com

メモリリーク問題

未解決の問題です。

ある時期から、NuxtJSアプリのメモリ使用量が上昇し続けるようになってしまいました。 一定を超えるとGCが走るのですが、実行コストが高く、HTTPリクエストに対するレスポンスに如実に現れてしまっていました。

根本的な解決方法としては、メモリリークの原因を突き止めて解消することなのですが、 これがどうにも解決できなかったので、「対処療法としてメモリ使用量が一定を超えたらECS Taskを入れ替える」という方法をとっています。

f:id:handlename:20191220174541p:plain
ECS Taskを入れ替える仕組み

対象ECS ServiceのMemoryUtilizationメトリクスを元に、Alarmを発火させます。 発火情報はSNS Topicに送信されるので、Bash Layerで動いているLambda functionがそれを受け取り、 ECS Serivceに対して aws ecs update-service コマンドを実行しています。

おかげでメモリ使用量のグラフがこんな感じになってしまいましたが、現状致し方無いといった状況です・・・。

f:id:handlename:20191220172537p:plain
不連続なMemory Usage

移行後

コンテナイメージをビルドする時点でつまずき、デプロイしてからもメモリリーク問題に苦しみ・・・と苦労が多かったプロジェクトでした。

副作用として良い効果もありました。 EC2インスタンス上で動かしていた頃は、「Googlebotの訪問が多すぎて同居している他のアプリケーションまでパフォーマンスが悪化する問題」があり、 botの訪問頻度を制限していました*2。 ECS Serviceとして独立してスケールするようになったことで制限を解除することができました。

Amazon Linux1 EoL対応はまだまだ残っています。 それらについてもいずれ本ブログで紹介する予定です。

*1:実はこの直前にAngularJSからNuxtJSに移行しているのですが、それはまた別の機会に・・・

*2:https://support.google.com/webmasters/answer/48620?hl=ja