RailsアプリケーションをGithub Actionsを用いてECS Fargateに任意のタイミングでデプロイする

# はじめに
需要があるのかわからなかったので、しばらく記事を途中まで書いて放置していたのですが、せっかく下書きにあるのと、誰かの役に立つかもしれないと思い、投稿します。
以前の記事[FastAPI + nginx on ECS Fargate](https://qiita.com/yuyasat/items/e3c5e9b9cfcbafce6c56)では、ECS Fargateにデプロイするところを説明しましたが、本記事では、Github ActionsでRailsプロダクトをECS Fargateにデプロイしたので紹介します。アプリケーションがFastAPIだったり、Railsだったりしていますが、本質的には変わらないです。（仕事で両方並行して触る機会があり・・。）また、CodePipelineなどを用いてもいいと思いますが、今回は以下の「実現したいこと」の理由によりGithub　Actionsでやってみました。

# 実現したいこと
 1. Github Actionsを用いて、ECS Fargateにデプロイしたい
    - トリガーとして、developにmergeされたときなどという制御ではなく、任意のタイミングでデプロイしたい場合など、Github Actionsによる操作でデプロイできるのは便利。
 2. Docker Imageのタグは任意で設定できるようにしたい
    - 例えばコミットのハッシュ値でタグを打つ例もありますが、自分たちでコントロールしたいなど。
 3. 任意のタイミングで（AWSアカウントを保持していない人でも）デプロイできるようにしたい
    - 例えば業務委託の方にはAWSアカウントは付与していない場合、productionはともかくstagingはデプロイしてもらいたい場合など。
 4. タスク定義をAWSのマネジメントコンソールやterraformなどで変更した場合、その変更は取り込みたい（リポジトリにtask_definition.jsonファイルを保持しているとこちらも変更する必要があり、変更忘れのリスクがある）。
    - 例えばインスタンスのスペックなどはアプリケーションエンジニア側ではなく、インフラ側や適切な権限を持つ人のみが管理したい。
    - したがって、環境に反映されているタスク定義をベースに（DockerのImageタグだけつけかえて）デプロイしたい。

# 前提
いくつか前提条件を共有します。
 1. 環境はstgとprodの２環境ある（あるいは省略せずにstaging、production）
 2. stgとprodの２環境とも`RAILS_ENV`は`production`で動かしたい
    - `config/environments/production.rb`の設定を`stg`と`prod`で同じにしたいため。
 3. 一方で、外部サービスとの接続情報といった秘匿値などは`stg`と`prod`で異なるようにしたい
 4. jsのビルドをDockerfile内で行なっているが、jsビルド時に環境変数を渡しており、`stg`と`prod`で別のDocker Imageをビルドしている
 5. 「実現したいこと」の4に関連するが、Github Actionsでのデプロイは、アプリケーション側の更新だけできればよく、インスタンスのスペック変更などはできる必要はない

2,3に関しては、秘匿値はRailsのcredentialsを利用して管理しますが、ECS Fargateの環境変数に`TIER_ENV`というものを導入し、この変数でcredentialsの制御をしています。具体的には、`config/environments/production.rb`にて
```ruby
Rails.application.configure do
 # 中略
  config.credentials.content_path = Rails.root.join("config/credentials/#{ENV['TIER_ENV']}.yml.enc")
  config.credentials.key_path = Rails.root.join("config/credentials/#{ENV['TIER_ENV']}.key")
end
```
のように設定しています。


# 環境ごとの制御とDocker Imageタグの任意設定
environmentに関しては、Githubの機能を利用します。`Settings`→`Code and automation`の`Envorionments`にて、`production`と`staging`を定義しました。GithubActions内のビルドで用いる秘匿値に関しては、GithubのEnvironments内のEnvironment secretsで定義するようにします。例えば、
 - AWS_IAM_ROLE_ARN: DockerのImageビルドやデプロイで利用するIAM ROLEのARN
 - AWS_ACCOUNT_ID: AWSのACCOUNT_ID（そこまで秘匿する値ではないですが念のため）
 - RAILS_MASTER_KEY: Railsのcredentialsでmaster.key

などの値をsecretsに定義しました。
Github Actionsでのデプロイ時には、環境（productionかstaging）を選択できるようにし、Docker Imageのタグも入力できるようにしたいので、`.github/workflows/deploy.yml`には次のように定義しました。ここで、vertagというのがDockerのImageにつけるタグ名を格納する変数になります。

```yaml
on:
  workflow_dispatch:
    inputs:
      environment:
        type: environment
        default: staging
      vertag:
        required: true
```
そうすると、Github Actions上では、下図のようにenvironmentを選択できたりvertagを入力できるようになります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/121304/b32c1d0c-b558-b81e-31cd-5ce72eb93805.png)

また、設定したsecretsは以下のようにして環境変数にセットできます。（AWS_REGIONは秘匿値とも言えないのレベルなのでベタ書きしています）
```
env:
  AWS_REGION: ap-northeast-1
  AWS_IAM_ROLE_ARN: ${{ secrets.AWS_IAM_ROLE_ARN }}
  AWS_ACCOUNT_ID: ${{ secrets.AWS_ACCOUNT_ID }}
  RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }}
```

# タスク定義の自動登録とECS Fargateへのデプロイ
ここから先は、主に[公式ドキュメント](https://docs.github.com/ja/actions/deployment/deploying-to-your-cloud-provider/deploying-to-amazon-elastic-container-service)通りなのですが、少し異なる部分もあるので、載せていきます。
jobsの冒頭の`Configure AWS credentials`ですが、AWS_ACCESS_KEY_IDやAWS_SECRET_ACCESS_KEYを利用してもいいですが、自分の場合、AWS_IAM_ROLE_ARNを利用しています。

```yaml
jobs:
  deploy:
    name: Deploy
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write
    steps:
    - name: Checkout
      uses: actions/checkout@v3
    - name: Configure AWS credentials
      uses: aws-actions/configure-aws-credentials@v1
      with:
        aws-region: ${{ env.AWS_REGION }}
        role-to-assume: ${{ env.AWS_IAM_ROLE_ARN }}
```
次はECRへのログインですが、こちらは公式と同じですが、一部変数を用いています。
```yaml
    - name: Login to Amazon ECR
      uses: aws-actions/amazon-ecr-login@v1
      env:
        ECR_REGISTRY: ${{ env.ENV_PREFIX }}.dkr.ecr.${{ env.AWS_REGION }}.amazonaws.com
```

`run`の中では、Githubで設定したEnvironmentは`production`や`staging`なのですが、AWS上では省略して`prod`や`stg`と使っている箇所もあるため、`ENV_PREFIX`という変数を用意しています。`run`の中の大まかな流れは、
1. 環境に応じて環境変数を適宜定義
2. Docker Imageのビルド
3. 新しいタスク定義の登録
4. CodeDeployを用いてデプロイ

となっています。ECSのデプロイの流れとしては、Docker Imageのビルド、タスク定義の登録、デプロイという流れになると思いますので、その流れを実施しているだけになります。
工夫点としては、新しいタスク定義の登録のところかと思います。
```yaml
    - name: my-app Build Image And Deploy To ECS
      env:
        VERTAG: ${{ github.event.inputs.vertag }}
        TIER_ENV: ${{ github.event.inputs.environment }}
      run: |
        # 1. 環境変数を適宜定義
        if [ "$TIER_ENV" = "production" ]; then
          ENV_PREFIX=prod
          # credential用のkeyをの準備
          echo $RAILS_MASTER_KEY > config/credentials/production.key
        else
          ENV_PREFIX=stg
          echo $RAILS_MASTER_KEY > config/credentials/staging.key
        fi
        ## タスク定義を環境ごとに用意している
        TASK_DEFINITION=my-app-$ENV_PREFIX
        ## CodeDeployのアプリケーション名とデプロイグループを変数に格納
        CODE_DEPLOY_APPLICATION_NAME=$ENV_PREFIX-my-app_web
        CODE_DEPLOY_DEPLOY_GROUP=$ENV_PREFIX-my-app_web
        
        # 2. Docker Imageのビルド
　　　　　　　　　　　　　　　　## vertagで指定されたImageがある場合は、Docker Imageのビルドをスキップする
        IMAGE_META=$(bash -c "aws ecr describe-images --repository-name=my-app_rails --image-ids=imageTag=$VERTAG 2> /dev/null" || true)
        pwd
        if [ "$IMAGE_META" = "" ]; then
          TIER_ENV=$TIER_ENV docker compose -f docker-compose.ecs_fargate.yml build
          docker tag my-app_rails:latest $AWS_ACCOUNT_ID.dkr.ecr.ap-northeast-1.amazonaws.com/my-app_rails:$VERTAG
          docker push $AWS_ACCOUNT_ID.dkr.ecr.ap-northeast-1.amazonaws.com/my-app_rails:$VERTAG
        fi

        # 3. 新しいタスク定義の登録
        # task-definition.jsonなどをリポジトリに保持していてもいいが、AWSコンソール上から更新された場合に戻ってしまうので、現在適用されている最新のタスク定義を持ってきて、さらに、DockerのImageタグのところをGithubAction実行時のvertagで指定されたものに変更して、input_json変数に格納している
        input_json=$(aws ecs describe-task-definition --task-definition $TASK_DEFINITION | jq '.taskDefinition | del(.taskDefinitionArn,.status,.compatibilities,.requiresAttributes,.revision,.registeredAt,.registeredBy)' | jq --arg VERTAG $VERTAG '(.containerDefinitions[] | select(.name == "rails") | .image) |= "$AWS_ACCOUNT_ID.dkr.ecr.ap-northeast-1.amazonaws.com/my-app_rails:" + $VERTAG')
        # 上記で格納したinput_jsonを利用して新しいタスク定義を登録
        result_json=$(aws ecs register-task-definition --cli-input-json "$input_json")

        # 4. CodeDeployを用いてデプロイ
        # タスク定義のarnをresult_jsonから取得しtask_definition_arn変数に格納
        task_definition_arn=$(echo $result_json | jq -r .taskDefinition.taskDefinitionArn)
        aws deploy create-deployment \
          --application-name $CODE_DEPLOY_APPLICATION_NAME \
          --deployment-group-name $CODE_DEPLOY_DEPLOY_GROUP \
          --region ap-northeast-1 \
          --revision '{"revisionType": "AppSpecContent", "appSpecContent": {"content": "{\"version\": 1, \"Resources\": [{\"TargetService\": {\"Type\": \"AWS::ECS::Service\", \"Properties\": {\"TaskDefinition\": \"'$task_definition_arn'\", \"LoadBalancerInfo\": {\"ContainerName\": \"nginx\", \"ContainerPort\": 80}}}}]}"}}'
```

# 「3. 新しいタスク定義の登録」のところをもう少し詳細に
この処理の中で大切なのは、「3. 新しいタスク定義の登録」になりますので、ここの部分を取り出して説明します。

```
aws ecs describe-task-definition --task-definition $TASK_DEFINITION
```
を実行すると、具体的には、
```json
{
    "taskDefinition": {
        "taskDefinitionArn": "arn:aws:ecs:ap-northeast-1:xxxxxxxxxxxx:task-definition/my-app:123",
        "containerDefinitions": [
            {
                "name": "rails",
                "image": "xxxxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/my-app_rails:1.0.0.1",
                "cpu": 0,
                "portMappings": [],
                "essential": true,
                "environment": [
                    {
                        "name": "TIER_ENV",
                        "value": "staging"
                    },
                    # その他の環境変数
                ],
                "mountPoints": [],
                "volumesFrom": [],
                "logConfiguration": {
                    "logDriver": "awslogs",
                    "options": {
                        "awslogs-group": "/aws/ecs/stg-my-app",
                        "awslogs-region": "ap-northeast-1",
                        "awslogs-stream-prefix": "container-stdout"
                    }
                }
            },
            {
                "name": "nginx",
                "image": "xxxxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/my-app_nginx:0.0.1",
                "cpu": 0,
                "portMappings": [
                    {
                        "containerPort": 80,
                        "hostPort": 80,
                        "protocol": "tcp"
                    }
                ],
                "essential": true,
                "environment": [],
                "mountPoints": [],
                "volumesFrom": [
                    {
                        "sourceContainer": "rails"
                    }
                ],
                "logConfiguration": {
                    "logDriver": "awslogs",
                    "options": {
                        "awslogs-group": "/aws/ecs/stg-my-app",
                        "awslogs-region": "ap-northeast-1",
                        "awslogs-stream-prefix": "container-stdout"
                    }
                }
            }
        ],
        "family": "my-app-stg",
        "taskRoleArn": "arn:aws:iam::xxxxxxxxxxxx:role/stg-my-app-task",
        "executionRoleArn": "arn:aws:iam::xxxxxxxxxxxx:role/stg-my-app-task-execution",
        "networkMode": "awsvpc",
        "revision": 463,
        "volumes": [],
        "status": "ACTIVE",
        "requiresAttributes": [
            {
                "name": "com.amazonaws.ecs.capability.logging-driver.awslogs"
            },
            {
                "name": "ecs.capability.execution-role-awslogs"
            },
            {
                "name": "com.amazonaws.ecs.capability.ecr-auth"
            },
            {
                "name": "com.amazonaws.ecs.capability.docker-remote-api.1.19"
            },
            {
                "name": "com.amazonaws.ecs.capability.task-iam-role"
            },
            {
                "name": "ecs.capability.execution-role-ecr-pull"
            },
            {
                "name": "com.amazonaws.ecs.capability.docker-remote-api.1.18"
            },
            {
                "name": "ecs.capability.task-eni"
            }
        ],
        "placementConstraints": [],
        "compatibilities": [
            "EC2",
            "FARGATE"
        ],
        "requiresCompatibilities": [
            "FARGATE"
        ],
        "cpu": "1024",
        "memory": "2048",
        "registeredAt": "2023-04-07T18:08:30.362000+09:00",
        "registeredBy": "arn:aws:sts::xxxxxxxxxxxx:assumed-role/my_app_from_github_actions/GitHubActions"
    },
    "tags": []
}
```
の様なJSONが帰ってきます。ここでデプロイ時に必要なのは、`containerDefinitions`, `family`, `taskRoleArn`, `executionRoleArn`, `networkMode`, `volumes`, `placementConstraints`, `requiresCompatibilities`, `cpu`, `memory`なので、
```
aws ecs describe-task-definition --task-definition $TASK_DEFINITION
 | jq '.taskDefinition | del(.taskDefinitionArn,.status,.compatibilities,.requiresAttributes,.revision,.registeredAt,.registeredBy)'
```
のように`jq`を利用することで不要な属性を取り除き、以下の様なJSONを得ることができます。
```json
{
  "containerDefinitions": [
    {
      "name": "rails",
      "image": "xxxxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/my-app_rails:stg-1.3.1.2"
      // 略
    }
  ],
  "family": "my-app-stg",
  "taskRoleArn": "arn:aws:iam:: xxxxxxxxxxxx:role/stg-my-app-task",
  "executionRoleArn": "arn:aws:iam::570924330089:role/stg-my-app-task-execution",
  "networkMode": "awsvpc",
  "volumes": [],
  "placementConstraints": [],
  "requiresCompatibilities": [
    "FARGATE"
  ],
  "cpu": "1024",
  "memory": "2048"
}
```
さらに、DockerImageのタグを変更してあげる必要があるので、再び`jq`を利用し、Imageタグを変更しています。
```
 | jq --arg VERTAG $VERTAG '(.containerDefinitions[] | select(.name == "rails") | .image) |= "$AWS_ACCOUNT_ID.dkr.ecr.ap-northeast-1.amazonaws.com/my-app_rails:" + $VERTAG')
        
```

# ファイルサンプル
参考までに、`docker compose`のymlとGithub Actionsで利用したWorkflowのymlを提示しておきます。`docker compose`はRailsだけでなくnginxのイメージもビルドしたいため、利用していますが、単一イメージで問題なければ`docker build`で良いと思います。


## docker-compose.ecs_fargate.yml
```yaml
version: '3.4'
services:
  rails:
    build:
      context: .
      dockerfile: Dockerfile-ecs-fargate
      args:
        TIER_ENV: $TIER_ENV
    environment:
      RAILS_ENV: production
      RAILS_LOG_TO_STDOUT: 'true'
  nginx:
    build:
      context: .
      dockerfile: ./ecs_fargate/nginx/Dockerfile
```

## .github/workflows/deploy.yml
```yaml
name: my-app Deploy To ECS

on:
  workflow_dispatch:
    inputs:
      environment:
        type: environment
        default: staging
      vertag:
        required: true

env:
  AWS_REGION: ap-northeast-1
  AWS_IAM_ROLE_ARN: ${{ secrets.AWS_IAM_ROLE_ARN }}
  AWS_ACCOUNT_ID: ${{ secrets.AWS_ACCOUNT_ID }}
  RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }}
jobs:
  deploy:
    name: Deploy
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write
    steps:
    - name: Checkout
      uses: actions/checkout@v3
    - name: Configure AWS credentials
      uses: aws-actions/configure-aws-credentials@v1
      with:
        aws-region: ${{ env.AWS_REGION }}
        role-to-assume: ${{ env.AWS_IAM_ROLE_ARN }}
    - name: Login to Amazon ECR
      uses: aws-actions/amazon-ecr-login@v1
      env:
        ECR_REGISTRY: ${{ env.AWS_ACCOUNT_ID }}.dkr.ecr.${{ env.AWS_REGION }}.amazonaws.com
    - name: my-app Build Image And Deploy To ECS
      env:
        VERTAG: ${{ github.event.inputs.vertag }}
        TIER_ENV: ${{ github.event.inputs.environment }}
      run: |
        if [ "$TIER_ENV" = "production" ]; then
          ENV_PREFIX=prod
          echo $RAILS_MASTER_KEY > config/credentials/production.key
        else
          ENV_PREFIX=stg
          echo $RAILS_MASTER_KEY > config/credentials/staging.key
        fi
        TASK_DEFINITION=my-app-${ENV_PREFIX}

        CODE_DEPLOY_APPLICATION_NAME=$ENV_PREFIX-my-app_web
        CODE_DEPLOY_DEPLOY_GROUP=$ENV_PREFIX-my-app_web

        IMAGE_META=$(bash -c "aws ecr describe-images --repository-name=my-app_rails --image-ids=imageTag=$VERTAG 2> /dev/null" || true)
        pwd
        if [ "$IMAGE_META" = "" ]; then
          TIER_ENV=$TIER_ENV docker compose -f docker-compose.ecs_fargate.yml build
          docker tag my-app_rails:latest $AWS_ACCOUNT_ID.dkr.ecr.ap-northeast-1.amazonaws.com/my-app_rails:$VERTAG
          docker push $AWS_ACCOUNT_ID.dkr.ecr.ap-northeast-1.amazonaws.com/my-app_rails:$VERTAG
        fi

        input_json=$(aws ecs describe-task-definition --task-definition $TASK_DEFINITION | jq '.taskDefinition | del(.taskDefinitionArn,.status,.compatibilities,.requiresAttributes,.revision,.registeredAt,.registeredBy)' | jq --arg VERTAG $VERTAG '(.containerDefinitions[] | select(.name == "rails") | .image) |= "$AWS_ACCOUNT_ID.dkr.ecr.ap-northeast-1.amazonaws.com/my-app_rails:" + $VERTAG')
        result_json=$(aws ecs register-task-definition --cli-input-json "$input_json")

        task_definition_arn=$(echo $result_json | jq -r .taskDefinition.taskDefinitionArn)
        echo $task_definition_arn
        aws deploy create-deployment \
          --application-name $CODE_DEPLOY_APPLICATION_NAME \
          --deployment-group-name $CODE_DEPLOY_DEPLOY_GROUP \
          --region ap-northeast-1 \
          --revision '{"revisionType": "AppSpecContent", "appSpecContent": {"content": "{\"version\": 1, \"Resources\": [{\"TargetService\": {\"Type\": \"AWS::ECS::Service\", \"Properties\": {\"TaskDefinition\": \"'$task_definition_arn'\", \"LoadBalancerInfo\": {\"ContainerName\": \"nginx\", \"ContainerPort\": 80}}}}]}"}}'
```