はじめに
AWS CodePipelineでは、CodeCommitをソースとするときに、標準オプションではディレクトリ単位での指定ができません。CodePipelineのユーザーガイドを見てみても、指定できるのはブランチ名のみです。
しかし、モノリポジトリ戦略(モノレポ戦略とも)をとる場合は、同一ブランチの中でも変更があったディレクトリによって別のパイプラインを起動させたいというニーズが発生します。
この記事は、CodeCommitおよびCodePipelineを使って、ディレクトリごとにアプリケーションを分けている場合、ソースに変更のあったアプリケーションのみパイプラインでビルドさせるためのTIPSを紹介します。
【注】本記事は、以前書いた記事で紹介した、CodeCommitでモノリポジトリを実現するためのLambdaを用いた仕組みの改良版です。もし時間があれば、前回の記事も見てみてください。
やりたいこと(前回の記事の再掲)
今回のやりたいことを図で表すと下記のような形です。
CodeCommit内のディレクトリ構造は、アプリケーションごとにディレクトリが切られていて各ディレクトリの下にアプリケーションのソースコードとbuildspecが配置してあります。
例えばアプリケーションAのソースコードのみを修正した場合、CodePipelineAのみが起動してほしいのですが、標準の機能で構成した場合CodePipelineAとCodePipelineBが同時に起動してしまいます。
解決策
アーキテクチャ
解決策の概要と前回からの改善点
前回の記事でも紹介した通り、各ディレクトリごとの変更を検知し、対応したCodePipelineを起動させる部分は、Lambdaを用いて実装しています。
boto3 のcodecommit get defference api を利用して、前回のコミットからの変更点をディレクトリ単位でフィルターし、変更がある場合は対応したパイプラインをcodepipeline start pipelineを利用してスタートさせます。詳細に関しては、冒頭にリンクを貼りましたので前回の記事をご参照ください。
しかし、前回の解決策では、Lambdaの環境変数にアプリケーションパスのリストを渡す必要があったり、CodePipelineのパイプライン名に制約があったり、CodeCommitのリポジトリごとにEventBridgeとLambdaのセットをデプロイしなくてはいけなかったりと、ややこしいところが多く正直利用しづらいものとなっていました。
今回は、これらの課題点を解決するために、アーキテクチャの下半分の部分(EventBridge, Lamdba, DynamoDB)を新しく導入しました。
これらは、下記のような流れで動作します。
- ユーザーは、CodePipelinに必要な情報(ビルド対象のCodeCommitリポジトリ名、ブランチ名、アプリケーションディレクトリのパス)をタグとして付与する
- CodePipelineにタグが付与されると、EventBridgeがそのイベントを検知しLamdba関数を起動する
- Lambdaはパイプラインに特定のタグが付与されている場合、その内容をDynamoDBに書き込む
- LambdaはDynamoDBにデータを書き込んだ後、EventBridgeのルールを自動デプロイする
- 対象のCodeCommitの対象のディレクトリ配下のファイルに変更があると、自動デプロイされたEventBridgeがそのイベントを検知し、パイプラインをトリガーするためのLambda関数を起動。Lambda関数は、DynamoDBに格納されているアプリケーションディレクトリのパスやパイプライン名に従って、変更差分の検知とパイプラインの起動を実行する
これにより、この仕組みをデプロイしておけば、あとはCodePipelineに特定の情報をタグとして付与するだけでディレクトリ単位でのCodePipelineのトリガーが可能になります。
CloudFormation のテンプレート
今回は、配布のしやすさを重視して、Lamdbaコードもすべて含めた一つのテンプレートファイルにまとめました。そのため、記述が長くなっていますが、逆に言うとこれをそのままコピペしてデプロイすれば利用できるようになります。
また注意点として、例のごとく例外処理等は実装されていないため、ミッションクリティカルな環境で利用したい場合は各自修正をお願いします。
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のマネジメントコンソールで、対象のパイプラインを選択して、設定⇒パイプラインタグと遷移します。
リポジトリ名とブランチ名、アプリケーションのパスをタグとして付与して、Submitをクリックします。
※今回は適当に私の環境にあったCodeCommitリポジトリで作成しているので、リポジトリ名やディレクトリ名は気にしないでください。
タグを付与した後しばらく(1分程度)して、DynamoDBのテーブルを見てみると、付与したタグの情報がCppbe-Tableという名前のテーブルに追加されていることがわかります。
操作は以上です。
実際に指定したCodeCommitリポジトリのディレクトリ上のファイルを修正しコミットすると。。
今回の記事は以上となります。
この記事が、少しでもAWS上でのCI/CDを実施するエンジニアの皆さまの助けとなれば幸いです。