LoginSignup
5
3

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

Last updated at Posted at 2023-03-27

はじめに

需要があるのかわからなかったので、しばらく記事を途中まで書いて放置していたのですが、せっかく下書きにあるのと、誰かの役に立つかもしれないと思い、投稿します。
以前の記事FastAPI + nginx on ECS Fargateでは、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の2環境ある(あるいは省略せずにstaging、production)
  2. stgとprodの2環境ともRAILS_ENVproductionで動かしたい
    • config/environments/production.rbの設定をstgprodで同じにしたいため。
  3. 一方で、外部サービスとの接続情報といった秘匿値などはstgprodで異なるようにしたい
  4. jsのビルドをDockerfile内で行なっているが、jsビルド時に環境変数を渡しており、stgprodで別のDocker Imageをビルドしている
  5. 「実現したいこと」の4に関連するが、Github Actionsでのデプロイは、アプリケーション側の更新だけできればよく、インスタンスのスペック変更などはできる必要はない

2,3に関しては、秘匿値はRailsのcredentialsを利用して管理しますが、ECS Fargateの環境変数にTIER_ENVというものを導入し、この変数でcredentialsの制御をしています。具体的には、config/environments/production.rbにて

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の機能を利用します。SettingsCode and automationEnvorionmentsにて、productionstagingを定義しました。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につけるタグ名を格納する変数になります。

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

そうすると、Github Actions上では、下図のようにenvironmentを選択できたりvertagを入力できるようになります。
image.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へのデプロイ

ここから先は、主に公式ドキュメント通りなのですが、少し異なる部分もあるので、載せていきます。
jobsの冒頭のConfigure AWS credentialsですが、AWS_ACCESS_KEY_IDやAWS_SECRET_ACCESS_KEYを利用してもいいですが、自分の場合、AWS_IAM_ROLE_ARNを利用しています。

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へのログインですが、こちらは公式と同じですが、一部変数を用いています。

    - 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はproductionstagingなのですが、AWS上では省略してprodstgと使っている箇所もあるため、ENV_PREFIXという変数を用意しています。runの中の大まかな流れは、

  1. 環境に応じて環境変数を適宜定義
  2. Docker Imageのビルド
  3. 新しいタスク定義の登録
  4. CodeDeployを用いてデプロイ

となっています。ECSのデプロイの流れとしては、Docker Imageのビルド、タスク定義の登録、デプロイという流れになると思いますので、その流れを実施しているだけになります。
工夫点としては、新しいタスク定義の登録のところかと思います。

    - 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

を実行すると、具体的には、

{
    "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を得ることができます。

{
  "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

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

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}}}}]}"}}'

備考

今回の紹介ではScheduled Tasks

5
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
3