はじめに
こんにちは! yu-Matsuです。
以前、セキュリティの観点からGitHubやSaaSのCI/CDを利用できないため、AWSのサービスのみでコードの管理とCI/CDを実現しなければならないという場面がありました。そこで色々と苦戦しつつも CodeBuild + Step Functions でのCI/CDパイプラインを構築しましたので、その経験談を記事にしたいと思います。同じような状況になっている方の一助になれば幸いです。
そもそもなんでCodeBuild + Step Functions?
AWSにはCI/CDパイプラインを実現するサービスとしてCodePipelineというサービスがあります。巷ではCode4兄弟とばれるサービス群のうちの一つで、「Pipeline」の名の如く、 CodeBuildを含む弟たち(?)をまとめてCI/CDパイプラインを構築するためのお兄ちゃん的なサービスです。
今回はなんでこの便利なCodePipelineを利用出来なかったのでしょうか。私が携わる開発の現場では、基本的にブランチ戦略として以下のようなGitflowが採用されています。

(画像の引用:https://blog.kinto-technologies.com/posts/2023-03-07-From-Git-flow-to-GitHub-flow-ja/)
featureブランチ名やreleaseブランチ名は、feature/{開発中の機能名} などのように、developブランチから切った際に動的に決まることになりますが、ここがミソです。CodePipelineの設定を進めると、「ソースステージを追加する」というステップがあり、ここでソースプロバイダーとブランチを指定することになるのですが、feature/* のようにワイルドカードを使用することができません。

これでは、featureブランチのpush時にテストやセキュリティチェックを走らせることが出来ませんし、releaseブランチへのマージ時にデプロイを実施することが出来ません。pipelineのV2タイプであればトリガーフィルターが利用できるので解決できそうですが、現状ソースプロバイダがCodeCommitの場合はトリガーフィルターを利用できないようです...
ですので、今回はCodePiplineをうまく利用することが出来ないので、CodeBuild + Step Functionsでの構築を試してみることになりました。
出来上がったものがこちらになります
色々と苦戦しながら構築したCI/CDパイプラインが以下になります。記事冒頭でも触れた通り、CodeBuild + Step Functionsの組み合わせで構築しています! 今回は例としてTerraformを実行するパイプラインを載せています。
- 左側のフロー(Terraform Plan PR): プルリクエストが作成される、もしくはプルリクエストに変更があった際に terraform plan を実行する。
- 真ん中のフロー(Terraform Plan feature): ローカルからリモートへfeatureブランチのpushがあった際に terraform plan を実行する。
- 右側のフロー(Terraform Plan/Apply): developブランチ、mainブランチに向けてのプルリクエストがマージされたら、terraform plan/apply を実行し、リソースをデプロイする。
また、全体のアーキテクチャは以下のようになっています。
featureブランチの取り扱い
まず前述のfeatureブランチ(ワイルドカード)の問題をどう解決したかについてですが、こちらは先駆者の方の以下の記事を参考にさせていただいています。
簡単に解説します。普通にCodeBuildのプロジェクトを作成する場合、CodePipelineと同じようにソースプロバイダとリファレンスタイプを選択することになりますが、ここでリファレンスタイプを「ブランチ」で指定しても、既存のブランチが選択肢として出てくるのみで、「feature/*」のようにワイルドカードで指定することが出来ません。

そこで、プロジェクト作成の際にはソースプロバイダを指定せず、Step Functionsから実行するタイミングでパラメータのオーバーライドを利用してソースプロバイダ、ブランチを指定するようにします。こうすることで、feature/*のような曖昧なブランチにも対応出来るようになります。

ソースプロバイダを指定しない場合は、buildspecコマンドをインラインで指定する必要があるため、適当なものを入れておきます。buildspecコマンドもStep Functionsから実行する際にオーバーライドします。

パイプラインの解説
まずStep Functionsの起動に関してですが、CodeCommit上のイベントを検知して起動するように、Event Bridgeを設定します。今回はブランチのpushやマージと、プルリクエストの作成/変更をトリガーとしたいため、2つのイベントルールを作成しています。それぞれのイベントパターンは以下のようになっています。これらのイベントルールのターゲットは言わずもがな、Step Functionsになります。
{
"source": ["aws.codecommit"],
"detail-type": ["CodeCommit Repository State Change"],
"resources": ["arn:aws:codecommit:us-east-1:xxxxxxxx:cicd-test"],
"detail": {
"event": ["referenceCreated", "referenceUpdated"],
"referenceType": ["branch"]
}
}
{
"source": ["aws.codecommit"],
"detail-type": ["CodeCommit Pull Request State Change"],
"resources": ["arn:aws:codecommit:us-east-1:xxxxxxxxx:cicd-test"],
"detail": {
"event": ["pullRequestCreated", "pullRequestSourceBranchUpdated"]
}
}
Step Functionsが起動すると、まず「PR or push」というChoiceアクションで、ブランチの作成/変更があった場合とプルリクエストの作成/変更があった場合で分岐します。Step Functionsのコードは以下のようになっています。
"PR or push": {
"Type": "Choice",
"Choices": [
{
"Variable": "$.detail-type",
"StringEquals": "CodeCommit Pull Request State Change",
"Next": "Get Reference Name"
},
{
"Variable": "$.detail-type",
"StringEquals": "CodeCommit Repository State Change",
"Next": "Plan feature or Deploy"
}
]
},
Event Bridgeからの入力に「detail-type」というパラメータがあり、この中にイベントタイプが格納されていますので、これを基に分岐しています。
プルリクエストの作成/変更があった場合のフローについて
左側のフローを見ていきます。こちらはプルリクエストの作成/変更があった場合のフローになりますが、CodeBuildのアクションが実行される前に、一つLambdaが挟まっています。

プルリクエストの作成/変更があった場合にStep Functionsに渡される入力の中に、以下のような情報があるのですが、ここから対象のブランチ名を取り出すためのLambdaとなります。コードは以下のようなイメージです。(併せてリポジトリ名も取り出しています)
"sourceReference": "refs/heads/feature/test"
export const handler = async (event) => {
const sourceReference = event.detail.sourceReference;
const branchName = sourceReference.split('refs/heads/')[1];
const repositoryName = event.detail.repositoryNames[0];
return {
'branchName': branchName,
'repositoryName': repositoryName,
};
};
"Get Reference Name": {
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke",
"OutputPath": "$.Payload",
"Parameters": {
"Payload.$": "$",
"FunctionName": "arn:aws:lambda:us-east-1:xxxxxx:function:get-reference-name:$LATEST"
},
"Retry": [
{
"ErrorEquals": [
"Lambda.ServiceException",
"Lambda.AWSLambdaException",
"Lambda.SdkClientException",
"Lambda.TooManyRequestsException"
],
"IntervalSeconds": 1,
"MaxAttempts": 3,
"BackoffRate": 2
}
],
"Next": "Terraform Plan PR"
},
次にCodeBuildのアクションの定義になります。ここでは、先で説明した通り、実行パラメータとbuildspecコマンドをオーバーライドして実行します。
"Terraform Plan PR": {
"Type": "Task",
"Resource": "arn:aws:states:::codebuild:startBuild.sync",
"Parameters": {
"ProjectName": "cicd-test",
"SourceTypeOverride": "CODECOMMIT",
"SourceLocationOverride.$": "States.Format('https://git-codecommit.us-east-1.amazonaws.com/v1/repos/{}', $.repositoryName)",
"BuildspecOverride": "cicd/planall.yml",
"SourceVersion.$": "$.branchName",
"EnvironmentVariablesOverride": [
{
"Name": "branch",
"Type": "PLAINTEXT",
"Value.$": "$.branchName"
},
{
"Name": "job",
"Type": "PLAINTEXT",
"Value": "plan"
}
]
},
"Catch": [
{
"ErrorEquals": [
"States.TaskFailed"
],
"Next": "Nofity Faliure"
}
],
"Next": "Nofity Success"
},
注目すべき点は「Parameters」配下でパラメータをオーバーライドしている箇所です。
- SourceTypeOverride: CodeBuildのプロジェクト作成時に選択しなかったソースプロバイダ(CODECOMMIT)を指定します。
- SourceLocationOverride: CodeCommitのリポジトリのurlを指定します。動的に指定する理由はあまりありませんが、Lambdaからの返り値を利用しています。
- BuildspecOverride: 詳しくは後述しますが、buildspecコマンドを上書きします。
- SourceVersion: 対象のブランチ名を指定します。
"SourceTypeOverride": "CODECOMMIT"
"SourceLocationOverride.$": "States.Format('https://git-codecommit.us-east-1.amazonaws.com/v1/repos/{}', $.repositoryName)",
"BuildspecOverride": "cicd/planall.yml",
"SourceVersion.$": "$.branchName",
BuildspecOverrideですが、リポジトリのルートディレクトの直下に「cicd」というディレクトリを作成し、その下に上書きするbuildspecコマンドを配置しています。内容は以下のとおりです。
version: 0.2
phases:
pre_build:
commands:
- yum install -y git-all
- git clone https://github.com/tfutils/tfenv.git ~/.tfenv
- ln -s ~/.tfenv/bin/* /usr/local/bin
- tfenv -v
- tfenv install latest
- tfenv use latest
build:
commands:
- echo ${CODEBUILD_SOURCE_VERSION}
- |
echo develop
cd ${CODEBUILD_SRC_DIR}/terraform/env/dev
terraform init
terraform validate
terraform plan
- |
echo production
cd ${CODEBUILD_SRC_DIR}/terraform/env/prod
terraform init
terraform validate
terraform plan
post_build:
commands:
- echo planall finished
詳細は省略しますが、各環境(開発環境/商用環境)ごとに terraform plan を実行するようなbuildspecコマンドになっています。CODEBUILD_SOURCE_VERSION、CODEBUILD_SRC_DIRはCodeBuildを実行時に含まれている環境変数で、それぞれ対象のブランチ名、ビルド対象のソースのルードディレクトリのパスが格納されています。
ブランチの作成/変更があった場合のフローについて
左側のフローは、ブランチの作成/変更があった場合のフローになります。こちらでは、CodeBuildのアクションに入る前に、featureブランチのpushか、develop/mainブランチへのマージかを判定します。Step Functionsへの入力の「detail.referenceName」が対象のブランチ名のため、こちらを利用して判定しています。

"Plan feature or Deploy": {
"Type": "Choice",
"Choices": [
{
"Variable": "$.detail.referenceName",
"StringMatches": "feature/*",
"Next": "Terrafrom Plan feature"
},
{
"Or": [
{
"Variable": "$.detail.referenceName",
"StringMatches": "main"
},
{
"Variable": "$.detail.referenceName",
"StringMatches": "develop"
}
],
"Next": "Terraform Plan"
}
]
},
featureブランチのpushの場合、CodeBuildのアクション「Terraform Plan feature」が実行されます。
"Terrafrom Plan feature": {
"Type": "Task",
"Resource": "arn:aws:states:::codebuild:startBuild.sync",
"Parameters": {
"ProjectName": "cicd-test",
"SourceTypeOverride": "CODECOMMIT",
"SourceLocationOverride.$": "States.Format('https://git-codecommit.us-east-1.amazonaws.com/v1/repos/{}', $.detail.repositoryName)",
"BuildspecOverride": "cicd/planall.yml",
"SourceVersion.$": "$.detail.referenceName",
"EnvironmentVariablesOverride": [
{
"Name": "branch",
"Type": "PLAINTEXT",
"Value.$": "$.detail.referenceName"
},
{
"Name": "job",
"Type": "PLAINTEXT",
"Value": "plan"
}
]
},
"Catch": [
{
"ErrorEquals": [
"States.TaskFailed"
],
"Next": "Nofity Faliure"
}
],
"Next": "Nofity Success"
},
ほとんど内容はTerraform Plan PRと一緒ですが、PRの作成/変更のイベントとは違い、get-reference-nameを使わずともStep Functionsへの入力から直接ブランチ名をとることが可能です。また、featureブランチのpush時のbuildspecコマンドはPRの作成/変更時と同じく「planall.yml」です。
次にdevelop/mainブランチへのマージの場合ですが、こちらは一番右端のフローが動きます。まずterraform plan を実行し、成功すれば後続のアクションで terraform apply を実行します。
それぞれのアクションのStep Functionsのコード定義は以下になります。
"Terraform Plan": {
"Type": "Task",
"Resource": "arn:aws:states:::codebuild:startBuild.sync",
"Parameters": {
"ProjectName": "cicd-test",
"SourceTypeOverride": "CODECOMMIT",
"SourceLocationOverride.$": "States.Format('https://git-codecommit.us-east-1.amazonaws.com/v1/repos/{}', $.detail.repositoryName)",
"BuildspecOverride": "cicd/plan.yml",
"SourceVersion.$": "$.detail.referenceName",
"EnvironmentVariablesOverride": [
{
"Name": "branch",
"Type": "PLAINTEXT",
"Value.$": "$.detail.referenceName"
},
{
"Name": "job",
"Type": "PLAINTEXT",
"Value": "plan"
}
]
},
"Next": "Terraform Apply",
"Catch": [
{
"ErrorEquals": [
"States.TaskFailed"
],
"Next": "Nofity Faliure"
}
]
},
"Terraform Apply": {
"Type": "Task",
"Resource": "arn:aws:states:::codebuild:startBuild.sync",
"Parameters": {
"ProjectName": "cicd-test",
"SourceTypeOverride": "CODECOMMIT",
"SourceLocationOverride.$": "$.Build.Source.Location",
"BuildspecOverride": "cicd/apply.yml",
"SourceVersion.$": "$.Build.SourceVersion",
"EnvironmentVariablesOverride": [
{
"Name": "branch",
"Type": "PLAINTEXT",
"Value.$": "$.Build.SourceVersion"
},
{
"Name": "job",
"Type": "PLAINTEXT",
"Value": "apply"
}
]
},
"Catch": [
{
"ErrorEquals": [
"States.TaskFailed"
],
"Next": "Nofity Faliure"
}
],
"Next": "Nofity Success"
}
buildspecコマンドに関して、Terraform Planの場合「plan.yml」、Terraform Applyの場合「apply.yml」で上書きします。
version: 0.2
phases:
pre_build:
commands:
- yum install -y git-all
- git clone https://github.com/tfutils/tfenv.git ~/.tfenv
- ln -s ~/.tfenv/bin/* /usr/local/bin
- tfenv -v
- tfenv install latest
- tfenv use latest
build:
commands:
- echo ${CODEBUILD_SOURCE_VERSION}
- |
case ${CODEBUILD_SOURCE_VERSION} in
"develop")
Env="dev"
;;
"main")
Env="prod"
;;
*)
exit 1
;;
esac
- |
echo ${CODEBUILD_SOURCE_VERSION}
cd ${CODEBUILD_SRC_DIR}/terraform/env/${Env}
terraform init
terraform validate
# planの場合はこちら
terraform plan
# applyの場合はこちら
terraform apply -auto-approve
post_build:
commands:
- echo plan finished
planとapplyで内容はほぼ同じで、最後に実行するterrafromコマンドが違うだけです。前述の通り、CODEBUILD_SOURCE_VERSIONにブランチ名が入っているので、その内容によってterraformコマンドを実行するディレクトリを分けています。
CI/CDの結果の通知に関して
Step Funcitonsで定義してきた各フローでは、最後に結果を通知するためのLambdaを実行するようにしています。成功の場合はNotify Successが、失敗の場合はNofity Failure が実行されます。詳細は割愛しますが、CodeBuildアクションの結果(出力)がそのままLambdaにイベントとして渡されるので、その内容を加工してSlackやTeams、メール通知を行う処理を実装します。
以上で作成したCI/CDパイプラインの解説は以上になります。今回はTerraformのCI/CDを例にしていましたが、CodeBuildの各アクションで上書きしていたbuildspecコマンドを変更すれば、例えばフロントエンドのコードのCI/CDなどにも流用することとが出来ます。(テスト用のCodeBuildアクションなどを別途追加する必要はありそうですが...)
動作イメージ
それではTerraformによる簡単なリソース作成を通して、CI/CDパイプラインの動作を確認していきたいと思います。リポジトリのディレクトリ構成は以下のようになっており、今回は開発環境(dev)と商用環境(prod)でS3バケットを作成します。
terraform
├ env
│ ├ dev
│ │ ├ main.tf
│ │ ├ terraform.tfvars # 変数 env='dev'を定義
│ │ └ variables.tf
│ └ prod
│ ├ main.tf
│ ├ terraform.tfvars # 変数 env='prod'を定義
│ └ variables.tf
└ module
└ common
└ s3.tf
resource "aws_s3_bucket" "cicd_bucket" {
bucket = "cicd-bucket-${var.env}-240707"
}
featureブランチをpushした際の動作
まずは「feature/create_bucket」というブランチを切り、上記のコードを作成しリモートにpushしてみます。作成したCI/CDパイプラインのうち、Terraform Plan featureが動くはずですが、どうでしょうか...
いい感じに動いていそう! 対象のブランチも問題なく「feature/create_bucket」になっています! イベントログからCodeBuildのログを開くことができるので、確認してみます。
各環境の terraform plan がばっちり動いています!
しばらく待っているとCI/CDパイプラインの実行が完了しました。これで、featureブランチのような曖昧なブランチも取り扱えることを確認出来ました!!
プルリクエストを作成した際の動作
次にプルリクエストを作成した際の動作を見てみたいと思います。pushしたfeatureブランチからdevelopブランチへのマージのプルリクエストを作成します。
プルリクエスト作成後、Terraform Plan PRが動いていることが確認できました! こちらも特に問題はなさそうです。
しばらく待った後、terraform planが成功したことも確認できました。
デプロイの際の動作
最後に、developブランチやmainブランチにマージした際に実行されるデプロイの動作を確認したいと思います。まず、先ほど作成したdevelopブランチへのプルリエストをマージします。この作業で開発環境リソースがデプロイされます。
こちらも問題なくTerraform Planの方が動きました!
CodeBuildのログを見ても、developブランチで terraform plan が動いていることがわかります。
無事にplanが成功し、applyのアクションが動き始めました!
しばらく経ってCodeBuildのログを確認してみると、applyが成功したことが確認できました! Step Functionsの方も実行が完了しています。
では実際にS3バケットが作成できているか確認してみます。S3のコンソールに移動し、「cicd-bucket」で検索をかけると...

バケットが作成出来ています! これで、ローカルのfeatureブランチでの編集内容をpush → developブランチへのプルリクエスト作成 → マージして開発環境リソースのデプロイ まで作成したCI/CDパイプラインで実行できることが確認できました!!
念の為、mainブランチへのマージ(商用環境リソースのデプロイ)も実施してみます。
こちらもちゃんとTerraform Planの方のフローが動いています。
CodeBuildのログを眺めていると、applyに無事成功したようです! Step Functionsの方も大丈夫でした。
S3のコンソールに移動し、「cicd-bucket-prod」で検索をかけると、商用環境リソースも作成出来ていました!

まとめと感想
今回は CodeBuild + Step Functions でのCI/CDパイプラインの構築に関しての経験談を記事にしてみました! 普段はGitHub ActionsなどのSaaSのCI/CDを利用しているため、Code4兄弟(の一部)に触れる貴重な機会となりました。
実際に構築してみて感じたことはやはりGitHub Actionsのありがたみでした。Actionsでは、workflow定義のyamlファイルさえ作成すればCI/CDを実現できますが(Enterprise版だとホストランナーを立てる必要あり)、今回の場合はAWSの複数サービスを組み合わせてパイプラインを組み上げる必要があったため、かなり構築に時間を要しました。また、今回の記事で紹介したものはベースラインということもありまだまだ足りていないものもありますが、運用上以下の点も気になるところです。
- パイプラインの実行ログを確認するにはStep FunctionsのイベントログからCodeBuildのログに飛ぶ必要があるので、過去の実行履歴を見ようとした際に煩雑になる
- もしCI/CDパイプラインが動かなくなった際に、複数サービスを組み合わせているため原因の切り分けが多少複雑になる
- CodeCommitのブランチ削除保護ができない(IAMユーザーの権限で縛る必要がある)
- DR構成を考えるのが大変
正直まだまだ経験が不足しており、もっと上手く構築する方法はいっぱいあるはずなので、色々とカスタマイズして強化していきたいと思っています。
本記事はこれで以上になります。長々と書いてしまいましたが、最後までご精読いただきましてありがとうございました!!