Pull Request毎の検証環境を自動構築したお話

この記事は Retty Part2 Advent Calendar 2021 の 22 日目の記事です。

adventar.org

はじめまして、Retty 技術部 インフラチームの中西と申します。
今回は Pull Request毎の検証環境を自動構築した お話となります。

要約

  • Pull Request をトリガとして ECS Fargate の環境を CI/CD で構築するようにした

というお話です。

構築の背景について

フロントエンドの開発チームから以下の相談を頂きました。

  • 各自が作成した Pull Request に応じた検証環境がほしい

CI/CDの設定で自動構築できそうと判断して設計や構築、検証を行いました。

技術スタックについて

使用した技術スタックは以下の通りとなります

  • AWS

    • ECS Fargate
    • ECR
  • CI/CD

    • CircleCI
      • build,deploy
    • GitHub Actions
      • 環境不要時の削除のみ実施

課題について

構築にあたっての疑問や課題を洗い出した所、以下の3点が課題となりました。

  1. Pull Request 毎の判定をどう行うか
  2. ECS関連のAWSリソースをどうやって作成してくか
  3. CI/CD のフローをどうしていくか

解決について

1. Pull Request 毎の判定をどう行うか

こちらは簡単で Pull Request 作成時の番号 (PR Number)を使うことにしました。

(例)PR Number -> 1817

https://github.com/pull/1817 

2. ECS関連のAWSリソースをどうやって作成してくか

Pull Request の環境毎に都度リソースをすべて構築するのはデリバリーの速度や. コストの面からも宜しくありません。
なるべく共通のAWSリソースを使用できるか確認、検証を行いました。
結果、以下の仕様としました.

※本記事で紹介している内容は架空の物なります

DNS について

  • PR Number を変数として設定する
  • PR Number 毎の DNS レコードを作成する
    • (例) ${pr_number}.pre-stg.me で作成

ALB について

  • PR Number 毎の Target group を作成
  • 1つのALBで複数の Listener rule を登録して Host header -> Target group の振り分けを設定する
    • Prirority 重複による作成エラーを防ぐ為、現在の Priority の 最大値 + 1 に設定して Listner rule を登録するようにする

f:id:sonic883b:20211217191133p:plain

ECS について

  • PR 作成時に Docker build -> PR Number に基づいた Docker tag を設定して ECR push を行う
  • PR Number 毎の ECS service を作成し、同じ PR Number の Target group を設定する

f:id:sonic883b:20211217191103p:plain

3. CI/CDフローをどうしていくか

状況に応じて CircleCI or GitHub Actions で行うようにしました。

環境の構築 or update 時 -> CircleCI を使用

  1. Docker コンテナbuild , PR Number で tag を付与
  2. ECR Push
  3. ALB Target Group を作成
  4. ELB Listener Rule 作成
  5. Task Definition 更新
  6. ECS Service の作成 or Update

フローとしては以下の通りとなります

f:id:sonic883b:20211215184124p:plain

環境不要時 -> GitHub Actions を使用

  1. Pull Request close 時の PR Number を取得
  2. PR Number に基づいた ECS関連のリソースを削除する.

※ 環境復活が必要な場合は再度Pull Request を作成してもらう運用としています

構成図について

上記課題を解決して作成した構成図はこちらになります

f:id:sonic883b:20211217185718p:plain

設定内容

CircleCI

CircleCI での設定の一例です。
※ 各 step で実行している shell script は割愛します


commands:
  deploy_frontend_pre_stg:
    steps:
      - run:
          name: Create Target Group
          command: |
            if [[  -n ${CIRCLE_PULL_REQUEST} ]]; then
              # When pull request is created/updated
              PR_NUMBER=`echo ${CIRCLE_PULL_REQUEST} | grep -oP '\d+$'`
              echo "export PR_NUMBER=$PR_NUMBER" >> $BASH_ENV
            else
              echo "Please make Pull Request on GitHub."
              exit 1
            fi
             HOST_HEADER="pr-${PR_NUMBER}.$HOST_DOMAIN"
             TARGET_GROUP_NAME="${SERVICE_NAME}-${APPLICATION_ENV_SHORT}-pr-${PR_NUMBER}"
             echo "export HOST_HEADER=$HOST_HEADER" >> $BASH_ENV
             echo "export TARGET_GROUP_NAME=$TARGET_GROUP_NAME" >> $BASH_ENV
             ### check Target Group exits
             set +e
             CHECK_EXITS=`aws elbv2 describe-target-groups --name ${TARGET_GROUP_NAME} | jq -r '.TargetGroups[] | .TargetGroupArn'`
             if [ $? -eq 0 ]; then
               echo "Taget Group: ${TARGET_GROUP_NAME} is already exits."
               echo "This step is skkiped."
             else
               echo -e  "Target Group: ${TARGET_GROUP_NAME} is not found\nCreate Target Group: ${TARGET_GROUP_NAME}"
               .circleci/scripts/aws/create_target_group.sh
               echo "Create Target Group: ${TARGET_GROUP_NAME} done"
               CHECK_EXITS=`aws elbv2 describe-target-groups --name ${TARGET_GROUP_NAME} | jq -r '.TargetGroups[] | .TargetGroupArn'`
             fi
             echo "Target Group ARN: $CHECK_EXITS"
             echo "export TARGET_GROUP_ARN=$CHECK_EXITS" >> $BASH_ENV
      - run:
          name: Create ELB Listener rule
          command: |
            set +e
            CHECK_EXITS=`aws elbv2  describe-rules --listener-arn $LISTENER_ARN | grep -w ${HOST_HEADER}`
            if [ $? -eq 0 ]; then
              echo -e "ELB Listener rule for ${HOST_HEADER} is already exits.\nThis step is skkiped."
            else
              PRIORITY=`.circleci/scripts/aws/set_elb_listener_rule_priority.sh $LISTENER_ARN`
              echo "export PRIORITY=$PRIORITY" >> $BASH_ENV
              echo "Priority Number: $PRIORITY"
              .circleci/scripts/aws/create_elb_listener_rule.sh
            fi
      - run:
          name: create task definition for frontend-pre-stg
          command: |
            sudo apt update -y && sudo apt install -y gettext jq
            export COMMIT_HASH=$CIRCLE_SHA1
            envsubst < .circleci/ecs/taskdefinition-frontend-pre-stg.json.template> .circleci/ecs/taskdefinition.json
            aws ecs register-task-definition --cli-input-json file://$PWD/.circleci/ecs/taskdefinition.json > newtask.json
      - run:
          name: create service for frontend-pre-stg
          command: |
            TASK_DEFINITION=$(cat newtask.json | jq -r '.taskDefinition.taskDefinitionArn')
            ECS_SERVICE_NAME="pr-${PR_NUMBER}"
            echo "export ECS_SERVICE_NAME=$ECS_SERVICE_NAME" >> $BASH_ENV
            echo "export TASK_DEFINITION=$TASK_DEFINITION" >> $BASH_ENV
            CHECK_EXITS=`aws ecs describe-services --cluster $CLUSTER_NAME --service $ECS_SERVICE_NAME | jq -r '.services[].status'`
            if [ $CHECK_EXITS == "ACTIVE" ]; then
              echo "ECS Sevice Name: $ECS_SERVICE_NAME is already exist."
              .circleci/scripts/aws/update_or_create_ecs_service.sh update
              echo "export PRE_STG_RESOURCE_EXIST='true'" >> $BASH_ENV
            else
              echo "ECS Sevice Name: $ECS_ERVICE_NAME is not exits."
              .circleci/scripts/aws/update_or_create_ecs_service.sh create
            fi
      - run:
          name: describe frontend-pre-stg resources
          command: |
            ## ELB target group Listenr rule
             aws elbv2 describe-target-groups --name ${TARGET_GROUP_NAME} | jq -r '.TargetGroups[] | .TargetGroupArn'
             aws elbv2 describe-rules --listener-arn $LISTENER_ARN | jq --arg host_value $HOST_HEADER '.Rules[] | select (.Conditions[].Values[] == $host_value )' | jq -r .RuleArn
            ### ecs service task
             aws ecs describe-services --cluster $CLUSTER_NAME --service $ECS_SERVICE_NAME
             aws ecs describe-task-definition --task-definition --frontend-pre-stg | jq -r '.taskDefinition | .taskDefinitionArn , .containerDefinitions[].dockerLabels'


jobs:

  deploy-frontend-pre-stg:
    executor: python
    environment:
      AWS_PAGER: ""
      APPLICATION_ENV: pre-staging
      APPLICATION_ENV_SHORT: pre-stg
      VPC_ID: "vpc-xxxx"
      SUBNET_GROUPS: "subnet-xxxx,subnet-xxxx"
      SECURITY_GROUPS: "sg-xxx" 
      LISTENER_ARN: "arn:aws:elasticloadbalancing:xxxx"
      HEALTH_CHECK_PATH: "/healthz"
      CLUSTER_NAME: "frontend-pre-stg"
      CONTAINER_NAME: "-frontend"
      CONTAINER_PORT: 80
      DESIRED_COUNT: 1
      PLATFORM_VERSION: "LATEST"
      TASK_CPU: 512
      TASK_MEMORY: 1024
      CPU_UNIT: 502
      MEMORY_RESERVE: 768
    steps:
      - checkout
      - aws_assume_role
      - deploy_frontend_pre_stg

GitHub Actions

Pull Request をCloseした時のみの処理となります。
CircleCI 内で設定しなかった理由はGitHub Actions のcontext である.
github.event.number で PR Numberの取得が簡単にできたからです。

name: Remove unnecessary pre-stg environment 

on:
  pull_request:
    types: [ closed ]

jobs:
  deploy:
    if: startsWith(github.head_ref, 'renovate-') == false
    name: Remove unnecessary pre-stg environment
    runs-on: ubuntu-latest
    env:
      PR_NUMBER: ${{ github.event.number }}
      LISTENER_ARN: "arn:aws:elasticloadbalancing:xxxx"
      CLUSTER_NAME: "frontend-pre-stg"
      SERVICE_NAME: "frontend"
      APPLICATION_ENV: pre-staging
      APPLICATION_ENV_SHORT: pre-stg

    steps:
    - name: Checkout
      uses: actions/checkout@v2
    - name: Configure AWS Credentials with assume role
      uses: aws-actions/configure-aws-credentials@v1
      with:
        aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
        aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
        aws-region: ap-northeast-1
        role-to-assume: ${{ secrets.AWS_ROLE_TO_ASSUME }}
        role-external-id: ${{ secrets.AWS_ROLE_EXTERNAL_ID }}
        role-duration-seconds: 900

    - name: remove ECS service
      run: |
        ECS_SERVICE="pr-${PR_NUMBER}"
        echo ${ECS_SERVICE}
        aws ecs delete-service --cluster $CLUSTER_NAME --service $ECS_SERVICE --force
    - name: remove ELB Lister rule
      run: |
        HOST_HEADER="pr-${PR_NUMBER}.${HOST_DOMAIN}"
        REMOVE_RULE_ARN=`aws elbv2 describe-rules --listener-arn $LISTENER_ARN | \
        jq --arg host_value ${HOST_HEADER} '.Rules[] | select (.Conditions[].Values[] == $host_value )' | jq -r .RuleArn`
        echo $REMOVE_RULE_ARN
        aws elbv2 delete-rule --rule-arn ${REMOVE_RULE_ARN}
    - name: remove ELB Target group
      run: |
        TARGET_GROUP_NAME="${SERVICE_NAME}-${APPLICATION_ENV_SHORT}-pr-${PR_NUMBER}"
        REMOVE_TARGET_GROUP_ARN=`aws elbv2 describe-target-groups --name ${TARGET_GROUP_NAME} --query 'TargetGroups[].TargetGroupArn' --output text`
        echo ${TARGET_GROUP_NAME}
        echo ${REMOVE_TARGET_GROUP_ARN}
        aws elbv2 delete-target-group --target-group-arn ${REMOVE_TARGET_GROUP_ARN}

最後に

こうしてAWS上で気軽に使い捨てできる環境を用意することができ、開発効率を上げることができました。
今後は他のプロダクトでも同様の環境が EC2で稼働しているので、今回の知見を活かして今回のような
Pull Request毎の検証環境に移行していく予定です

似たような要件で悩んでいた方のご参考になれば幸いです。
お読みくださいましてありがとうございました。

今年の Advent Calendar は Part 1 と Part2 がございます。
引き続きお楽しみ下さい。

adventar.org adventar.org