0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

GoとAWS CDKでGitHubからECS FargateへのCI/CDを構築した

0
Posted at

GoとAWS CDKでECS FargateへのBlue/Green CI/CDを構築した

1. はじめに

GitHubへコードをpushしたあと、Dockerイメージのビルド、Amazon ECRへのpush、Amazon ECS Fargateへの反映を毎回手作業で行うのは手間がかかります。作業手順が増えるほど、タグの指定間違いやデプロイ漏れも起こりやすくなります。

そこで、GitHubの develop ブランチへのpushを起点に、GoアプリケーションをECS Fargateへ自動デプロイするCI/CDパイプラインを構築しました。パイプラインを含むAWSリソースは、AWS CDK for Goで定義しています。

この記事では、特に次の点を扱います。

  • CodePipelineのSource、Build、Deployがどのようにつながるか
  • CodeBuildがDockerイメージをビルドし、ECRへpushするまでの流れ
  • Pipeline Artifactとして渡す taskdef.jsonimageDetail.jsonappspec.yamlの役割
  • CodeDeploy ECS Deploy ActionがGreen Task Setを作成する仕組み
  • BlueとGreenのトラフィックをALBで切り替える流れ
  • 初回デプロイで必要になるBootstrap Image
  • 実際に発生したエラーと、その切り分け方

一方、VPC、Subnet、Security Groupの詳細な設計は扱いません。ECSとALBについても一般的な設計には踏み込まず、Blue/Greenに必要なDeployment Controller、2つのTarget Group、Production Listenerに絞って説明します。

今回のDeployステージではCodeDeployを使用します。新しいGreen Task Setを起動してヘルスチェックに合格したあと、Production Listenerの転送先をBlueからGreenへ一度に切り替え、5分後に旧Blue Task Setを終了します。

最終的なゴールは、次の流れを自動化することです。

GitHubのdevelopブランチへpush
              |
              v
      CodePipelineが起動
              |
              v
 CodeBuildがDockerイメージを作成
              |
              v
        ECRへイメージをpush
              |
              v
 CodeDeployがGreen Task Setを作成
              |
              v
 ALBの転送先をBlueからGreenへ切替
              |
              v
       ALB経由でAPIへアクセス

2. 対象読者

この記事は、次のような方を対象としています。

  • CodePipelineを初めて構築する方
  • GitHubからECS Fargateへのデプロイを自動化したい方
  • CodePipeline、CodeBuild、CodeDeployの役割を整理したい方
  • ECS FargateへBlue/Greenデプロイしたい方
  • AWS CDK for GoでCI/CDパイプラインをコード化したい方
  • Pipelineでエラーが起きたときの調査方法を知りたい方

3. 前提技術・前提知識

今回使用した主な技術は次のとおりです。

技術・サービス 今回の役割
Go デプロイ対象のWeb APIとCDKコードを記述する
GitHub アプリケーションとインフラのソースコードを管理する
Docker Goアプリケーションをコンテナイメージにする
AWS CDK for Go AWSリソースをコードで定義する
AWS CodeConnections CodePipelineとGitHubを接続する
AWS CodePipeline Source、Build、Deployの処理をつなぐ
AWS CodeBuild DockerイメージをビルドしてECRへpushする
AWS CodeDeploy Green Task Setを作成し、ALBのトラフィックを切り替える
Amazon ECR Dockerイメージを保存する
Amazon ECS Fargate コンテナを実行する
Application Load Balancer 外部からGo APIへHTTPリクエストを振り分ける
AWS CloudFormation CDKが生成したテンプレートをもとにStackをデプロイする

GitとGitHubの基本操作、Dockerイメージとコンテナの基礎、AWSアカウント・リージョン・IAMの基礎を理解していることを前提とします。また、ECRとECSの役割、AWS CDKとCloudFormationの関係を知っていると読み進めやすくなります。

4. CodePipelineとは

CodePipelineは、ソースコードの取得、ビルド、デプロイといった処理を、StageとActionの組み合わせでつなぐサービスです。

今回のPipelineは、次の3つのStageで構成しました。

Stage Action 処理
Source CodeConnections Source Action GitHubからソースコードを取得する
Build CodeBuild Action Dockerイメージを作成してECRへpushする
Deploy CodeDeploy ECS Deploy Action Green Task Setを作成し、Production Trafficを切り替える

CodePipeline自身がDockerイメージをビルドしたり、ALBのトラフィックを切り替えたりするわけではありません。CodeBuildやCodeDeployを呼び出し、Stageの実行順序と入出力を管理します。

CDKでは、Pipelineを作成してから AddStage で各Stageを追加します。実際のコードを骨格だけにすると、次のようになります。

pipeline := awscodepipeline.NewPipeline(stack, _jsii_.String("Pipeline"), &awscodepipeline.PipelineProps{
	PipelineName: _jsii_.String(common.ResourceName(cfg.Environment, "pipeline")),
})

pipeline.AddStage(&awscodepipeline.StageOptions{
	StageName: _jsii_.String("Source"),
	Actions:   &[]awscodepipeline.IAction{sourceAction},
})

pipeline.AddStage(&awscodepipeline.StageOptions{
	StageName: _jsii_.String("Build"),
	Actions:   &[]awscodepipeline.IAction{buildAction},
})

pipeline.AddStage(&awscodepipeline.StageOptions{
	StageName: _jsii_.String("Deploy"),
	Actions:   &[]awscodepipeline.IAction{deployAction},
})

このように、CodePipelineは各処理を実行するサービスというより、複数の処理を順番につなぐオーケストレーターとして捉えると分かりやすいです。実際のビルドはCodeBuild、Green環境の作成とトラフィック切り替えはCodeDeployが担当します。

5. 構築したCI/CDの全体像

今回構築したCI/CDの全体像は次のとおりです。

GitHub developブランチ
        |
        | CodeConnections
        v
Source Action -- SourceOutput --> CodeBuild Action
     |                                |        |
     | appspec.yaml       Docker image|        | BuildOutput
     |                                v        | taskdef.json
     |                               ECR       | imageDetail.json
     |                                         |
     +--------------------+--------------------+
                          v
               CodeDeploy ECS Deploy Action
                          |
                          v
                    CodeDeploy
                          |
              Green Task Setを作成
                          |
                          v
                ALB Production Listener
                    |             |
                    v             v
              Blue TG          Green TG

ここで重要なのは、Dockerイメージ本体とPipeline Artifactの保存先が異なることと、Deploy Actionが2種類のArtifactを入力に取ることです。

  • Dockerイメージ本体はECRへ保存する
  • GitHubから取得したソースコードは SourceOutput としてBuildへ渡す
  • SourceOutputに含まれる appspec.yamlをDeployへも渡す
  • BuildOutputには taskdef.jsonimageDetail.jsonを含める
  • CodeDeploy ECS Deploy Actionが2つのArtifactを組み合わせる

ArtifactはCDKで次のように定義しています。

sourceOutput := awscodepipeline.NewArtifact(_jsii_.String("SourceOutput"), nil)
buildOutput := awscodepipeline.NewArtifact(_jsii_.String("BuildOutput"), nil)

Source Actionの出力を sourceOutput、CodeBuild Actionの入力を同じ sourceOutputにすることで、取得したリポジトリの内容をBuildへ渡します。Deploy Actionには、appspec.yamlを含む sourceOutputと、CodeBuildが生成した2ファイルを含む buildOutputの両方を渡します。

6. AWS CDKによるPipelineの構成方針

このリポジトリでは、インフラを2つのCDK Appに分割しました。

infra/
├── network/
│   ├── Network Stack
│   └── ECR Stack
└── application/
    ├── ALB Stack
    ├── ECS Stack
    └── Pipeline Stack(CodeBuild、CodePipeline、CodeDeploy)

network AppではVPCとECRを管理し、application AppではALB、ECS、Pipelineを管理します。VPCとECRはアプリケーションの配送基盤より先に必要になるため、ライフサイクルを分けています。

Application App内では、Stack間のデプロイ依存関係を ALB -> ECS -> Pipelineの順に定義します。

albStack := stacks.NewAlbStack(
	app,
	common.ResourceName(cfg.Environment, "alb-stack"),
	cfg,
	stackProps,
)

ecsStack := stacks.NewEcsStack(
	app,
	common.ResourceName(cfg.Environment, "ecs-stack"),
	cfg,
	albStack,
	stackProps,
)
ecsStack.AddDependency(albStack.Stack, nil)

pipelineStack := stacks.NewPipelineStack(
	app,
	common.ResourceName(cfg.Environment, "pipeline-stack"),
	cfg,
	ecsStack,
	albStack,
	stackProps,
)
pipelineStack.AddDependency(ecsStack.Stack, nil)

Pipeline Stackは、CodeBuildのpush先としてECR Repositoryを参照します。また、CodeDeploy Deployment Groupを作成するため、ECS Service、ALB Production Listener、BlueとGreenのTarget Groupも参照します。そのため、ALB StackとECS Stackを作成したあとにPipeline Stackを作成します。

ALB Stackでは、同じポートとヘルスチェックを持つ2つのTarget Groupを作成します。初期状態ではProduction ListenerをBlue Target Groupへ接続し、Green Target GroupはCodeDeploy Deployment Groupから使用します。

targetGroup := awselasticloadbalancingv2.NewApplicationTargetGroup(/* ... */)
greenTargetGroup := awselasticloadbalancingv2.NewApplicationTargetGroup(/* ... */)

listener.AddTargetGroups(
	_jsii_.String("DefaultTargetGroup"),
	&awselasticloadbalancingv2.AddApplicationTargetGroupsProps{
		TargetGroups: &[]awselasticloadbalancingv2.IApplicationTargetGroup{
			targetGroup,
		},
	},
)

ECS ServiceのDeployment Controllerには CODE_DEPLOYを設定します。

service := awsecs.NewFargateService(stack, _jsii_.String("Service"), &awsecs.FargateServiceProps{
	Cluster:        cluster,
	TaskDefinition: taskDefinition,
	DeploymentController: &awsecs.DeploymentController{
		Type: awsecs.DeploymentControllerType_CODE_DEPLOY,
	},
	// 省略
})

Service作成時はBlue Target Groupへ関連付けます。CodeDeployはデプロイ時に、現在Production Trafficを受けているTask SetとTarget Groupを判定し、もう一方へGreen Task Setを作成します。

CDK Appを分けた場合、別AppのConstructを直接渡すことはできません。Network App側でVPCやECRの値をCloudFormation OutputとしてExportし、Application App側では Fn_ImportValueを使って参照しています。

この記事ではVPCやECSの詳細設計には踏み込みませんが、Pipelineが参照するリソースをどのStackが所有しているかは、作成順序を理解するうえで重要です。

7. 初回デプロイで直面する依存関係

初回構築では、次の依存関係が問題になります。

ECRへBootstrap Imageをpush
        |
        | タスク起動時に取得できるイメージが必要
        v
ECS Serviceの作成・安定化
        |
        | Deploy先として必要
        v
Pipelineの作成

Pipelineが最初のイメージを作ってくれるのを待ちたいところですが、そのPipelineのDeploy Actionを作るには、更新先となるECS Serviceが必要です。タスク定義自体は参照先のイメージが存在しなくても登録できますが、ECS Serviceがタスクを起動して安定化するには、実際に取得できるイメージを先にECRへ用意する必要があります。

そこで、初回だけBootstrap Imageを手動でECRへpushします。現在の構成ではタグ realをBootstrap Imageとして使用しています。

ECSタスク定義では、次のようにECR RepositoryとBootstrap Imageのタグを指定しています。

container := taskDefinition.AddContainer(_jsii_.String("AppContainer"), &awsecs.ContainerDefinitionOptions{
	ContainerName: _jsii_.String(common.AppContainerName),
	Image:         awsecs.ContainerImage_FromEcrRepository(repository, _jsii_.String(cfg.BootstrapImageTag)),
	Logging:       awsecs.LogDriver_AwsLogs(&awsecs.AwsLogDriverProps{
		StreamPrefix: _jsii_.String(common.ResourceName(cfg.Environment, "app")),
	}),
})

初回デプロイの順序は次のようになります。

  1. Network Appをデプロイし、VPCとECR Repositoryを作成する
  2. Bootstrap Imageをタグ realでECRへ手動pushする
  3. ECS Stackをデプロイし、ECS Serviceを作成する
  4. Pipeline Stackをデプロイする
  5. 以降のイメージ更新はPipelineから行う

Bootstrap Imageは次のようにpushします。値は自身の環境に置き換えてください。

AWS_ACCOUNT_ID=<AWS_ACCOUNT_ID>
AWS_REGION=ap-northeast-1
IMAGE_REPO_NAME=dev-codepipeline-practice-app
IMAGE_TAG=real

aws ecr get-login-password --region "$AWS_REGION" \
  | docker login \
      --username AWS \
      --password-stdin "$AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com"

docker build -f build/Dockerfile -t "$IMAGE_REPO_NAME:$IMAGE_TAG" .

docker tag \
  "$IMAGE_REPO_NAME:$IMAGE_TAG" \
  "$AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/$IMAGE_REPO_NAME:$IMAGE_TAG"

docker push \
  "$AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/$IMAGE_REPO_NAME:$IMAGE_TAG"

初回だけ手動作業が入りますが、ECS ServiceとPipelineが作成されたあとは、GitHubへのpushを起点に更新できます。

8. Source・Build・Deployステージの設計

ここから、CodePipelineを構成する3つのStageを順番に見ていきます。

Sourceステージ

Sourceステージでは、AWS CodeConnectionsを使ってGitHub Repositoryと接続します。

pipeline.AddStage(&awscodepipeline.StageOptions{
	StageName: _jsii_.String("Source"),
	Actions: &[]awscodepipeline.IAction{
		awscodepipelineactions.NewCodeStarConnectionsSourceAction(
			&awscodepipelineactions.CodeStarConnectionsSourceActionProps{
				ActionName:    _jsii_.String("GitHubSource"),
				ConnectionArn: _jsii_.String(cfg.ConnectionArn),
				Owner:         _jsii_.String(cfg.RepositoryOwner),
				Repo:          _jsii_.String(cfg.RepositoryName),
				Branch:        _jsii_.String(cfg.RepositoryBranch),
				Output:        sourceOutput,
			},
		),
	},
})

設定値の対応は次のとおりです。

プロパティ 内容
ConnectionArn GitHubとの接続に使用するConnection ARN
Owner GitHub RepositoryのOwner
Repo GitHub Repository名
Branch 監視するブランチ。今回は develop
Output 次のBuildステージへ渡す SourceOutput

CodeConnectionsのConnectionは、事前にAWSコンソールで作成し、GitHub側の認可を完了させます。CDKのデプロイ時にはConnection ARNを環境変数で渡します。

export CODEPIPELINE_CONNECTION_ARN='<CONNECTION_ARN>'

CDKは、認可済みのConnectionをSource Actionへ紐付けます。GitHubの認可操作そのものまでCDKだけで完結するわけではない点に注意が必要です。

Buildステージ

Buildステージでは、CodeBuildが次の処理を実行します。

  1. ECRへログインする
  2. CodeBuildのBuild Numberをもとに数値のDockerイメージタグを生成する
  3. ECS Serviceが現在使用しているTask Definitionを取得する
  4. 出力専用のメタ情報を削除し、イメージをプレースホルダーへ変えた taskdef.jsonを生成する
  5. build/Dockerfileを使ってイメージをビルドする
  6. ECRへpushする
  7. 新しいイメージURIを記載した imageDetail.jsonを生成する

タグ realは初回のECS Serviceを起動するためのBootstrap Imageにだけ使用します。Pipelineの作成後は、CodeBuildのBuild Numberをもとにした数値タグをECS Serviceの更新に使用します。

BuildSpecは別ファイルではなく、CDKコード内で定義しました。

buildSpecObject := map[string]interface{}{
	"version": "0.2",
	"phases": map[string]interface{}{
		"pre_build": map[string]interface{}{
			"commands": []string{
				"aws ecr get-login-password --region $AWS_REGION | docker login --username AWS --password-stdin $AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com",
				"export IMAGE_TAG=$CODEBUILD_BUILD_NUMBER",
				"export TASK_DEFINITION_ARN=$(aws ecs describe-services --cluster $ECS_CLUSTER_NAME --services $ECS_SERVICE_NAME --query 'services[0].taskDefinition' --output text)",
				"aws ecs describe-task-definition --task-definition $TASK_DEFINITION_ARN --query taskDefinition --output json > current-taskdef.json",
				taskDefinitionCommand,
			},
		},
		"build": map[string]interface{}{
			"commands": []string{
				"docker build -f build/Dockerfile -t $IMAGE_REPO_NAME:$IMAGE_TAG .",
				"docker tag $IMAGE_REPO_NAME:$IMAGE_TAG $AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/$IMAGE_REPO_NAME:$IMAGE_TAG",
			},
		},
		"post_build": map[string]interface{}{
			"commands": []string{
				"docker push $AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/$IMAGE_REPO_NAME:$IMAGE_TAG",
				imageDetailCommand,
			},
		},
	},
	"artifacts": map[string]interface{}{
		"files": []string{"taskdef.json", "imageDetail.json"},
	},
}

taskDefinitionCommandでは現在のTask Definitionから再登録時に指定できないメタ情報を削除し、appコンテナの image<IMAGE1_NAME>へ置き換えます。このプレースホルダーはDeploy Actionが新しいイメージURIへ置換します。

Dockerコマンドを実行するため、CodeBuild Projectではprivileged modeを有効にします。また、Account ID、Region、ECR Repository名を環境変数として渡します。

project := awscodebuild.NewPipelineProject(stack, _jsii_.String("Project"), &awscodebuild.PipelineProjectProps{
	ProjectName: _jsii_.String(common.ResourceName(cfg.Environment, "build")),
	Environment: &awscodebuild.BuildEnvironment{
		BuildImage: awscodebuild.LinuxBuildImage_STANDARD_7_0(),
		Privileged: _jsii_.Bool(true),
	},
	EnvironmentVariables: &map[string]*awscodebuild.BuildEnvironmentVariable{
		"AWS_ACCOUNT_ID": {Value: _jsii_.String(cfg.AccountID)},
		"AWS_REGION":     {Value: _jsii_.String(cfg.Region)},
		"IMAGE_REPO_NAME": {
			Value: ecsStack.Repository.RepositoryName(),
		},
		"ECS_CLUSTER_NAME": {
			Value: ecsStack.Cluster.ClusterName(),
		},
		"ECS_SERVICE_NAME": {
			Value: ecsStack.Service.ServiceName(),
		},
	},
	BuildSpec: awscodebuild.BuildSpec_FromObject(&buildSpecObject),
})

ecsStack.Repository.GrantPullPush(project)

GrantPullPushによって、CodeBuild Projectへ対象ECR Repositoryのpull/push権限を付与します。さらに現在のTask Definitionを取得するため、ecs:DescribeServicesecs:DescribeTaskDefinitionも許可します。

project.AddToRolePolicy(awsiam.NewPolicyStatement(&awsiam.PolicyStatementProps{
	Actions:   _jsii_.Strings("ecs:DescribeServices", "ecs:DescribeTaskDefinition"),
	Resources: _jsii_.Strings("*"),
}))

作成したProjectはCodeBuild Actionへ渡し、入力に SourceOutput、出力に BuildOutputを指定します。

pipeline.AddStage(&awscodepipeline.StageOptions{
	StageName: _jsii_.String("Build"),
	Actions: &[]awscodepipeline.IAction{
		awscodepipelineactions.NewCodeBuildAction(&awscodepipelineactions.CodeBuildActionProps{
			ActionName: _jsii_.String("DockerBuild"),
			Project:    project,
			Input:      sourceOutput,
			Outputs:    &[]awscodepipeline.Artifact{buildOutput},
		}),
	},
})

Deployステージ

最初に、ECS Service、Production Listener、2つのTarget Groupを使ってCodeDeploy Deployment Groupを作成します。

deploymentGroup := awscodedeploy.NewEcsDeploymentGroup(
	stack,
	_jsii_.String("DeploymentGroup"),
	&awscodedeploy.EcsDeploymentGroupProps{
		Service: ecsStack.Service,
		BlueGreenDeploymentConfig: &awscodedeploy.EcsBlueGreenDeploymentConfig{
			BlueTargetGroup:     albStack.TargetGroup,
			GreenTargetGroup:    albStack.GreenTargetGroup,
			Listener:            albStack.Listener,
			TerminationWaitTime: awscdk.Duration_Minutes(_jsii_.Number(5)),
		},
		DeploymentConfig: awscodedeploy.EcsDeploymentConfig_ALL_AT_ONCE(),
		AutoRollback: &awscodedeploy.AutoRollbackConfig{
			FailedDeployment:  _jsii_.Bool(true),
			StoppedDeployment: _jsii_.Bool(true),
		},
	},
)

今回は ECSAllAtOnceを選択しました。Green Task SetがHealthyになったあと、Production Trafficを一度にGreenへ切り替えます。デプロイ失敗時と手動停止時の自動ロールバックを有効にし、切り替え成功から5分後に旧Blue Task Setを終了します。

Deployステージでは CodeDeployEcsDeployActionへDeployment Groupと2つのArtifactを渡します。

pipeline.AddStage(&awscodepipeline.StageOptions{
	StageName: _jsii_.String("Deploy"),
	Actions: &[]awscodepipeline.IAction{
		awscodepipelineactions.NewCodeDeployEcsDeployAction(&awscodepipelineactions.CodeDeployEcsDeployActionProps{
			ActionName:                  _jsii_.String("CodeDeployBlueGreen"),
			DeploymentGroup:             deploymentGroup,
			TaskDefinitionTemplateInput: buildOutput,
			AppSpecTemplateInput:        sourceOutput,
			ContainerImageInputs: &[]*awscodepipelineactions.CodeDeployEcsContainerImageInput{
				{
					Input:                     buildOutput,
					TaskDefinitionPlaceholder: _jsii_.String("IMAGE1_NAME"),
				},
			},
		}),
	},
})

Deploy Actionは taskdef.json<IMAGE1_NAME>imageDetail.jsonのURIへ置換して新しいTask Definition revisionを登録します。CodeDeployは appspec.yamlに従ってGreen Task Setを作成し、ALBのヘルスチェックに合格したらProduction ListenerをGreen Target Groupへ切り替えます。

9. Blue/Greenデプロイで使うArtifactの役割

CodeDeploy Blue/Greenでは、Dockerイメージ本体とは別に、次の3ファイルをDeploy Actionへ渡します。

SourceOutput
`-- appspec.yaml

BuildOutput
|-- taskdef.json
`-- imageDetail.json

taskdef.json

taskdef.jsonは新しいTask Definition revisionを登録するためのtemplateです。今回は現在稼働中のTask DefinitionをCodeBuildで取得し、再登録できないメタ情報を削除しています。

対象コンテナのイメージには、実際のURIではなくプレースホルダーを設定します。

{
  "family": "dev-codepipeline-practice-task",
  "containerDefinitions": [
  {
    "name": "app",
    "image": "<IMAGE1_NAME>",
    "portMappings": [
      {
        "containerPort": 80
      }
    ]
  }
  ]
}

CodeBuildでは jqを使い、appコンテナの imageを置換します。

taskDefinitionCommand := fmt.Sprintf(
	"jq 'del(.taskDefinitionArn, .revision, .status, .requiresAttributes, .compatibilities, .registeredAt, .registeredBy) | (.containerDefinitions[] | select(.name == \"%s\") | .image) = \"<IMAGE1_NAME>\"' current-taskdef.json > taskdef.json",
	common.AppContainerName,
)

imageDetail.json

imageDetail.jsonは、次にデプロイするDockerイメージのURIを示します。

{
  "ImageURI": "<AWS_ACCOUNT_ID>.dkr.ecr.ap-northeast-1.amazonaws.com/dev-codepipeline-practice-app:2"
}

CDKコードでは、次のコマンド文字列を組み立てています。

imageDetailCommand := "printf '{\"ImageURI\":\"%s\"}' $AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/$IMAGE_REPO_NAME:$IMAGE_TAG > imageDetail.json"

BuildSpecの artifacts.filesには、taskdef.jsonimageDetail.jsonを指定します。

"artifacts": map[string]interface{}{
	"files": []string{"taskdef.json", "imageDetail.json"},
},

appspec.yaml

appspec.yamlは、CodeDeployがGreen Task Setを作成するときの仕様を定義します。今回はリポジトリルートへ配置し、Source ArtifactからDeploy Actionへ渡します。

version: 0.0
Resources:
  - TargetService:
      Type: AWS::ECS::Service
      Properties:
        TaskDefinition: <TASK_DEFINITION>
        LoadBalancerInfo:
          ContainerName: app
          ContainerPort: 80

<TASK_DEFINITION>はDeploy Actionが新しく登録したTask Definition ARNへ置換します。ContainerNameContainerPortは、ECSタスク定義の設定と一致させます。

container := taskDefinition.AddContainer(_jsii_.String("AppContainer"), &awsecs.ContainerDefinitionOptions{
	ContainerName: _jsii_.String(common.AppContainerName),
	Image:         awsecs.ContainerImage_FromEcrRepository(repository, _jsii_.String(cfg.BootstrapImageTag)),
})

container.AddPortMappings(&awsecs.PortMapping{
	ContainerPort: _jsii_.Number(cfg.ContainerPort),
})

Deploy Actionの処理を整理すると、次のようになります。

  1. taskdef.json<IMAGE1_NAME>imageDetail.jsonImageURIへ置換する
  2. 新しいTask Definition revisionを登録する
  3. appspec.yaml<TASK_DEFINITION>を新しいARNへ置換する
  4. CodeDeployがGreen Task Setを作成する
  5. Green Target Groupのヘルスチェックが成功するまで待つ
  6. Production Listenerの転送先をBlueからGreenへ切り替える
  7. 5分後に旧Blue Task Setを終了する

Dockerイメージ本体をPipeline Artifactとして運ぶわけではありません。イメージ本体はECRへ置き、ArtifactではTask Definitionのtemplate、イメージURI、CodeDeployのデプロイ仕様を受け渡します。

CodeBuild
   |
   +---- Docker image ----------------------> ECR
   |
   +---- taskdef.json -----------------------+
   +---- imageDetail.json -------------------+--> CodeDeploy ECS Deploy Action
                                             |
SourceOutput ---- appspec.yaml ---------------+

10. デプロイの実行と動作確認

Network Appをデプロイする

最初に、VPCとECRを含むNetwork Appをデプロイします。

(cd infra/network && cdk deploy --all)

ECR Repositoryが作成されたら、7章で説明したBootstrap Imageをタグ realでpushします。

CodeConnectionsを準備する

AWSコンソールでGitHubへのConnectionを作成し、GitHub側で認可します。Connectionの状態が利用可能になったら、ARNを環境変数へ設定します。

export CODEPIPELINE_CONNECTION_ARN='<CONNECTION_ARN>'

Application Appをデプロイする

Pipelineを作成する前に、appspec.yamldevelopブランチへcommitしておきます。Deploy Actionは SourceOutputからこのファイルを読み込むため、ローカルにあるだけでは使用できません。

Application AppにはALB、ECS、Pipelineの3つのStackがあります。初回は --allを指定してデプロイします。ALB StackではBlueとGreenのTarget Groupを、ECS Stackでは CODE_DEPLOY ControllerのServiceを、Pipeline StackではCodeDeploy Deployment GroupとPipelineを作成します。

(cd infra/application && cdk deploy --all)

BuildSpecなどPipeline Stackだけを変更した場合は、Stack名を指定してデプロイできます。

(cd infra/application && cdk deploy dev-codepipeline-practice-pipeline-stack)

Goアプリケーションを変更する

デプロイ対象は、HTTPリクエストにJSONを返すシンプルなGo APIです。

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(http.StatusOK)
		_, _ = w.Write([]byte(`{"message":"deployed Hello, World!"}`))
	})

	const port string = ":80"
	server := &http.Server{
		Addr:    port,
		Handler: mux,
	}

	log.Fatal(server.ListenAndServe())
}

DockerfileではGoアプリケーションをビルドし、実行用イメージへバイナリをコピーします。

FROM golang:1.26 AS builder

WORKDIR /src

COPY go.mod ./
COPY cmd ./cmd

RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/app ./cmd

FROM gcr.io/distroless/static-debian12

WORKDIR /app
COPY --from=builder /out/app /app/app

EXPOSE 80
ENTRYPOINT ["/app/app"]

変更を developブランチへpushすると、Pipelineが起動します。

git add cmd/main.go
git commit -m 'change API response'
git push origin develop

AWSコンソールでSource、Build、Deployの各Stageが成功することを確認します。あわせて、次の点も確認します。

  1. ECRに新しいタグのイメージが作成されている
  2. CodeBuildが taskdef.jsonimageDetail.jsonを出力している
  3. ECS Task Definitionのリビジョンが増えている
  4. CodeDeployがGreen Task Setを作成している
  5. Green Target GroupがHealthyになっている
  6. Production Listenerの転送先がGreen Target Groupへ切り替わっている
  7. ALB経由のAPIレスポンスが更新されている
  8. 5分後に旧Blue Task Setが終了している
curl http://<ALB_DNS_NAME>/
{"message":"deployed Hello, World!"}

ここまで確認できれば、GitHubへのpushからCodeDeploy Blue/GreenによるECS Fargateへの反映までが一連で動作しています。次回のデプロイでは、現在のGreenがBlue(現行環境)となり、もう一方のTarget Groupに新しいGreenが作られます。

11. 実際に発生したトラブルと切り分け

CI/CDを構築すると、CodePipelineの画面だけでは原因を特定できないエラーに遭遇します。今回、実際に発生した問題と切り分け方を紹介します。

ALBが502または503を返す

最初にALBへアクセスした際、502 Bad Gateway、その後 503 Service Temporarily Unavailableが返りました。ECS Serviceのイベントには、Target Groupのヘルスチェック失敗が記録されていました。

(port 80) is unhealthy in (target-group ...) due to (reason Health checks failed).

原因はポートの不一致でした。

  • ALB、Target Group、ECSタスク定義はポート 80
  • Goアプリケーションはポート 8080

ALBからコンテナへ到達できないためヘルスチェックが失敗し、ECSがタスクを停止していました。Goアプリケーションの待受ポートとDockerfileを、インフラ側のポート 80へ統一して解決しました。

const port string = ":80"
EXPOSE 80

Buildが成功していても、起動したGreen Taskが正常とは限りません。CodeDeploy Deployment Event、Green Target GroupのTarget Health、ECS Task Set、コンテナログまで確認する必要があります。GreenがHealthyにならなければProduction TrafficはBlueのまま維持され、CodeDeploy Deploymentは失敗します。

CloudFormation Stackを更新できない

ECS Stackのデプロイ中に、次のエラーが発生しました。

Stack ... is in CREATE_IN_PROGRESS state and can not be updated.

ローカルで実行中の cdk deployCtrl+Cで中断しても、AWS側で開始済みのCloudFormation処理は停止しません。ECS Serviceの安定化を待っている間に再度デプロイしたため、作成中のStackに対する更新が拒否されました。

まずCloudFormation Stackの状態を確認します。

aws cloudformation describe-stacks \
  --stack-name dev-codepipeline-practice-ecs-stack \
  --query 'Stacks[0].StackStatus' \
  --output text

ECS Serviceが安定化できていない場合は、Service Eventも確認します。

aws ecs describe-services \
  --cluster dev-codepipeline-practice-cluster \
  --services dev-codepipeline-practice-service \
  --query 'services[0].events[0:10].message'

今回の根本原因は先ほどのポート不一致でした。正常なイメージへ更新してECS Serviceを安定させ、CloudFormationの処理完了後にCDKを再実行しました。

CodeBuildがDockerfileを見つけられない

Buildステージでは、次のエラーも発生しました。

failed to read dockerfile: open Dockerfile: no such file or directory

docker build ... .は、何も指定しなければBuild Context直下の Dockerfileを探します。しかし、このリポジトリのDockerfileは build/Dockerfileにあります。

そこで、-fオプションでDockerfileのパスを明示しました。

docker build -f build/Dockerfile -t $IMAGE_REPO_NAME:$IMAGE_TAG .

BuildSpecはCDKからCodeBuild Projectへ設定しています。そのため、CDKコードを修正してGitHubへpushするだけでは、AWS上のCodeBuild Projectは更新されません。Pipeline Stackを再デプロイしてからPipelineを再実行します。

(cd infra/application && cdk deploy dev-codepipeline-practice-pipeline-stack)

後続エラーに惑わされない

Docker buildが失敗したあと、さらに次のエラーが出力されました。

An image does not exist locally with the tag: ...
no matching artifact paths found

これらは別々の原因ではなく、Docker build失敗による連鎖エラーです。

Docker build失敗
       |
       +--> pushするローカルイメージが存在しない
       |
       +--> imageDetail.jsonが生成されない
                    |
                    +--> Artifact Uploadに失敗する

最後に表示されたエラーから調べるのではなく、ログを上から追って最初に失敗したコマンドを探すことが重要です。今回の場合はDockerfileのパスを修正することで、後続エラーも解消しました。

CodeDeploy Blue/Greenで確認する場所

CodeDeploy Blue/GreenのDeployステージでは、CodeDeployとECS Task Setも調査対象です。問題が起きた場合は、次の順で確認します。

CodeBuild Logs
      |
      v
CodePipeline Deploy Action
      |
      v
CodeDeploy Deployment Events
      |
      v
ECS Task Set / Service Events
      |
      v
ALB Target Health
      |
      v
CloudFormation Events

Deploy Actionが開始前に失敗する場合は、Artifact内に次のファイルが存在するかを確認します。

  • BuildOutput/taskdef.json
  • BuildOutput/imageDetail.json
  • SourceOutput/appspec.yaml

新しいTask Definitionの登録時に失敗する場合は、taskdef.json<IMAGE1_NAME>とDeploy Actionの TaskDefinitionPlaceholderが対応しているか確認します。

Green Task Setの起動後に止まる場合は、appspec.yamlのコンテナ名・ポート、Green Target Groupのヘルスチェック、コンテナログを確認します。CodeDeployは PRIMARYのTask SetをBlueとして扱い、もう一方のTarget GroupへGreenを作成するため、Target Group名ではなく現在どちらがProduction Trafficを受けているかで判断します。

12. 今回の構成で得た学びと改善案

今回の構築を通じて、CodePipelineは単にAWSサービスを順番に並べるだけではなく、Artifactを介して各Actionの入出力をつなぐ仕組みだと理解できました。

特に重要だった点は次のとおりです。

  • CDKでは、awscdk.NewStackで作成したStackがCloudFormationのデプロイ単位になる
  • Source、Build、DeployはArtifactを介してつながる
  • Dockerイメージ本体はECRへ保存する
  • taskdef.jsonimageDetail.jsonappspec.yamlはそれぞれ異なる役割を持つ
  • CodeDeploy ECS Deploy ActionがTask Definitionを登録し、CodeDeployがGreen Task Setとトラフィック切り替えを管理する
  • BlueとGreenは固定された環境名ではなく、デプロイごとに現行側と置換側が入れ替わる
  • Pipelineの障害調査では、CodeBuild、CodeDeploy、ECS、ALB、CloudFormationを横断して確認する
  • CDKで定義したBuildSpecを変更した場合は、Pipeline Stackの再デプロイが必要になる

今回は技術検証を優先した最小構成です。本番運用へ進める場合は、次の改善を検討できます。

  • BuildステージでGoのテストを実行する
  • CloudWatch AlarmをDeployment Groupへ関連付け、エラー率やレイテンシーをもとに自動ロールバックする
  • Test ListenerとLambda Lifecycle Hookを追加し、Production Traffic切り替え前にGreenを検証する
  • ECSAllAtOnceからCanaryまたはLinear Traffic Shiftへ変更する
  • ECSタスクをプライベートサブネットへ配置する
  • HTTPSと独自ドメインを設定する
  • Pipelineの失敗通知を追加する
  • stg、productionへの昇格フローを用意する
  • 本番反映前にManual Approval Actionを追加する
  • ECR Lifecycle Policyで古いイメージを削除する
  • イメージタグへGit Commit IDなど追跡可能な値を使用する
  • CDKのdeprecated APIや警告を解消する

どこまで実装するかは、システムの可用性、セキュリティ、運用体制、コストに応じて判断します。

13. まとめ

AWS CDK for Goを使い、GitHubの developブランチへのpushから、CodeDeploy Blue/GreenによるECS Fargateへのデプロイまでを自動化しました。

全体の流れは次のとおりです。

  1. CodeConnectionsがGitHubの変更をCodePipelineへ渡す
  2. CodeBuildがDockerイメージをビルドしてECRへpushする
  3. CodeBuildが taskdef.jsonimageDetail.jsonを生成する
  4. CodeDeploy ECS Deploy Actionが appspec.yamlと2つのBuild成果物を組み合わせる
  5. 新しいTask Definition revisionとGreen Task Setを作成する
  6. GreenがHealthyになったらProduction Trafficを切り替える
  7. 5分後に旧Blue Task Setを終了する

実装を通じて、DockerイメージとPipeline Artifactは別の役割を持ち、taskdef.jsonimageDetail.jsonappspec.yamlがBuildとCodeDeployをつなぐことが分かりました。また、エラー発生時にはCodePipelineだけを見るのではなく、CodeBuildのログ、CodeDeploy Deployment Event、ECS Task Set、ALBのTarget Health、CloudFormation Stackの状態を順番に確認することが重要でした。

GitHubへのpushからアプリケーションへの反映までを一本の流れとして理解すると、CodePipelineの各Stageに何を入力し、何を出力すべきかが見えやすくなります。

0
0
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
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?