DSOC Infrastructure Groupの藤田です。
最近は汚い牧場物語をやりながらCyberpunk 2077を待つ日々を過ごしております。
私は昨年の11月頃からDSOCで運用している名刺データ化システムをコンテナ化、ECSへ移行するというプロジェクトに携わっていました。今回はその中で躓いたいこと、工夫したことを皆様に共有できればと思っております。
背景、ゴール
名刺データ化システム
SansanやEightで取り込んだ名刺は名刺データ化システム(GEES)へ送られてきます。GEESで名刺画像をデータ化、Sansan/Eightへ結果を返して最終的にエンドユーザへたどり着きます。いわばSansan/Eightの屋台骨のような役割を担っています。データ化には様々なプロセスが含まれています。具体的にどんな処理を行っているかはDSOCのウェブサイトでわかりやすく説明されています。このサイトカッコいいですよね、何やらすごい賞を獲ったらしいです。
上記サイトで説明されている通り、名刺のデータ化にはマイクロタスク化、マルチソーシング、項目入力、項目チェックなど多数の工程が必要です。GEESでは各プロセスはそれぞれをサービスとして切り出し、マイクロサービスっぽい構成として実装されています。機械学習、画像処理を扱う部分とは実装が別れており、今回は以下の処理を行う部分を対象に移行を実施しました。
- 名刺データ化のフロー管理
- 項目入力
- マージ
- チェック、補正
- データ化受付、納品
各サービスはAPI、ワーカーを実装しています。サービス間のやりとりはAPI(RESTエンドポイント)に対してHTTPリクエストで行います。サービス内のタスクはジョブ・キューという形式で積まれていき、ワーカーが非同期に処理を行います。また、今回の移行対象は以下の技術を利用しています
- プログラミング言語
- Ruby(Ruby on Rails)
- インフラ
- EC2
- RDS(Aurora MySQL)
- S3
- SWF
- ElastiCache(Redis)
- etc...
課題・ゴール
ワーカーについてもう少し詳しく説明します。ワーカーによって読み込み先がSidekiqキュー、SWFジョブ、ローカルデータベースと異なりますが、すべてのワーカーが常駐するRubyのプロセスとして稼働しています。そのため、当たり前ではありますがプロセス数を増やすことで処理能力を向上させることができ、ジョブ・キューが滞留した際にはワーカー数を増やすことで対応しています。ワーカー数は独自に開発した管理ツールを使って操作しています。このツールでEC2インスタンス:hogeにサービス:fooのワーカー:barをn台追加のような操作を行い、各サービスのワーカー数を適正に保っていました。すでにお気づきとは思いますがこの操作に工数を大きく取られていました。ジョブ・キューの滞留、EC2インスタンスのリソース状況を考慮しながら手動でワーカー数を変更する必要があり、運用担当者への負担が大きくなっていました。
この課題の解決策として、コンテナ化を検討しました。ワーカーをコンテナ化、ジョブ・キューの滞留に対応したオートスケーリングを実装することで現在の手動オペレーションをなくすことができると考えました。また、合わせてAPI側もコンテナ化することでEC2ベースよりも柔軟かつ高速なオートスケーリングを実現しようと考えました。以上から移行のゴールは以下のように設定しました
ワーカーをオートスケールして手動オペレーションをなくす
インフラ構成
コンテナ管理
ECS or EKS
AWSでコンテナを運用するのであれば現在はECSとEKSが選択肢に上がると思います(AWS Batchもありますが今回は用途とマッチしなかったので省略します)。AWSさん、チーム内でも議論し今回はECSを採用することとしました。理由は以下のとおりです。
- Kubernetesでしか達成できないことがなかった
- アプリケーションの要件としても特殊な部分が少なく、カスタムコントローラやKubernetesの特定のエコシステムがないと実現できないような要件がありませんでした
- Kubernetesのアップデートに追従できるエンジニアリソースがなかった
- 社内の体制的にKubernetesの面倒だけに集中できるエンジニアリソースがないため、新しい機能・エコシステム・バグフィックスを追うことが難しく効果的に利用できないと考えました
- 半年に1回はコントロールプレーンのアップデートが必要となり、その検証・実施を含めた工数を捻出できないと判断しました
Fargate or EC2
次にデータプレーン(実際にコンテナが稼働する環境)をどうするかを検討しました。選択肢としてはFargateとEC2があります。Fargateはインスタンスレスでタスクのことだけ考えることができるようになるのは非常に大きな強みです。一方でコストはEC2よりも高いとよく言われますが、インスタンスの管理コストを考えれば妥当な金額なのではと思います。EC2はインスタンス管理が必要なものの、CPU/MEMの組み合わせが自由なことやGPUが使えるなどFargateに比べてコンテナを利用する上での制限が少ないと言えます。今回の移行の観点からそれぞれのPros/Consをまとめてみました。
- Fargate
- Pros
- インスタンスの管理がいらない
- タスクごとに実行環境が分離されてるのでよりセキュア
- Cons
- EC2に比べると単純な費用は割高
- CPU/MEMの組み合わせに制限がある
- docker execできない
- Pros
- EC2
- Pros
- 1インスタンスに複数タスクを集約可能
- CPU/MEMの組み合わせに制限がない
- GPUが使える
- Cons
- インスタンスの管理が必要
- スケールアウトの速度がFargateに比べて遅い
- Pros
結論から言うと今回の移行ではEC2を採用しました。理由は以下のとおりです
- タスクによって性能要件のばらつきがあった
- 外部APIとのやりとりが大半を占めI/O処理だけでほとんどCPUを使わないワーカー、ひたすら正規表現のマッチ判定をするのでCPUは凄い消費するけどMEMは全然使わないワーカーなどワークロードが様々でした。FargateではCPUかMEMのどちらかが極端に必要な場合でも、もう片方を引き上げなければならない制限があったため今回の要件とはマッチしづらいと判断しました
- インスタンス管理はCapacity Providerを利用することで負担を減らせる
- 2019年にリリースされたCapacity Providerを利用することで、ECSで必要なタスク数に応じてEC2をオートスケールしてくれるのでインスタンスの管理コストはそこまで高くならないだろうと考えました
- Rails Console叩きたいのでコンテナにはログインしたい
- 現状、Fargateでは
docker exec
相当のコンテナにログインする機能がありません。開発や障害対応でRails Consoleを利用する機会は多く、コンテナに直接ログインする機能は必要だと判断しました
- 現状、Fargateでは
オートスケール
ECSサービスのタスクのオートスケールにはApplication Auto Scalingサービスを利用します。AWSコンソールのECSサービスから設定すると何が作られているのかよくわからないですが、実際にはCloudwatch AlarmからApplication Auto Scalingが動き、ECSサービスのタスク数をコントロールしてくれています。オートスケール方式は以下のように用途によって使い分けました。
対象 | 方式 | 詳細 |
---|---|---|
ワーカー | Step Scaling | 滞留(保留中のタスク数)は常に0を目指すため、しきい値を上回った場合タスク数を増やし続け、下回った場合タスクを減らし続ける |
API | Target Tracking Scaling | 1コンテナあたりに対してレイテンシが許容範囲を超えないリクエスト数を目標値として設定、全体のリクエスト数に応じてタスク数を調整してくれる |
SWFはデフォルトで滞留をCloudwatch Metricsとして出力しているのでそれを利用できました。Sidekiqキュー、ローカルデータベースのレコード数を参照するワーカーについてはこれらの値をCloudWatchのカスタムメトリクスとして扱うことで同じようにオートスケールが可能です。現時点では手が回っておらずここまで対応できていないため、一部ワーカーはCPU使用率に対してターゲット追跡スケーリングポリシーを適用しております。
最終的な構成
最終的には以下のような構成になりました。AWSサービスのアイコンをたくさん使って図を書くとなんだか凄いものを作った気になれますね。
デプロイ
CodePipeline + CodeBuild + ecs-cli
GEESのデプロイフローでは以下を達成できるような構成にしようと考えました。
- デプロイのタイミングを細かくコントロールしたい
- エンドユーザへの影響が大きな変更は事前にメンテナンス画面に切り替え、内部で確認作業を行ってからリリースを行っています。コンテナイメージのビルドからデプロイまでがすべて一連の流れで止まることなく実行されてしまうと、メンテナンス画面の切り替えタイミングがコントールできなくなるため、コンテナビルド、DB Migrate、デプロイなど細かくタイミングを制御できるのが理想でした
- ECS Scheduled Taskの更新もデプロイフローで完結させたい
- Rundeckというジョブスケジューラで管理しているジョブがいくつかあり、こちらは最終的にはECS Scheduled Tasksに乗り換えたいという思いがありました。そのためCodeDeployやCodePipelineの標準機能では実現できないと判断し、こちらは早めに候補から外しました
これらを考慮して以下のようなデプロイフローが完成しました。
GEESはブランチ戦略としてgit-flowを採用しています。そこでSlackから指定したブランチをリリースできるようにしました。最初のCodePipelineでは指定されたアプリケーション名、ブランチ名を元にgit clone, docker buildでイメージを作成します。Gitのコミットハッシュをイメージのタグとして設定してpush、以降のCodeBuildで設定ファイルのイメージのタグを前述のものに置換してデプロイします。
デプロイのタイミングはCodePipelineの手動承認を利用することでコントロールできました。今回はそこまでの作り込みをしておりませんが、承認可能なユーザを制限することで厳密な承認フローを組み込んだデプロイも実現できると思います。
ecs-cliはdocker-compose + ecs-params(ECS独自の設定)という形式で設定が書けるため、アプリ開発側としても比較的とっつきやすいものでした。また、環境変数を利用して環境差分を吸収できることも大きかったです。
ただし、現在はecs-cliの後継であるcopilot-cliが登場したこともあり、ecs-cliがECSの新機能へなかなか追従できていないです。こちらは今後なにか別のツールへ移行することを検討中です。
DB MigrateはECS Run Taskとして実行
Railsアプリケーションにおいて、テーブル構成の変更を伴うデプロイにはDB Migrateが必要となります。DB MigrateはECSタスクとして構成することで、CodePipeline内のCodeBuildからRun Taskを実行しています。Run TaskのReturn Codeを確認することでDB Migrateの成功・失敗を確認しています。また、DB Migrateで接続する先のRDS(Aurora MySQL)はセキュリティグループで接続元を制限しているため、CodeBuildをVPC内で起動するように設定しています。
Amazon Virtual Private Cloud による AWS CodeBuild の使用 - AWS CodeBuild
環境差分はParameter Storeで吸収
GEESには本番環境、ステージング環境(DSOCのステージング環境の定義はおそらく他社とは少しことなり、開発&性能検証のようなイメージ)の2つが存在しています。環境が複数存在する場合、デプロイの際には以下のような環境差分が発生します。
- DBユーザ、パスワード
- 外部エンドポイントのAPI
- アプリケーションの設定値
これらの環境差分は起動時にSSM Parameter Storeに格納した値を環境変数として読み込むことで解決させました。ecs-cliであればecs-paramsの secrets
、タスク定義であれば secretOptions
で定義できます。
Amazon ECS パラメータの使用 - Amazon Elastic Container Service
Systems Manager パラメータストアを使用した機密データの指定 - Amazon ECS
また、ECS on EC2であれば現在はS3にアップロードした.envファイルを環境変数として読み込むことができるので、環境差分を吸収するための環境変数は.envに書き出し、DBパスワードなどの機密情報はParameter StoreやSecrets Managerに保管すればより簡単に設定値を管理できるようになると思います。
[アップデート]ECS on EC2で環境変数の設定がS3から参照できるようになりました | Developers.IO
デプロイ、手動承認はSlackから可能に
弊社ではコミュニケーションツールとしてSlackを利用しているため、デプロイの開始やCodePipelineの手動承認をSlack内で完結できるようにしました。Slack AppのModal機能を利用することで対話的に処理を進め、デプロイに必要な入力(アプリケーション名、ブランチ名など)をストレスなく入力させることができました。
Slack Appから転送されるリクエストを元に実際に手動承認の連携、CodePipelineの起動を処理する部分はAPI Gateway+Lambdaで構築しました。ただし、Lambdaの初回起動にかかる時間がSlack Appのタイムアウトを超えてしまうことがあり、AWS費用に余裕があるのであればELB+ECSで実装するほうが良さそうだと思いました。
移行
移行の方法自体は特筆すべきところがあまりなかったので簡単に記載します。
対象は小さい、かつ影響の少ないアプリケーションから着手していきました。アプリケーション数が多く一気に移行は大変だったこと、まずは小さいアプリケーションでECSの勘所を掴んでおきたかったことが理由です。APIの切り替えはRoute53レコードのAliasをECS用のALBに変更することで行いました。アプリケーションの影響範囲、リクエスト数などに応じて加重ルーティングポリシーを利用して徐々に移行するのがおすすめです。ワーカーの移行もAPIと合わせて行いました。
TIPS
desiredStatusとlastStatus
ECSサービスのタスクをすべて停止したい場合、タスク数を0にするという処理を行います。この場合、ECSサービスの desiredStatus
が0に設定されます。これだけではまだタスクが停止したかどうかは判別できません。 lastStatus
にタスクの最後の既知のステータスが追跡されます。これが STOPPED
になって初めて停止します。DB Migrateの前などでタスクを全停止する場合、desiredStatusだけではなくlastStatusまで確認しておかないとタスクが起動した状態でDB Migrateが実施されてしまい、意図しないエラーが発生する可能性があるので注意が必要です。
またlastStatusがRUNNING
のままタスクが残り続けてECSで設定されたタイムアウト値を超えても終了処理が完了しない場合、強制的にタスクは停止(SIGKILL)されてしまいます。今回はワーカーによっては終了処理中にコンテナが停止してしまうと、アプリケーションが意図しない状態となる可能性があったため(もちろん、それを考慮した作りになっていれば言うことはないのですが)、 ECS_CONTAINER_STOP_TIMEOUT
を利用してタイムアウト値を変更しました。ちなみにFargateの場合、タイムアウトの最大値は120秒となります。
Amazon ECS Container Agent Configuration - Amazon Elastic Container Service
Capacity Providerはスケールインが苦手
Capacity ProviderはECSクラスターと連携して、必要なタスク数に応じてEC2インスタンスをオートスケールしてくれます。使ってみた感想としてスケールアウトに関してはほぼ文句なしなのですが、スケールインが得意ではないという感想を持ちました。詳細はAWSさんのブログに書かれていますが、簡単に言うとインスタンス上で1つでもタスク(DAEMONサービスを除く)が動いている場合、上手くインスタンスがスケールインされません。タスクの配置戦略でCPU/MEMに対してbinpackにすることで、リソース集約を意識したタスクのスケールインが可能ですが1クラスタに複数サービスが存在している場合はあまり上手くいきません。こちらも今後はタスクの再配置(Rebalancing)のような機能がリリースされると信じてはいますが、現状ではAWSコストの最適化を図るのであればLambdaなどを利用してリソースに余剰があるインスタンスをDRAINする必要があります。
DAEMONサービスの終了順序は考慮されない
今回の構成では各EC2インスタンス上でFluentdとDatadog agentをログ収集・監視のためDAEMONサービスとして稼働させています。EC2のスケールインが発生してインスタンスがDRAININGステータスになった際、本来であればこれらのDAEMONサービスには最後まで残ってログ収集と監視を継続してほしいのですが、現状では順序が考慮されることなく全タスクへ平等にSIGTERMが送られます。これを防ぐために自前のツールを作成したりしましたが、既に改善予定なようですのでこれを楽しみに待っています。
最後に
詳細を大分省いた形にはなりましたが、移行で考えたことや学んだことを共有させていただきました。今後ECSへの移行を考えている方のお力になれれば幸いです。
buildersbox.corp-sansan.com
buildersbox.corp-sansan.com
buildersbox.corp-sansan.com