🍰

AmplifyのContainer APIのCPUとメモリを増やす

2023/04/14に公開

Amplifyを使ってAPIをContainerで作った場合の最大の問題とも言えるこちらのissueに挑みます。
https://github.com/aws-amplify/amplify-category-api/issues/696

何が問題か?

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周りの構成をキレイに作れる人はキレイに作ったほうが良いです。

  1. プロジェクト直下のpackage.jsonに以下を追加(なければ作る)
package.json
{
  "scripts": {
    "amplify:[lambda関数名]": "cd amplify/backend/function/[lambda関数名]/src && npx --package typescript tsc && cd -"
  }
}
  1. amplify/backend/function/[lambda関数名]/src を開く

  2. src直下にtsconfig.jsonを追加

amplify/backend/function/[lambda関数名]/src/tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true
  },
  "include": [
    "index.ts",
    "/**/*.ts"
  ],
  "exclude": []
}
  1. src直下のpackage.jsonを編集
amplify/backend/function/[lambda関数名]/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も追加しています(後で使う)

  1. src直下で npm install
  2. index.jsindex.tsにリネームする(中身は後で書き換えます)

Lambdaの権限を設定する

amplify/backend/function/[lambda関数名]/custom-policies.jsonを編集して、LambdaにECSのサービスを更新する権限を付けます。
[AWSアカウントID]の部分は自分のアカウント名に書き換えてください。

amplify/backend/function/[lambda関数名]/custom-policies.json
[
  {
    "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をピンポイントで指定したほうがセキュリティ的によいです
  • ecs:RegisterTaskDefinition
  • ecs:DescribeTaskDefinition
    • この2つは、ECSのタスクを作るのに使います
    • タスクは新規に作ることになり特定のリソースに絞ることが出来ませんので*を指定します
  • iam:PassRole
    • ECSのタスクを作る時に、ECSのタスクにIAMロールを付与する為に必要です

lambda関数の中身を書く

index.tsを編集します。

amplify/backend/function/[lambda関数名]/src/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を編集します。

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:RegisterTaskDefinitionecs:DescribeTaskDefinitionのresourceは*

権限を*に付けるのは広すぎないか?と思うのですが、、、
ecsのタスク定義が更新ではなく新規作成なので幅広く権限を持っていないといけないみたいです。絞る方法ご存じの方いれば教えて下さい。

iam:PassRoleが必要

たかがタスク定義を更新するのにiam周りの権限を渡すのは、個人的には抵抗あります。しかし必要です。ecsのタスク定義が更新ではなく新規作成なので、新しく作ったタスクにRoleを付与する必要があるためです。

なんでecsのタスク定義は更新でなくて新規作成が必要なのでしょうか?かなりめんどうくさかったです。

ECSのサービス更新をトリガーにしたらCodePilelineのデプロイに失敗した

当初の構想では、ECSの更新完了をトリガーにLambdaを動かそうとしたのですが、その方法だとamplify push時のCodePilelineが失敗になることがある。という事象が発生しました。

ECSの更新完了をCodePilelineが確認する前にEventBridgeが検知して上書き更新した為に、CodePilelineはDeploy完了を検知できなかったという状況になっていると思われます。
実際は成功しているしロールバックもしてないのであまり実害はなさそうですが、これはよろしくないのでCodePilelineの完了を検知するように修正しました。

以上です。
正直かなり面倒くさいので、AWSさんには根本的な機能改善をお願いしたいです。

NCDCエンジニアブログ

Discussion