CodeCommitの任意ブランチに対してCI実行する基盤をAWS CDKで作ってみた
はじめに
CircleCIやGitHub Actionsなどでは簡単な設定をするだけで任意のfeatureブランチに対して自動でCIを実行することが可能です。
一方でAWSの各種サービスのみで同様のことを実現したい場合、現状では幾つかのマネージドサービスを併せて作り込む必要があります。
プロジェクト/案件の都合次第ではどうしてもAWSのみしか使えない場合もあり得ることを想定して、今回はAWSの各種マネージドサービスのみを利用してCI/CDを実現する一例をご紹介したいと思います。
AWS CDKとは
構築に際して今回はAWS Cloud Development Kit (CDK) を利用します。
AWS CDKはAWSが公式に開発している各種AWSサービス構築用のIaCツールです。
コードの記述にはTypescriptやPython等のWebアプリケーション開発時に使用頻度が高いプログラミング言語を利用することができます。
AWS CDKで作成されたアプリケーションはCloudFormation経由でデプロイされます。
構成
CodeCommitリポジトリにブランチが新たに作成または削除されるとEventBridgeで検知されるようにRuleを設定します。
EventBridge RuleではTargetとしてCodeBuildプロジェクトを指定します。
指定されたCodeBuildプロジェクト内でAWS CDKのデプロイコマンドが実行され、作成されたfeatureブランチ専用のCodePipelineを作成または削除します。
実装内容
ここからは実際のコードを紹介していきます。
共通部分
複数のプロジェクト/リポジトリ共通で使いまわせる共通部分のStackです。
setup-pipeline-stack.ts
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as events from 'aws-cdk-lib/aws-events';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as targets from "aws-cdk-lib/aws-events-targets"
import * as s3 from "aws-cdk-lib/aws-s3"
import * as codebuild from "aws-cdk-lib/aws-codebuild"
import * as codecommit from "aws-cdk-lib/aws-codecommit"
import {Duration} from "aws-cdk-lib";
export class SetupPipelineStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
// CodePiplineサービスロール
new iam.Role(this, 'CodePipelineRole', {
roleName: "PipelineRole",
assumedBy: new iam.ServicePrincipal('codepipeline.amazonaws.com'),
managedPolicies: [
iam.ManagedPolicy.fromAwsManagedPolicyName("AWSCodePipeline_FullAccess"),
iam.ManagedPolicy.fromAwsManagedPolicyName("AWSCodeCommitFullAccess"),
iam.ManagedPolicy.fromAwsManagedPolicyName("AWSCodeCommitFullAccess"),
iam.ManagedPolicy.fromAwsManagedPolicyName("AmazonS3FullAccess")
]
})
// CodeBuildサービスロール
const codebuildRole = new iam.Role(this, 'CodeBuildRole', {
roleName: "CodeBuildRole",
assumedBy: new iam.ServicePrincipal('codebuild.amazonaws.com'),
managedPolicies: [
iam.ManagedPolicy.fromAwsManagedPolicyName("AdministratorAccess")
]
})
// アーティファクト用S3バケット
const bucket = new s3.Bucket(this, 'TemplateBucket', {
bucketName: `${cdk.Stack.of(this).account}-setup-pipeline-templates`
});
bucket.addToResourcePolicy(new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: ['s3:*'],
resources: [bucket.bucketArn,bucket.arnForObjects('*')],
principals: [new iam.AccountRootPrincipal()],
}));
// 任意のブランチが作成・削除されたタイミングでCodePipelineの作成・削除を実行するCodeBuild
const createCodePipelineCodebuild = new codebuild.Project(this,"CreateCodePipelineCodebuild", {
projectName: "SetupCodeBuild",
buildSpec: codebuild.BuildSpec.fromSourceFilename("./buildspecs/PipelineCreationAction.yaml"),
source: codebuild.Source.codeCommit({
repository: codecommit.Repository.fromRepositoryArn(this,"CreateCodePipelineCodebuildSource",`arn:aws:codecommit:${cdk.Stack.of(this).region}:${cdk.Stack.of(this).account}:codecommit-multi-branch-trigger-test`),
branchOrRef: "refs/heads/main"
}),
environment: {
buildImage: codebuild.LinuxBuildImage.STANDARD_6_0,
computeType: codebuild.ComputeType.MEDIUM
},
role: codebuildRole,
timeout: Duration.minutes(15)
})
// ブランチ作成・削除を検知するEventBridge Rule
new events.Rule(this, 'CreatePipelineRule', {
ruleName: "SetupPipelineRule",
eventPattern: {
detail: {
event: ["referenceDeleted", "referenceCreated"],
referenceType: ["branch"],
repositoryName: ["sample-app"],
referenceName: [{ "anything-but": ["develop", "release"] }]
},
source: ['aws.codecommit'],
},
targets: [new targets.CodeBuildProject(createCodePipelineCodebuild, {
event: events.RuleTargetInput.fromObject(
{
"environmentVariablesOverride": [
{
"name":"BRANCH",
"value":events.EventField.fromPath("$.detail.referenceName")
},
{
"name":"REPO_NAME",
"value":events.EventField.fromPath("$.detail.repositoryName")
},
{
"name":"EVENT",
"value":events.EventField.fromPath("$.detail.event")
},
{
"name":"STACK_NAME",
"value": `Pipeline-${events.EventField.fromPath("$.detail.repositoryName")}-${events.EventField.fromPath("$.detail.referenceName")}`
},
]
}
)
})],
})
}
}
リポジトリ作成 + 開発/本番用パイプライン作成
CodeCommitリポジトリ及び開発環境・本番環境用のCodePipelineを定義しています。
ここでは開発環境はdevelopブランチ、本番環境はreleaseブランチを想定しています。
repository-stack.ts
import * as cdk from "aws-cdk-lib";
import {Construct} from "constructs";
import * as path from 'path';
import * as codecommit from "aws-cdk-lib/aws-codecommit"
import * as codebuild from "aws-cdk-lib/aws-codebuild"
import * as codepipeline from "aws-cdk-lib/aws-codepipeline"
import * as codepipeline_actions from "aws-cdk-lib/aws-codepipeline-actions"
import * as iam from "aws-cdk-lib/aws-iam"
import * as s3 from "aws-cdk-lib/aws-s3"
import {Duration} from "aws-cdk-lib";
type repositoryStack = {
repositoryName : string
} & cdk.StackProps
export class RepositoryStack extends cdk.Stack {
constructor(scope: Construct, id: string, props: repositoryStack) {
super(scope, id, props);
// CodeCommitリポジトリ
new codecommit.Repository(this, 'Repository', {
repositoryName: props.repositoryName,
code: codecommit.Code.fromDirectory(path.join(__dirname, '../buildspecs/'), "develop"),
});
const sourceOutput = new codepipeline.Artifact('SourceArtifact');
// CodeBuildプロジェクト
const ciAction = new codebuild.PipelineProject(this, `CIActionBranch`, {
projectName: `CIAction-${props.repositoryName}`,
buildSpec: codebuild.BuildSpec.fromSourceFilename("CIAction.yaml"),
environment: {
buildImage: codebuild.LinuxArmBuildImage.AMAZON_LINUX_2_STANDARD_2_0,
computeType: codebuild.ComputeType.SMALL
},
role: iam.Role.fromRoleArn(this, `CIActionServiceRole`, `arn:aws:iam::${cdk.Stack.of(this).account}:role/CodeBuildRole`),
timeout: Duration.minutes(10),
});
const cdActionDev = new codebuild.PipelineProject(this, `CDActionDev`, {
projectName: `CDActionDev-${props.repositoryName}`,
buildSpec: codebuild.BuildSpec.fromSourceFilename("CDAction.yaml"),
environment: {
buildImage: codebuild.LinuxArmBuildImage.AMAZON_LINUX_2_STANDARD_2_0,
computeType: codebuild.ComputeType.SMALL
},
environmentVariables: {
"pipeline_environment": {
value: "DEV"
}},
role: iam.Role.fromRoleArn(this, "CDActionDevServiceRole", `arn:aws:iam::${cdk.Stack.of(this).account}:role/CodeBuildRole`),
timeout: Duration.minutes(10)
});
const cdActionProd = new codebuild.PipelineProject(this, `CDActionProd`, {
projectName: `CDActionProd-${props.repositoryName}`,
buildSpec: codebuild.BuildSpec.fromSourceFilename("CDAction.yaml"),
environment: {
buildImage: codebuild.LinuxArmBuildImage.AMAZON_LINUX_2_STANDARD_2_0,
computeType: codebuild.ComputeType.SMALL
},
environmentVariables: {
"pipeline_environment": {
value: "PROD"
}},
role: iam.Role.fromRoleArn(this, "CDActionProdServiceRole", `arn:aws:iam::${cdk.Stack.of(this).account}:role/CodeBuildRole`),
timeout: Duration.minutes(10)
});
// 開発環境・本番環境用CodePipeline
new codepipeline.Pipeline(this, 'DevPipeline', {
pipelineName: `${props.repositoryName}-develop`,
role: iam.Role.fromRoleArn(this, 'CodePipelineDevActionRole', `arn:aws:iam::${cdk.Stack.of(this).account}:role/PipelineRole`),
artifactBucket: s3.Bucket.fromBucketName(this, "dev-artifact-bucket", `${cdk.Stack.of(this).account}-setup-pipeline-templates`),
stages: [
{
stageName: 'Source',
actions: [
new codepipeline_actions.CodeCommitSourceAction({
actionName: "App",
repository: codecommit.Repository.fromRepositoryName(this, "fromRepositoryNameDev", props.repositoryName),
branch: "develop",
output: sourceOutput,
runOrder: 1,
})
],
},
{
stageName: 'Continuous-Integration',
actions: [
new codepipeline_actions.CodeBuildAction({
actionName: "CI-Action",
input: sourceOutput,
project: ciAction,
runOrder: 1
})
],
},
{
stageName: 'Deploy-Dev',
actions: [
new codepipeline_actions.CodeBuildAction({
actionName: "CDActionDev",
input: sourceOutput,
project: cdActionDev,
runOrder: 1
})
]
}
]
});
new codepipeline.Pipeline(this, 'PrdPipeline', {
pipelineName: `${props.repositoryName}-release`,
role: iam.Role.fromRoleArn(this, 'CodePipelinePrdActionRole', `arn:aws:iam::${cdk.Stack.of(this).account}:role/PipelineRole`),
artifactBucket: s3.Bucket.fromBucketName(this, "prd-artifact-bucket", `${cdk.Stack.of(this).account}-setup-pipeline-templates`),
stages: [
{
stageName: 'Source',
actions: [
new codepipeline_actions.CodeCommitSourceAction({
actionName: "App",
repository: codecommit.Repository.fromRepositoryName(this, "fromRepositoryNamePrd", props.repositoryName),
branch: "release",
output: sourceOutput,
runOrder: 1,
})
],
},
{
stageName: 'Continuous-Integration',
actions: [
new codepipeline_actions.CodeBuildAction({
actionName: "CI-Action",
input: sourceOutput,
project: ciAction,
runOrder: 1
})
],
},
{
stageName: 'Deploy-Prod',
actions: [
new codepipeline_actions.CodeBuildAction({
actionName: "CDActionProd",
input: sourceOutput,
project: cdActionProd,
runOrder: 1
})
]
}
]
});
}
}
featureブランチ用
featureブランチに対して作成されるCodePipelineを定義しています。
create-pipeline-for-feature-branch-stack.ts
import * as cdk from "aws-cdk-lib";
import {Construct} from "constructs";
import * as codecommit from "aws-cdk-lib/aws-codecommit"
import * as codebuild from "aws-cdk-lib/aws-codebuild"
import * as codepipeline from "aws-cdk-lib/aws-codepipeline"
import * as codepipeline_actions from "aws-cdk-lib/aws-codepipeline-actions"
import * as iam from "aws-cdk-lib/aws-iam"
import * as s3 from "aws-cdk-lib/aws-s3"
type repositoryStack = {
repositoryName : string
branchName: string
} & cdk.StackProps
export class CreatePipelineForFeatureBranchStack extends cdk.Stack {
constructor(scope: Construct, id: string, props: repositoryStack) {
super(scope, id, props)
const featureSourceOutput = new codepipeline.Artifact("FeatureSourceArtifact")
const ciActionProject = codebuild.PipelineProject.fromProjectArn(this, "CIActionProject", `arn:aws:codebuild:${cdk.Stack.of(this).region}:${cdk.Stack.of(this).account}:project/CIAction-${props.repositoryName}`)
// 任意のfeatureブランチ用のCodePipeline
new codepipeline.Pipeline(this, 'FeaturePipeline', {
pipelineName: props.repositoryName + "-" + props.branchName,
role: iam.Role.fromRoleArn(this, 'FeaturePipelineServiceRole', `arn:aws:iam::${cdk.Stack.of(this).account}:role/PipelineRole`),
artifactBucket: s3.Bucket.fromBucketName(this, "artifact-bucket", `${cdk.Stack.of(this).account}-setup-pipeline-templates`),
stages: [
{
stageName: 'Source',
actions: [
new codepipeline_actions.CodeCommitSourceAction({
actionName: "App",
repository: codecommit.Repository.fromRepositoryName(this, "fromRepositoryNameFeature", props.repositoryName),
branch: props.branchName,
output: featureSourceOutput,
runOrder: 1,
})
],
},
{
stageName: 'Continuous-Integration',
actions: [
new codepipeline_actions.CodeBuildAction({
actionName: "CI-Action",
input: featureSourceOutput,
project: ciActionProject,
runOrder: 1
})
],
},
]
});
}
}
CodeBuild用のbuildspec.yaml
featureブランチが作成されたタイミングで実行されるCodeBuildプロジェクトに指定するbuildspec.yaml
です。
こちらのbuildspec.yaml
にはcdk deploy
コマンドを記述しています。deployコマンドのオプションとしてfeatureブランチ用のCodePipelineを作成するStackを指定しています。
version: 0.2
env:
shell: bash
phases:
install:
runtime-versions:
nodejs: 16.x
commands:
- npm ci
build:
commands:
- export REPO_NAME=$REPO_NAME
- export BRANCH=$BRANCH
- |
if [ "$EVENT" == "referenceCreated" ]; then
npx cdk deploy CreatePipelineForFeatureBranchStack --require-approval never
else
npx cdk destroy CreatePipelineForFeatureBranchStack --force
fi
動作確認
セットアップ〜開発環境・本番環境のPipeline作成まで
まずあらかじめ今回のAWS CDKアプリケーションをCodeCommitにpushしておきます。
共通部分
、リポジトリ作成 + 開発/本番用パイプライン作成
の順でデプロイします。
$ cdk deploy SetupPipelineStack
$ export REPO_NAME=sample-app
$ cdk deploy RepositoryStack
この時点で、開発環境、本番環境用のCodepipelineが作成されています。
featureブランチの作成・削除
作成
sample-app
リポジトリに対して任意のfeatureブランチを作成します。
作成したブランチ用のCodePipelineが作成されています。
この状態で更に新しいfeatureブランチを作成した場合(例:fuga
)、さらに新しいCodePipelineが作成されます。(例:sample-app-fuga
)
削除
最後に、先ほど作成したfeatureブランチをリモートから削除します。
すると対応するCodePipelineも同様に削除されます。
(※Pipeline削除をCodeBuildで実行している)
CreatePipelineForFeatureBranchStack (Pipeline-sample-app-hoge): destroying...
(中略)
✅ CreatePipelineForFeatureBranchStack (Pipeline-sample-app-hoge): destroyed
参考
Multi-branch CodePipeline strategy with event-driven architecture