0
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Amazon ECS で組み込みのブルー/グリーンデプロイを AWS CDK で CI/CD 化してみる

Posted at

はじめに

少し遅いですが、Amazon ECS で組み込みのブルー/グリーンデプロイが利用可能になったとのことですので試してみたいと思います。

そもそも今更になったというのも、今回以下の条件でやってみたいというのがありました。

  • AWS CDK で構築したい
  • 本番稼働を想定してインフラとアプリケーションを分離させたい
  • アプリケーションは、CI/CD によるデプロイをしたい(今回インフラは CI/CD にしませんが、間違って本番稼働へデプロイしてしまうことをミスを考慮すると、CI/CD にした方が安全かと思います)

構成図

ecs-blue-green-architecture.png.png

準備する IaC コード

./lib/ecs-application-stack.ts
./lib/ecs-application-stack.ts
import * as cdk from 'aws-cdk-lib';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as ecs from 'aws-cdk-lib/aws-ecs';
import * as elbv2 from 'aws-cdk-lib/aws-elasticloadbalancingv2';
import * as iam from "aws-cdk-lib/aws-iam";
import { Construct } from 'constructs';

export interface EcsApplicationStackProps extends cdk.StackProps {
  readonly containerImage?: string;
}

export class EcsApplicationStack extends cdk.Stack {
  public readonly vpc: ec2.Vpc;
  public readonly ecsSg: ec2.SecurityGroup;
  public readonly albSg: ec2.SecurityGroup;
  public readonly cluster: ecs.Cluster;
  public readonly service: ecs.FargateService;
  public readonly taskDefinition: ecs.FargateTaskDefinition;
  public readonly alb: elbv2.ApplicationLoadBalancer;
  public readonly blueTargetGroup: elbv2.ApplicationTargetGroup;
  public readonly greenTargetGroup: elbv2.ApplicationTargetGroup;

  constructor(scope: Construct, id: string, props?: EcsApplicationStackProps) {
    super(scope, id, props);

    // VPC, サブネット
    this.vpc = new ec2.Vpc(this, 'ApacheVpc', {
      vpcName: 'apache-vpc',  
      ipAddresses: ec2.IpAddresses.cidr('10.100.0.0/16'),
      maxAzs: 2,
      natGateways: 1,
      subnetConfiguration: [
        {
          name: 'apache-public',
          subnetType: ec2.SubnetType.PUBLIC,
          cidrMask: 24,
        },
        {
          name: 'apache-private-ecs',
          subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS,
          cidrMask: 24,
        },
        {
          name: 'apache-private-alb',
          subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
          cidrMask: 24,
        },
        {
          name: 'apache-private-client',
          subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
          cidrMask: 24,
        },
      ],
    });

    // ECS クラスター
    this.cluster = new ecs.Cluster(this, 'ApacheCluster', {
      clusterName: 'apache-cluster',
      defaultCloudMapNamespace: {
        name: 'apache.local',
        vpc: this.vpc,
      },
      vpc: this.vpc,
    });

    // ECS タスク実行ロール
    const taskExecutionRole = new iam.Role(this, "ApacheEcsTaskExecutionRole", {
      roleName: "apache-ecs-task-execution-role",
      assumedBy: new iam.ServicePrincipal("ecs-tasks.amazonaws.com"),
      managedPolicies: [
        iam.ManagedPolicy.fromAwsManagedPolicyName(
          "service-role/AmazonECSTaskExecutionRolePolicy"
        ),
      ],
    });

    // タスク定義
    this.taskDefinition = new ecs.FargateTaskDefinition(this, 'ApacheTaskDefinition', {
      cpu: 256,
      memoryLimitMiB: 512,
      executionRole: taskExecutionRole,
    });

    const containerImage = props?.containerImage || 'public.ecr.aws/docker/library/httpd:2.4';

    this.taskDefinition.addContainer('ApacheContainer', {
      containerName: 'apache-container',
      image: ecs.ContainerImage.fromRegistry(containerImage),
      essential: true,
      logging: ecs.LogDrivers.awsLogs({
        streamPrefix: 'apache',
      }),
      portMappings: [
        {
          containerPort: 80,
          protocol: ecs.Protocol.TCP,
        },
      ],
    });

    // セキュリティグループ (ECS)
    this.ecsSg = new ec2.SecurityGroup(this, 'ApacheEcsSg', {
      securityGroupName: 'apache-ecs-sg',
      description: 'Security group for ECS',
      vpc: this.vpc,
      allowAllOutbound: true,
    });

    // インフラストラクチャロール
    const infrastructureRole = new iam.Role(this, "InfrastructureEcsRole", {
      roleName: "infrastructure-ecs-role",
      assumedBy: new iam.ServicePrincipal("ecs.amazonaws.com"),
      managedPolicies: [
        iam.ManagedPolicy.fromAwsManagedPolicyName(
          "AmazonECSInfrastructureRolePolicyForLoadBalancers"
        ),
      ],
    });

    // ECS サービス
    this.service = new ecs.FargateService(this, 'ApacheService', {
      serviceName: 'apache-service',
      cluster: this.cluster,
      taskDefinition: this.taskDefinition,
      platformVersion: ecs.FargatePlatformVersion.LATEST,
      desiredCount: 1,
      assignPublicIp: false,
      securityGroups: [this.ecsSg],
      vpcSubnets: this.vpc.selectSubnets({
        subnetGroupName: "apache-private-ecs",
      }),
      deploymentStrategy: ecs.DeploymentStrategy.BLUE_GREEN,
      bakeTime: cdk.Duration.minutes(5),
    });

    // セキュリティグループ(ALB)
    this.albSg = new ec2.SecurityGroup(this, "ApacheAlbSg", {
      securityGroupName: "apache-alb-sg",
      description: "Security group for ALB",
      vpc: this.vpc,
      allowAllOutbound: true,
      disableInlineRules: true,
    });

    new ec2.CfnSecurityGroupIngress(this, 'AlbSgIngress80', {
      groupId: this.albSg.securityGroupId,
      ipProtocol: 'tcp',
      fromPort: 80,
      toPort: 80,
      cidrIp: '10.100.0.0/16',
    });

    new ec2.CfnSecurityGroupIngress(this, 'AlbSgIngress8080', {
      groupId: this.albSg.securityGroupId,
      ipProtocol: 'tcp',
      fromPort: 8080,
      toPort: 8080,
      cidrIp: '10.100.0.0/16',
    });

    // ALB
    this.alb = new elbv2.ApplicationLoadBalancer(this, 'ApacheAlb', {
      loadBalancerName: 'apache-alb',
      vpc: this.vpc,
      internetFacing: false,
      securityGroup: this.albSg,
          vpcSubnets: this.vpc.selectSubnets({
        subnetGroupName: "apache-private-alb",
      }),
    });

    // ターゲットグループ (Blue)
    this.blueTargetGroup = new elbv2.ApplicationTargetGroup(this, 'ApacheBlueTargetGroup', {
      targetGroupName: 'apache-blue-target-group',
      vpc: this.vpc,
      protocol: elbv2.ApplicationProtocol.HTTP,
      targetType: elbv2.TargetType.IP,
      healthCheck: {
        path: '/',
        port: "80",
        protocol: elbv2.Protocol.HTTP,
      },
    });

    // ターゲットグループ (Green)
    this.greenTargetGroup = new elbv2.ApplicationTargetGroup(this, 'ApacheGreenTargetGroup', {
      targetGroupName: 'apache-green-target-group',
      vpc: this.vpc,
      protocol: elbv2.ApplicationProtocol.HTTP,
      targetType: elbv2.TargetType.IP,
      healthCheck: {
        path: '/',
        port: "80",
        protocol: elbv2.Protocol.HTTP,
      },
    });

    // リスナー
    const listener = this.alb.addListener('ApacheListener', {
      port: 80,
      protocol: elbv2.ApplicationProtocol.HTTP,
      open: false,
      defaultAction: elbv2.ListenerAction.fixedResponse(404, {
        contentType: "text/plain",
        messageBody: "Not Found",
      }),
    });
    const listenerRule = new elbv2.ApplicationListenerRule(this,"AlbListenerRule",
      {
        listener: listener,
        priority: 1,
        conditions: [elbv2.ListenerCondition.pathPatterns(["*"])],
        action: elbv2.ListenerAction.forward([this.blueTargetGroup]),
      }
    );

    const testListener = this.alb.addListener("ApacheTestListener", {
      port: 8080,
      protocol: elbv2.ApplicationProtocol.HTTP,
      open: false,
      defaultAction: elbv2.ListenerAction.fixedResponse(404, {
        contentType: "text/plain",
        messageBody: "Not Found",
      }),
    });
    const testListenerRule = new elbv2.ApplicationListenerRule(this,"AlbTestListenerRule",
      {
        listener: testListener,
        priority: 1,
        conditions: [elbv2.ListenerCondition.pathPatterns(["*"])],
        action: elbv2.ListenerAction.forward([this.greenTargetGroup]),
      }
    );
    
    const target = this.service.loadBalancerTarget({
      containerName: 'apache-container',
      containerPort: 80,
      protocol: ecs.Protocol.TCP,
      alternateTarget: new ecs.AlternateTarget("AlternateTarget", {
        alternateTargetGroup: this.greenTargetGroup,
        productionListener:
          ecs.ListenerRuleConfiguration.applicationListenerRule(
            listenerRule
          ),
        testListener:
          ecs.ListenerRuleConfiguration.applicationListenerRule(
            testListenerRule
          ),
        role: infrastructureRole,
      }),
    });
    target.attachToApplicationTargetGroup(this.blueTargetGroup);


    // ALB Outputs
    new cdk.CfnOutput(this, 'LoadBalancerDNS', {
      value: this.alb.loadBalancerDnsName,
      description: 'DNS name of the Application Load Balancer',
      exportName: `${this.stackName}-LoadBalancerDNS`,
    });

  }
}
./lib/ecs-cicd-stack.ts
./lib/ecs-cicd-stack.ts
import * as cdk from 'aws-cdk-lib';
import * as codebuild from 'aws-cdk-lib/aws-codebuild';
import * as codepipeline from 'aws-cdk-lib/aws-codepipeline';
import * as codepipelineActions from 'aws-cdk-lib/aws-codepipeline-actions';
import * as ecr from 'aws-cdk-lib/aws-ecr';
import * as ecs from 'aws-cdk-lib/aws-ecs';
import { Construct } from 'constructs';

export interface EcsCicdStackProps extends cdk.StackProps {
  readonly ecsService: ecs.FargateService;
  readonly ecrRepository?: ecr.Repository;
  readonly githubOwner: string;
  readonly githubRepo: string;
  readonly githubBranch?: string;
  readonly codestarConnectionArn: string;
}

export class EcsCicdStack extends cdk.Stack {
  public readonly pipeline: codepipeline.Pipeline;
  public readonly ecrRepository: ecr.Repository;

  constructor(scope: Construct, id: string, props: EcsCicdStackProps) {
    super(scope, id, props);

    // ECR リポジトリ
    this.ecrRepository = props.ecrRepository || new ecr.Repository(this, 'ApacheRepository', {
      repositoryName: 'apache-repository',
      imageTagMutability: ecr.TagMutability.IMMUTABLE,
      removalPolicy: cdk.RemovalPolicy.DESTROY,
    });

    // CodeBuild プロジェクト
    const buildProject = new codebuild.PipelineProject(this, 'ApacheBuildProject', {
      projectName: 'apache-build-project',
      environment: {
        buildImage: codebuild.LinuxBuildImage.STANDARD_7_0,
        privileged: true,
        computeType: codebuild.ComputeType.SMALL,
      },
      buildSpec: codebuild.BuildSpec.fromSourceFilename('buildspec.yml'),
      environmentVariables: {
        AWS_DEFAULT_REGION: {
          value: this.region,
        },
        AWS_ACCOUNT_ID: {
          value: this.account,
        },
        IMAGE_REPO_NAME: {
          value: this.ecrRepository.repositoryName,
        },
      },
    });

    // CodeBuild に ECR へのプッシュ権限を付与
    this.ecrRepository.grantPullPush(buildProject);

    // CodePipeline アーティファクト
    const sourceOutput = new codepipeline.Artifact('SourceOutput');
    const buildOutput = new codepipeline.Artifact('BuildOutput');

    // CodePipeline
    this.pipeline = new codepipeline.Pipeline(this, 'ApachePipeline', {
      pipelineName: 'apache-pipeline',
      pipelineType: codepipeline.PipelineType.V2,
      restartExecutionOnUpdate: false,
      stages: [
        {
          stageName: 'Source',
          actions: [
            new codepipelineActions.CodeStarConnectionsSourceAction({
              actionName: 'Source',
              owner: props.githubOwner,
              repo: props.githubRepo,
              branch: props.githubBranch || 'main',
              connectionArn: props.codestarConnectionArn,
              output: sourceOutput,
            }),
          ],
        },
        {
          stageName: 'Build',
          actions: [
            new codepipelineActions.CodeBuildAction({
              actionName: 'CodeBuild',
              project: buildProject,
              input: sourceOutput,
              outputs: [buildOutput],
            }),
          ],
        },
        {
          stageName: 'Deploy',
          actions: [
            new codepipelineActions.EcsDeployAction({
              actionName: 'ApacheEcsDeploy',
              service: props.ecsService,
              deploymentTimeout: cdk.Duration.minutes(15),
              input: buildOutput,
            }),
          ],
        },
      ],
    });

  }

}
./bin/ecs-blue-green-deployment.ts
./bin/ecs-blue-green-deployment.ts
#!/usr/bin/env node
import * as cdk from 'aws-cdk-lib';
import { EcsApplicationStack } from '../lib/ecs-application-stack';
import { EcsCicdStack } from '../lib/ecs-cicd-stack';

const app = new cdk.App();

// デフォルトの環境設定
const env = {
  account: process.env.CDK_DEFAULT_ACCOUNT,
  region: process.env.CDK_DEFAULT_REGION,
};

// GitHub リポジトリ情報 (Context から取得、なければデフォルト値)
const githubOwner = app.node.tryGetContext('githubOwner') || '<GitHub のユーザー名>';
const githubRepo = app.node.tryGetContext('githubRepo') || 'ecs-blue-green-deployment';
const githubBranch = app.node.tryGetContext('githubBranch') || 'main';

// CodeStar Connection ARN (Context から取得、必須)
const codestarConnectionArn = app.node.tryGetContext('codestarConnectionArn') || '<CodeStar Connection の ARN>';
if (!codestarConnectionArn) {
  throw new Error('codestarConnectionArn context value is required. Please set it using: cdk deploy -c codestarConnectionArn=arn:aws:codestar-connections:region:account:connection/connection-id');
}

// Application スタック
const appStack = new EcsApplicationStack(app, 'EcsApplicationStack', {
  env,
  description: 'ECS Blue/Green Deployment - Application Infrastructure',
});

// CI/CD Pipeline スタック
const cicdStack = new EcsCicdStack(app, 'EcsCicdStack', {
  env,
  description: 'ECS Blue/Green Deployment - CI/CD Pipeline',
  ecsService: appStack.service,
  githubOwner,
  githubRepo,
  githubBranch,
  codestarConnectionArn,
});

// CI/CD スタックはアプリケーションスタックに依存
cicdStack.addDependency(appStack);

// Add tags to all resources
cdk.Tags.of(app).add('Project', 'apache-ecs');
cdk.Tags.of(app).add('Environment', app.node.tryGetContext('environment') || 'dev');
Dockerfile
FROM public.ecr.aws/docker/library/httpd:2.4

RUN echo '<html><body><h1>Apache Blue/Green Deployment - Version 2.0</h1><p>This is running with automatic pipeline trigger! </p></body></html>' > /usr/local/apache2/htdocs/index.html

EXPOSE 80

CMD ["httpd-foreground"]
buildspec.yml
buildspec.yml
version: 0.2

phases:
  pre_build:
    commands:
      - echo Logging in to Amazon ECR...
      - aws ecr get-login-password --region $AWS_DEFAULT_REGION | docker login --username AWS --password-stdin $AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com
      - REPOSITORY_URI=$AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com/$IMAGE_REPO_NAME
      - COMMIT_HASH=$(echo $CODEBUILD_RESOLVED_SOURCE_VERSION | cut -c 1-7)
      - IMAGE_TAG=${COMMIT_HASH:=latest}
      - echo Repository URI is $REPOSITORY_URI
      - echo Image tag is $IMAGE_TAG

  build:
    commands:
      - echo Build started on `date`
      - echo Building the Docker image...
      - docker build -t $IMAGE_REPO_NAME:$IMAGE_TAG .
      - docker tag $IMAGE_REPO_NAME:$IMAGE_TAG $REPOSITORY_URI:$IMAGE_TAG

  post_build:
    commands:
      - echo Build completed on `date`
      - echo Pushing the Docker image...
      - docker push $REPOSITORY_URI:$IMAGE_TAG
      - echo Writing image definitions file for ECS built-in Blue/Green deployment...
      - printf '[{"name":"apache-container","imageUri":"%s"}]' $REPOSITORY_URI:$IMAGE_TAG > imagedefinitions.json
      - cat imagedefinitions.json

artifacts:
  files:
    - imagedefinitions.json
  name: BuildArtifact

cache:
  paths:
    - '/root/.docker/**/*'

1. アプリケーション環境の構築と動作確認

1-1. アプリケーションスタックのデプロイ

cdk deploy EcsApplicationStack

1-2. アプリケーションスタックの動作確認

デプロイされたら、一旦ここまで接続を確認しておきます。

CloudFormation の画面から ALB の DNS 名を確認し、メモしておきます。
スクリーンショット 2025-09-28 18.17.18.png

クライアントは、CloudShell を使います。「Creat VPC environment(max 2)」をクリックします。
スクリーンショット 2025-09-28 18.22.07.png

適当な「Name」、VPC は、「apache-vpc」、Subnet は「apache-private-clientSubnet1」、Security Group は「default」を選択し、「Create」をクリックします。
スクリーンショット 2025-09-28 18.21.24.png

default のセキュリティグループのアウトバウンドで、送信先:0.0.0.0/0 へのすべてのトラフィックを許可するようにしておきます。
スクリーンショット 2025-09-28 18.23.31.png

先ほどメモした ALB の DNS 名に CloudShell からアクセスできるか確認しておきます。
スクリーンショット 2025-09-28 18.38.51.png

1-3. デプロイ内容の確認

まず、ECS からイベントを確認してみました。すると、最初は apache-blue-target-group にデプロイされると思ったのですが、apache-green-target-group にデプロイされていました。
スクリーンショット 2025-09-29 19.29.42.png

次にリスナーを確認してみます。apache-green-target-group に 100 % 転送するようなっています。
スクリーンショット 2025-09-29 19.34.40.png

1-4. 変更を加えてデプロイ

アプリケーションの中身が変わるわけではないですが、 ./lib/ecs-application-stack.ts

    const containerImage = props?.containerImage || 'public.ecr.aws/docker/library/httpd:2.4';

    const containerImage = props?.containerImage || 'public.ecr.aws/docker/library/httpd:latest';

に変えて再度デプロイしてみます。

cdk deploy EcsApplicationStack

1-5. 変更を加えてデプロイ後の内容確認

ECS からイベントを確認してみます。最初は apache-green-target-group から apache-blue-target-group に切り替わってデプロイされていることが確認できます。
スクリーンショット 2025-09-29 20.06.31.png

次にリスナーを確認してみます。apache-blue-target-group に 100 % 転送するようなっています。
スクリーンショット 2025-09-29 20.14.33.png

今回、ALB のアクセスログを有効にしておかなかったため、8080 番ポートのリスナーでテスト後、80 番ポートのリスナーへ切り替わっている様子は確認できませんでしたが、実際に詳しく動作を確認されたい方は、ALB のアクセスログを有効にしておくか、実際にトラフィックを流して確認してみると良いかと思われます。

2. CI/CD 環境の構築と動作確認

2-1. GitHub への接続の作成

以下、AWS ドキュメントを参考に GitHub への接続を作成しておきます。 ARN は次の手順で利用するのでコピペなどをして控えておきます。

スクリーンショット 2025-10-05 17.44.57.png

2-2. CI/CD スタックのデプロイ

cdk deploy EcsCicdStack --context githubOwner=<GitHub アカウント名> --context githubRepo=ecs-blue-green-deployment --context codestarConnectionArn=<【2-1. GitHub への接続の作成】で作成した接続の Arn> --require-approval never

2-3. push してパイプラインを実行する

git add .
git commit -m "Update ECS Blue/Green deployment with CI/CD pipeline"
git push origin main

当たり前ですが、Amazon ECS で組み込みのブルー/グリーンデプロイされるため、詳細な確認は、Amazon ECS 側で確認することとなるようです。
スクリーンショット 2025-10-05 18.57.53.png
スクリーンショット 2025-10-05 18.58.43.png

2-4. パイプライン実行後の動作確認と内容確認

ECS からイベントを確認してみます。最初は apache-green-target-group から apache-blue-target-group に切り替わってデプロイされていることが確認できます。
Amazon ECS で組み込みのブルー/グリーンデプロイで実行しているため、基本的に【1-5. 変更を加えてデプロイ後の内容確認】と変わらないようです。
スクリーンショット 2025-10-05 18.53.07.png

最後に

Amazon ECS で組み込みのブルー/グリーンデプロイを AWS CDK で CI/CD 化してみました。

今回実施はしませんでしたが気になる点として 2 つ。

  • 単純にブルーグリーンでデプロイしましたが最終まで完了した後、どう切り戻しを実施するか
  • AWS ブログでも紹介されているデプロイライフサイクルフックを利用することで、認証ページが突破できた場合のみ切り替えるようなことはできるか

時間に余裕が出た時に深掘りしてみたいと思います。

0
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?