6
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

AWS CodeCommit で始めるモノリポジトリCI/CD(改良版)

Last updated at Posted at 2023-03-28

はじめに

 AWS CodePipelineでは、CodeCommitをソースとするときに、標準オプションではディレクトリ単位での指定ができません。CodePipelineのユーザーガイドを見てみても、指定できるのはブランチ名のみです。
 しかし、モノリポジトリ戦略(モノレポ戦略とも)をとる場合は、同一ブランチの中でも変更があったディレクトリによって別のパイプラインを起動させたいというニーズが発生します。
 この記事は、CodeCommitおよびCodePipelineを使って、ディレクトリごとにアプリケーションを分けている場合、ソースに変更のあったアプリケーションのみパイプラインでビルドさせるためのTIPSを紹介します。

【注】本記事は、以前書いた記事で紹介した、CodeCommitでモノリポジトリを実現するためのLambdaを用いた仕組みの改良版です。もし時間があれば、前回の記事も見てみてください。

やりたいこと(前回の記事の再掲)

今回のやりたいことを図で表すと下記のような形です。

yaritaikoto.png

 CodeCommit内のディレクトリ構造は、アプリケーションごとにディレクトリが切られていて各ディレクトリの下にアプリケーションのソースコードとbuildspecが配置してあります。
 例えばアプリケーションAのソースコードのみを修正した場合、CodePipelineAのみが起動してほしいのですが、標準の機能で構成した場合CodePipelineAとCodePipelineBが同時に起動してしまいます。

解決策

アーキテクチャ

 今回の解決策のアーキテクチャは下記のような形です。
CodePipelinePathBaseExtension.png

解決策の概要と前回からの改善点

 前回の記事でも紹介した通り、各ディレクトリごとの変更を検知し、対応したCodePipelineを起動させる部分は、Lambdaを用いて実装しています。
 boto3 のcodecommit get defference api を利用して、前回のコミットからの変更点をディレクトリ単位でフィルターし、変更がある場合は対応したパイプラインをcodepipeline start pipelineを利用してスタートさせます。詳細に関しては、冒頭にリンクを貼りましたので前回の記事をご参照ください。

 しかし、前回の解決策では、Lambdaの環境変数にアプリケーションパスのリストを渡す必要があったり、CodePipelineのパイプライン名に制約があったり、CodeCommitのリポジトリごとにEventBridgeとLambdaのセットをデプロイしなくてはいけなかったりと、ややこしいところが多く正直利用しづらいものとなっていました。
 今回は、これらの課題点を解決するために、アーキテクチャの下半分の部分(EventBridge, Lamdba, DynamoDB)を新しく導入しました。
 これらは、下記のような流れで動作します。

  1. ユーザーは、CodePipelinに必要な情報(ビルド対象のCodeCommitリポジトリ名、ブランチ名、アプリケーションディレクトリのパス)をタグとして付与する
  2. CodePipelineにタグが付与されると、EventBridgeがそのイベントを検知しLamdba関数を起動する
  3. Lambdaはパイプラインに特定のタグが付与されている場合、その内容をDynamoDBに書き込む
  4. LambdaはDynamoDBにデータを書き込んだ後、EventBridgeのルールを自動デプロイする
  5. 対象のCodeCommitの対象のディレクトリ配下のファイルに変更があると、自動デプロイされたEventBridgeがそのイベントを検知し、パイプラインをトリガーするためのLambda関数を起動。Lambda関数は、DynamoDBに格納されているアプリケーションディレクトリのパスやパイプライン名に従って、変更差分の検知とパイプラインの起動を実行する

これにより、この仕組みをデプロイしておけば、あとはCodePipelineに特定の情報をタグとして付与するだけでディレクトリ単位でのCodePipelineのトリガーが可能になります。

CloudFormation のテンプレート

 今回は、配布のしやすさを重視して、Lamdbaコードもすべて含めた一つのテンプレートファイルにまとめました。そのため、記述が長くなっていますが、逆に言うとこれをそのままコピペしてデプロイすれば利用できるようになります。
 また注意点として、例のごとく例外処理等は実装されていないため、ミッションクリティカルな環境で利用したい場合は各自修正をお願いします。

template.yaml
AWSTemplateFormatVersion: 2010-09-09
Description: CodePipelinePathBaseExtension(CPPBE)

Resources:
  #--------------------------------------------------------------------------------------------------
  # DynamoDB
  #--------------------------------------------------------------------------------------------------
  DynamoDbTable:
    Type: AWS::DynamoDB::Table
    Properties: 
      AttributeDefinitions: 
        - AttributeName: pipelineName
          AttributeType: S
        - AttributeName: codeRepositoryName
          AttributeType: S
      BillingMode: PAY_PER_REQUEST
      KeySchema: 
        - AttributeName: pipelineName
          KeyType: HASH
        - AttributeName: codeRepositoryName
          KeyType: RANGE
      TableName: CppbeTable
      GlobalSecondaryIndexes:
        - IndexName: codeRepositoryNameIndex
          KeySchema:
            - AttributeName: codeRepositoryName
              KeyType: HASH
          Projection:
            ProjectionType: ALL
    DeletionPolicy: Delete
    UpdateReplacePolicy: Delete

  #--------------------------------------------------------------------------------------------------
  # CodePipeline Trigger Lambda
  #--------------------------------------------------------------------------------------------------
  TriggerPipelineLambdaExecutionRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: CppbeTriggerPipelineLambdaRole
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - lambda.amazonaws.com
            Action: sts:AssumeRole
      Path: /

  TriggerPipelineLambdaExecutionPolicy: 
    Type: AWS::IAM::ManagedPolicy
    Properties: 
      ManagedPolicyName: CppbeTriggerPipelineLambdaPolicy
      Roles: 
        - !Ref TriggerPipelineLambdaExecutionRole
      PolicyDocument: 
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Action:
            - dynamodb:*
            Resource: 
            - !GetAtt DynamoDbTable.Arn
            - !Sub '${DynamoDbTable.Arn}*'
          - Effect: Allow
            Action:
            - codecommit:GetDifferences
            Resource: !Sub 'arn:${AWS::Partition}:codecommit:${AWS::Region}:${AWS::AccountId}:*'
          - Effect: Allow
            Action: 
            - codepipeline:StartPipelineExecution
            Resource: !Sub 'arn:${AWS::Partition}:codepipeline:${AWS::Region}:${AWS::AccountId}:*' 
          - Effect: Allow
            Action: 
            - logs:CreateLogGroup
            Resource: !Sub 'arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:*'
          - Effect: Allow
            Action: 
            - logs:CreateLogStream
            - logs:PutLogEvents
            Resource: !Sub 'arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:log-group:*'

  TriggerPipelineLambdaFunction:
    Type: AWS::Lambda::Function
    Properties:
      Code:
        ZipFile: |
          import os
          import json
          import boto3
          from boto3.dynamodb.conditions import Key

          DYNAMO_TABLE_NAME = os.environ['DYNAMO_TABLE_NAME']
          REGION_NAME = os.environ['REGION_NAME']
          ACCOUNT_ID = os.environ['ACCOUNT_ID']

          dynamodb = boto3.resource('dynamodb', region_name=REGION_NAME)
          codecommit = boto3.client('codecommit')
          codepipeline = boto3.client('codepipeline')

          def get_dynamodb_item(code_repository_name):
              table = dynamodb.Table(DYNAMO_TABLE_NAME)
              options = {
                  'IndexName': 'codeRepositoryNameIndex',
                  'KeyConditionExpression': Key('codeRepositoryName').eq(code_repository_name),
              }
              res = table.query(**options)
              
              item_list = []
              if res["Count"] == 0:
                  pass
              else:
                  item_list = res["Items"]
                  
              return item_list
              
          def lambda_handler(event, context):
              print(event)
              
              afterCommitId = event['detail']['commitId']
              beforeCommitId = event['detail']['oldCommitId']
              repositoryName = event['detail']['repositoryName']
              
              item_list = get_dynamodb_item(repositoryName)      

              for item in item_list:
                  print(item)
                  if item["appPath"] != "":
                      print(item["appPath"])
                      response = codecommit.get_differences(
                          repositoryName=repositoryName,
                          beforeCommitSpecifier=beforeCommitId,
                          afterCommitSpecifier=afterCommitId,
                          beforePath=item["appPath"],
                          afterPath=item["appPath"]
                      )
                      if len(response['differences']) > 0:
                          codepipeline.start_pipeline_execution(name=item["pipelineName"])
                  else:
                      response = codecommit.get_differences(
                          repositoryName=repositoryName,
                          beforeCommitSpecifier=beforeCommitId,
                          afterCommitSpecifier=afterCommitId
                      )
                      if len(response['differences']) > 0:
                          codepipeline.start_pipeline_execution(name=item["pipelineName"])

              return {
                  'statusCode': 200,
                  'body': json.dumps('CICD Pipeline Triggered')
              }
      Environment:
        Variables:
          DYNAMO_TABLE_NAME: !Ref DynamoDbTable
          REGION_NAME: !Ref AWS::Region
          ACCOUNT_ID: !Ref AWS::AccountId
      FunctionName: CppbeTriggerPipelineLambda
      Handler: index.lambda_handler
      Runtime: python3.9
      Timeout: '200'
      Role: !GetAtt TriggerPipelineLambdaExecutionRole.Arn

  #--------------------------------------------------------------------------------------------------
  # Event Rule Create Lambda
  #--------------------------------------------------------------------------------------------------
  EventRuleCreateLambdaExecutionRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: CppbeCreateEventLambdaRole
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - lambda.amazonaws.com
            Action: sts:AssumeRole
      Path: /

  EventRuleCreateLambdaExecutionPolicy: 
    Type: AWS::IAM::ManagedPolicy
    Properties: 
      ManagedPolicyName: CppbeCreateEventLambdaPolicy
      Roles: 
        - !Ref EventRuleCreateLambdaExecutionRole
      PolicyDocument: 
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Action:
            - lambda:AddPermission
            Resource: !GetAtt TriggerPipelineLambdaFunction.Arn
          - Effect: Allow
            Action:
            - dynamodb:*
            Resource: !GetAtt DynamoDbTable.Arn
          - Effect: Allow
            Action:
            - events:*
            Resource: '*'
          - Effect: Allow
            Action:
            - codepipeline:GetPipeline
            - codepipeline:ListTagsForResource
            Resource: '*'
          - Effect: Allow
            Action: 
            - logs:CreateLogGroup
            Resource: !Sub 'arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:*'
          - Effect: Allow
            Action: 
            - logs:CreateLogStream
            - logs:PutLogEvents
            Resource: !Sub 'arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:log-group:*'

  EventRuleCreateLambda:
    Type: AWS::Lambda::Function
    Properties:
      Code:
        ZipFile: |
          import os
          import json
          import boto3
          import datetime
          from boto3.dynamodb.conditions import Key

          DYNAMO_TABLE_NAME = os.environ['DYNAMO_TABLE_NAME']
          REGION_NAME = os.environ['REGION_NAME']
          ACCOUNT_ID = os.environ['ACCOUNT_ID']
          LAMBDA_FUNCTION_NAME = os.environ['LAMBDA_FUNCTION_NAME']

          dynamodb = boto3.resource('dynamodb', region_name=REGION_NAME)
          events = boto3.client('events')
          codepipeline = boto3.client('codepipeline')
          lambda_client = boto3.client('lambda')

          def create_event_rule(check_result):
              rule_not_exist_flag=0
              
              try:
                  response = events.describe_rule(
                      Name='CPPBE-' + check_result["PIPELINE_NAME"],
                      EventBusName='default'
                  )
                  
              except events.exceptions.ResourceNotFoundException as e:
                  rule_not_exist_flag=1

              response = events.put_rule(
                  Name='CPPBE-' + check_result["PIPELINE_NAME"],
                  EventPattern=json.dumps(
                      {
                          "source": ["aws.codecommit"],
                          "detail-type": ["CodeCommit Repository State Change"],
                          "resources": ["arn:aws:codecommit:"+REGION_NAME+":"+ACCOUNT_ID+":"+check_result["CODE_REPOSITORY"]],
                          "detail": {
                              "event": ["referenceCreated", "referenceUpdated"],
                              "referenceType": ["branch"],
                              "referenceName": [check_result["BRANCH_NAME"]]
                          }
                      }
                  ),
                  State='ENABLED',
                  Description='CodePipelinePathBaseExtensionEventRule'
              )
              events.put_targets(
                  Rule='CPPBE-' + check_result["PIPELINE_NAME"],
                  Targets=[
                      {
                          "Id": "CPPBE-" + check_result["PIPELINE_NAME"],
                          "Arn": "arn:aws:lambda:"+REGION_NAME+":"+ACCOUNT_ID+":function:"+LAMBDA_FUNCTION_NAME
                      }    
                  ]
              )
              try:
                  lambda_client.add_permission(
                      FunctionName=LAMBDA_FUNCTION_NAME,
                      StatementId='lambda-invoke-'+check_result["PIPELINE_NAME"]+'-'+check_result["APP_PATH"].replace('/','-'),
                      Action='lambda:InvokeFunction',
                      Principal='events.amazonaws.com',
                      SourceArn='arn:aws:events:'+REGION_NAME+':'+ACCOUNT_ID+':rule/CPPBE-'+check_result["PIPELINE_NAME"]
                  )
              except lambda_client.exceptions.ResourceConflictException as e:
                  print("Lambda Permission already exist.")

              return response

          def put_dynamodb_item(check_result):
              table = dynamodb.Table(DYNAMO_TABLE_NAME)
              options = {
                  'KeyConditionExpression': Key('pipelineName').eq(check_result["PIPELINE_NAME"]),
              }
              res = table.query(**options)
              time_now = datetime.datetime.now().isoformat()
              response = ''
              if res["Count"] == 0:
                  insert_item = {
                      'pipelineName': check_result["PIPELINE_NAME"],
                      'codeRepositoryName': check_result["CODE_REPOSITORY"],
                      'branchName': check_result["BRANCH_NAME"],
                      'appPath': check_result["APP_PATH"],
                      'createTime': time_now
                  }
                  response = table.put_item(Item=insert_item)
              else:
                  response = table.update_item(
                      Key={'pipelineName': check_result["PIPELINE_NAME"], 'codeRepositoryName': check_result["CODE_REPOSITORY"]},
                      UpdateExpression="set updateTime = :updateTime, branchName = :branchName, appPath = :appPath",
                      ExpressionAttributeValues={
                          ':updateTime': time_now,
                          ':branchName': check_result["BRANCH_NAME"],
                          ':appPath': check_result["APP_PATH"]
                      },
                      ReturnValues="UPDATED_NEW"
                  )
              
              return response    

          def check_codepipeline_tags(codepipeline_arn):
              response = codepipeline.list_tags_for_resource(
                  resourceArn=codepipeline_arn
              )
              
              check_result='0'
              
              code_repository_name = ''
              branch_name = ''
              app_path = ''
              app_path_flag = ''
              pipeline_name = codepipeline_arn.split(':')[len(codepipeline_arn.split(':'))-1]
              
              tag_list = response['tags']
              for tag in tag_list:
                  if tag['key'] == 'CODE_REPOSITORY':
                      code_repository_name = tag['value']
                  elif tag['key'] == 'BRANCH_NAME':
                      branch_name = tag['value']
                  elif tag['key'] == 'APP_PATH':
                      app_path = tag['value']
                      if tag['value'] == '':
                          app_path_flag = 'root_dir'
                  else:
                      pass
              if code_repository_name and branch_name and ((app_path and not app_path_flag) or (not app_path and app_path_flag)):
                  check_result = {
                      "PIPELINE_NAME":pipeline_name, 
                      "CODE_REPOSITORY":code_repository_name, 
                      "BRANCH_NAME":branch_name, 
                      "APP_PATH":app_path
                  }
              else:
                  check_result = '0'
              
              return check_result

          def lambda_handler(event, context):
              pipeline_arn = ""
              if event["detail"]["eventName"]=="CreatePipeline":
                  pipeline_arn = "arn:aws:codepipeline:" + event["region"] + ":" + event["account"] + ":" + event["detail"]["requestParameters"]["pipeline"]["name"]
              elif event["detail"]["eventName"]=="TagResource":
                  pipeline_arn = event["detail"]["requestParameters"]["resourceArn"]

              check_result = check_codepipeline_tags(pipeline_arn)
              
              response = ''
              if check_result == '0':
                  pass
              else:
                  put_dynamodb_item(check_result)
                  response = create_event_rule(check_result)

              return response
      Environment:
        Variables:
          DYNAMO_TABLE_NAME: !Ref DynamoDbTable
          REGION_NAME: !Ref AWS::Region
          ACCOUNT_ID: !Ref AWS::AccountId
          LAMBDA_FUNCTION_NAME: !Ref TriggerPipelineLambdaFunction
      FunctionName: CppbeCreateEventLambda
      Handler: index.lambda_handler
      Runtime: python3.9
      Timeout: '200'
      Role: !GetAtt EventRuleCreateLambdaExecutionRole.Arn

  EventRuleCreateLambdaEventRule:
    Type: AWS::Events::Rule
    Properties:
      EventPattern:
        source:
          - aws.codepipeline
        detail-type:
          - 'AWS API Call via CloudTrail'
        detail:
          eventName:
            - CreatePipeline
            - TagResource
          eventType:
            - AwsApiCall
          errorCode:
            - exists: false
      Targets:
        - Arn: !GetAtt EventRuleCreateLambda.Arn
          Id: event-rule-create-lambda

  PermissionForEventsToInvokeLambda:
    Type: AWS::Lambda::Permission
    Properties:
      FunctionName: !Ref EventRuleCreateLambda
      Action: lambda:InvokeFunction
      Principal: events.amazonaws.com
      SourceArn: !GetAtt EventRuleCreateLambdaEventRule.Arn

利用方法

 まずは、上記のテンプレートをCloudFormationでデプロイしてください。CloudFormationのデプロイ方法については本記事では割愛させていただきます。

 次に、CodePipelineをデプロイします。CodePipelineは、手動でデプロイしてもいいですし、CloudFormationでデプロイしても問題ありません。ただ、手動でデプロイした場合はEventBridgeルールが自動で作成されるので、自動作成されたルールは無効化もしくは削除しておいてください。

 CodePipelineがデプロイできたら、パイプラインに下記3つのタグを付与します。

# Key Value Value 例
1 CODE_REPOSITORY パイプラインをトリガーする対象となるCodeCommitのリポジトリ名 sample-repository
2 BRANCH_NAME パイプラインをトリガーする対象となるCodeCommitのブランチ名 master
3 APP_PATH アプリケーションのディレクトリのパス(ルートディレクトリの場合は空文字) compenents/application-a

Value 例に書いたのは、sample-repositoryという名前のCodeCommitリポジトリで、masterブランチを対象としていて、アプリケーションの構成が下記のようになっている場合に、application-aのディレクトリを対象とするケースの例です。

/(sample-repository)
 ∟ components
   ∟ application-a
     ∟ buildspec
     ∟ src
   ∟ application-b
     ∟ buildspec
     ∟ src

ディレクトリ単位でのパイプライントリガーを実現するためにユーザーが実施するのは、これだけです。
① CloudFormationをデプロイする
② CodePipelineにタグを付ける
この2つの操作だけで対象のディレクトリ配下のファイルの変更時にだけパイプラインが動いてくれるようになります。むしろ、①は初回一度だけの実行でよいので、あとは②のタグ付けだけで新規パイプラインに関しても実装が可能です。

パイプラインのタグ付けの画面イメージ

CodePipelineのマネジメントコンソールで、対象のパイプラインを選択して、設定⇒パイプラインタグと遷移します。
image001.png

編集をクリックします。
image002.png

リポジトリ名とブランチ名、アプリケーションのパスをタグとして付与して、Submitをクリックします。
※今回は適当に私の環境にあったCodeCommitリポジトリで作成しているので、リポジトリ名やディレクトリ名は気にしないでください。
image003.png

タグを付与した後しばらく(1分程度)して、DynamoDBのテーブルを見てみると、付与したタグの情報がCppbe-Tableという名前のテーブルに追加されていることがわかります。
image005.png

操作は以上です。

実際に指定したCodeCommitリポジトリのディレクトリ上のファイルを修正しコミットすると。。
image006.png

パイプラインが回り始めました!
image007.png

今回の記事は以上となります。
この記事が、少しでもAWS上でのCI/CDを実施するエンジニアの皆さまの助けとなれば幸いです。

6
6
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
6
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?