AWS

CodeCommitの任意ブランチに対してCI実行する基盤をAWS CDKで作ってみた

jp

はじめに

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

AUTHOR
jp
jp
記事URLをコピーしました