Backlog GitからAWSにCICDする構成を構築する

この記事を書いたメンバー:

Daiki Handa

Backlog GitからAWSにCICDする構成を構築する

目次

はじめに

こんにちは、半田(@handy)です。

プロジェクト管理・タスク管理ツールであるBacklogには、Git機能が統合されています。Git機能を利用すると、Backlog上でソースコード管理や特定のプルリクエストと課題を紐づけることができるようになっています。
今回はそのBacklog上のGitリポジトリをソースとして、AWS上にCICDを行う構成を構築してみました。


構築構成

実際に構築した構成は以下になります。

2023年10月現在、AWSのCICDサービスであるCode BuildやCode Pipelineでは、BacklogのGitリポジトリを直接ソースとして設定することができません。
そのため、Backlog GitのWebhook機能とAPI Gateway/Lambda/S3を使用して、指定したブランチにソースコードがマージされた場合にCode Pipelineが実行される構成を検討しました。
また、API Gatewayの裏側にあるLambdaでは対象とするブランチをリリース用ブランチに限定するようにし、それ以外のブランチへのリクエストの場合はCICDが実行されないようにしました。


構築方法

CICD基盤部分はTerraformを使用して構築し、デプロイ部分はServerless Frameworkを使用して構築を行うように構成しました。
基盤部分とデプロイ部分は更新されるライフサイクルが異なるため、別のリポジトリで管理されるようにし、S3やParameter Storeは敢えて手動で作成してTerraform管理外にしています。
※以下図にはParameter StoreがTerraform範囲に含まれていますが、あくまで余白の関係で入っているだけで、実際にはTerraformに含まれておりません


前提

構築は自分が普段使用している開発環境で行ったため、以下の作業は既に実施済みになります。

Terraform

  • Terraform Cloudの有効化
  • Terraform CloudとAWSアカウントとのOIDC設定
  • Terraform Cloud上にプロジェクト・ワークスペースの作成
  • 開発端末上でTerraformの初期化

Backlog

  • Backlogプロジェクトの作成
  • Backlog Git機能の有効化
  • Backlog Gitリポジトリの作成(リポジトリ名:sample-app)
  • 開発端末上でGitリポジトリのクローン作成


事前準備

実際に構築する前に、以下の作業を事前に行います。

IAM

  • Serverless Frameworkで利用するデプロイ用IAMユーザー作成、アクセスキー発行(ユーザー名:lambda-deploy-user)
    • IAMポリシー:AdministratorAccess
  • Serverless FrameworkでデプロイするLambdaにアタッチするIAMロール(ロール名:backlog-cicd-lambda-role)
    • IAMポリシー:AmazonS3FullAccess、CloudWatchLogsFullAccess

S3

  • ソースコード格納用S3作成(バケット名:cicd-[AWSアカウントID])
  • Code Pipeline用S3作成(バケット名:cicd-artifact-[AWSアカウントID])

  • Serverless Framework用S3作成(バケット名:[AWSアカウントID]-lambda)
  • CodeBuildキャッシュ用S3作成(バケット名:backlog-git-cache-[AWSアカウントID])

Parameter Store

  • Backlogユーザーの認証情報登録
  • Serverless Frameworkデプロイユーザーのアクセスキー登録
    • アクセスキー
    • シークレットアクセスキー


実作業

CICD用Lambda作成

コード実装

API Gatewayの後ろに配置するLambdaはPythonで実装しました。
とりあえず動くものを準備したので、実装の中身についてはわりと適当です。
全部を説明するのは長くなってしまうので、以下の部分だけ抜粋して説明します。

  • Parameter Storeパラメータ取得(Lambda拡張利用)
  • 後続処理実行条件
  • ソースコード取得
  • Zip圧縮・S3アップロード
import jsonimport urllib.parse
import os
import tempfile
import io
import shutil
import boto3
import zipfile
from dulwich import porcelain

BUCKET_NAME = os.environ['BUCKET_NAME']
ZIP_FILE_NAME = os.environ['ZIP_FILE_NAME']
USER = os.environ['USER']
PARAMETER_STORE_NAME = os.environ['PARAMETER_STORE_NAME']
REPOSITORY = os.environ['REPOSITORY']
DEV_BLANCH = os.environ['DEV_BLANCH']
UAT_BLANCH = os.environ['UAT_BLANCH']
PROD_BLANCH = os.environ['PROD_BLANCH']
COMMIT_MESSAGE_KEYWORD = os.environ['COMMIT_MESSAGE_KEYWORD']

s3 = boto3.client('s3')
aws_session_token = os.environ.get('AWS_SESSION_TOKEN')

def get_backlog_credentials():
    paramete_name = PARAMETER_STORE_NAME.replace('/','%2F')
    req = urllib.request.Request(f'http://localhost:2773/systemsmanager/parameters/get?name={paramete_name}&withDecryption=true')
    req.add_header('X-Aws-Parameters-Secrets-Token', aws_session_token)
config = urllib.request.urlopen(req).read()
return json.loads(config)

def lambda_handler(event, context):
print(f"event:{event}")

    payloadStr = urllib.parse.unquote(event["body"][8:])

    payload = json.loads(payloadStr)
    repository = payload["repository"]["name"]
    url = payload["repository"]["url"]
    branch = payload["ref"][11:]
    message = payload["revisions"][0]["message"]
print(f"repository:{repository} branch:{branch} uri:{url} message:{message}")

if repository == REPOSITORY and branch in (DEV_BLANCH,UAT_BLANCH,PROD_BLANCH) and COMMIT_MESSAGE_KEYWORD in message:
        # backlogの認証情報取得
        passStr = get_backlog_credentials()['Parameter']['Value']

        # gitパスの生成
        site = urllib.parse.urlparse(url)
        userStr = urllib.parse.quote(USER)
        uri = site.scheme +"://" + userStr + ":" + passStr +"@" + site.netloc + site.path + ".git"

        # 作業ディレクトリの生成
        tmpDir  = tempfile.mkdtemp()

        # clone/zip/upload
        try:
            # clone
            porcelain.clone(uri, tmpDir, branch=branch)
print(f"git clone success: {branch}")

            # zip
            zip_buffer = io.BytesIO()
            with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
                # Gitリポジトリ内のファイルをZipファイルに追加
for root, dirs, files inos.walk(tmpDir):
for file in files:
                        file_path = os.path.join(root, file)
                        zip_file.write(file_path, os.path.relpath(file_path, tmpDir))

            # Zipファイルをバイト列に変換
            zip_bytes = zip_buffer.getvalue()
iflen(zip_bytes) > 50000000:
                raise Exception('Zip file size exceeds 50MB limit')
print("zip success")

            # upload
            zip_file = branch.replace("/","_") +'/' + ZIP_FILE_NAME
            s3.put_object(Bucket=BUCKET_NAME, Key=zip_file, Body=zip_bytes)
print(f"s3 upload success: s3://{BUCKET_NAME}/{zip_file}")
        except Exception as e:
print(f"ERROR {e}")

        # 後始末
        shutil.rmtree(tmpDir)
else:
print(f"skip s3 upload because of not match condition. [uri:{url}  repository:{repository} branch:{branch} message:{message}]")
return {
'statusCode': 200
    }


【Parameter Storeパラメータ取得(Lambda拡張利用)】

Parameter Storeからのパラメータ取得にはSDKを使用することが多いかと思いますが、Lambdaの拡張機能(AWS Parameters and Secrets Lambda Extension)を使用することで実行時間の削減やパフォーマンスの向上を図ることができます。
今回はその拡張機能を使用して実装しました。

def get_backlog_credentials():
    paramete_name = PARAMETER_STORE_NAME.replace('/','%2F')
    req = urllib.request.Request(f'http://localhost:2773/systemsmanager/parameters/get?name={paramete_name}&withDecryption=true')
    req.add_header('X-Aws-Parameters-Secrets-Token', aws_session_token)
    config = urllib.request.urlopen(req).read()
    return json.loads(config)


以下記事でパフォーマンスを計測した結果が記載されておりますので、興味のある方はご確認ください。


【後続処理実行条件】

Lambda関数の実装で後続のCICDを実行する対象を指定しています。
具体的には以下の条件を指定しています。

  • リポジトリ名が「sample-app」
  • ブランチ名が「release/dev」 , 「release/uat」 , 「release/prod」
  • コミットメッセージが「Merge+pull+request」
    if repository == REPOSITORY and branch in (DEV_BLANCH,UAT_BLANCH,PROD_BLANCH) and COMMIT_MESSAGE_KEYWORD in message:

Backlog GitのWebhook機能は送信リクエストを絞ることができないので、実装側でリリース用ブランチとマージリクエストを対象とするようにしました。
実際にやるかどうかは別として、権限を細かく制御していないのであればリリース用ブランチに対して直接プッシュすること自体はできるため、念のためマージリクエストのみに限定しました。

条件に合致しない場合はスキップする旨のメッセージを出力するようにしています。

    else:
        print(f"skip s3 upload because of not match condition. [uri:{url}  repository:{repository} branch:{branch} message:{message}]")


【ソースコード取得】

条件に合致した場合、Backlog Gitリポジトリからソースコードの取得を行います。
subprocessを使用してgitコマンドを実行する方法もありましたが、今回はGitリポジトリをPythonで扱うためのモジュールである「dulwich」を使用しました。

            # clone
            porcelain.clone(uri, tmpDir, branch=branch)
            print(f"git clone success: {branch}")


【Zip圧縮・S3アップロード】

取得したソースコードをZip圧縮して、S3へのアップロードを行います。
ZipファイルはLambdaの一時ストレージではなくオンメモリ上に保持するようにして、S3アップロード時に実ファイルが生成されるようにしました。

            # zip
            zip_buffer = io.BytesIO()
            with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
                # Gitリポジトリ内のファイルをZipファイルに追加
                for root, dirs, files in os.walk(tmpDir):
                    for file in files:
                        file_path = os.path.join(root, file)
                        zip_file.write(file_path, os.path.relpath(file_path, tmpDir))

            # Zipファイルをバイト列に変換
            zip_bytes = zip_buffer.getvalue()
            if len(zip_bytes) > 50000000:
                raise Exception('Zip file size exceeds 50MB limit')
            print("zip success")

            # upload
            zip_file = branch.replace("/","_") +'/' + ZIP_FILE_NAME
            s3.put_object(Bucket=BUCKET_NAME, Key=zip_file, Body=zip_bytes)
            print(f"s3 upload success: s3://{BUCKET_NAME}/{zip_file}")
        except Exception as e:
            print(f"ERROR {e}")


S3アップロード先のプレフィックスですが、ブランチ名毎に分けることで後続のCodePipelineを環境ごとに作成できるようにしています。

zip_file = branch.replace("/","_") +'/' + ZIP_FILE_NAME


Lambda実装

CICD用LambdaのTerraformのコードは以下になります。

locals {
  layer_zip_path = "${path.root}/.terraform/tmp/cicd-lambda-layer.zip"
}

# Archive
data "archive_file" "lambda_layer" {
  type             = "zip"
  output_path      = local.layer_zip_path
  source_dir       = "./lambda_layer_tmp/tmp.6MKI7Mnpgb"
  output_file_mode = "0644"
}

data "archive_file" "cicd_function_zip" {
  type        = "zip"
  source_dir  = "build/function/cicd_func/src"
  output_path = "cicd_lambda/function.zip"
}

# Layer
resource "aws_lambda_layer_version" "lambda_layer" {
  layer_name          = "cicd-lambda"
  filename            = data.archive_file.lambda_layer.output_path
  compatible_runtimes = ["python3.9"]
  source_code_hash    = data.archive_file.lambda_layer.output_base64sha256
}

# Function
resource "aws_lambda_function" "cicd_lambda" {
  function_name = "cicd-func"
  handler          = "lambda_handler.lambda_handler"
  filename         = data.archive_file.cicd_function_zip.output_path
  runtime          = "python3.9"
  role             = aws_iam_role.lambda_role.arn
  source_code_hash = data.archive_file.cicd_function_zip.output_base64sha256
  timeout          = 600
  memory_size      = 256
  layers = [
    "${aws_lambda_layer_version.lambda_layer.arn}",
    "arn:aws:lambda:ap-northeast-1:133490724326:layer:AWS-Parameters-and-Secrets-Lambda-Extension:11"
  ]
  environment {
    variables = {
      BUCKET_NAME            = "cicd-123456789012"
      ZIP_FILE_NAME          = "sample-app.zip"
      USER                   = "daiki.handa@beex-inc.com"
      PARAMETER_STORE_NAME   = "/Backlog/users/CICD/credential"
      REPOSITORY             = "sample-app"
      DEV_BLANCH             = "release/dev"
      UAT_BLANCH             = "release/uat"
      PROD_BLANCH            = "release/prod"
      COMMIT_MESSAGE_KEYWORD = "Merge+pull+request"
    }
  }
  ephemeral_storage {
    size = 512 # Min 512 MB and the Max 10240 MB
  }
}


LayerのZipファイル化はバッチファイルで行っていますが、詳細な説明は割愛します。尚、バッチファイルにはrequirements.txtを読み込ませており、ファイルには以下を記載しています。

dulwich
urllib3<2


Lambda拡張機能のLayerは固定の名前になっているので、「"arn:aws:lambda:ap-northeast-1:133490724326:layer:AWS-Parameters-and-Secrets-Lambda-Extension:xx"」を設定するだけで済みます。
xxの部分には最新のバージョンを指定してください。

  layers = [
    "${aws_lambda_layer_version.lambda_layer.arn}",
"arn:aws:lambda:ap-northeast-1:133490724326:layer:AWS-Parameters-and-Secrets-Lambda-Extension:11"
  ]


CICD用API Gateway作成

CICD用API GatewayのTerraformのコードは以下になります。
REGIONALのREST APIを構築するコードで、デプロイとリソースポリシーの設定もまとめて行うようにしています。
リソースポリシーのIPはBacklog GitのWebhook送信サーバのIPアドレスを指定して、そのIP以外からのAPIコールを拒否しています。(Webhook送信サーバのIP)

 ################################# API Gateway################################
resource "aws_api_gateway_rest_api" "api" {
  name = "cicd-api"
  body = jsonencode({
    openapi = "3.0.1"
    info = {
      title   = "api"
      version = "1.0"
    }
    paths = {
      "/" = {
        post = {
          x-amazon-apigateway-integration = {
            httpMethod           = "POST"
            payloadFormatVersion = "1.0"
            type                 = "AWS_PROXY"
            passthroughBehavior  = "NEVER"
            uri                  = aws_lambda_function.cicd_lambda.invoke_arn
            credentials          = aws_iam_role.api_gateway_role.arn
          },
          responses = {
            "200" = {
              responseModels = "{\"application/json\": \"Empty\"}"
            }
          }
        }
      }
    }
  }
)
  endpoint_configuration {
    types = ["REGIONAL"]
  }
}

resource "aws_api_gateway_deployment" "cicd_deployment" {
  rest_api_id = aws_api_gateway_rest_api.api.id
  depends_on  = [aws_api_gateway_rest_api.api]
  stage_name  = "prod"
  triggers = {
    redeployment = sha1(jsonencode(aws_api_gateway_rest_api.api))
  }
}

## API Gateway method settings
resource "aws_api_gateway_method_settings" "path_specific" {
  rest_api_id = aws_api_gateway_rest_api.api.id
  stage_name  = aws_api_gateway_deployment.cicd_deployment.stage_name
  method_path = "*/*"

  settings {
    logging_level = "INFO"
  }
}

## API Gateway Permission
data "aws_iam_policy_document" "api_gateway_policy" {
  statement {
    effect = "Allow"
    principals {
      type        = "*"
      identifiers = ["*"]
    }
    actions   = ["execute-api:Invoke"]
    resources = ["${aws_api_gateway_rest_api.api.execution_arn}/*/*/*"]
  }

  statement {
    effect = "Deny"
    principals {
      type        = "*"
      identifiers = ["*"]
    }
    actions   = ["execute-api:Invoke"]
    resources = ["${aws_api_gateway_rest_api.api.execution_arn}/*/*/*"]
    condition {
      test     = "NotIpAddress"
      variable = "aws:SourceIp"
      values   = [
        "54.64.128.240/32",
        "54.178.233.194/32",
        "13.112.1.142/32",
        "13.112.147.36/32",
        "54.238.175.47/32",
        "54.168.25.33/32",
        "52.192.156.153/32",
        "54.178.230.204/32",
        "52.197.88.78/32",
        "13.112.137.175/32",
        "34.211.15.3/32",
        "35.160.57.23/32",
        "54.68.48.106/32",
        "52.88.47.69/32",
        "52.68.247.253/32",
        "18.182.251.152/32",
      ]
    }
  }
}

resource "aws_api_gateway_rest_api_policy" "policy" {
  rest_api_id = aws_api_gateway_rest_api.api.id
  policy      = data.aws_iam_policy_document.api_gateway_policy.json
  depends_on = [
    aws_api_gateway_rest_api.api,
    ]
}


CodeBuild作成

テスト用CodeBuild

CI部分にあたるCodeBuildのTerraformのコードは以下になります。
buildspecには「runtest-buildspec.yml」を設定しているため、後ほどソースコードにファイルを追加します。
今回の実行環境は3つあるため、concurrent_build_limitには"3"を設定して3並列での実行ができるようにしました。

resource "aws_codebuild_project" "run_test_build_project" {
  name                   = "backlog-git-cicd-test-project"
  service_role           = aws_iam_role.codebuild_role.arn
  build_timeout          = "60"
  concurrent_build_limit = 3

  source {
    type     = "CODEPIPELINE"
    buildspec = "runtest-buildspec.yml"
  }

  environment {
    compute_type    = "BUILD_GENERAL1_SMALL"
    image           = "aws/codebuild/amazonlinux2-x86_64-standard:3.0"
    type            = "LINUX_CONTAINER"
    privileged_mode = true
  }

  artifacts {
    type = "CODEPIPELINE"
  }

  cache {
    type     = "S3"
    location = "backlog-git-cache-123456789123"
  }

  logs_config {
    cloudwatch_logs {
      group_name  = "/aws/codebuild/build-cicd-logs"
      stream_name = "build-log"
    }
  }
}


デプロイ用CodeBuild

CD部分にあたるCodeBuildのTerraformのコードは以下になります。
buildspecには「rundeploy-buildspec.yml」を設定しているため、こちらも後ほどソースコードにファイルを追加します。
内容はCI部分のCodeBuildとそこまで変わらないですが、こちらにはデプロイで使用する「environment_variable」を追加しています。
この値はCodePipeline側の環境変数で上書きするので、ダミー値を設定しています。

resource "aws_codebuild_project" "deploy_lambda_build_project" {
  name                   = "backlog-git-cicd-deploy-project"
  service_role           = aws_iam_role.codebuild_role.arn
  build_timeout          = "60"
  concurrent_build_limit = 3

  source {
    type     = "CODEPIPELINE"
    buildspec = "rundeploy-buildspec.yml"
  }

  environment {
    compute_type    = "BUILD_GENERAL1_SMALL"
    image           = "aws/codebuild/amazonlinux2-x86_64-standard:4.0"
    type            = "LINUX_CONTAINER"
    privileged_mode = true

    environment_variable {
      name  = "AWS_ACCESS_KEY_ID"
      value = "aws_access_key_id"
    }
    environment_variable {
      name  = "AWS_SECRET_ACCESS_KEY"
      value = "aws_secret_access_key"
    }
    environment_variable {
      name  = "STAGE"
      value = "stage"
    }
  }

  artifacts {
    type = "CODEPIPELINE"
  }

  cache {
    type     = "S3"
    location = "backlog-git-cache-123456789123"
  }

  logs_config {
    cloudwatch_logs {
      group_name  = "/aws/codebuild/build-cicd-logs"
      stream_name = "build-log"
    }
  }
}


"environment"内の"image"で使用されるDockerイメージを指定していますが、このイメージには複数のタグがあり、イメージとタグの組み合わせによってはサポートしていないランタイムバージョンがあるので注意が必要です。(使用可能なランタイム)

 "aws/codebuild/amazonlinux2-x86_64-standard:4.0"


CodePipeline作成

Dev環境のCodePipelineのTerraformのコードは以下になります。
「S3ObjectKey」にはLambdaで実装したブランチごとのプレフィックスを指定します。
あとテスト後即デプロイされないように「Approval」ステージを追加して、デプロイ前に承認が必要にしています。今回は実装していませんが、SNSなどを使って承認者にメールを送ることも可能です。

resource "aws_codepipeline" "backlog_code_pipeline_dev" {
  name     = "dev-backlog-git-cicd-pipeline"
  role_arn = aws_iam_role.codepipeline_role.arn

  artifact_store {
    location = var.cicd_artifact_s3_bucket
    type     = "S3"
  }

  stage {
    name = "Source"
    action {
      category = "Source"
      configuration = {
        PollForSourceChanges = "true"
        S3Bucket             = var.cicd_s3_bucket
        S3ObjectKey          = "release_dev/sample-app.zip"
      }
      name             = "Source"
      output_artifacts = ["SourceArtifact"]
      owner            = "AWS"
      provider         = "S3"
      run_order        = "1"
      version          = "1"
    }
  }
  stage {
    name = "Build"


    action {
      name             = "Build"
      category         = "Build"
      owner            = "AWS"
      provider         = "CodeBuild"
      input_artifacts  = ["SourceArtifact"]
      output_artifacts = ["BuildArtifact"]
      version          = "1"

      configuration = {
        ProjectName = aws_codebuild_project.run_test_build_project.name
      }
    }
  }
  stage {
    name = "Approval"
    action {
      name             = "Approval"
      category         = "Approval"
      owner            = "AWS"
      provider         = "Manual"
      version          = "1"
      configuration = {
        NotificationArn = aws_sns_topic.cicd_approval_sns_topic.arn
        CustomData      = "Lambda Deploy to Dev Env."
      }
    }
  }
  stage {
    name = "Deploy"

    action {
      name             = "Build"
      category         = "Build"
      owner            = "AWS"
      provider         = "CodeBuild"
      input_artifacts  = ["SourceArtifact"]
      version          = "1"

      configuration = {
        ProjectName = aws_codebuild_project.deploy_lambda_build_project.name
        EnvironmentVariables = "[{\"name\": \"ENV\",\"value\": \"dev\",\"type\": \"PLAINTEXT\"},{\"name\": \"AWS_ACCESS_KEY_ID\",\"value\": \"${var.dev_aws_access_key_id}\",\"type\": \"PARAMETER_STORE\"},{\"name\": \"AWS_SECRET_ACCESS_KEY\",\"value\": \"${var.dev_aws_secret_access_key}\",\"type\": \"PARAMETER_STORE\"}]"
      }
    }
  }
}


CodePipelineで特筆する点としては以下の箇所になります。
ここの実装でCodeBuildの実行時にCodeBuildの環境変数ではなく、CodePipelineの環境変数を利用するようにしています。
今回はキーは全環境で同じものを利用しているので、環境ごとに異なるのは"ENV"部分のみになります。
UAT環境とPROD環境のTerraformコードは、以下をコピペして、"dev"を"uat"や"prod"に置き換えするだけです。

EnvironmentVariables = "[{\"name\": \"ENV\",\"value\": \"dev\",\"type\": \"PLAINTEXT\"},{\"name\": \"AWS_ACCESS_KEY_ID\",\"value\": \"${var.dev_aws_access_key_id}\",\"type\": \"PARAMETER_STORE\"},{\"name\": \"AWS_SECRET_ACCESS_KEY\",\"value\": \"${var.dev_aws_secret_access_key}\",\"type\": \"PARAMETER_STORE\"}]"


IAM

CICD用Lambda向けIAMロール

CICD用のLambdaにアタッチするIAMロールとIAMポリシーのTerraformコードは以下になります。
LambdaとS3に加えてSSMとKMSの権限も付与しています。

resource "aws_iam_role" "lambda_role" {
  name               = "cicd-lambda-role"
  assume_role_policy = data.aws_iam_policy_document.lambda_assume_role.json
}

data "aws_iam_policy_document" "lambda_ssm_policy" {
  statement {
    sid    = "SSMPolicy"
    effect = "Allow"
    actions = [
      "ssm:GetParameter",
      "kms:Decrypt",
      "lambda:GetLayerVersion",
    ]
    resources = ["*"]
  }
}

resource "aws_iam_policy" "lambda_ssm_policy" {
  name   = "Lambda_SSM_Policy"
  policy = data.aws_iam_policy_document.lambda_ssm_policy.json
}

resource "aws_iam_role_policy_attachment" "lambda_ssm_policy" {
  role       = aws_iam_role.lambda_role.name
  policy_arn = aws_iam_policy.lambda_ssm_policy.arn
}

resource "aws_iam_role_policy_attachment" "lambda_policy" {
  role       = aws_iam_role.lambda_role.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}

resource "aws_iam_role_policy_attachment" "lambda_s3_policy" {
  role       = aws_iam_role.lambda_role.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonS3FullAccess"
}

data "aws_iam_policy_document" "lambda_assume_role" {
  statement {
    actions = ["sts:AssumeRole"]
    effect  = "Allow"
    principals {
      type        = "Service"
      identifiers = ["lambda.amazonaws.com"]
    }
  }
}


API Gateway向けIAMロール

API GatewayにアタッチするIAMロールとIAMポリシーのTerraformコードは以下になります。
CloudWatch LogsとLambdaの権限を付与しています。

 resource "aws_iam_role" "api_gateway_role" {
  name               = "cicd-apigateway-role"
  assume_role_policy = data.aws_iam_policy_document.api_gateway_assume_role.json
}

resource "aws_iam_role_policy_attachment" "api_gateway_policy_logs" {
  role       = aws_iam_role.api_gateway_role.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonAPIGatewayPushToCloudWatchLogs"
}

resource "aws_iam_role_policy_attachment" "api_gateway_policy_lambda" {
  role       = aws_iam_role.api_gateway_role.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaRole"
}

data "aws_iam_policy_document" "api_gateway_assume_role" {
  statement {
    actions = ["sts:AssumeRole"]
    effect  = "Allow"
    principals {
      type        = "Service"
      identifiers = ["apigateway.amazonaws.com"]
    }
  }
}


CodePipeline向けIAMロール

CodePipelineにアタッチするIAMロールとIAMポリシーのTerraformコードは以下になります。
この辺りは動かしながら作成したので、権限的にはもう少しスリム化できると思います。

 data "aws_iam_policy_document" "codepipeline_policy" {
  statement {
    sid    = "CodePipelinePolicy"
    effect = "Allow"
    actions = [
      "cloudwatch:*",
      "s3:*",
      "ssm:GetParameter",
    ]
    resources = ["*"]
  }

  statement {
    sid    = "BuildPolicy"
    effect = "Allow"
    actions = [
      "codebuild:BatchGetBuilds",
      "codebuild:StartBuild",
    ]
    resources = ["*"]
  }
}

data "aws_iam_policy_document" "codepipeline_assume_policy" {
  statement {
    sid    = ""
    effect = "Allow"
    principals {
      identifiers = [
        "codepipeline.amazonaws.com",
      ]
      type = "Service"
    }
    actions = [
      "sts:AssumeRole",
    ]
  }
}

resource "aws_iam_role" "codepipeline_role" {
  name = "backlog-codepipeline-role"
  assume_role_policy = data.aws_iam_policy_document.codepipeline_assume_policy.json
}

resource "aws_iam_role_policy" "codepipeline_role_policy" {
  name = "CodePipelineAccess-policy"
  role = aws_iam_role.codepipeline_role.id
  policy = data.aws_iam_policy_document.codepipeline_policy.json
}


CodeBuild向けIAMロール

CodeBuildにアタッチするIAMロールとIAMポリシーのTerraformコードは以下になります。
このあたりの権限ももう少しスリム化できると思います。今回は2つのCodeBuildで同じIAMロールを使用するように実装しています。

data "aws_iam_policy_document" "codebuild_access_policy" {
  statement {
    sid    = "CloudWatchlogsPolicy"
    effect = "Allow"
    actions = [
      "logs:DescribeLogGroups",
      "logs:CreateLogGroup",
      "logs:CreateLogStream",
      "logs:PutLogEvents"
    ]
    resources = [
      "*",
    ]
  }

  statement {
    sid    = "S3Policy"
    effect = "Allow"
    actions = [
      "s3:GetObject",
      "s3:PutObject",
      "s3:ListAllMyBuckets",
      "s3:CreateBucket",
    ]
    resources = [
      "*"
    ]
  }

  statement {
    sid    = "SSMPolicy"
    effect = "Allow"
    actions = [
      "ssm:GetParameters",
    ]
    resources = [
      "arn:aws:ssm:ap-northeast-1:${data.aws_caller_identity.current.account_id}:parameter/serverless-framework/*"
    ]
  }

  statement {
    sid    = "KMSPolicy"
    effect = "Allow"
    actions = [
      "kms:Decrypt",
    ]
    resources = [
      "arn:aws:kms:ap-northeast-1:${data.aws_caller_identity.current.account_id}:key/alias/aws/ssm"
    ]
  }

  statement {
    sid    = "DeployPolicy"
    effect = "Allow"
    actions = [
      "cloudformation:*Stack*",
      "cloudformation:Describe*",
      "cloudformation:List*",
      "cloudformation:Get*",
      "cloudformation:ValidateTemplate",
      "lambda:GetFunction",
      "lambda:CreateFunction",
      "lambda:DeleteFunction",
      "lambda:UpdateFunctionConfiguration",
      "lambda:UpdateFunctionCode",
      "lambda:ListVersionsByFunction",
      "lambda:PublishVersion",
      "lambda:CreateAlias",
      "lambda:DeleteAlias",
      "lambda:UpdateAlias",
      "lambda:GetFunctionConfiguration",
      "lambda:AddPermission",
      "lambda:RemovePermission",
      "lambda:InvokeFunction",
      "iam:GetRole",
      "iam:GetRolePolicy",
      "iam:CreateRole",
      "iam:DeleteRole",
      "iam:DeleteRolePolicy",
      "iam:PutRolePolicy",
      "iam:PassRole",
    ]
    resources = [
      "*"
    ]
  }
}

resource "aws_iam_policy" "codebuild_access_policy" {
  name   = "CodeBuildAccess_Policy"
  policy = data.aws_iam_policy_document.codebuild_access_policy.json
}

resource "aws_iam_role" "codebuild_role" {
  name               = "backlog-codebuild-role"
  assume_role_policy = data.aws_iam_policy_document.codebuild_assume_role.json
}

resource "aws_iam_role_policy_attachment" "codebuild_policy" {
  role       = aws_iam_role.codebuild_role.name
  policy_arn = aws_iam_policy.codebuild_access_policy.arn


  depends_on = [aws_iam_policy.codebuild_access_policy]
}

data "aws_iam_policy_document" "codebuild_assume_role" {
  statement {
    actions = ["sts:AssumeRole"]
    effect  = "Allow"
    principals {
      type        = "Service"
      identifiers = ["codebuild.amazonaws.com"]
    }
  }
}


Variable

手動で作成したS3とParameter Storeはvariables.tfファイルで以下のように指定しておきます。

variable "cicd_s3_bucket" {
  type    = string
  default = "cicd-123456789123"
}

variable "cicd_artifact_s3_bucket" {
  type    = string
  default = "cicd-artifact-123456789123"
}

variable "dev_aws_access_key_id" {
  type    = string
  default = "/serverless-framework/IAMUser/AccessKeyId"
}

variable "dev_aws_secret_access_key" {
  type    = string
  default = "/serverless-framework/IAMUser/SecretAccessKeyId"
}

variable "uat_aws_access_key_id" {
  type    = string
  default = "/serverless-framework/IAMUser/AccessKeyId"
}

variable "uat_aws_secret_access_key" {
  type    = string
  default = "/serverless-framework/IAMUser/SecretAccessKeyId"
}

variable "prod_aws_access_key_id" {
  type    = string
  default = "/serverless-framework/IAMUser/AccessKeyId"
}

variable "prod_aws_secret_access_key" {
  type    = string
  default = "/serverless-framework/IAMUser/SecretAccessKeyId"
}


ソースコード準備

実際のデプロイを実行するソースコードは以下になります。

README.md
docker-compose.yml
package.json
setup.cfg
tox.ini
runtest-buildspec.yml
rundeploy-buildspec.yml
config/environment/dev.json
config/environment/uat.json
config/environment/prod.json
config/serverless/dev.json
config/serverless/uat.json
config/serverless/prod.json
functions/__init__.py
functions/serverless.yml
functions/sample_app/lambda_handler.py
functions/test/__init__.py
functions/test/test_lambda_handler.py
functions/testFiles/test.txt


関数

実際にデプロイされるLambda関数に関係するファイルは以下になります。
lambda_hundler.pyにはあるS3上のテキストファイルを別のS3に移動するPythonコードが実装されています。

functions/serverless.yml 
functions/sample_app/lambda_handler.py


serverless.ymlファイルの中身は以下になります。
細かくは説明しませんが、デプロイ時に環境を指定することで、その環境ごとの設定ファイルを読み込むように実装しています。

service: sapmle-app
frameworkVersion: '3'

provider:
  name: aws
  runtime: python3.9
  region: ap-northeast-1
  stage: ${opt:stage, self:custom.defaultStage}
  role: ${self:custom.config.role}
  deploymentBucket:
    name: ${self:custom.config.deploymentBucket}
  memorySize: 512
  timeout: 180
  environment:
    STAGE: ${self:provider.stage}
  tags:
    project: serverless-project
    env: ${self:custom.config.tags-env}

plugins:
  - serverless-python-requirements
  - serverless-offline
custom:
  pythonRequirements:
    fileName: ../requirements.txt
  defaultStage: dev
  config: ${self:custom.configfile.${self:provider.stage}}
  configfile:
    dev: ${file(../config/serverless/dev.json)}
    uat: ${file(../config/serverless/uat.json)}
    prod: ${file(../config/serverless/prod.json)}
  environment:
    dev: ${file(../config/environment/dev.json)}
    uat: ${file(../config/environment/uat.json)}
    prod: ${file(../config/environment/prod.json)}

functions:
  sampleApp-Functions:
    name: SampleApp-${self:provider.stage}
    handler: sample_app/lambda_handler.handler
    environment: ${self:custom.environment.${self:provider.stage}}
    maximumRetryAttempts: 0
    maximumEventAge: 60


テスト

CI部分で実際に実行されるテストに関係するファイルは以下になります。
functions配下はデプロイするLambda関数のテスト用ファイルです。
テストツールにはtoxを利用し、テスト環境としてLocalStackを使用するため、LocalStackコンテナを起動するdocker-compose.ymlファイルも準備します。

docker-compose.yml
runtest-buildspec.yml
tox.ini
functions/test/__init__.py
functions/test/test_lambda_handler.py
functions/testFiles/test.txt


docker-compose.ymlファイルの中身は以下になります。
最初はDocker Hubのイメージを使用していたのですが、検証中にDocker Hubのプル上限に引っかかったので、LocalStack公式が提供しているECR Public Galleryのイメージを使用するように変更しました。

version: '3'

services:
  localstack:
    image: public.ecr.aws/localstack/localstack:latest
    ports:
      - 4566:4566
    environment:
      - SERVICES=s3
      - DEFAULT_REGION=ap-northeast-1


runtest-buildspec.ymlにはテスト用CodeBuildで実行するコマンドが記載しています。

version: 0.2

phases:
  install:
    commands:
      - echo "Installing pip, pipenv, and Pipfile"
      - pip install --user --upgrade pipenv==2022.8.5
      - pipenv install --dev
      - docker-compose up -d

  pre_build:
    commands:
      - curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"
      - unzip awscliv2.zip
      - ls -l /root/.pyenv/shims/aws
      - ./aws/install --bin-dir /root/.pyenv/shims --install-dir /usr/local/aws-cli --update
  build:
    commands: |
        echo "Running tox"
        pip install tox
        tox


デプロイ

CD部分で実際に実行されるテストに関係するファイルは以下になります。

package.json
rundeploy-buildspec.yml


rundeploy-buildspec.ymlにはデプロイ用CodeBuildで実行するコマンドが記載しています。
ここで呼び出される変数はCodeBuildとCodePipelineで指定した変数名と同じにしておきます。

version: 0.2

env:
  variables:
    ENV: "dev"

phases:
  install:
    runtime-versions:
      python: 3.9
      nodejs: 16
    commands:
      - echo "Installing pip, pipenv, and Pipfile"
      - pip install --user --upgrade pipenv==2022.8.5
      - pipenv install --dev

  pre_build:
    commands:
      - echo "Installing Node.js, awscli2"
      - n 16.20.2
      - npm install
      - curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"
      - unzip awscliv2.zip
      - ls -l /root/.pyenv/shims/aws
      - ./aws/install --bin-dir /root/.pyenv/shims --install-dir /usr/local/aws-cli --update
  build:
    commands:
      - echo "Lambda Deploy"
      - python -m venv env
      - . env/bin/activate
      - aws configure set aws_access_key_id $AWS_ACCESS_KEY_ID
      - aws configure set aws_secret_access_key $AWS_SECRET_ACCESS_KEY
      - pip install -U pipenv==2022.8.5
      - pipenv lock -r > requirements.txt
      - npm run deploy-$ENV


実行確認

terraform applyを実行しCICD基盤が構築されたことを確認したら、実際にソースコードをBacklog Gitのリリース用ブランチにマージしてCICDが実行されるか確認します。

  • プルリクエストの作成


  • プルリクエストのマージ



CICD用Lambdaのログと出力先のS3を見るとZipファイルが存在していることが確認できました。

  • 出力ログ

  • アップロードファイル


対象の環境のCodePipelineが実行されていることが確認できます。


Approvalしてしばらく待つとデプロイされ、Lambda関数が作成されます。
全てのCodePipelineを実行し、3環境分のLambda関数が正常に作成されていることが確認できました。
これでリリース用ブランチにマージ後に自動で実行されるCICDパイプラインの構築完了になります。


まとめ

TerraformとAWSのCICDサービスを使用して、Backlog Gitリポジトリから自動でAWSにCICDするパイプラインを構築してみました。

既に別のGitサービスを利用している方であればBacklogのGit機能を使う機会はあまりないかと思いますが、Backlogを使用していてGitサービスを使ってないケースやAWSのCode系サービスでサポートしていないソースからCICDしたいケースで利用可能な構成だと思います。


この記事がどなたかの参考になれば幸いです。

カテゴリー
タグ

この記事を書いたメンバー

SAPシステムや基幹システムのクラウド移行・構築・保守、
DXに関して
お気軽にご相談ください

03-6260-6240 (受付時間 平日9:30〜18:00)