AmplifyのContainer APIのCPUとメモリを増やす
Amplifyを使ってAPIをContainerで作った場合の最大の問題とも言えるこちらのissueに挑みます。
何が問題か?
Amplifyで作ったContainer APIは、0.5 vCPU,1024 Memoryで固定されます。
少なすぎて、運用に耐えられません。。。
対策の方針
Amplifyはオープンソースなので、自分で修正して貢献するのが一番いいとは思いますが、それは一旦置いておきます。
今回は、Amplifyのデプロイが終わった後にECSのタスクを書き換えることを考えます。
ただし、手動でなんかやってられないので自動化します。かつ、管理対象は増やしたくないのでAmplifyの世界で完結させます。
デプロイ完了を検知してECSのタスクを書き換えるのに必要なAWSのリソースは2つです。
- Lambda
- ECSのタスクを書き換えてECSのサービスを更新する処理を実行する
- EventBridge
-
amplify push
時にECSのサービス更新実施するCodePipelineの処理完了を検知して、Lambdaを動かす
-
これらを、なんとかAmplifyの世界で作ればよいわけです。
手順
前提
AmplifyでContainerのAPIを作成済であることを前提にします。
以下は、前提達成までの最低限の手順です。
既にContainerのAPIがある方はスキップしてください。
- amplifyを初期化
% amplify init
- Containerを有効化
% amplify configure project
? Which setting do you want to configure? Advanced: Container-based deployments
? Do you want to enable container-based deployments? Yes
- APIを追加
% amplify add api
? Select from one of the below mentioned services: REST
? Which service would you like to use API Gateway + AWS Fargate (Container-based)
- デプロイ
% amplify push
ECSのタスク定義を書き換えてサービスを更新するLambdaを作る
ここからが本題です。
ECSのタスク定義を書き換えてCPUとメモリを変更し、ECSのサービスに適用するLambdaを作ります。
Lambdaの追加
amplify add function
でLambdaを追加します。
% amplify add function
? Select which capability you want to add: Lambda function (serverless
function)
? Provide an AWS Lambda function name: updateEcsService
? Choose the runtime that you want to use: NodeJS
? Choose the function template that you want to use: Hello World
? Do you want to configure advanced settings? Yes
? Do you want to access other resources in this project from your Lambd
a function? No
? Do you want to invoke this function on a recurring schedule? No
? Do you want to enable Lambda layers for this function? No
? Do you want to configure environment variables for this function? No
? Do you want to configure secret values this function can access? No
? Do you want to edit the local lambda function now? Yes
Javascriptは嫌なのでTypescriptにする
JSは嫌なのでTSにします。本題ではないので、さくっと最低限のやり方だけ記載します。
ディレクトリ構成がおかしい気がするが、自動生成された時点でおかしいのでスルーします。
TS周りの構成をキレイに作れる人はキレイに作ったほうが良いです。
- プロジェクト直下の
package.json
に以下を追加(なければ作る)
{
"scripts": {
"amplify:[lambda関数名]": "cd amplify/backend/function/[lambda関数名]/src && npx --package typescript tsc && cd -"
}
}
-
amplify/backend/function/[lambda関数名]/src
を開く -
src直下に
tsconfig.json
を追加
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true
},
"include": [
"index.ts",
"/**/*.ts"
],
"exclude": []
}
- src直下の
package.json
を編集
{
"name": "[lambda関数名]",
"version": "1.0.0",
"description": "TypeScript Lambda function",
"main": "index.js",
"dependencies": {
"@aws-sdk/client-ecs": "^3.309.0",
"@types/aws-lambda": "^8.10.91"
},
"devDependencies": {
"typescript": "^4.9.5"
}
}
ついでに、aws-sdkも追加しています(後で使う)
- src直下で
npm install
-
index.js
をindex.ts
にリネームする(中身は後で書き換えます)
Lambdaの権限を設定する
amplify/backend/function/[lambda関数名]/custom-policies.json
を編集して、LambdaにECSのサービスを更新する権限を付けます。
[AWSアカウントID]
の部分は自分のアカウント名に書き換えてください。
[
{
"Effect": "Allow",
"Action": [
"ecs:RegisterServices",
"ecs:DescribeServices",
"ecs:UpdateService"
],
"Resource": [
"arn:aws:ecs:ap-northeast-1:[AWSアカウントID]:service/amplify-*"
]
},
{
"Effect": "Allow",
"Action": [
"ecs:RegisterTaskDefinition",
"ecs:DescribeTaskDefinition"
],
"Resource": [
"*"
]
},
{
"Effect": "Allow",
"Action": [
"iam:PassRole"
],
"Resource": [
"arn:aws:iam::[AWSアカウントID]:role/amplify-*"
]
}
]
簡単な説明
- ecs:RegisterServices
- ecs:DescribeServices
- ecs:UpdateService
- この3つは、ECSのサービスの取得や更新に使います
- 上に貼ったものは汎用性を重視してワイルドカードを使いましたが、対象のサービスのARNをピンポイントで指定したほうがセキュリティ的によいです
- この3つは、ECSのサービスの取得や更新に使います
- ecs:RegisterTaskDefinition
- ecs:DescribeTaskDefinition
- この2つは、ECSのタスクを作るのに使います
- タスクは新規に作ることになり特定のリソースに絞ることが出来ませんので*を指定します
- iam:PassRole
- ECSのタスクを作る時に、ECSのタスクにIAMロールを付与する為に必要です
lambda関数の中身を書く
index.tsを編集します。
import { ECS, RegisterTaskDefinitionCommandInput } from '@aws-sdk/client-ecs';
import { Handler } from 'aws-lambda';
const ecs = new ECS({});
// サービスARNからクラスター名を取得する関数
const getClusterName = (serviceArn: string): string => serviceArn.split('/')[1];
// Lambdaハンドラー関数
export const handler: Handler = async (event: any) => {
console.log(event);
// 受信したイベントから CPU とメモリを取得
const { cpu: newCpu, memory: newMemory, serviceArn } = event;
// サービスARNからクラスター名を取得
const clusterName = getClusterName(serviceArn);
// サービスを取得
const { services } = await ecs.describeServices({ services: [serviceArn], cluster: clusterName });
// サービスが見つからない場合、エラーをスロー
if (!services || services.length === 0) {
console.error(`Service not found: ${serviceArn}`);
throw new Error(`Service not found: ${serviceArn}`);
}
// サービスのタスク定義 ARN とサービス名を取得
const { taskDefinition: taskDefinitionArn, serviceName } = services[0];
// タスク定義を取得
const { taskDefinition } = await ecs.describeTaskDefinition({ taskDefinition: taskDefinitionArn });
// タスク定義が見つからない場合、エラーをスロー
if (!taskDefinition) {
console.error(`Task definition not found: ${taskDefinitionArn}`);
throw new Error(`Task definition not found: ${taskDefinitionArn}`);
}
// 更新不要の場合は処理を終了
if (newCpu === taskDefinition.cpu && newMemory === taskDefinition.memory) {
console.log('No need to update task definition.');
return;
}
// 新しいタスク定義を作成
const newTaskDefinition = {
cpu: newCpu,
memory: newMemory,
containerDefinitions: taskDefinition.containerDefinitions,
executionRoleArn: taskDefinition.executionRoleArn,
family: taskDefinition.family,
inferenceAccelerators: taskDefinition.inferenceAccelerators,
ipcMode: taskDefinition.ipcMode,
networkMode: taskDefinition.networkMode,
pidMode: taskDefinition.pidMode,
placementConstraints: taskDefinition.placementConstraints,
proxyConfiguration: taskDefinition.proxyConfiguration,
requiresCompatibilities: taskDefinition.requiresCompatibilities,
taskRoleArn: taskDefinition.taskRoleArn,
volumes: taskDefinition.volumes,
};
// タスク定義を登録
const { taskDefinition: newTaskDef } = await ecs.registerTaskDefinition(newTaskDefinition);
const newTaskDefinitionArn = newTaskDef?.taskDefinitionArn;
// タスク定義が正常に登録されていない場合、エラーをスロー
if (!newTaskDefinitionArn) {
console.error('Failed to register task definition.');
throw new Error('Failed to register task definition.');
}
// サービスを更新
const updateServiceResult = await ecs.updateService({
cluster: clusterName,
service: serviceName,
taskDefinition: newTaskDefinitionArn,
forceNewDeployment: true,
});
console.log('Updated service with new task definition:', updateServiceResult);
};
デプロイ
ECSのタスク定義を書き換えてサービスを更新するLambdaが出来たので、一旦ここでdeployしておきましょう。
% amplify push
Lambdaを実行するEventBridge Ruleを作る
APIのContainerのサービスが更新されたことを検知してLambdaを実行する為に、EventBridgeを使います。
amplifyのカスタムリソースを使ってcdkで作成することができます。
カスタムリソースの追加
amplify add custom
でカスタムリソースを追加します。
% amplify add custom
✔ How do you want to define this custom resource? · AWS CDK
✔ Provide a name for your custom resource · updateEcsServiceEventRule
cdkの編集
amplify/backend/custom/[カスタムリソース名]/cdk-stack.ts
を編集します。
import * as cdk from 'aws-cdk-lib';
import * as AmplifyHelpers from '@aws-amplify/cli-extensibility-helper';
import { AmplifyDependentResourcesAttributes } from '../../types/amplify-dependent-resources-ref';
import { Construct } from 'constructs';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as events from 'aws-cdk-lib/aws-events';
import * as eventsTargets from 'aws-cdk-lib/aws-events-targets';
export class cdkStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps, amplifyResourceProps?: AmplifyHelpers.AmplifyResourceProps) {
super(scope, id, props);
/* Do not remove - Amplify CLI automatically injects the current deployment environment in this input parameter */
new cdk.CfnParameter(this, 'env', {
type: 'String',
description: 'Current Amplify CLI env name',
});
const envName = AmplifyHelpers.getProjectInfo().envName; // envを取得
const cpu = '1024'; // CPUを指定
const memory = '2048'; // メモリを指定
/* 環境毎に編集する箇所 ここから */
// stackの名前(=team-provider-info.jsonのstackName)
const stackName = (envName === 'dev') ? "[stack名]" : "";
// ECSのクラスター名
const clusterName =
(envName === 'dev') ? `[ECSクラスター名]` : "";
// ECSのサービス名
const serviceName = "[ECSサービス名]"
// Lambda関数名
const functionName = "[Lambda関数名]"
// Region
const region = "ap-northeast-1";
// AccountId
const accountId = "[AWSアカウントID]";
/* 環境毎に編集する箇所 ここまで */
// ECSサービスのARN
const serviceArn = `arn:aws:ecs:${region}:${accountId}:service/${clusterName}/${serviceName}`;
// Lambda関数のARN
const lambdaArn = `arn:aws:lambda:${region}:${accountId}:function:${functionName}-${envName}`;
// Lambda関数を取得
const lambdaFunction = lambda.Function.fromFunctionArn(this, 'lambdaFunction', lambdaArn);
// CodePipelineの名前
const codePipelineName = `${stackName}-${serviceName}`
// CodePipelineのARN
const codePipelineArn = `arn:aws:codepipeline:${region}:${accountId}:${codePipelineName}`;
// EventBridgeルールの作成
const ecsTaskDefinitionRule = new events.Rule(this, 'EcsServiceUpdateRule', {
eventPattern: {
source: ['aws.codepipeline'],
detailType: ['CodePipeline Pipeline Execution State Change'],
resources: [codePipelineArn],
detail: {
state: ["SUCCEEDED"]
},
},
});
// ターゲットとしてLambda関数を追加し、引数としてCPUとメモリを指定
const target = new eventsTargets.LambdaFunction(lambdaFunction, {
event: events.RuleTargetInput.fromObject({
cpu: cpu,
memory: memory,
serviceArn: serviceArn,
}),
});
ecsTaskDefinitionRule.addTarget(target);
// Lambda関数にリソースベースのポリシーを明示的に追加
// CDKで作成したLambda関数に対しては、明示的に追加する必要はないが、
// Amplify CLIで作成したLambda関数に対しては、明示的に追加する必要がある
new lambda.CfnPermission(this, 'LambdaPermission', {
action: 'lambda:InvokeFunction',
functionName: lambdaArn,
principal: 'events.amazonaws.com',
sourceArn: ecsTaskDefinitionRule.ruleArn,
});
}
}
[stack名]
[ECSクラスター名]
[ECSサービス名]
[Lambda関数名]
[AWSアカウントID]
の5つは手動で入力が必要になりますので編集してください。stack名やECSクラスター名はenvごとに変わるので、envで場合分けしてください。またstack名はamplify/team-provider-info.json
に書いてあるものを使用してください。
全てのリソースをAmplifyが作るのでCFnの出力から動的な取得もできそうですが、大変そうなので手動で定義することにしました。(やろうとしたら何かをミスってCFnが壊れて再起不能になったので心が折れました)
deploy
amplify push
動作確認
ここまでで、準備できたので動作確認します。
apiを適当に編集してamplify push
% amplify push
✔ Successfully pulled backend environment dev from the cloud.
Current Environment: dev
┌──────────┬───────────────────────────┬───────────┬───────────────────┐
│ Category │ Resource name │ Operation │ Provider plugin │
├──────────┼───────────────────────────┼───────────┼───────────────────┤
│ Api │ sampleapi │ Update │ awscloudformation │
├──────────┼───────────────────────────┼───────────┼───────────────────┤
│ Auth │ amplifyecsapicustom │ No Change │ awscloudformation │
├──────────┼───────────────────────────┼───────────┼───────────────────┤
│ Function │ updateEcsService │ No Change │ awscloudformation │
├──────────┼───────────────────────────┼───────────┼───────────────────┤
│ Custom │ updateEcsServiceEventRule │ No Change │ awscloudformation │
└──────────┴───────────────────────────┴───────────┴───────────────────┘
✔ Are you sure you want to continue? (Y/n) · yes
しばらく待ちます。
コマンドが完了しても、その後ContainerをビルドしてECSのサービスを更新する動作が動きます。
今回の処理は、その後に更にタスクを作り直してサービスを再更新します。
のんびり待ちましょう。
ECSのサービスの画面でしばらく待つとタスクが2つになります
リビジョン3が、更新前のAPIで
リビジョン4が、Amplifyが更新したAPI
更に待つと3が消え、、、4が残り、、、
5が登場。想定通りCPUとメモリが増えてる!!
そして、4が消えて5だけが残ることで更新完了!
ということで、成功です!
ハマったこと。蛇足です。
以上の処理を作る上でいろいろハマったので、それを記載します。
amplify push時に、TypeScriptのLambda関数がビルドがされない
amplify add function
で追加されるLambdaはデフォルトでJavaScriptなので、ドキュメント を見たりググったりChatGPTに聞いたりして、TypeScriptに変更しました。
それでドキュメント等にはpackage.json
にビルドコマンドを書くように書いてあるんですが、Lambdaのpackage.json
に書いても全く動かなくて困りました。
プロジェクトルートのpackage.json
が正解でした。ちゃんとドキュメントを読むと確かに書いてありました。でもさ、Amplify使ったことがある人なら分かると思うけど、Amplifyのバックエンドのリソースって全てamplifyフォルダ配下に作られるのですよ。。それなのにプロジェクト直下に書くなんて考えるはずないじゃないですか。間違うよこんなの。。。
EventBridgeにLambda関数の実行権限が付与されない
CDKでEventBridge駆動でLambdaを実行するコードを書く場合、Lambdaの実行権限はCDKが自動で設定してくれます。
しかし、これはLambdaとEventBridgeルールの両方をCDKで作った場合であり既存のLambdaを実行するEventBridgeルールをCDKで書く場合は手動で権限を付けなければなりません。
この件は原因もやり方も分からず、1日くらい悩みました。
適当なことばかり言ってきたAIは許さんよ。
↑完全に嘘です。イベントがルールに一致してターゲットを実行しようとしても、権限が設定されていなければ動かないです。権限を設定していて正常に動く状態であればLambdaコンソールのトリガータブに表示されます。逆に言えば表示されない場合は何かを間違えています。AIが主張するようにトリガーが表示されていなくても動くということは無いと思ってください。
Lambdaに付与する権限が複雑
前述のは、Lambdaを実行する権限でしたが、こちらはLambdaがECSを操作する為の権限の話です。custom-policies.json
の中身のことです。
ハマリポイントは2つありました。
ecs:RegisterTaskDefinition
とecs:DescribeTaskDefinition
のresourceは*
権限を*に付けるのは広すぎないか?と思うのですが、、、
ecsのタスク定義が更新ではなく新規作成なので幅広く権限を持っていないといけないみたいです。絞る方法ご存じの方いれば教えて下さい。
iam:PassRole
が必要
たかがタスク定義を更新するのにiam周りの権限を渡すのは、個人的には抵抗あります。しかし必要です。ecsのタスク定義が更新ではなく新規作成なので、新しく作ったタスクにRoleを付与する必要があるためです。
なんでecsのタスク定義は更新でなくて新規作成が必要なのでしょうか?かなりめんどうくさかったです。
ECSのサービス更新をトリガーにしたらCodePilelineのデプロイに失敗した
当初の構想では、ECSの更新完了をトリガーにLambdaを動かそうとしたのですが、その方法だとamplify push
時のCodePilelineが失敗になることがある。という事象が発生しました。
ECSの更新完了をCodePilelineが確認する前にEventBridgeが検知して上書き更新した為に、CodePilelineはDeploy完了を検知できなかったという状況になっていると思われます。
実際は成功しているしロールバックもしてないのであまり実害はなさそうですが、これはよろしくないのでCodePilelineの完了を検知するように修正しました。
以上です。
正直かなり面倒くさいので、AWSさんには根本的な機能改善をお願いしたいです。
NCDC株式会社( ncdc.co.jp/ )のエンジニアチームです。 募集中のエンジニアのポジションや、採用している技術スタックの紹介などはこちら( github.com/ncdcdev/recruitment )をご覧ください! ※エンジニア以外も記事を投稿することがあります
Discussion