AWS ECSとCDKを使った推論APIサーバの構築
はじめに
新卒1年目の櫻井です。GENIEE SEARCH事業のインフラチームで検索サービスの保守運用とレコメンドサービスのインフラ設計・構築を担当しています。
この記事では、先日リリースが発表されたGENIEE RECOMMENDのインフラで採用したECS on FargateとCDKについて紹介したいと思います。
プロジェクトとインフラ構成について
GENIEE RECOMMENDは、売上順やPV順など画一的なロジックのみならず、ユーザ一人ひとりの行動をAIが学習・分析し、独自のロジックによりユーザの好みにパーソナライズされた商品提案サービスです。サービスの裏側では様々な推薦手法を用いており、一部では訓練した推論モデルをホスティングしたAPIサーバに問い合わせることで高速に推薦結果を返す仕組みがあります。今回はその推論モデルのホスティングを行っているECSというAWSのサービスとその構成管理をするIaC(Infrastructure as Code)であるCDKの実装について紹介します。推論APIサーバは、Flaskとuwsgiで構築し、起動前に推論モデルをメモリに読み込むようにしています。
上記の仕組みにおけるインフラ構成図は下図のようになっています。様々なワークロードやトラフィックに対応することや運用コストを削減することを目的としてECS on Fargateを採用しています。ECSではFargateに必要なインフラコストに対して運用コストの削減やセキュリティ面、コンテナ運用によるアジリティの向上などのメリットが大きいと実感しています。
実現方法
ここでは上記アーキテクチャをCDKで実装したときに使用した機能を説明していきます。基本的なアプリケーション構築はこちらの書籍やAWSがaws-samples
で公開しているサンプルリポジトリがCDKで構築する上で参考になりました。今回は以下のECSの発展的な機能について説明したいと思います。
- アプリケーションログのカスタムルーティング設定
- ECSタスクへのEFSボリュームのマウント
- ECSサーキットブレーカーを使用したデプロイ失敗時のロールバック
アプリケーションログのカスタムルーティング設定
ECSではデフォルトで全てのアプリケーションログがCloudWatch Logsに送られます。ログが少ない場合は料金が気にならないですが、リクエスト数が多くなったり調査のためにログを増やしたりする場合はCloudWatch Logsの料金が高くなります。具体的にはログの保管よりもCloudWatch Logsにデータを取り込む際の料金がS3と比較して高いため不要なログや緊急度の低いログを送ることを避けるのが一般的です。
ECSではFirelensというログルーターが内部で動作しており、標準出力に出力されたログを収集し転送する役割を担っています。カスタムログルーティングを行うために、ECSのタスク定義でFirelensのログの転送先にFluentbitを指定します。これにより、Fluentbitでログのフィルターや転送先のカスタム設定をすることが出来ます。
実際のCDKでの設定例は以下のようになります。
taskDefinition.addContainer("App", {
image: ecs.ContainerImage.fromEcrRepository(
containerRepository,
"commit-hash"
),
logging: ecs.LogDrivers.firelens({}),
});
taskDefinition.addFirelensLogRouter("FirelensLogRouter", {
containerName: "FirelensLogRouter",
firelensConfig: {
type: ecs.FirelensLogRouterType.FLUENTBIT,
options: {
configFileType: ecs.FirelensConfigFileType.FILE,
configFileValue: "/fluent-bit/custom.conf",
},
},
environment: {
AWS_REGION: cdk.Stack.of(this).region,
ERROR_LOG_GROUP_NAME: appLogGroupError.logGroupName,
},
image: ecs.ContainerImage.fromEcrRepository(
ecsLogRouterContainer,
"commit-hash"
),
logging: ecs.LogDrivers.awsLogs({
logGroup: new cwl.LogGroup(this, "FluentbitLogGroup", {
retention: cwl.RetentionDays.THREE_MONTHS,
}),
streamPrefix: "fluentbit",
}),
});
Appコンテナのログドライバにfirelensを指定することでAppコンテナでのログをfirelensに送信します。また、サイドカーコンテナとしてfluentbitコンテナをタスク定義に追加します。紛らわしいですがaddFirelensLogRouter内のloggingプロパティの設定はfluentbit自身が出力するログの設定ですので注意しましょう。fluentbitの設定ファイルはS3から取得することも出来ますが、ローカルでの動作確認やアプリとのDockerイメージのアップロード手順の統一などの利便性を考慮してfluentbitイメージのビルド時に設定ファイルをコピーしています。
ECSタスクへのEFSボリュームのマウント
ECSではECSタスクに対するEFSボリュームのマウントをサポートしています。通常のファイルシステムからの読み込みと同様にメモリにファイルを読み込むことができるため、S3からローカルにファイルをダウンロードする手間の削減やスループットの向上、ECSタスクの起動時間短縮などに寄与します。レコメンドでは推論モデルをEFSに置き、APIサーバの起動前にメモリに読み込むということをしています。
CDKでECSタスクにEFSをマウントする例です。
const nfs = new efs.FileSystem(this, "Efs", {
vpc: vpc,
vpcSubnets: {
subnetType: ec2.SubnetType.PROTECTED,
},
encrypted: true,
});
const taskDefinition = new ecs.FargateTaskDefinition(
this,
"TaskDefinition",
{
executionRole: taskExecutionRole,
taskRole: taskRole,
cpu: 2048,
memoryLimitMiB: 4096,
volumes: [{
name: "efs",
efsVolumeConfiguration: {
fileSystemId: nfs.fileSystemId,
rootDirectory: "/models",
},
}],
}
);
const ecsContainer = taskDefinition.addContainer("App", {
image: ecs.ContainerImage.fromEcrRepository(
containerRepository,
"commit-hash"
),
});
ecsContainer.addMountPoints({
containerPath: "/path/to/models/",
readOnly: true,
sourceVolume: "efs",
});
EFSを作成し、タスク定義のvolumesプロパティでEFSのファイルシステムIDを指定します。次に、AppコンテナのファイルシステムにEFSをマウントします。コンテナ側のEFSをマウントしたいパスを指定します。また、ECSタスクからのEFSへのアクセスを許可するためにFileSystem.connectionsを使用してECSサービスからのインバウンドを許可するようにしましょう。
ECSサーキットブレーカーを使用したデプロイ失敗時のロールバック
サーキットブレーカーはデプロイの失敗を繰り返している場合に自動でデプロイを停止する仕組みです。サーキットブレーカーが無効化されている場合、ECSはタスク定義の状態となるようにデプロイを繰り返します。失敗に気づけない間はECRからのコンテナのPullを繰り返すためデータ転送料金がその分かかってしまうことになります。サーキットブレーカーが作動する基準は次の2つのステージからなります。
- ECSタスクがRUNNING状態になるかどうか (コンテナのPullの失敗時など)
- 実行後のヘルスチェックの失敗回数が閾値を超えたかどうか
CDKでサーキットブレーカーを有効化する設定は以下のように簡単にできます。
const service = new ecs.FargateService(this, "Service", {
cluster: cluster,
vpcSubnets: vpc.selectSubnets({
subnetGroupName: "ProtectedApplication",
}),
securityGroups: [appSg],
taskDefinition,
desiredCount: 3,
circuitBreaker: {
rollback: true,
},
enableExecuteCommand: true,
});
circuitBreakerプロパティを定義することで有効化されます。また、rollbackプロパティをtrueにすることでデプロイが成功した最新のデプロイメントに自動でロールバックすることが出来ます。タスク定義ではない外部の要因によりデプロイの失敗が起こり得る場合はrollbackを無効化することも考慮する必要があります。また、enableExecuteCommandを有効化することで手元のCLIからECSで稼働しているコンテナ内に接続することが出来ます。コンテナ内の環境からバグや権限などの調査行うことが出来るため適宜有効化して活用しましょう。
さらに、ECSはタスクやサービスの状態を追跡しており、状態の変化がイベントとしてEventBridgeに送信されます。EventBridge側でイベントルールを設定しサービスの状態を指定することでSNSやLambdaを通してSlackやメールなどに通知することも出来ます。
最後に
ECSを扱う上で新しい概念が出てきて困惑することもあるかと思いますが、EC2上にアプリを構築するのと比べて運用コストを大幅に削減することができます。また、App RunnerやGoogle Cloud Runなどにはないような拡張性も持ち合わせており、様々なコンテナオーケストレーションの恩恵を受けることが出来ます。また、新しい機能やFargateでのコンテナ実行時間など年々サービス自体もアップデート・改善がなされており、今後もより便利になっていくと思います。
最後に、新卒1年目ながら弊社が提供するサービスの1つの大部分を構築できたことは貴重な体験だったと感じております。まだまだ可観測性やDevOpsの観点で課題があるため今後も改善していきたいと考えています。
Discussion