CloudFormationを使ったEC2のCodeDeployのタググループ設定

Cloud FormationでEC2のCodeDeployを作成して時に躓いた設定になります。
EC2用のタググループを設定するAWSコンソール画面だと以下の個所です。

1 つのタググループ: タググループによって識別される任意のインスタンスのデプロイ先。
タググループによって識別される任意のインスタンスのデプロイ先。


以下の設定で正常に動作しました。

単一タググループ設定

Ec2TagSet:
  Ec2TagSetList:
    - Ec2TagGroup:
      - Key: "DeployTest"
        Value: "dev"
        Type: "KEY_AND_VALUE"

Typeは以下の設定も可能です
・キー(Key)のみ:KEY_ONLY
・値(Value)のみ:VALUE_ONLY

公式サイトを注意深く読むと「Ec2TagGroup」の設定が必要なことが分かるのですが、理解できるまで30分ぐらいかかりました。 docs.aws.amazon.com

ちなみに以下のように「Ec2TagGroup」を付けないとエラーになります。

単一タググループ設定(間違っている設定

Ec2TagSet:
  Ec2TagSetList:
    - Key: "DeployTest"
      Value: "dev"
      Type: "KEY_AND_VALUE"
エラーメッセージ
この AWS::CodeDeploy::DeploymentGroup リソースは CREATE_FAILED 状態です。
Encountered unsupported property Type

ECS関連のCI/CDでCloud Formationを使ったことは何度かあったのですが、EC2はなかったので他のところでも少し苦戦しました。
途中で少ない台数の時はEC2は手動で作った方が良いような気もしてきましたが、なんとか頑張れました。

CloudFormationでCodeDeployのECSのBlue/Greenデプロイメントを作成する

CloudFormationでCodeDeployのアプリケーションとデプロイグループ作成しました。
作成時に発生したエラーと解決方法を記事にします。

以下、デプロイの要件です。

  • ECSのデプロイ
  • Blue/Greenデプロイメント

正常に動作したCloudFormationファイル

Resources:
# ------------------------------------------------------------#
# CodeDeploy
# ------------------------------------------------------------#
  CodeDeployApplication:
    Type: "AWS::CodeDeploy::Application"
    Properties:
      ApplicationName: "test-codedeploy-web-ap"
      ComputePlatform: "ECS"

  CodeDeployDeploymentGroup:
    Type: "AWS::CodeDeploy::DeploymentGroup"
    Properties:
      ApplicationName: !Ref CodeDeployApplication
      DeploymentGroupName: "test-codedeploy-web-dg"
      DeploymentConfigName: "CodeDeployDefault.ECSAllAtOnce"
      ServiceRoleArn: !Sub "arn:aws:iam::${AWS::AccountId}:role/test-role-codedeploy-ecscmn"
      AlarmConfiguration: 
        Enabled: false
        IgnorePollAlarmFailure: false
      DeploymentStyle: 
        DeploymentType: "BLUE_GREEN"
        DeploymentOption: "WITH_TRAFFIC_CONTROL"
      BlueGreenDeploymentConfiguration:
        TerminateBlueInstancesOnDeploymentSuccess:
          Action: TERMINATE
          TerminationWaitTimeInMinutes: 5
        DeploymentReadyOption:
          ActionOnTimeout: CONTINUE_DEPLOYMENT
          WaitTimeInMinutes: 0
      ECSServices:
        - ServiceName: "test-ecs-web-service"
          ClusterName: "test-ecs-web-cluster"
      LoadBalancerInfo:
        TargetGroupPairInfoList:
          - TargetGroups:
              - Name: "test-tg-web-1"
              - Name: "test-tg-web-2"
            ProdTrafficRoute:
              ListenerArns: 
                - {'Fn::ImportValue': 'test-alblistener-web'}


エラーと対応方法

↑に正常に動作したCloudFormationファイルがありますが、作成時に発生したエラーと対応方法を書いていきます。

1、LoadBalancerInfo 不要エラー

エラーメッセージ
Property LoadBalancerInfo cannot be specified.
原因

ComputePlatform: "ECS" を指定している場合、LoadBalancerInfo を直接指定することができないため
※後々にLoadBalancerInfoは必要だと分かる

対応方法

「LoadBalancerInfo 」を削除


2、LoadBalancerInfo 必須エラー

エラーメッセージ
For ECS deployment group, loadBalancerInfo must be specified (Service: AmazonCodeDeploy; Status Code: 400; Error Code: InvalidLoadBalancerInfoException; Request ID: 20347954-XXXX-XXXX-XXXX-XXXXXXXXXX; Proxy: null)
原因

ECSのBlue/Greenデプロイには LoadBalancerInfoが必須である。

対応方法

LoadBalancerInfoにTargetGroupPairInfoListを使って指定する。
ListenerArnsはALBリスナー作成時にOutputsでExportした。

      LoadBalancerInfo:
        TargetGroupPairInfoList:
          - TargetGroups:
              - Name: !Sub "test-tg-web-1"
              - Name: !Sub "test-tg-web-2"
            ProdTrafficRoute:
              ListenerArns: 
                - {'Fn::ImportValue':  'test-alblistener-web'}

ALBリスナー作成時のCloudFormation(OutPuts部分を抜粋)

Outputs:
  ALBListener:
    Value: !Ref ALBListener
    Export:
      Name:  "test-alblistener-web"


3、blueGreenDeploymentConfiguration 必須エラー

エラーメッセージ
For ECS deployment group, blueGreenDeploymentConfiguration must be specified (Service: AmazonCodeDeploy; Status Code: 400; Error Code: InvalidBlueGreenDeploymentConfigurationException; Request ID: 41a1246f-XXXX-XXXX-XXXX-XXXXXXXXXX; Proxy: null)
原因

ECSの Blue/Greenデプロイ を使用する場合に、blueGreenDeploymentConfiguration プロパティが必須であるため

対応方法

blueGreenDeploymentConfiguration を追加

      BlueGreenDeploymentConfiguration:
        TerminateBlueInstancesOnDeploymentSuccess:
          Action: TERMINATE
          TerminationWaitTimeInMinutes: 5
        DeploymentReadyOption:
          ActionOnTimeout: CONTINUE_DEPLOYMENT
          WaitTimeInMinutes: 0

■ 補足
・TerminationWaitTimeInMinutes:新しいバージョンへのトラフィック切り替えが完了した後、古いバージョンを終了するまでの待機時間。
開発や検証環境では「0」を設定し、本番環境では「5~15」を設定

・WaitTimeInMinutes:テストトラフィック(Test Listener)で新しいバージョンを検証するために待機する時間。
今回はテストを実施しないので「0」を設定


最後に一言二言

今回は手動で作成したサービスをFomer2を利用してCloudFormationを作成しました。
Fomer2は出力がシンプルなのですが、出力できない設定もあります。
IaC ジェネレーターは出力が多すぎて、どちらを使った方が良いのか悩ましいです。
以下はIaC ジェネレーターで対応サービスの一覧です。(未対応のサービス半分ぐらい?) docs.aws.amazon.com

CodePipeline実行時にconfig.propertiesを使って環境ごとの値を切り替える方法【application.ymlの環境変数のように設定】

Code Pipeline内のCode Build実行時にconfig.propertiesに環境変数から値を取得しようと思ったときに躓いた話です。
環境変数から値を取得することはできず、config.propertiesにプレースホルダーを使いbuildspec.yml内で「sed」にて置換することで対応しました。
今まで携わったJava開発のプロジェクトはSpring Bootでapplication.ymlを使用していたため、以下のように設定すれば自動(プログラム対応無し)で環境変数から値を取得することができました。

application.yml

datasource:
  username: ${DB_USERNAME}
  password: ${DB_PASSWORD}


結論

Spring Bootを利用していないJavaプロジェクトでは自動でconfig.propertiesに環境変数から値は取得できない。
Javaコードで環境変数を読み込む」や「config.propertiesにプレースホルダーを使いJavaで置換する」などでプログラム対応すれば可能

プログラム修正はせずに対応する方法

目的がconfig.propertiesを各AWS環境で共通化することであれば、config.propertiesに環境変数から値を取得したかのようにはできます。
config.propertiesにプレースホルダーを使い、buildspec.yml内で「sed」にて置換する方法になります

前提:Secrets Managerに値は設定(Parameter StoreでもOK)

config.properties

test.db.username={DB_USERNAME}
test.db.password={DB_PASSWORD}

buildspec.yml(必要な箇所のみ抜粋)

env:
  secrets-manager:
    dbusername: /cmn/db:username   # 右辺はSecrets Managerキー
    dbpassword: /cmn/db:password   # 右辺はSecrets Managerキー
・
・
・
  build:
    commands:
      - sed -i "s/{DB_USERNAME}/${dbusername}/g" config.properties
      - sed -i "s/{DB_PASSWORD}/${dbpassword}/g" config.properties


以上になります。
もっとシンプルな方法で対応できそうな気もしますが、思いつきませんでした。

AWSSystemsManagerDefaultEC2InstanceManagementRoleeployActionは存在しなかった

以下の公式サイトでCodePipelineを使用してEC2 にデプロイできるとのことでやってみたのですが、「AWSSystemsManagerDefaultEC2InstanceManagementRoleeployAction」というマネージドポリシーは存在しなかったです。

docs.aws.amazon.com

結論

以下のポリシーがあれば動作します。

  • AmazonSSMManagedInstanceCore
  • AmazonS3FullAccess

⇒「AWSSystemsManagerDefaultEC2InstanceManagementRoleeployAction」マネージドポリシーは不要

実際設定したスクショです。

推測

「AWSSystemsManagerDefaultEC2InstanceManagementRoleeployAction」はIAMポリシーでなく作成する任意のロール名なのでは?と思うことにしました。(公式サイトの記述ミス?)

その他

CodePipelineを使用してEC2 へのデプロイはファイルをアップすることはできましたが、運用を考えるといろいろできないことがありました。
結局はCode Deployを利用しました。
便利そうな機能ですが、まだ普及していないような気がします。

AWS ECS間連携の同期設定(Javaのサンプルプログラムあり)

AWS ECS間で同期して連携し、さらに連携先の同時実行数を制御したいとの要望がありサンプルソースを作成しました。

Java(Spring boot)で開発した2つのアプリがあります。

  • Webサイト(呼び出し元)
  • データ集計(呼び出し先)


以下、要件です。

  • Webサイトからデータ集計に引数を渡して呼び出す
  • データ集計の同時実行数を5にする
  • Webサイトからデータ集計は同期したい
  • データ集計は1日に10回実行され通常は5分、週に1回は30分かかる
  • AWSのサービス利用料は安くしたい


以下、構成図です。

コンテナ間連携

構成概要

  • Webサイトは常時起動のタスク
  • Webサイトからデータ集計のタスクを起動しjava -jar コマンドを発行
  • データ集計のタスクは停止状態

メリット

  • WEBサイト、データ集計それぞれでタスク数を管理できる
  • データ集計実行時(高負荷)にWebサイトに影響を与えない
  • データ集計のタスクは実行時のみ起動するため、AWSサービス利用料は安い

デメリット

  • タスク起動のオーバーヘッド(タスクを起動するのに20秒かかる)

サンプルソース

※ブログの横幅が狭いので一部インシデントを省略、クラスも省略

import java.io.IOException;
import java.util.ArrayList;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;

import software.amazon.awssdk.services.ecs.EcsClient;
import software.amazon.awssdk.services.ecs.model.*;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider;
import java.util.List;

public int executeDataProcess() throws InterruptedException, IOException{
int exitValue = -1;   // 復帰値
String loggerStr = null;

//実行するコマンドを構成する
ArrayList<String> command = new ArrayList<String>();
command.add("java -jar ");        // javaコマンド
command.add("/app/data.jar");     // データ集計jarファイル
command.add(" test1");            // 引数1
command.add(" test2");            // 引数2
command.add(" test3");            // 引数3

// ------------------------------------------
// 1、ECS呼び出し前の準備
// ------------------------------------------
String clusterName = "xxxxx";        // クラスタ名 
String taskDefinition = "xxxxx";     // タスク定義名
String containerName = "xxxxx";      // コンテナ名(分かりやすい名前でOK)
String startedBy = "xxxxx";          // タスクの起動元を識別する文字列
String subnetId = "xxxxx";           // サブネットID
String securityGroupId = "xxxxx";    // セキュリティグループ

EcsClient ecsClient = EcsClient.builder()
    .region(Region.AP_NORTHEAST_1) // 東京リージョン
    .credentialsProvider(DefaultCredentialsProvider.create())
    .build();

RunTaskRequest runTaskRequest = RunTaskRequest.builder()
    .cluster(clusterName)
    .launchType(LaunchType.FARGATE)
    .taskDefinition(taskDefinition)
    .startedBy(startedBy)
    .networkConfiguration(NetworkConfiguration.builder()
        .awsvpcConfiguration(AwsVpcConfiguration.builder()
            .subnets(subnetId)               // サブネットID
            .securityGroups(securityGroupId) // セキュリティグループID
            // ローカルから接続したい場合は有効にする
            //.assignPublicIp(AssignPublicIp.ENABLED) 
            .build())
        .build())
    .overrides(TaskOverride.builder()
        .containerOverrides(ContainerOverride.builder()
            .name(containerName)
            .command(command)
            .build())
        .build())
    .build();

// -------------------------------------------------
// 2、呼び出し先(データ集計)同時実行数確認(上限以下になるまで待機)
//     ※起動中のタスク数(データ集計処理)を確認
// -------------------------------------------------
int maxRunningTasks = 5;

while (true) {
    ListTasksRequest listTasksRequest = ListTasksRequest.builder()
    .cluster(clusterName)
    .startedBy(startedBy)
    .desiredStatus(DesiredStatus.RUNNING)
    .build();
    // memo:コンテナ名を指定するフィールドは無い

    ListTasksResponse listTasksResponse = ecsClient.listTasks(listTasksRequest);
    List<String> runningTaskArns = listTasksResponse.taskArns();
    int runningCount = runningTaskArns.size();

    logger.info("現在のRUNNINGタスク数: " + runningCount);

    if (runningCount <= maxRunningTasks) {
        break;
    }

    Thread.sleep(10000); // 1秒待機
}

// -------------------------------------------------
// 3、タスク起動 & コンテナ内のjarファイル実行
// -------------------------------------------------
logger.info("Task start");

// タスク起動 & コンテナ内のjarファイル実行
RunTaskResponse response = ecsClient.runTask(runTaskRequest);

// タスク起動失敗チェック(設定情報、権限があれば失敗することは無い)
if (!response.failures().isEmpty()) {
    for (Failure failure : response.failures()) {
        loggerStr = "レポートタスク起動失敗 原因: " + failure.reason() + " ARN: " + failure.arn() + " 詳細: " + failure.detail();
        logger.error(loggerStr);
    }
    return exitValue;
}

String taskArn = response.tasks().get(0).taskArn();

// ------------------------------------------
// 4、タスクの完了をポーリングで待機
// ------------------------------------------
while (true) {
    DescribeTasksResponse describeResponse = ecsClient.describeTasks(DescribeTasksRequest.builder()
    .cluster(clusterName)
    .tasks(taskArn)
    .build());

    Task task = describeResponse.tasks().get(0);
    String lastStatus = task.lastStatus();
    logger.info("タスクステータス: " + lastStatus);

    if ("STOPPED".equals(lastStatus) || "DEPROVISIONING".equals(lastStatus)) {
        Container container = task.containers().get(0);
        // 呼び出し元の終了ステータスを取得
        exitValue = container.exitCode();
        loggerStr = "データ集計処理完了: " + "Exit code: " + exitValue + " Reason: " + container.reason();
        logger.info(loggerStr);
        break;
    }

    Thread.sleep(1000); // 1秒待機
}

ecsClient.close();  // 無くても良いかも
logger.info("レポート出力プロセスを終了しました。");
return exitValue;
}

pom.xml(追加分のみ)

     <dependency>
            <groupId>software.amazon.awssdk</groupId>
            <artifactId>ecs</artifactId>
            <version>2.30.36</version>
        </dependency>
        <dependency>
            <groupId>software.amazon.awssdk</groupId>
            <artifactId>auth</artifactId>
            <version>2.30.36</version>
        </dependency>

サンプルソース補足

1、ECS呼び出し前の準備

呼び出し先(データ集計)のECSタスク定義ファイルを事前に作成し、そのECSタスク定義を指定し配置するサブネットやセキュリティグループを設定する。
「startedBy 」は同時実行数を確認するために必要。

2、同時実行数確認(上限以下になるまで待機)

クラスタ名とタスクの起動元を識別する文字列(startedBy )で現在実行状態のタスク数を確認する。
コンテナ名を指定するフィールドが無かったため、「startedBy」を利用する。

3、タスク起動 & コンテナ内のjarファイル実行

runTaskはすぐ完了するのだが、実際のタスクが起動するまでけっこう時間がかかる。
タスク実行時間を早くするため以下の対応をした。

  • イメージの軽量化(headlessにした)
  • 起動先のCPUとメモリを増やす
  • SOCI対応 ⇒ イメージサイズが小さいのでほぼ効果なし)
    4、タスク起動 & コンテナ内のjarファイル実行

    タスクの完了は「STOPPED」or「DEPROVISIONING」のステータスで判断した。
    「DEPROVISIONING」はENIの解放やリソースの解放なので呼び出し先の処理は終わっていると判断して良い。
    「DEPROVISIONING」で終了と判断することで数秒WEBサイトの処理が早く終わる
    「DEPROVISIONING」でも呼び出し先のexitCodeも取得できる

最後に

サンプルプログラムを作成する際、公式サイト以外はほぼ参考になるサイトが無かったので少しでも誰かのお役に立てれば良いなと思います
AIに聞きながらプログラムを作成したので、意外とスムーズにできました。
プログラムでいろいろ対応してしまいましたが、もっとインフラ的にスマートに対応できる気がしています。
ECSタスクの起動時間を早くするのはかなり苦労しましたが、ぜんぜん早くなりませんでした。。

CloudFormationで作成するサービスにタグを複数つけるには2パターンある

すごく地味な記事なります。
CloudFormationで作成するサービスにタグを複数つけるには2パターンの記述方法があることが分かりました。

1、記述パターン

パターン①
      Tags:
        - Key: Test1
          Value: "111"
        - Key: Test2
          Value: "222"


パターン②
      Tags:
        Test1: "111"
        Test2: "222"


2、記述方法の切り分け方

AWS公式サイトを見る。

EC2のインスタンス:「"Tags" : [ Tag, ... ],」

⇒これはパターン①でOK

docs.aws.amazon.com

AWS Systems Manager パラメータストア:「Key: Value

⇒これはパターン②でOK

docs.aws.amazon.com


3、エラーの例

AWS Systems Manager パラメータストアにパターン①で記述すると、以下のエラーとなりました。

エラーメッセージ
Properties validation failed for resource SSMParameter2 with message: [#/Tags: expected type: JSONObject, found: JSONArray]


AWS Systems Manager パラメータストアにタグを複数つける正しい記述方法

以下のように「Key: Value」の形式で複数記述する

AWSTemplateFormatVersion: "2010-09-09"
Description: "Parameter Store"

Resources:

  SSMParameter1:
    Type: "AWS::SSM::Parameter"
    Properties:
      Name: "/cmn/envname"
      Type: "String"
      Value: "prd"
      DataType: "text"
      Description: "環境名"
      Tier: "Standard"
      Tags:
        Env: "prd"
        Test1: "111"
        Test2: "222"


AWS公式サイトをちゃんと参照すればわかるのですが、私はエラーが解決するまで時間がかかってしましました。

Amazon ECSで複数のコンテナがある場合、同じタスク内にするか?別々のタスクにするか?

ECSで複数のコンテナがある場合、同じタスク内にするか?別々のタスクにするか?
管理が簡単そうでなんとなく同じタスク内で定義していたのですが、理由を聞かれ困ったので構築後に調査しました。
システムの要件によって判断するということ分かりました。

公式サイトには以下の記載があります。
以下の要件がある場合、1 つのタスク定義にコンテナを配置することをお勧めします。

  • 各コンテナが同じライフサイクルを共有している (起動と終了が同時に行われる)。
  • 行基盤となるホストが同じになるようにコンテナを実行する (あるコンテナが、localhost ポート上の別のコンテナを参照する) 必要がある。
  • 各コンテナがリソースを共有している。
  • コンテナでデータボリュームを共有している。

docs.aws.amazon.com

私が担当したシステムは以下のような要件でした。

  • コンテナ構成:Nginx と Spring Boot
  • システム概要:業務管理システム
  • Nginxではリソース共有、データボリュームは共有していない
  • 各コンテナのライフサイクルは共有しなくても良い(微妙ですが)
    ⇒ 「実行基盤となるホストが同じになるよう・・・」に当てはまる

NginxはWebサーバのみの機能でSpring Bootにリクエストを渡すだけ。
以下の構成図のように同じタスク内にコンテナを定義しました。

前置きが長くなってしまいましたが、回答は以下としました。

  • 本システムの要件(構成)では同じタスク内にコンテナを定義することがAWSのベストプラクティス
  • NginxがSpringBootアプリケーションに対してlocalhostを使用してリクエストを転送することでネットワークのオーバーヘッドを減らし、通信を効率化する

■ 補足
一般的には別々のタスクに定義することでスケーリングや障害対応が柔軟にでき、運用面での利便性が高まると言われていました。
別々のタスクにする場合はクラスター内にコンテナごとにサービスを作って、それぞれタスク数を設定していく感じだと思います。
理由を聞かれて即答できる人間になりたいです。