18
2

はじめに

こんにちは! 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 を実行し、リソースをデプロイする。 
     スクリーンショット 2024-07-06 22.47.43.png

また、全体のアーキテクチャは以下のようになっています。

cicd.png

featureブランチの取り扱い

 まず前述のfeatureブランチ(ワイルドカード)の問題をどう解決したかについてですが、こちらは先駆者の方の以下の記事を参考にさせていただいています。

 簡単に解説します。普通にCodeBuildのプロジェクトを作成する場合、CodePipelineと同じようにソースプロバイダとリファレンスタイプを選択することになりますが、ここでリファレンスタイプを「ブランチ」で指定しても、既存のブランチが選択肢として出てくるのみで、「feature/*」のようにワイルドカードで指定することが出来ません。

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

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

パイプラインの解説

 まずStep Functionsの起動に関してですが、CodeCommit上のイベントを検知して起動するように、Event Bridgeを設定します。今回はブランチのpushやマージと、プルリクエストの作成/変更をトリガーとしたいため、2つのイベントルールを作成しています。それぞれのイベントパターンは以下のようになっています。これらのイベントルールのターゲットは言わずもがな、Step Functionsになります。

ブランチのpushやマージを検知するイベントパターン
{
  "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
"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"
Node.js
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
"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
"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コマンドを配置しています。内容は以下のとおりです。

planall.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}
      - |
        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
"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
"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 を実行します。
スクリーンショット 2024-07-07 16.40.44.png
それぞれのアクションのStep Functionsのコード定義は以下になります。

Terraform Plan
"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
"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」で上書きします。

plan/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、メール通知を行う処理を実装します。

スクリーンショット 2024-07-07 18.44.10.png

 以上で作成した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
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が動くはずですが、どうでしょうか...

スクリーンショット 2024-07-07 22.18.19.png

いい感じに動いていそう! 対象のブランチも問題なく「feature/create_bucket」になっています! イベントログからCodeBuildのログを開くことができるので、確認してみます。

スクリーンショット 2024-07-07 22.18.35.png
スクリーンショット 2024-07-07 22.20.47.png
スクリーンショット 2024-07-07 22.20.54.png

各環境の terraform plan がばっちり動いています! 
しばらく待っているとCI/CDパイプラインの実行が完了しました。これで、featureブランチのような曖昧なブランチも取り扱えることを確認出来ました!!

スクリーンショット 2024-07-07 22.21.16.png

プルリクエストを作成した際の動作

 次にプルリクエストを作成した際の動作を見てみたいと思います。pushしたfeatureブランチからdevelopブランチへのマージのプルリクエストを作成します。
スクリーンショット 2024-07-07 22.22.22.png

プルリクエスト作成後、Terraform Plan PRが動いていることが確認できました! こちらも特に問題はなさそうです。

スクリーンショット 2024-07-07 22.23.07.png

しばらく待った後、terraform planが成功したことも確認できました。

スクリーンショット 2024-07-07 22.24.42.png

デプロイの際の動作

 最後に、developブランチやmainブランチにマージした際に実行されるデプロイの動作を確認したいと思います。まず、先ほど作成したdevelopブランチへのプルリエストをマージします。この作業で開発環境リソースがデプロイされます。
スクリーンショット 2024-07-07 22.25.01.png

こちらも問題なくTerraform Planの方が動きました!

スクリーンショット 2024-07-07 22.25.43.png

CodeBuildのログを見ても、developブランチで terraform plan が動いていることがわかります。

スクリーンショット 2024-07-07 22.26.49.png

無事にplanが成功し、applyのアクションが動き始めました!

スクリーンショット 2024-07-07 22.27.39.png

しばらく経ってCodeBuildのログを確認してみると、applyが成功したことが確認できました! Step Functionsの方も実行が完了しています。

スクリーンショット 2024-07-07 22.28.35.png

スクリーンショット 2024-07-07 22.29.13.png

では実際にS3バケットが作成できているか確認してみます。S3のコンソールに移動し、「cicd-bucket」で検索をかけると...

バケットが作成出来ています! これで、ローカルのfeatureブランチでの編集内容をpush → developブランチへのプルリクエスト作成 → マージして開発環境リソースのデプロイ まで作成したCI/CDパイプラインで実行できることが確認できました!!

 念の為、mainブランチへのマージ(商用環境リソースのデプロイ)も実施してみます。

スクリーンショット 2024-07-07 22.33.10.png

こちらもちゃんとTerraform Planの方のフローが動いています。

スクリーンショット 2024-07-07 22.34.03.png

CodeBuildのログを眺めていると、applyに無事成功したようです! Step Functionsの方も大丈夫でした。

スクリーンショット 2024-07-07 22.36.20.png

スクリーンショット 2024-07-07 22.36.36.png

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構成を考えるのが大変

正直まだまだ経験が不足しており、もっと上手く構築する方法はいっぱいあるはずなので、色々とカスタマイズして強化していきたいと思っています。

 本記事はこれで以上になります。長々と書いてしまいましたが、最後までご精読いただきましてありがとうございました!!

18
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
18
2