CDK(Python)でCodePipelineからAWS Lambdaを呼び出す構成を作ってみた

CDK(Python)でCodePipelineからAWS Lambdaを呼び出す構成を作ってみました。
2020.01.31

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

設定ファイルPushでゴールデンイメージを自動作成する構成を考えてみたで紹介しました、以下の構成をCDKとAWS CLIを利用して構築してみたいと思います。

00

CDKまたはCFnで一撃で作成したかったのですが、現時点でEC2 Image BuilderがCDK、CFn未対応のため、EC2 Image BuilderはAWS CLIで、それ以外のリソースはCDKで作成してみたいと思います。

前提

  • EC2 Image Builderのビルド環境(VPC等)が作成済みであること
  • コンポーネント定義のアップロード先バケットが作成済みであること
  • aws-cli 1.17.9
  • cdk 1.22.0

EC2 Image Builder構築

リソース名定義

以降コマンドベースで操作するため、コマンドの再利用がしやすいよう、イメージパイプラインの名称等を環境変数に定義します。

BUCKET_NAME=test-imagebuilder-buildcomponent # コンポーネント定義のアップロード先バケット
COMPONENT_NAME=ApacheConfig # コンポーネント名
RECIPE_NAME=TestRecipe # レシピ名
INFRA_CONFIG_NAME=TestInfraConfig # イメージパイプラインインフラ設定名
PIPELINE_NAME=TestImagePipeline # イメージパイプライン名
SORCE_IMAGE='arn:aws:imagebuilder:ap-northeast-1:aws:image/amazon-linux-2-x86/2020.1.8' # レシピに定義するソースイメージ
#以下、イメージパイプラインのインフラ設定
INSTANCE_TYPES='t3.micro'
INSTANCE_PROFILE_NAME='TestEC2ImageBulderRole'
SECURITY_GROUP_ID='sg-0d8ac4fccc77c8f16'
SUBNET_ID='subnet-0888e5f3d06784cf7'
KEY_PAIR='test'

意図した通りに設定されているか確認しましょう。

cat << ETX
  BUCKET_NAME: ${BUCKET_NAME}
  COMPONENT_NAME: ${COMPONENT_NAME}
  RECIPE_NAME: ${RECIPE_NAME}
  INFRA_CONFIG_NAME: ${INFRA_CONFIG_NAME}
  PIPELINE_NAME: ${PIPELINE_NAME}
  SORCE_IMAGE: ${SORCE_IMAGE}
  INSTANCE_TYPES: ${INSTANCE_TYPES}
  INSTANCE_PROFILE_NAME: ${INSTANCE_PROFILE_NAMEs}
  SECURITY_GROUP_ID: ${SECURITY_GROUP_ID}
  SUBNET_ID: ${SUBNET_ID}
  KEY_PAIR: ${KEY_PAIR}
ETX

コンポーネントドキュメント作成

以下の定義でcomponent.yamlを作成します。ここではコンポーネントドキュメントはS3に格納したいと思います。

component.yaml

name: Apache Config
description: This is Apache Config testing document.
schemaVersion: 1.0

phases:
  - name: build
    steps:
      - name: InstallLinuxUpdate
        action: UpdateOS
      - name: InstallApache
        action: ExecuteBash
        inputs:
          commands:
            - sudo yum install httpd -y
            - sudo systemctl enable httpd
            - sudo systemctl start httpd
      - name: DownloadConfig
        action: S3Download
        inputs:
            - source: s3://test-pipeline-deploy/etc/httpd/conf/httpd.conf
              destination: /etc/httpd/conf/httpd.conf
      - name: StartApache
        action: ExecuteBash
        inputs:
          commands:
            - sudo systemctl enable httpd

作成したドキュメントをS3バケットにアップロードします。

aws s3 cp component.yaml s3://${BUCKET_NAME}/component.yaml

ビルドコンポーネント作成

アップロードしたドキュメント定義でビルドコンポーネントを作成します。

aws imagebuilder create-component \
  --name ${COMPONENT_NAME} \
  --semantic-version 1.0.0 \
  --platform Linux \
  --uri s3://${BUCKET_NAME}/component.yaml

後続のコマンドで、作成したコンポーネントのARNを利用したいのですが、上記コマンドの戻り値にARNが含まれていないため、コンポーネント名をキーにコンポーネントのARNを取得します。

COMPONENT_ARN=`aws imagebuilder list-components \
  --filters "name=name,values=${COMPONENT_NAME}" \
  --query "componentVersionList[0].arn"`

レシピ作成

作成したコンポーネントを利用するレシピを作成します。

IMAGE_RECIPE_ARN=`aws imagebuilder create-image-recipe \
  --name ${RECIPE_NAME} \
  --semantic-version 1.0.0 \
  --components componentArn="${COMPONENT_ARN}" \
  --parent-image "${SORCE_IMAGE}" \
  --query "imageRecipeArn"`

オプション設定

IAMロールやサブネットなど、ビルドを行うインフラ環境を設定します。

INFRA_CONFIG_ARM=`aws imagebuilder create-infrastructure-configuration \
  --name "${INFRA_CONFIG_NAME}" \
  --instance-types "${INSTANCE_TYPES}" \
  --instance-profile-name "${INSTANCE_PROFILE_NAME}" \
  --security-group-ids "${SECURITY_GROUP_ID}" \
  --subnet-id "${SUBNET_ID}" \
  --key-pair "${KEY_PAIR}" \
  --query "infrastructureConfigurationArn"`

イメージパイプライン作成

作成したレシピ、オプション設定を指定し、イメージパイプラインを作成します。

IMAGE_PIPELINE_ARN=`aws imagebuilder create-image-pipeline \
  --name ${PIPELINE_NAME} \
  --image-recipe-arn ${IMAGE_RECIPE_ARN//'"'/} \
  --infrastructure-configuration-arn ${INFRA_CONFIG_ARM//'"'/} \
  --query "imagePipelineArn"`

なお、イメージパイプラインのARNは後続のCDKの中で利用します。 ちなみに、レシピ、インフラ設定のARNに"があるとうまく動作しなかったので、変数展開の際に"を除いています。

正常に作成できると、イメージパイプラインが作成されます。

$ aws imagebuilder list-image-pipelines
{
    "requestId": "30391592-3d3f-46f2-9259-c65b411e6f7c",
    "imagePipelineList": [
        {
            "arn": "arn:aws:imagebuilder:ap-northeast-1:XXXXXXXXXXXX:image-pipeline/testimagepipeline",
            "name": "TestImagePipeline",
            "platform": "Linux",
            "imageRecipeArn": "arn:aws:imagebuilder:ap-northeast-1:XXXXXXXXXXXX:image-recipe/testrecipe/1.0.0",
            "infrastructureConfigurationArn": "arn:aws:imagebuilder:ap-northeast-1:XXXXXXXXXXXX:infrastructure-configuration/testinfraconfig",
            "imageTestsConfiguration": {
                "imageTestsEnabled": true,
                "timeoutMinutes": 720
            },
            "status": "ENABLED",
            "dateCreated": "2020-01-30T14:32:17.441Z",
            "dateUpdated": "2020-01-30T14:32:17.441Z",
            "tags": {}
        }
    ]
}

CodePipelineとかその他リソース構築

CDK プロジェクト作成

CDKプロジェクトの用のディレクトリを作成します。

$ mkdir test-app && cd test-app

cdk initコマンドにて、プロジェクトの初期化を行います。

$ cdk init --language python

パッケージインストール

setup.pyに今回利用するパッケージを追加します。

test-app/setup.py

...
    install_requires=[
        "aws-cdk.core",
        "aws-cdk.aws-s3",
        "aws-cdk.aws-lambda",
        "aws-cdk.aws_codecommit",
        "aws-cdk.aws_codepipeline",
        "aws-cdk.aws_codepipeline_actions",
        "aws-cdk.aws_iam",
    ],
...

先に実行したcdk initにて、virtualenvを利用した仮想環境も作成されていますので、仮想環境を有効化します。こちらで、既存の環境に影響を与えずに任意のパッケージをインストールすることが可能になります。

$ source .env/bin/activate
(.env) $ pip install -r requirements.txt

外部定義

Lambdaがイメージパイプラインを起動する際にARNが必要になります。一連の流れでシェル変数には定義されていますが、ログアウト等でCDKが再利用できなくなってしまうので、ここでは外部ファイルに定義しています。作成したイメージパイプラインのARN、CodePipelineでデプロイを行うS3バケット、処理終了後にPublishするSNS Topicを外部ファイルに定義しておきます。

test-app/setting.ini

[test]
IMAGE_PIPELINE_ARN = arn:aws:imagebuilder:ap-northeast-1:XXXXXXXXXXXX:image-pipeline/testimagepipeline
DEPLOY_BUCKET_NAME = test-pipeline-deploy
SNS_TOPIC_ARN = arn:aws:sns:ap-northeast-1:XXXXXXXXXXXX:test-topic

Lambda Function用のコード作成

Lambda Function用のコード、格納ディレクトリを作成します。

$ mkdir lambda

lambda/lambda_function.py

import boto3
import logging
import json
import os
import traceback

logger = logging.getLogger()
logger.setLevel(logging.INFO)

codepipeline_client = boto3.client('codepipeline')
imagebuilder_client = boto3.client('imagebuilder')
sns_client = boto3.client('sns')

#正常終了
def put_job_success(job_id):
    logger.info('Putting job success')
    codepipeline_client.put_job_success_result(jobId=job_id)

#処理継続/CodePipelineに継続トークン返却
def continue_job_later(job_id,image_build_version_arn):
    logger.info('Putting job continuation')
    continuation_token = json.dumps({'ImageBuildVersionArn':image_build_version_arn})
    codepipeline_client.put_job_success_result(
        jobId=job_id,
        continuationToken=continuation_token
    )

#異常終了
def put_job_failure(job_id, err):
    logger.error('Putting job failed')
    message = str(err)
    codepipeline_client.put_job_failure_result(
        jobId=job_id,
        failureDetails={
            'type': 'JobFailed',
            'message': message
        }
    )

#SNS通知
def sns_publish(sns_topic_arn, pipeline_name, job_id, job_status):
    logger.info('Publish to SNS topic')

    message = 'PipelineName: ' + pipeline_name + '\n'
    message += 'JobId: ' + job_id + '\n'
    message += 'Status: ' + job_status + '\n'

    res = sns_client.publish(
        TopicArn=sns_topic_arn,
        Message=message
    )
    messaeg_id = res['MessageId']
    logger.info('SNS Messaeg ID is %s', messaeg_id)

def lambda_handler(event, context):
    try:
      job_id = event['CodePipeline.job']['id']
      job_data = event['CodePipeline.job']['data']
      image_pipeline_arn = os.environ['IMAGE_PIPELINE_ARN']

      #CodePipelineユーザーパラメーター取得
      user_parameters = json.loads(
          job_data['actionConfiguration']['configuration']['UserParameters']
      )
      pipeline_name = user_parameters['PipelineName']
      sns_topic_arn = user_parameters['SnsTopicArn']

      logger.info('ImagePipelineArn is %s', image_pipeline_arn)
      logger.info('CodePipeline Event is %s',event['CodePipeline.job'])

      #継続トークン有無確認
      if 'continuationToken' in job_data:
          continuation_token = json.loads(job_data['continuationToken'])
          image_build_version_arn = continuation_token['ImageBuildVersionArn']

          logger.info(image_build_version_arn)

          #ビルドの状態取得
          response = imagebuilder_client.get_image(
            imageBuildVersionArn = image_build_version_arn
          )
          build_status = response['image']['state']['status']
          logger.info(build_status)

          if build_status == 'AVAILABLE':
              sns_publish(sns_topic_arn, pipeline_name, job_id, job_status='success')
              put_job_success(job_id)
          elif build_status == 'FAILED':
              sns_publish(sns_topic_arn, pipeline_name, job_id, job_status='failed')
              errmsg='Build Error'
              put_job_failure(job_id, errmsg)
          else:
              continue_job_later(job_id,image_build_version_arn)
      else:
          #ビルド実行
          response = imagebuilder_client.start_image_pipeline_execution(
              imagePipelineArn=image_pipeline_arn
          )
          image_build_version_arn = response['imageBuildVersionArn']
          logger.info('imageBuildVersionArn is %s', image_build_version_arn)
          continue_job_later(job_id,image_build_version_arn)

    except Exception as err:
        logger.error('Function exception: %s', err)
        traceback.print_exc()
        sns_publish(sns_topic_arn, pipeline_name, job_id, job_status='failed')
        put_job_failure(job_id, 'Function exception: ' + str(err))

    logger.info('Function complete')
    return "Complete."

スタック定義

スタックを定義しているtest_app_stack.pyに、デプロイしたいリソースを定義します。ここでは、CodeCommitも作成しているので継続的に利用する際はご注意ください。

test_app/test_app_stack.py

import configparser
from aws_cdk import (
    core,
    aws_s3,
    aws_lambda,
    aws_codecommit,
    aws_codepipeline,
    aws_codepipeline_actions,
    aws_iam
)

# 設定ファイル読み込み
config = configparser.ConfigParser()
config.read('setting.ini')

class TestAppStack(core.Stack):
    def __init__(self, scope: core.Construct, id: str, **kwargs) -> None:
        super().__init__(scope, id, **kwargs)

        # デプロイ用S3バケット作成
        deploy_bucket = aws_s3.Bucket(self, "MyBucket",
            bucket_name = config['test']['DEPLOY_BUCKET_NAME'],
            versioned = True,
        )

        # IAMロール(Lambda Function)
        lambdaRole = aws_iam.Role(
            self,'lambdaRole',
            role_name= 'TestRole',
            assumed_by= aws_iam.ServicePrincipal('lambda.amazonaws.com')
        )
        lambdaRole.add_managed_policy(aws_iam.ManagedPolicy.from_aws_managed_policy_name('AWSImageBuilderFullAccess'))
        lambdaRole.add_managed_policy(aws_iam.ManagedPolicy.from_aws_managed_policy_name('AWSLambdaExecute'))
        lambdaRole.add_managed_policy(aws_iam.ManagedPolicy.from_aws_managed_policy_name('AmazonSNSFullAccess'))

        # Lambda Function
        fn = aws_lambda.Function(self, "TestFunction",
                runtime=aws_lambda.Runtime.PYTHON_3_8,
                handler="lambda_function.lambda_handler",
                role= lambdaRole,
                code=aws_lambda.AssetCode(path="./lambda"),
                environment={
                    "IMAGE_PIPELINE_ARN": config['test']['IMAGE_PIPELINE_ARN']
                }
             )

        # CodeCommit作成
        repo = aws_codecommit.Repository(self, "Repository",
            repository_name="TestRepository"
        )
        # CodePipeline作成
        pipeline = aws_codepipeline.Pipeline(self, "MyPipeline",
            pipeline_name="TestCodePipeline"
        )
        source_output = aws_codepipeline.Artifact()
        #パイプラインアクション作成
        source_action = aws_codepipeline_actions.CodeCommitSourceAction(
            action_name="CodeCommit",
            repository=repo,
            output=source_output
        )
        deploy_action = aws_codepipeline_actions.S3DeployAction(
            bucket=deploy_bucket,
            action_name="S3",
            input=source_output
        )
        build_action = aws_codepipeline_actions.LambdaInvokeAction(
            action_name="Lambda",
            user_parameters={"PipelineName":"TestCodePipeline","SnsTopicArn":config['test']['SNS_TOPIC_ARN']},
            lambda_=fn
        )
        # CodePipelineステージ追加
        source_stage = pipeline.add_stage(
            stage_name="SourceStage",
            actions=[source_action]
        )
        deploy_stage = pipeline.add_stage(
            stage_name="DeployStage",
            actions=[deploy_action]
        )
        build_stage = pipeline.add_stage(
            stage_name="BuildStage",
            actions=[build_action]
        )

修正したファイルは以下となります。(修正していないファイルはツリーから除いています。)

.
├── lambda
│   └── lambda_function.py
├── setting.ini
├── setup.py
└── test_app
    └── test_app_stack.py

デプロイ

スタックを確認します。

(.env) % cdk ls
test-app

デプロイします。

(.env) % cdk deploy
This deployment will make potentially sensitive changes according to your current security approval level (--require-approval broadening).
Please confirm you intend to make the following modifications:

(省略)

Do you wish to deploy these changes (y/n)? y
test-app: deploying...

(省略)

 ✅  test-app

Stack ARN:
arn:aws:cloudformation:ap-northeast-1:XXXXXXXXXXXX:stack/test-app/5b7ce630-4378-11ea-9329-06cfba976b48

CodePipelineが作成されました。

(.env) % aws codepipeline list-pipelines
{
    "pipelines": [
        {
            "name": "TestCodePipeline",
            "version": 1,
            "created": 1580435174.207,
            "updated": 1580435174.207
        }
    ]
}

動かしてみた

動作については「設定ファイルPushでゴールデンイメージを自動作成する構成を考えてみた」と同様になりますので詳細は割愛しますが、 CodeCommitへのPushでパイプライン処理が起動します。処理が終わるとPushした設定ファイルを含むAMIが取得されます。

CodePipeline

EC2 Image Builder

さいごに

イメージパイプラインのARNを外部に定義したり結果的に手が入ってしまったので、EC2 Image Builderの作成もBoto 3を利用してPythonスクリプト内で完結させた方がシンプルだったなーと思いました。EC2 Image BuildeのCDK、CFn対応のアップデートに期待したいと思います。

参考