使用 AWS CDK 實作 CodeDeploy Blue/Green、Canary 部署

POSTED BY   Chris
2021 年 8 月 23 日
使用 AWS CDK 實作 CodeDeploy Blue/Green、Canary 部署

原理和流程的部分在進階持續部署 – 使用 Blue/Green、Canary 降低發版風險已說明過,這篇來說明使用 AWS CDK 的實作方式

 

概述

此篇最主要介紹 ECS on Fargate 中使用 CodePipeline 整合 CodeDeploy 的方式來實現 Blue/Green or Canary 的部署方式

AWS CodePipeline 可以整合 CodeDeploy 一起使用,自家產品的整合支援一定是比較好也比較方便,所以在 AWS CodePipeline 的流程中,Source 階段是從 GitHub 而來,而 Build 階段會使用 AWS CodeBuild (新版本程式碼 push 到 ECR 用,以及準備相關 CodeArtifact 給 CodeDeploy),而 Deploy 階段就是整合 AWS CodeDeploy 來實現 Blue/Green Deployment

 

準備事項

  • AWS ALB、TWO ALB Listener、TWO ALB Target Groups
  • ECS Cluster and Service
  • ECR Repository

要建立以上這些 Resources 可以參考 RestAPINetwork StackEcsFargate Stack

準備好這些 Resources 後,就可以進入本篇的重點,使用 CodePipeline 整合 CodeDeploy 實現 Blue/Green Deployment,而再來要說明的部分,完整的程式碼都在 Pipeline Stack,再來主要也會針對這個 Stack 中的 CodeDeploy 部分來做說明,若對 CodePipeline 不熟的話,可以先參考 這篇

 

CDK 實作 AWS CodePipeline 和 CodeDeploy

這邊假設已準備好在準備事項的相關 Resources,再來就是搭建 CodePipeline 和 CodeDeploy,並且在 GitHub 主 branch(main) push 新的 commit 後,就可以觸發 CodePipeline 到 CodeDeploy 進行部署流程

 

Source Stage
TypeScript
pipeline.addStage({
      stageName: 'Source',
      actions: [
        new codepipeline_actions.CodeStarConnectionsSourceAction({
          actionName: 'GitHub_Source',
          owner: 'kimisme9386',
          repo: 'lab-ecs-fargate-cd-infra',
          output: sourceArtifact,
          connectionArn:
            'arn:aws:codestar-connections:ap-northeast-1:482631629698:connection/6a6dd11d-2713-4129-9e5d-23289c8968d6',
          variablesNamespace: 'GitHubSourceVariables',
          branch: 'main',
          codeBuildCloneOutput: true,
        }),
      ],
    });

這邊使用 GitHub 做為 Source,當然也可以使用 ButBucket、CodeCommit、S3 Bucket、ECR 等…,目前 AWS CDK 已支援使用 CodeStarConnections,也就是在 AWS Management Console 上選擇 GitHub (Version2),再點擊 Connect to GitHub 後,照步驟就能產生出一組 CodeStarConnection

 

Build Stage
TypeScript
const codebuildProject = new codebuild.PipelineProject(
      this,
      'CodeBuildWithinCodePipeline',
      {
        buildSpec: codebuild.BuildSpec.fromObject({
          version: '0.2',
          env: {
            shell: 'bash',
          },
          phases: {
            pre_build: {
              commands: [
                'codebuild-breakpoint # Ref https://docs.aws.amazon.com/codebuild/latest/userguide/session-manager.html',
                'echo Logging in to Amazon ECR...',
                '$(aws ecr get-login --region $AWS_DEFAULT_REGION --no-include-email)',
                'COMMIT_HASH=$(echo $CODEBUILD_RESOLVED_SOURCE_VERSION | cut -c 1-7)',
                'IMAGE_TAG=${COMMIT_HASH:=latest}',
              ],
            },
            build: {
              'on-failure': 'ABORT',
              'commands': [
                'cd ./flask.d',
                'docker build -t $REPOSITORY_URI:latest .',
                'docker tag $REPOSITORY_URI:latest $REPOSITORY_URI:$IMAGE_TAG',
                'cd ../',
              ],
            },
            post_build: {
              commands: [
                'echo Build completed on $(date)',
                'echo Pushing the Docker images...',
                'docker push $REPOSITORY_URI:latest',
                'docker push $REPOSITORY_URI:$IMAGE_TAG',
                'printf \'{"ImageURI":"%s"}\' $REPOSITORY_URI:$IMAGE_TAG > imageDetail.json',
              ],
            },
          },
          artifacts: {
            'files': ['*'],
            'secondary-artifacts': {
              imageDetail: {
                files: ['imageDetail.json'],
              },
              manifest: {
                files: ['taskdef.json', 'appspec.yaml'],
              },
            },
          },
        }),
        environment: {
          buildImage: codebuild.LinuxBuildImage.STANDARD_4_0,
          computeType: codebuild.ComputeType.SMALL,
          privileged: true,
        },
        environmentVariables: {
          REPOSITORY_URI: {
            value: ecrRepository.repositoryUri,
          },
          ECS_CONTAINER_NAME: {
            value: ecsContainerName,
          },
        },
        cache: codebuild.Cache.local(codebuild.LocalCacheMode.DOCKER_LAYER),
      }
    );

ecrRepository.grantPullPush(codeBuild.role as IRole);

const imageArtifact = new codePipeline.Artifact('imageDetail');

const manifestArtifact = new codePipeline.Artifact('manifest');

pipeline.addStage({
      stageName: 'Build',
      actions: [
        new codepipeline_actions.CodeBuildAction({
          actionName: 'AWS_CodeBuild',
          input: sourceArtifact,
          project: codebuildProject,
          type: codepipeline_actions.CodeBuildActionType.BUILD,
          outputs: [imageArtifact, manifestArtifact],
        }),
      ],
    });

首先先看一下建立 CodeBuild project 的部分,這邊的 buildSpec 的參數可以使用 codebuild.BuildSpec.fromSourceFilename('buildspec.yml') 使用 Source Repository 中的 buildspec.yml 來建置,或是像此例子這樣,直接寫在 CDK 程式碼內,依不同情境可以自行選用,而 CodeBuild 中有 3 個重點要做

1. 使用 docker build 建立新版本 container 後 push 到 ECR

這邊使用 flask 當範例,在 這邊 有範例可以參考,所以在 Source Repo 中需要先準備 Dockerfile 和 相關程式碼,此例子為了方便放在同一個 repo 中,但實務上 application 程式碼應該會獨立一個 repo

 

2. 準備 imageDetail.json Artifact

承第 1 點,push 到 ECR 後,會有相對應的 REPOSITORY URI 和 IMAGE TAG,準備這個檔案,並指定到 buildspec 中的 secondary-artifacts,為了準備給 CodeDeploy 做部署用

 

3. 準備 taskdef.json 和 appspec.yaml Artifact

這兩個檔案跟 builsepc 比較無關,這是預先準備好在 Source Repo 中,也是為了準備給 CodeDeploy 使用,只是在 CodeBuild 中需要 output 成 Artifact 給 CodeDeploy,也是一樣必需指定到 buildspec 中的 secondary-artifacts

這兩個檔案特別要說明的是,其內容中特定欄位都有 placehold 佔位符需要保留給 CodeDeploy 自行產生, taskdef.json 請參考這裡,可以看到 image 欄位指定 <IMAGE1_NAME>,因為 tag 會有新版本,所以必需保留給 CodeDeploy 去做指定

而 appspec.yaml 請參考這裡,可以看到 TaskDefinition 欄位指定 <TASK_DEFINITION>,這邊是 ECS Task 所指定的版本,task definition 每個版本號會遞增,所以也是交由 CodeDeploy 做處理

其餘這兩個檔案的欄位就不多加說明,可以自行觀看 AWS 文件,taskdef.json 最主要就是定義 task definition 供 ECS Task 啟動時的配置,而 appspec.yaml 則是 CodeDeploy 部署過程中最重要的檔案,部署的 lifecycle event 都是依據這個檔案做相關的動作

 

另外也注意一下這一行

ecrRepository.grantPullPush(codeBuild.role as IRole);

因為 CodeBuild 需要有權限 push image tag 到 ECR,故此行是必需的,使用 CDK 可以很方便的用一行就可以把權限搞定

 

再來就是把 CodeBuild project 加到 CodePipeline 中,先看一下這兩行,這邊 Artifact 中的名稱需要注意和 buildspec 中 secondary-artifacts 區塊所指定的 key name 要一致,否則會報找不到 Artifact 的錯誤

const imageArtifact = new codePipeline.Artifact('imageDetail');
const manifestArtifact = new codePipeline.Artifact('manifest');

而這兩個 artifacts 指定到 Build Stage action 中的 outputs 後,這部分就算完成了

 

Deploy Stage
TypeScript
const deploymentConfig = new EcsDeploymentConfig(
      this,
      'DeploymentConfig',
      {
        deploymentConfigName: 'Canary20Percent5Minute',
        trafficRoutingConfig: {
          type: 'TimeBasedCanary',
          timeBasedCanary: {
            canaryInterval: 5,
            canaryPercentage: 20,
          },
        },
      }
    );

const deploymentGroup = new EcsDeploymentGroup(this, 'DeploymentGroup', {
      applicationName: 'ecs-blue-green-application',
      deploymentGroupName: 'ecs-blue-green-deployment-group',
      ecsServices: [
        {
          clusterName: fargateService.cluster.clusterName,
          serviceName: fargateService.serviceName,
        },
      ],
      targetGroupNames: [
        blueGreenOptions.prodTargetGroup.targetGroupName,
        blueGreenOptions.testTargetGroup.targetGroupName,
      ],
      prodTrafficListener: {
        listenerArn: blueGreenOptions.prodTrafficListener.listenerArn,
      },
      testTrafficListener: {
        listenerArn: blueGreenOptions.testTrafficListener.listenerArn,
      },
      terminationWaitTimeInMinutes: 0,
      autoRollbackOnEvents: [RollbackEvent.DEPLOYMENT_FAILURE],
      deploymentConfig,
    });

    pipeline.addStage({
      stageName: 'DeploymentByCodeDeploy',
      actions: [
        new codepipeline_actions.CodeDeployEcsDeployAction({
          actionName: 'EcsCodeDeployBlueGreen',
          deploymentGroup: deploymentGroup,
          taskDefinitionTemplateInput: manifestArtifact,
          appSpecTemplateInput: manifestArtifact,
          containerImageInputs: [
            {
              input: imageArtifact,
              taskDefinitionPlaceholder: 'IMAGE1_NAME',
            },
          ],
        }),
      ],
    });

這邊使用的 EcsDeploymentGroup Construct 是用 third party 而非 CDK 官方原生的,原因在於目前 CloudFormation 還沒有支援 ECS 的 DeploymentGroup,所以先用 third party 套件來做,而此 third party 是用 Custom Resources 呼叫 lambda 使用 AWS SDK 來實作這個部分

參數部分把之前已經建立的 ECS Service、ALB Target Group、ALB Listener 帶進來,而這個 Construct 會產生 CodeDeploy application 和 CodeDeploy DeploymentGroup

最後就是新增 Deploy Stage, deploymentGroup 參數就是剛才建立出來的 deploymentGroup,而 taskDefinitionTemplateInput 和 appSpecTemplateInput 就是在 buildspec 中 output 出來的 manifestArtifact,containerImageInputs 參數也是 buildspec 中 output 出來的 imageArtifact

到此,CodePipeline 設定完成,已經可以開始測試,但這邊還要額外說明一下 cdk-blue-green-container-deployment 這個 third party Construct,目前 AWS CodeDeploy 支援 Canary、Linear、All-at-once 3種部署方式,但此 Construct 目前只有支援 All-at-once,故我這邊已有拉 PR,但會不會被採用不知道 (2021/8/24 已被 merge),不過總之,目前在 AWS Management Console 上建立 DeploymentConfig 其實很容易,建立完再 update CodeDeploy DeploymentGroup 設定即可使用,也不會太難

 

在 AWS Console Management 中觀測

DeploymentConfig 是這樣設定

TypeScript
const deploymentConfig = new EcsDeploymentConfig(
      this,
      'DeploymentConfig',
      {
        deploymentConfigName: 'Canary20Percent5Minute',
        trafficRoutingConfig: {
          type: 'TimeBasedCanary',
          timeBasedCanary: {
            canaryInterval: 5,
            canaryPercentage: 20,
          },
        },
      }
    );

這邊採用 Canary 的方式部署,為了方便測試,暫停時間只設定 5 分鐘,在這 5 分鐘內會有 20% 的流量會被導到 green instances (新版本),也就平均 5 個 request 會有 1 個 request 被導到新版本去,設定好後就來實際試試

在 Source Repo 上 push 一個新的 commit 後,就可以到 CodePipeline 中觀察,而當跑到 Deploy Stage 時,就可以切換到 CodeDeploy 介面來看看

Hint: 點擊圖片可放大

可以很清楚的看到,目前 Orignal(blue) 中佔有 80% 的流量,而 Replacement (green) 佔有 20% 的流量,而這個狀態會持續 5 分鐘,畫面往下滾動後,會看到這個畫面

第一個紅框也可以看到流量分配的比例,而第二個紅框可以很清楚的看到,CodeDeploy lifecycle event 目前停在 AllowTraffic,至於停多久就看設定的分鐘數是多少

而在這個時期上,可以進行的事情如 A/B Test,或是內部進行 UI 的腳本測試等等…,都是可以的,只要在 Origin instances 還沒刪除前,發現任何異常都是可以 Rollback

最後若沒有問題,過 5 分鐘後,就會看到部署完成的畫面

這邊也額外提一下,還有兩個地方可以做暫停的時機,一個是 blueGreenDeploymentConfiguration 中的 terminationWaitTimeInMinutes (third party construct 中可以設定),這個是指在真正刪除 Origin instances (blue) 之前的暫停時間,這也是最後可以 Rollback 的時機點,因為一旦刪除 Origin instances,想 Rollback 也沒辦法了

另一個是 deploymentReadyOption 中的 waitTimeInMinutes (third party construct 沒支援設定),這個是指在安裝完 green instances 後,對應到 lifecycle event 是 Afterinstall,但還未 AllowTestTraffic 之前可以暫停的時間,至於應用在什麼情境,目前是想不到

以上這兩個參數,可以參考 AWS SDK 文件

 

使用 Newman 進行 E2E Testing

Blue/Green deployment 的優勢在之前的文章已提過,所以測試新版本是否穩定是這種部署的優勢之一,這邊我用 newman (postman cli) 的方式來測試,在之前 buildspec 中所提到的 appspec.yaml 中可以看到這個設定

- BeforeAllowTraffic: "BlueGreenDeploymentHook"

也就是指定 lambda function BlueGreenDeploymentHook 來執行 newman 測試,而為什麼選用這個 event hook 中執行,因為這時候,還沒有任何流量切換 green(新版),而這時候 TestListener Traffic 已經通了,以這個 repo 的試驗,也就是使用 8080 port 來測試新版本,而舊版本還是跑 443 port,彼此不會影響到,如果新版本測試有任何問題就 Rollback,也不會影響線上 Users,故選用此 event hook

而 lambda CDK 如下

TypeScript
const hookLambda = new lambda.DockerImageFunction(
      this,
      'BlueGreenDeploymentHook',
      {
        functionName: 'BlueGreenDeploymentHook',
        code: lambda.DockerImageCode.fromImageAsset(
          path.join(__dirname, '../deployment-hooks-node')
        ),
        timeout: cdk.Duration.seconds(100),
        environment: {
          REGION: cdk.Aws.REGION,
          DEBUG: 'true',
          POSTMAN_API_KEY: ssm.StringParameter.valueForStringParameter(
            this,
            '/postman/api-key'
          ),
          POSTMAN_COLLECTION_UID: ssm.StringParameter.valueForStringParameter(
            this,
            '/postman/collection-uid'
          ),
        },
      }
    );

    hookLambda.addToRolePolicy(
      new iam.PolicyStatement({
        actions: ['codedeploy:PutLifecycleEventHookExecutionStatus'],
        resources: [
          `arn:aws:codedeploy:${cdk.Aws.REGION}:${cdk.Aws.ACCOUNT_ID}:deploymentgroup:ecs-blue-green-application/ecs-blue-green-deployment-group`,
        ],
      })
    );

這邊把 postman 的 api key 和 collection id 透過 SSM 的方式指定到 lambda 中的環境變數,timeout 記得不能設定太短,否則 newman 還沒跑完 lambda 就先 timeout,這樣會讓 CodeDeploy 整個卡住

然後權限的部分要特別注意,因為 event hook 的 lambda 需要回傳狀態成功或失敗給 CodeDeploy,故權限的部分也需要設定才不會有問題

Lambda 中的 Code 是很單純的,newman 這邊只需要一行

`newman run https://api.getpostman.com/collections/${postmanCollectionUid}?apikey=${postmanApiKey}`

不過 Async await 的寫法可能要注意一下,完整的程式碼在這邊

當然 newman 也有提供 js 的 sdk,也可以使 postman sdk 來撰寫,之後再 push commit 後,來觀察一下 CodeDeploy,來看一下畫面

這邊可以看到如之前敘述,流量都還沒切換到 green,往下滾動後也可以看到這個畫面

畫面停在 BeforeAllowTraffic event 中,也就是 newman 正在測試中,CodeDeploy 會等 event hook lambda 回傳狀態後,才會繼續下一個 event hook

不管 newman 測試成功或失敗,都是可以到 lambda CloudWatch logs 中去觀看結果,先來看一張成功的畫面

newman 的測試 output 做的滿清楚,可以知道測試了幾個 case、成功或失敗等等

而測試失敗的畫面如下

如紅框所示,一旦有 faild 的時候,newman 會 throw Error ,故 catch error 後就可以回傳 Failed 給 CodeDeploy,之後就會整個 Rollback,如此一來,就可以降低發版風險

 

結論

Blue/Green deployment 相對 rolling update 算是複雜不少,不過使用 AWS CodeDeploy 已經相對輕鬆很多,而 Blue/Green 的彈性滿大的,每個團隊可以針對自己專案適合的測試情境,選用不同的測試方式,達到降低發版風險,並且快速且無後遺症的交付功能到客戶端,不僅能加快產品迭代,又能在安全且低風險的狀況下進行,是一件很棒的事

歡迎留言
0

您可能也想看

Workaround for AWS Grafana alerting
2023 年 8 月 3 日
AWS, DevOps
AWS VPC Endpoint 使用場景
2022 年 3 月 14 日
AWS, CDK, Network
CDK 指定 Physical names 運作方式
2021 年 12 月 4 日
AWS, CDK, Cloudformation