この記事のポイント
- CloudFormationとClick Opsで作成されている環境をCDKに統一したYo
- ECS + Lambda inside VPCの構成をAWS CDKで定義したときの辛みや驚きを書いているYo
- ECSに触っていたら思わぬことにも出くわしたYo
- 今後の展望について書いているYo
はじめに
この記事では「AWS CDKでEC2からECS + Lambda inside VPCに移行してみた」話を書きます。
当時の情報で書いているので今はもしかしたら違うかもしれません。
今は若干、AWS CDK(以下、CDK)の勝手は違うかもと思いつつ読んでいただければと思います。
before: 最初はどんな構成・要件だったか
ふとしたときに新しい仕事を任されて何かなと思ったら、EC2で動作しているバックエンドアプリケーションをECSに載せ替えるという話でした。
要件としては以下のとおりです。
- 特定のIPのみを利用して処理を実行する
- 実行すべき処理はSQSのキューから取得
- 一度実行された処理は実行が完了するまで割り込みで止めることが許されない(グレースフルシャットダウンが必要)
- 処理は別のプロセスで実行
- マルチリージョン、マルチAZ構成
- 障害時に即時復旧できること
- 1つの処理は15分以内でおさめる
利用サービスは以下のとおりです。
- VPC
- EC2
- SQS
- AWS Backup
- AWS Systems Manager パラメータストア
- CloudFormation
なお、今回はバックエンドの構成に絞って書いているのでフロントエンド側のコンポーネントについては省略しています。
また、ここまでくるとELBも使っているのでは?と思うかもしれませんが、SQSキューをトリガーに常時起動しているEC2がPythonのサブプロセスを発行して処理を実行するというものですのでロードバランサーはありません。
今回はこの構成にあるEC2をECSに変更し、EC2の一部の処理をLambda inside VPCに移行しました。
after: どんな構成になったか
先に結論を書くと利用サービス・ツールは以下のとおりです。
- VPC
- NAT Gateway
- ECS
- ECR
- Lambda
- SQS
- AWS Systems Manager パラメータストア
- Secret Manager
- AWS CodeArtifact
- AWS CDK
移行にあたって検証したこと
実際に上記のサービスを使って移行したので一部ですが、検証したことについていくつか紹介していきたいと思います。
- マルチリージョン、マルチAZの構成をCDKで定義するときの変数
- 柔軟性と弾力性を担保するためのネットワーク構成
- ECSのスケーリング(主にスケールイン)
- ECSからAWS Lambdaに処理を委託するときの注意点
マルチリージョン、マルチAZの構成をCDKで定義するときの変数
まずはマルチリージョン構成というところですが、主に以下の変数が重要でした。
- リージョン情報(ap-northeast-1なのかus-westみたいなところ)
- Elastic IPで固定しているNAT GatewayのアロケーションID
マルチアカウント構成でもあったのでどのIAMロールを使うのかというところで環境情報としてSTAGE(dev、stg、prdいずれか)の情報も必要でした。
つまりはマルチリージョンだと合計で6環境になります。6環境を同時に立ち上げることができるCDKを書いたということになります。
(我ながら狂ってんな?!)
柔軟性と弾力性を担保するためのネットワーク構成
次にネットワーク構成です。EC2を使っていたときはElastic IPをインスタンスメタデータを使って起動時にElastic IPを関連付けていたようですが、ECSではそうもいきません。
これがフロントエンドのECSであれば、ELBでIPを固定するといったこともできたかなと思いますが
SQSに届いたキューを捌くだけのECSと考えるとELBは設置できません。
※かといってLambdaを使ってECSを起動するとオーバヘッドがすごいので15分の処理要件を満たせない可能性もあります。
そんなわけでElastic IPをNAT Gatewayに割り当てたのですが、AWS CDKでこの要件満たそうとすると実は結構厄介であることに気づきます。
検索するとあるissueにたどり着きます。
Elastic IP association for generated NAT in VPC #4067
結論から言ってしまうと、AWS CDKでNAT Gatewayを起動した際、EIPではなく、自動生成されたPublic IPが使われてしまいます。これだと特定のIPからアクセスするという要件を満たせません。
ということで筆者が取った方法はどんな方法かと言うと
「NAT GatewayのアロケーションIDをcdk deploy時に渡してCfnNatGatewayのallocationIdプロパティに設定する」ということにしました。
具体的には以下のとおりです。
const cfnNatGateway = new aws_ec2.CfnNatGateway(this, `${Env}-natgw`, {
subnetId: NatGwSubnet.subnetId,
allocationId: props.EipAlloc,
});
ECSのスケーリング(主にスケールイン)
ネットワークの柔軟性を担保したところで次にコンピューティングがスケーリングできるように検証を進めました。
結論から先に説明するとスケーリングの中でもスケールアウトは問題なく、どちらかというとスケールインが問題になりました。
というのも以下の要件を実現するのが実は結構難しいです。
一度実行された処理は実行が完了するまで割り込みで止めることが許されない(グレースフルシャットダウンが必要)
グレースフルシャットダウン(Graceful Shutdown)というのは何かというと処理中のスレッドをすべて終了させてからプロセスを終了するという手法です。
スポットインスタンスを使って安全にECSを利用する場合によく使われます。ちなみにAWSのブログにも書いてあります。
これだけ聞くとAWS都合や外部からの割り込みだけ注意すれば良いと勘違いしがちですが
ECSはスケールインする際、サービスからタスクに対してSIGTERMを送信するので処理の途中でタスクが止まってしまうことがあります。
※タスクプロテクションをかけることも可能だが、プロテクトを解除し忘れるとタスクは終わろうにも終われず考えるのやめてしまいます。(カーズ様みたいな、そのうち考えるのをやめた)
ECSのタスク定義ではstopTimeoutのパラメータで定義されるところです。
参考:Amazon ECS タスク定義パラメータ - Amazon Elastic Container Service
このstopTimeoutはデフォルトで30秒決まっているので意図せず、処理が中断してしまうといったことが起きます。
最大では2分なのでスケールインの命令が飛んでから2分かかるような長め処理はできません。
なお、具体的にはFargateTaskDefinitionのaddContainerにあるstopTimeoutで設定できます。
const container = taskDefinition.addContainer(`${Env}-container`, {
image: ecs.ContainerImage.fromEcrRepository(repository,image_tag),
logging: logDriver,
pseudoTerminal: false,
stopTimeout: cdk.Duration.seconds(120),
});
ECSからAWS Lambdaに処理を委託するときの注意点
最後に従来までEC2でやらせていた処理をリソース不足の観点からLambdaへ委託することになったんですが、これもまた落とし穴がありました。
これはもはやCDKというより、invoke_lambdaの仕様なんですが
結論から先に説明するとConnection TimeoutとConnection Read Timeoutの2つを忘れていて処理が意図せず終了してしまうというところです。
Lambdaというと以下のようにtimeoutに気を使っておけば良いと勘違いしがちです。
const LambdaStack = new lambda.Function(this, `${Env}-lambda-executor`, {
functionName: `${Env}-lambda-executor`,
runtime: lambda.Runtime.PYTHON_3_11,
handler: 'lambda_function.lambda_handler',
code: lambda.Code.fromAsset(path.join(__dirname, '/lambda/function')),
memorySize: 128,
timeout: cdk.Duration.seconds(900),
architecture: lambda.Architecture.X86_64,
description: 'Lambda Executor',
role: lambdaRole,
layers: [actionLayer, otelLayer],
vpc: vpc,
vpcSubnets: subnets,
});
AWS Lambdaは同期呼び出しの際、指定の時間内に処理が終わらないと再試行ポリシーに基づいて処理を再実行します。この内容については以下のブログに詳しく書いていますので興味のあるかたはのぞいてみてください。
【AWS】検証!botocoreでboto3のリクエストを制御する
便利なところも多い
いくつかつらみを書きましたが、実際のところ使い勝手の良いところも多いのでその話も書きます。
Name タグの一括指定
スタックごとにまとめてタグの指定ができるところは良いと思いました。
具体的には以下のとおりです。
new CdkSqsStack(app, `${Stage}CdkSqsStack`, {
env: {
account: process.env.CDK_DEFAULT_ACCOUNT,
region: process.env.CDK_DEFAULT_REGION,
},
stage: Stage,
tags: {
Name: 'test',
Environment: Stage
}
});
上記のようなスタックが並んでいる場合はtestのところを変数にしておけば、すべてのスタックで定義できるので便利です。
Stack管理
なんやかんやでこれが一番良かったかなと思うところです。Stackクラスを継承して作るということだけを念頭においておけば、新しいサービスが出たらとりあえずクラスを作るという考えになるので認知負荷がとても低いです。
(とはいえ、実際は考えることが多い)
今後の展望
今後の展望としてはやはり、この年末に発表されたLambda関連の発表が今後の実装に関わってくるかなと予想しています。あと、NAT Gatewayがリージョン対応したのでその点にも注目しています。
具体的には以下の3つです。
- Managed Instance Lambda
- Lambda Durable Functions
- NAT GatewayのRegion対応
今回は以上です。