GitHub ActionsでECS自動デプロイ環境を構築する（DBのmigrate処理まで）

Github ActionsでECSにデプロイする構成自体はかなり多くの記事が出回っているので苦労はしないと思うのですが、IAMのキーの運用についてや、migrateタスクを自動で更新してコンテナを立ち上げて動かすのに少し時間がかかったので、網羅的にまとめておこうと思い、記事を書きます。

## この記事の内容

- IAMの作成(Assume Role)
- GitHubActionsの設定ファイルの書き方

## GitHub Actions参考ドキュメント
https://github.com/aws-actions
https://github.com/aws-actions/amazon-ecr-login
https://github.com/aws-actions/configure-aws-credentials
https://github.com/aws-actions/amazon-ecs-deploy-task-definition
https://docs.github.com/ja/actions/using-workflows/workflow-syntax-for-github-actions
https://docs.github.com/ja/actions/using-workflows/reusing-workflows


## 事前準備
- まずこちらの記事を読むことをお勧め（わかりやすくECSデプロイの基本をつかめる非常に良記事）
    - https://dev.classmethod.jp/articles/ecs-deploy-all/


- デプロイ用の環境変数を設定

リポジトリの「Settings」→「secrets」→「Actions」から設定できる
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2367608/12d1b85b-e2b4-93e2-1735-88e1b634569e.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2367608/142bf9bf-87e4-e6d4-79a5-904d9eb15e19.png)

### GithubActions向けにIAMまわりのリソースを作成
GitHub Actionsで使うIAMユーザーをAssumeRoleを使ってセキュアに作ってみる。

参考: 
https://github.com/aws-actions/configure-aws-credentials
https://aws.amazon.com/jp/blogs/news/create-a-ci-cd-pipeline-for-amazon-ecs-with-github-actions-and-aws-codebuild-tests/

- AssumeRole は、IAM ロールを引き受けるためのアクション
- sts:AssumeRoleを使うと、IAM ロールが持っている権限を引き受けてその権限を使用できる。

-  作成するリソースは以下
    - GithubActionsで実際に使用する認証情報を持ったIAMユーザー
        - AssumeRole(特定の指定したRoleのみを引き受けることができる)をおこなうためのIAMポリシーをインラインポリシーに作成(sts:AssumeRole)
    - AssumeRoleの対象となるIAMロール
        - 実際にECR/ECSへの権限を付与したIAMポリシー
        - 信頼ポリシーにsts:AssumeRole,sts:TagSessionアクションを引き受ける対象のリソース(最初に作ったGitHubActions用のIAMユーザー)を定義


```yml:iam_for_github_actions.yml
AWSTemplateFormatVersion: "2010-09-09"
Description:
  Create IAM User and Role for Github Actions
Parameters:
  ENV:
    Type: String
    AllowedValues:
      - prod
      - stg
  PREFIX:
    Type: String

Resources:
  # GiHubActionsで認証情報として使うIAMユーザ
  DeployUserForServer:
    Type: AWS::IAM::User
    Properties:
      UserName: !Sub "${PREFIX}-${ENV}-deploy-user-for-server"

  # GiHubActionsで認証情報として使うIAMユーザに付与するIAMポリシー（AssumeRole実施権限）
  DeployPoricyForServer:
    Type: AWS::IAM::Policy
    Properties:
      PolicyName: !Sub "${PREFIX}-${ENV}-deploy-policy-for-server"
      PolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: "Allow"
            Action: "sts:AssumeRole"
            Resource: !GetAtt DeployRoleForServer.Arn
      Users:
        - !Ref DeployUserForServer

  # GiHubActionsで認証情報として使うIAMユーザがAssumeRole（引き受け）するIAMロール
  DeployRoleForServer:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub "${PREFIX}-${ENV}-deploy-role-for-server"
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
　　　　　　# 信頼ポリシーで引き受け先とアクションを指定する
          - Effect: "Allow"
            Action: "sts:AssumeRole"
            Principal:
              AWS:
                - !GetAtt DeployUserForServer.Arn
          # 信頼ポリシーで引き受け先とアクションを指定する
          # GitHubActionsの処理でAssumeRoleする際にセッションタグを渡すので必要になる
          - Effect: "Allow"
            Action: "sts:TagSession"
            Principal:
              AWS:
                - !GetAtt DeployUserForServer.Arn
      MaxSessionDuration: 3600

  # IAMロールに付与するIAMポリシー（ECR/ECSに対する権限）
  DeployPoricyForAssumeRole:
    Type: AWS::IAM::Policy
    Properties:
      PolicyName: !Sub "${PREFIX}-${ENV}-deploy-policy-for-assume-role"
      PolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: "Allow"
            Action:
              - "ecr:CompleteLayerUpload"
              - "ecr:UploadLayerPart"
              - "ecr:InitiateLayerUpload"
              - "ecr:BatchCheckLayerAvailability"
              - "ecr:PutImage"
            Resource: !Sub "arn:aws:ecr:${AWS::Region}:${AWS::AccountId}:repository/${PREFIX}-${ENV}*"
          - Effect: "Allow"
            Action:
              - "ecs:RegisterTaskDefinition"
              - "ecs:DescribeTaskDefinition"
              - "ecs:RunTask"
              - "ecs:ListTaskDefinitions"
              - "ecs:DescribeTasks"
              - "ecr:GetAuthorizationToken"
            Resource: "*"
          # タスク定義更新時にタスクロール/タスク実行ロールをタスク定義にPassRoleできる権限を与えておく
          - Effect: "Allow"
            Action: 
              - "iam:PassRole"
            Resource: 
              - !Sub "arn:aws:iam::303975652040:role/${PREFIX}-${ENV}-ECSTaskRole"
              - !Sub "arn:aws:iam::303975652040:role/${PREFIX}-${ENV}-ECSTaskExecution_Role"
      Roles:
        - !Ref DeployRoleForServer

Outputs:
  DeployUserForServer:
    Description: "IAM User for Server"
    Value: !GetAtt DeployUserForServer.Arn

  DeployRoleForServer:
    Description: "IAM Role (AssumeRole) for Server"
    Value: !GetAtt DeployRoleForServer.Arn
```

```bash
aws cloudformation create-stack \
--template-body file://iam_for_github_actions.yml \
--stack-name stg-iam-for-github-actions \
--parameters ParameterKey=ENV,ParameterValue=stg ParameterKey=PREFIX,ParameterValue=hoge \
--capabilities CAPABILITY_NAMED_IAM
```

### CloudFormationで作成したスタックを確認
- IAMロールのARNを確認すること
```bash
aws cloudformation describe-stacks \
--stack-name stg-iam-for-github-actions \
--query 'Stacks[].Outputs'
```

### IAMユーザーの認証情報を作成
- ACCESS_KEY_IDとSECRET_ACCESS_KEYを発行する
```bash
aws iam create-access-key \
--user-name <GiHubActionsで認証情報として使うIAMユーザ名>

{
    "AccessKey": {
        "UserName": <GiHubActionsで認証情報として使うIAMユーザ名>,
        "AccessKeyId": "***",
        "Status": "Active",
        "SecretAccessKey": "***",
        "CreateDate": "2021-06-30T07:40:56+00:00"
    }
}
```

発行した認証情報をGitHubActionsのSettingsからセットする。

### Assume Role実施箇所
GitHubActions上での実施箇所

```yml:cd.yml
# 2. AWS認証情報を設定(Assume Roleで一時的な権限付与をしている)
- name: Configure AWS credentials
  uses: aws-actions/configure-aws-credentials@v1
  with:
    aws-access-key-id: ${{ secrets.DEV_AWS_ACCESS_KEY_ID }}
    aws-secret-access-key: ${{ secrets.DEV_AWS_SECRET_ACCESS_KEY }}
    aws-region: ap-northeast-1
    # 以下の設定箇所で実際にAssume RoleしてIAMに一時的な権限の付与をおこなっている
    role-to-assume: ${{ secrets.DEV_AWS_ASSUME_ROLE_ARN }}
    role-duration-seconds: 1200 # 権限がくっついている期間は1200seconds
```


## GitHub Actionsでのデプロイ処理フロー
IAMができたので、GitHubActionsの処理を書いていく。

1. CI実行
1. AWS認証情報をGitHub Actions上の実行環境にセット
1. AWS CLIのインストール
1. Amazon ECRにログイン
1. NginxとRailsのDockerイメージをビルド
1. タスク定義の自動更新
1. タスク定義の実行
1. 実行結果をslackに通知


## CIの設定

- CIの実行はデプロイより前に必ず実行され、CIが通らない限りはデプロイされないように制御
- テストは並列実行用のライブラリを入れている( https://qiita.com/Dai_Kentaro/items/5741ed87c3b1fe68d5df )

```ci.yml
name: CI

on:
  push:
    # 以下のデプロイに紐づくブランチではciファイルは実行せずにcdファイル内で呼び出して実行するようにする
    branches-ignore:
      - develop
      - staging
      - production
  # https://docs.github.com/ja/actions/using-workflows/reusing-workflows
  # CDファイルから呼び出されるときにCDファイル内から環境変数を渡しこちらで受け取るようにしないと設定した環境変数が使えない
  workflow_call:
    secrets:
      RAILS_MASTER_KEY:
        required: true
      CC_TEST_REPORTER_ID:
        required: true

jobs:
  rspec:
    runs-on: ubuntu-latest
　　 # コミットメッセージに特定の文字列を入れるとCIをスキップできる
    if: ${{ !contains(github.event.commits.*.message, '[skip ci]') }}

    env:
      RAILS_ENV: test
      DB_HOST: 127.0.0.1
      DB_PORT: 33060
      DB_USERNAME: root
      DB_PASSWORD: ''
      RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }}
      CC_TEST_REPORTER_ID: ${{ secrets.CC_TEST_REPORTER_ID }}
      AWS_ACCESS_KEY_ID: AWS_ACCESS_KEY_ID # AWSアクセスキーを使ったアクセスが必要なテストがあれば適当な文字列でテスト環境内のみアクセスできるようにしておく
      AWS_SECRET_ACCESS_KEY: AWS_SECRET_ACCESS_KEY # AWSアクセスキーを使ったアクセスが必要なテストがあれば適当な文字列でテスト環境内のみアクセスできるようにしておく
      AWS_DEFAULT_REGION: ap-northeast-1
      AWS_DEFAULT_OUTPUT: json

    services:
      mysql:
        image: mysql:8.0
        ports:
          - 33060:3306
        env:
          MYSQL_ALLOW_EMPTY_PASSWORD: yes # mysqlのパスワードを使わない場合はyes
          BIND-ADDRESS: 0.0.0.0
        options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 # mysqlの疎通確認

    steps:
      # pumaのimageをbuild
      - uses: actions/checkout@v2
      - name: Check ./docker/puma/prod/Dockerfile
        uses: hadolint/hadolint-action@v1.5.0
        with:
          dockerfile: ./docker/puma/prod/Dockerfile
      # nginxのimageをbuild
      - name: Check ./docker/nginx/prod/Dockerfile
        uses: hadolint/hadolint-action@v1.5.0
        with:
          dockerfile: ./docker/nginx/prod/Dockerfile
      - name: Set up Ruby 2.7
        uses: ruby/setup-ruby@v1
        with:
          ruby-version: 2.7.3
          bundler-cache: true
      - name: Setup DB
        run: |
          sudo service mysql start
          bundle exec rails db:prepare
      # S3のモックを使いたいためminioを導入
      - name: Setup minio
        env:
          AWS_ACCESS_KEY_ID: minio_access_key
          AWS_SECRET_ACCESS_KEY: minio_secret_key
          AWS_EC2_METADATA_DISABLED: true
        run: |
          docker run -d -p 9000:9000 --name minio \
                     -e "MINIO_ACCESS_KEY=minio_access_key" \
                     -e "MINIO_SECRET_KEY=minio_secret_key" \
                     -v /tmp/data:/data \
                     -v /tmp/config:/root/.minio \
                     minio/minio server /data
          aws --endpoint-url http://localhost:9000/ s3 mb s3://<bucket名>
　　　 # rubocopを回す
      - name: Run RuboCop
        run: bundle exec rubocop
      # Code Climateにデータを送っている（入れてなければ必要なし）
      - name: Setup Code Climate test-reporter
        run: |
          curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter
          chmod +x ./cc-test-reporter
          ./cc-test-reporter before-build
　　　 # テストを回す(並列実行用のgem(parallel_tests)を入れている。入れてなければ普通にbundle exec rspecコマンドのみで良い)
      - name: Run RSpec
        env:
          AWS_S3_ENDPOINT: http://127.0.0.1:9000
        run: |
          ./cc-test-reporter before-build
          bundle exec rake parallel:create
          bundle exec rake parallel:prepare
          bundle exec rake parallel:spec
          ./cc-test-reporter after-build

```


## CDの設定

### GithubAcitonsで用意してくれているECS関連のプラグイン的なものを使う

- configure-aws-credentials
    - AWSへアクセスするときのAWS認証情報を設定
- amazon-ecr-login
    - ECRにログインする
- amazon-ecs-render-task-definition
    - タスク定義にイメージURIを挿入する
- amazon-ecs-deploy-task-definition
    - 挿入した最新イメージURIでタスク定義を更新してデプロイ(処理順的にはamazon-ecs-render-task-definitionの後に来るようにする)

### その他
- ワークフローの再利用して呼び出しCIを先行実行
- migrate用のタスク定義にmigrateコマンドを上書きして設定したものを予め作成しておき、自動で最新のimageでタスク定義を更新してタスク実行するところで少し苦戦した。

    - migrate用のタスク定義の中身を新しいimageに更新する処理を、``name: Render Amazon ECS migrate task definition with app container``のjobで行ってくれていると思っていたが、あくまで、imageをセットしているだけで、実際のタスク定義を最新のimageで更新する処理を行ってくれない模様
    - ``name: Revision to Amazon ECS migrate task``のjob内で、新しいimageを紐づけたタスク定義を新リビジョンとして更新するjobを明示的に実行すると新しいimageで更新されたタスク定義が作成されることを確認できた
    - その後、``name: Deploy to Amazon ECS migrate service``で最新Revisionのタスク定義を取得して、タスク定義を実行するとmigrate処理が完了する

- 指定したid内のjob内で、``echo "::set-output name=image::$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG"
``みたいな感じにしておくと、別のstepで、``${{ steps.build-image-app.outputs.image }}``とすれば変数として活用することが可能

- GitHub上に登録した環境変数は``${{ secrets.DEV_AWS_ACCESS_KEY_ID }}``みたいな要領で使える

- それ以外のところは通常のECSデプロイのフローの通り

### 設定ファイル

```cd.yml
name: CD
on:
  push:
    branches:
      - develop

jobs:
  # CIを先行実行(別のワークフロー（CI）をここで呼び出す)
  rspec:
    uses: ./.github/workflows/ci.yml
    # ここで別のワークフローに変数を渡している
    secrets:
      RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }}
      CC_TEST_REPORTER_ID: ${{ secrets.CC_TEST_REPORTER_ID }}

  # テスト完了後にデプロイ開始
  deploy:
    needs: [rspec] # rspec完了後にデプロイが実行されるようにしておく
    name: Deploy
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v2

      # 1. デプロイ用の環境変数を設定（デプロイの実行を環境ごとに分けるため）
      - name: Set env values for dev
        run: echo "TARGET=dev" >> $GITHUB_ENV

      # 2. AWS認証情報を設定(Assume Roleで一時的な権限付与をしている)
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v1
        with:
          aws-access-key-id: ${{ secrets.DEV_AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.DEV_AWS_SECRET_ACCESS_KEY }}
          aws-region: ap-northeast-1
          role-to-assume: ${{ secrets.DEV_AWS_ASSUME_ROLE_ARN }}
          role-duration-seconds: 1200 # Assume roleしてroleを引き受けている秒数

      # 3. AWS CLIのセット
      - name: Setup Python 3.7 for awscli
        uses: actions/setup-python@v1
        with:
          version: '3.7'
          architecture: 'x64'

      # 4. Amazon ECRにログイン
      - name: Login to Amazon ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v1

      # 5. NginxとRailsのDockerイメージをビルド
      - name: Build, tag, and push Nginx image to Amazon ECR
        id: build-image-web
        env:
          ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
          ECR_REPOSITORY: <プロジェクト名>-${{ env.TARGET }}-web
          IMAGE_TAG: ${{ github.sha }} # ランダムなタグ名をつける
        run: |
          docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG -f ./docker/nginx/dev_server/Dockerfile ./docker/nginx/dev_server
          docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
          echo "::set-output name=image::$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG"

      - name: Build, tag, and push Rails image to Amazon ECR
        id: build-image-app
        env:
          ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
          ECR_REPOSITORY: <プロジェクト名>-${{ env.TARGET }}-app
          IMAGE_TAG: ${{ github.sha }}
        run: |
          docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG -f ./docker/puma/dev_server/Dockerfile .
          docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
          echo "::set-output name=image::$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG"

      # 6. タスク定義の自動更新
      - name: Download task definition
        run: |
          aws ecs describe-task-definition --task-definition <プロジェクト名>-${{ env.TARGET }} --query taskDefinition > app-task-definition.json
          aws ecs describe-task-definition --task-definition <プロジェクト名>-${{ env.TARGET }}-worker --query taskDefinition > worker-task-definition.json
          aws ecs describe-task-definition --task-definition <プロジェクト名>-${{ env.TARGET }}-migrate --query taskDefinition > migrate-task-definition.json

      - name: Render Amazon ECS app task definition for web container
        id: render-web-container
        uses: aws-actions/amazon-ecs-render-task-definition@v1
        with:
          task-definition: app-task-definition.json
          container-name: <プロジェクト名>-${{ env.TARGET }}-web
          image: ${{ steps.build-image-web.outputs.image }}

      - name: Render Amazon ECS app task definition with app container
        id: render-app-container
        uses: aws-actions/amazon-ecs-render-task-definition@v1
        with:
          task-definition: ${{ steps.render-web-container.outputs.task-definition }}
          container-name: <プロジェクト名>-${{ env.TARGET }}-app
          image: ${{ steps.build-image-app.outputs.image }}

      - name: Render Amazon ECS worker task definition with app container
        id: render-worker-container
        uses: aws-actions/amazon-ecs-render-task-definition@v1
        with:
          task-definition: worker-task-definition.json
          container-name: <プロジェクト名>-${{ env.TARGET }}-worker
          image: ${{ steps.build-image-app.outputs.image }}

      - name: Render Amazon ECS migrate task definition with app container
        id: render-migrate-container
        uses: aws-actions/amazon-ecs-render-task-definition@v1
        with:
          task-definition: migrate-task-definition.json
          container-name: <プロジェクト名>-${{ env.TARGET }}-app-migrate
          image: ${{ steps.build-image-app.outputs.image }}

      # 7. タスク定義の実行
      # 新しいimageを紐付けたmigrate用のタスク定義の新しいRevisionを明示的に作成する必要がある
      - name: Revision to Amazon ECS migrate task
        uses: aws-actions/amazon-ecs-deploy-task-definition@v1
        with:
          task-definition: ${{ steps.render-migrate-container.outputs.task-definition }}
          cluster: <プロジェクト名>-${{ env.TARGET }}

      # 最新Revisionのmigrate用のタスク定義でmigrateを実行する
      - name: Deploy to Amazon ECS migrate service
        env:
          CLUSTER_ARN: ${{ secrets.DEV_CLUSTER_ARN }}
          ECS_SUBNET_ID: ${{ secrets.DEV_ECS_SUBNET_ID }}
          ECS_SECURITY_GROUP: ${{ secrets.DEV_ECS_SECURITY_GROUP }}
        run: |
          LATEST_REVISION=$(aws ecs list-task-definitions | jq -r '.taskDefinitionArns[]' | grep <プロジェクト名>-${{ env.TARGET }}-migrate: | tail -n1)
          aws ecs run-task --launch-type FARGATE --cluster $CLUSTER_ARN --task-definition $LATEST_REVISION --network-configuration "awsvpcConfiguration={subnets=[$ECS_SUBNET_ID],securityGroups=[$ECS_SECURITY_GROUP],assignPublicIp=DISABLED}" > run-task.log
          TASK_ARN=$(jq -r '.tasks[0].taskArn' run-task.log)
          aws ecs wait tasks-stopped --cluster $CLUSTER_ARN --tasks $TASK_ARN
          TASK_DEFINITION_ARN=$(aws ecs describe-tasks --cluster <プロジェクト名>-${{ env.TARGET }} --tasks $TASK_ARN --query "tasks[].taskDefinitionArn" --output text)
          echo ${TASK_DEFINITION_ARN}

      # 最新Revisionのapp用のタスク定義でappコンテナをデプロイする
      - name: Deploy to Amazon ECS app service
        uses: aws-actions/amazon-ecs-deploy-task-definition@v1
        with:
          task-definition: ${{ steps.render-app-container.outputs.task-definition }}
          service: <プロジェクト名>-${{ env.TARGET }}
          cluster: <プロジェクト名>-${{ env.TARGET }}

      # 最新Revisionのworker用のタスク定義でworkerコンテナをデプロイする
      - name: Deploy to Amazon ECS worker service
        uses: aws-actions/amazon-ecs-deploy-task-definition@v1
        with:
          task-definition: ${{ steps.render-worker-container.outputs.task-definition }}
          service: <プロジェクト名>-${{ env.TARGET }}-worker
          cluster: <プロジェクト名>-${{ env.TARGET }}

      # 8. 実行結果をslackに通知
      - name: Slack Notification on Success
        if: success()
        uses: rtCamp/action-slack-notify@v2.0.2
        env:
          SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_URL }}
          SLACK_USERNAME: GitHUb Actions
          SLACK_TITLE: Workflow Succeeded
          SLACK_ICON: https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png
          SLACK_MESSAGE: 'dev環境へのデプロイに成功しました。Run number : #${{ github.run_number }}'
          SLACK_COLOR: good

      - name: Slack Notification on Failure
        uses: rtCamp/action-slack-notify@v2.0.2
        if: failure()
        env:
          SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_URL }}
          SLACK_USERNAME: GitHUb Actions
          SLACK_TITLE: Workflow failed
          SLACK_ICON: https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png
          SLACK_MESSAGE: 'dev環境へのデプロイに失敗しました。Run number : #${{ github.run_number }}'
          SLACK_COLOR: danger

```



いかがでしょうか？？migrateのところとかは色々なやり方あると思うのでご意見いただきたいです。よろしくお願いいたします。