less is more

心のフルスタックエンジニア👨‍💻バイブスでコードを書いています🤘

Github ActionsでFargateをデプロイする

Github Actions

f:id:bluepixel:20200417003553p:plain

github.co.jp

これまではCodePipeline上に構築することが多かったんですが、もう少しサクッと作れないかなーと思い試してみました。

環境

  • Ruby製のアプリケーション 。
  • コンテナは、pumaを動かすアプリケーションとNginxの2台構成。
  • ECS Serviceとして常駐させる。
  • 起動タイプはFargate。

f:id:bluepixel:20200417004959p:plain

ECSの設定、解説は特にしません。
VPC等々のリソースも作成済みとします。

そのあたり気になる方は、こちらの記事でCloudFormationで一発で作るテンプレートを公開してるのでどうぞ。

qiita.com

ワークフローを作っていく

Actionsにはいくつかテンプレートが用意されていて、対話形式でWeb上で作っていくことができるのですが、今回は先にdevelopブランチにpushしたかったので、ymlをコピーしてきて手元でカスタマイズしました。

f:id:bluepixel:20200417104907p:plain

Deploy to Amazon ECSをベースにします。

on:
  push:
    branches:
      - develop

name: Deploy to staging ECS

jobs:
  deploy:
    name: Deploy
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v2

      - name: Configure AWS credentials
        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

      - name: Login to Amazon ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v1

      - name: Build, tag, and push app image to Amazon ECR
        id: build-app-image
        env:
          ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
          ECR_REPOSITORY: app-staging
          IMAGE_TAG: ${{ github.sha }}
        run: |
          docker build --build-arg DEPLOYMENT=true -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
          docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
          echo "::set-output name=image::$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG"
   
      - name: Build, tag, and push nginx image to Amazon ECR
        id: build-nginx-image
        env:
          ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
          ECR_REPOSITORY: app-staging
          IMAGE_TAG: ${{ github.sha }}
        run: |
          docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:nginx_$IMAGE_TAG ./docker/nginx/
          docker push $ECR_REGISTRY/$ECR_REPOSITORY:nginx_$IMAGE_TAG
          echo "::set-output name=image::$ECR_REGISTRY/$ECR_REPOSITORY:nginx_$IMAGE_TAG"

      - name: Fill in the new app image ID in the Amazon ECS task definition
        id: task-def1
        uses: aws-actions/amazon-ecs-render-task-definition@v1
        with:
          task-definition: staging-task-definition.json
          container-name: app
          image: ${{ steps.build-app-image.outputs.image }}

      - name: Fill in the new nginx image ID in the Amazon ECS task definition
        id: task-def2
        uses: aws-actions/amazon-ecs-render-task-definition@v1
        with:
          task-definition: ${{ steps.task-def1.outputs.task-definition }}
          container-name: nginx
          image: ${{ steps.build-nginx-image.outputs.image }}

      - name: Deploy Amazon ECS task definition
        uses: aws-actions/amazon-ecs-deploy-task-definition@v1
        with:
          task-definition: ${{ steps.task-def2.outputs.task-definition }}
          service: app-staging
          cluster: app-staging
          wait-for-service-stability: true

      - name: Slack notification
        env:
          SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
        uses: Ilshidur/action-slack@master
        with:
          args: '(staging) app deployed to ECS'

一つずつ見ていきます。

トリガー

on:
  push:
    branches:
      - develop

developブランチへのプッシュにしています。 ここは様々な条件を付けることができて、releaseやtag、特定のファイルが変更されたら、という条件も可能なよう。
また、cron式によるスケジューラや、Github外部のイベントも設定できるしい。

ワークフロー名

name: Deploy to staging ECS

ここに表示される名前です。

f:id:bluepixel:20200417105829p:plain

ジョブ

jobs:
  deploy:
    name: Deploy
    runs-on: ubuntu-latest
    steps:

今回はdeployという一つのジョブにまとめました。このジョブという単位は、並列で実行することができる(依存関係も定義できる)ので、効率的に実行するためには適切に分割する設計が求められます。

RunnerはUbuntuの最新バージョンです。
Github Actionsではワークフローを実行するマシンをRunnerと呼びます。

ステップ

stepsで各処理を記述します。 ほぼほぼテンプレートのまま流用しているのですが、イメージのビルドを2つやる必要があるところと、最後にSlackに通知を飛ばすところを変更・追加しています。

チェックアウト

- name: Checkout
  uses: actions/checkout@v2

特に解説はいらないですね。
usesは、他で定義されているアクションを利用するという意味です。
ライブラリみたいなものと考えておけばいいと思います。

AWS認証

- name: Configure AWS credentials
  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
  1. Github Actionsが利用するAPIの許可を与えたAWSユーザーを用意します。
  2. そいつの認証情報をSecretsに保存します。
  3. aws-regionを書き換えます。

今回必要となるIAMポリシーはこんな感じです。 ECRへのプッシュ、タスク定義の更新、サービスへのデプロイなどです。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "0",
            "Effect": "Allow",
            "Action": [
                "ecs:RegisterTaskDefinition",
                "ecr:GetAuthorizationToken"
            ],
            "Resource": "*"
        },
        {
            "Sid": "1",
            "Effect": "Allow",
            "Action": [
                "ecs:UpdateService",
                "iam:PassRole",
                "ecr:GetDownloadUrlForLayer",
                "ecr:BatchGetImage",
                "ecr:CompleteLayerUpload",
                "ecs:DescribeServices",
                "ecr:UploadLayerPart",
                "ecr:InitiateLayerUpload",
                "ecr:BatchCheckLayerAvailability",
                "ecr:PutImage"
            ],
            "Resource": [
                "arn:aws:ecr:*:*:repository/app-staging",
                "arn:aws:ecs:*:*:service/app-staging/app-staging",
                "arn:aws:iam::*:role/app-staging-ecs-task",
            ]
        }
    ]
}

これをアタッチしたユーザーを作成して、アクセスキーとシークレットキーをSettingsSecretsに保存します。

f:id:bluepixel:20200417111526p:plain

ECRログイン

- name: Login to Amazon ECR
  id: login-ecr
  uses: aws-actions/amazon-ecr-login@v1

特に解説いらないですね。 いつもやってるやつです。

Dockerビルド

- name: Build, tag, and push app image to Amazon ECR
  id: build-app-image
  env:
    ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
    ECR_REPOSITORY: app-staging
    IMAGE_TAG: ${{ github.sha }}
  run: |
    docker build --build-arg DEPLOYMENT=true -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
    docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
    echo "::set-output name=image::$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG"

ECR_REPOSITORYリポジトリ名を記述します。
イメージタグにはgitのコミットハッシュをつけるようになっているのでそのまま。

runに実行するコマンドを書いていきます。
--build-arg とかプロジェクト固有のオプションがある場合は変更します。
最後のset-outputで、ビルドされたイメージ名を後のステップで参照できるようにしています。

テンプレートは1つだけでしたが、Nginxの方のビルドを追加します。
ECRリポジトリは共通のものを使い、タグのプレフィックスnginx_をつけることとします。

- name: Build, tag, and push nginx image to Amazon ECR
  id: build-nginx-image
  env:
    ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
    ECR_REPOSITORY: app-staging
    IMAGE_TAG: ${{ github.sha }}
  run: |
    docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:nginx_$IMAGE_TAG ./docker/nginx/
    docker push $ECR_REGISTRY/$ECR_REPOSITORY:nginx_$IMAGE_TAG
    echo "::set-output name=image::$ECR_REGISTRY/$ECR_REPOSITORY:nginx_$IMAGE_TAG"

タスク定義の更新

- name: Fill in the new app image ID in the Amazon ECS task definition
  id: task-def1
  uses: aws-actions/amazon-ecs-render-task-definition@v1
  with:
    task-definition: staging-task-definition.json
    container-name: app
    image: ${{ steps.build-app-image.outputs.image }}

新しくビルドされたイメージを使うように、タスク定義の新しいリビジョンを作成します。

タスク定義が記述された staging-task-definition.json というファイルを作成してプロジェクトのルートに置きます。

既存のものから雛形を作るのが楽なので以下のコマンドを実行します。

aws ecs describe-task-definition --task-definition app-staging:1 --query taskDefinition > staging-task-definition.json

必要ない属性が記述されているとこんな感じのWarningが出るので、適宜削除します。

f:id:bluepixel:20200417112855p:plain

{
    "containerDefinitions": [
        {
            "name": "app",
            "image": "",
            "cpu": 0,
            "memoryReservation": 512,
            "essential": true,
            "command": [
                "bundle",
                "exec",
                "pumactl",
                "start"
            ],
            "environment": [
                {
                    "name": "APP_ENV",
                    "value": "production"
                }
            ],
            "logConfiguration": {
                "logDriver": "awslogs",
                "options": {
                    "awslogs-create-group": "true",
                    "awslogs-group": "app-staging",
                    "awslogs-region": "ap-northeast-1",
                    "awslogs-stream-prefix": "app-app"
                }
            },
            "healthCheck": {
                "command": [
                    "CMD-SHELL",
                    "ps aux | grep puma | grep -v grep",
                    "|| exit 1"
                ],
                "interval": 30,
                "timeout": 5,
                "retries": 3,
                "startPeriod": 15
            }
        },
        {
            "name": "nginx",
            "image": "",
            "cpu": 0,
            "memoryReservation": 512,
            "portMappings": [
                {
                    "containerPort": 80,
                    "hostPort": 80,
                    "protocol": "tcp"
                }
            ],
            "essential": true,
            "volumesFrom": [
                {
                    "sourceContainer": "app",
                    "readOnly": true
                }
            ],
            "dependsOn": [
                {
                    "containerName": "app",
                    "condition": "HEALTHY"
                }
            ],
            "logConfiguration": {
                "logDriver": "awslogs",
                "options": {
                    "awslogs-create-group": "true",
                    "awslogs-group": "app-staging",
                    "awslogs-region": "ap-northeast-1",
                    "awslogs-stream-prefix": "app-nginx"
                }
            },
            "healthCheck": {
                "command": [
                    "CMD-SHELL",
                    "curl -f http://localhost/",
                    "|| exit 1"
                ],
                "interval": 30,
                "timeout": 5,
                "retries": 3,
                "startPeriod": 30
            }
        }
    ],
    "family": "app-staging",
    "taskRoleArn": "arn:aws:iam::{AWS_ID}:role/app-staging-ecs-task",
    "executionRoleArn": "arn:aws:iam::{AWS_ID}:role/app-staging-ecs-task",
    "networkMode": "awsvpc",
    "requiresCompatibilities": [
        "FARGATE"
    ],
    "cpu": "256",
    "memory": "1024"
}

このタスク定義に、先ほどビルドしたイメージIDを突っ込んでくれるのがこのアクションの本質です。

そして今回はコンテナが2台あるので続けてこのアクションを実行します。
ポイントは、タスク定義ファイルに、前のステップで出力されたタスク定義を指定しているところです。
こうしないと、appコンテナのイメージIDが注入される前のタスク定義に戻ってしまいます。
複数コンテナに一括で注入できるような仕様は今の所ないらしいです。

- name: Fill in the new nginx image ID in the Amazon ECS task definition
  id: task-def2
  uses: aws-actions/amazon-ecs-render-task-definition@v1
  with:
    task-definition: ${{ steps.task-def1.outputs.task-definition }}
    container-name: nginx
    image: ${{ steps.build-nginx-image.outputs.image }}

サービスの更新

- name: Deploy Amazon ECS task definition
  uses: aws-actions/amazon-ecs-deploy-task-definition@v1
    with:
      task-definition: ${{ steps.task-def2.outputs.task-definition }}
      service: app-staging
      cluster: app-staging
      wait-for-service-stability: true

できあがったタスク定義のリビジョンを使ってECSサービスを更新します。
wait-for-service-stabilityは、サービスが安定するまでステップの成否判定を待つオプションです。

Slack通知

- name: Slack notification
  env:
    SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
  uses: Ilshidur/action-slack@master
  with:
    args: '(staging) app deployed to ECS'

コミュニティのアクションがいろいろあるんですが、Webフックが使いたかったので、それに対応していてスターが多いものを選びました。

github.com

WebフックをSecretsに保存します。
argsにはメッセージを記述します。
チャンネル、ユーザー名、アバター画像はデフォルトのままです。

まとめ

Github Actionsの作法が分かればCodePipelineよりサクッと組めると思います。
CloudFormationのテンプレートとか持ってなくて一から作る場合は、圧倒的にこちらをおすすめします。初学者にも分かりやすい。

Lambdaとか手動の承認プロセスとか凝ったことをやりたい場合はAWSの方に軍配があがるかなと。AWSの機能だから当たり前なんですけど。

Githubに結びついてる分、デプロイファーストへの敷居下がりますね〜