GitHub Actionsでデプロイを並列に実行させてCI/CDを高速化してみた

2021.06.22

開発の規模が大きくなると、CI/CDに時間がかかるようになります。特にクラウド環境を用いた開発で、インフラ構成までコードで管理している場合、差分の確認やインフラサービスの更新で処理の待ち時間が発生します。

各機能やサービスに依存関係がないのであれば、処理を並列に実行することで、デプロイ等にかかる時間を短縮することが出来ます。デプロイ以外にもビルドやテストで時間がかかっているのであれば、機能単位などに分割して並列に実行させるのも良いと思います。

本記事ではAWS環境へのデプロイをGitHub Actionsで並列に実行させてみます。

GitHub Actions並列デプロイ

ワークフローを実装

AWS環境にデプロイするワークフローを実装します。.github/workflowsにYAMLファイルを作成すると、プッシュ時にGitHub Actionsがワークフローを実行します。

以下のワークフローでは、指定したブランチにプッシュされた際、パッケージのインストールやビルドとテスト、ブランチに対応したAWS環境への並列デプロイを実行します。

.github/workflows/deploy-aws.yml

name: Deploy AWS
env:
  PROJECT_NAME: sample
on:
  push:
    branches:
      - develop
      - release
      - main
jobs:
  setup:
    name: Setup
    runs-on: ubuntu-latest
    timeout-minutes: 5
    outputs:
      AWS_ACCOUNT_ID: ${{ steps.setenv.outputs.AWS_ACCOUNT_ID }}
      STAGE_NAME: ${{ steps.setenv.outputs.STAGE_NAME }}
    steps:
      - name: Checkout
        uses: actions/checkout@v2
      - name: Use Node
        uses: actions/setup-node@v1
        with:
          node-version: "14.x"
      - name: Set environment
        id: setenv
        env:
          ITG_AWS_ACCOUNT_ID: 111111111111
          STG_AWS_ACCOUNT_ID: 222222222222
          PRD_AWS_ACCOUNT_ID: 333333333333
        run: |
          if ${{ github.ref == 'refs/heads/develop' }}; then
            echo "::set-output name=AWS_ACCOUNT_ID::$ITG_AWS_ACCOUNT_ID"
            echo "::set-output name=STAGE_NAME::itg"
          elif ${{ github.ref == 'refs/heads/release' }}; then
            echo "::set-output name=AWS_ACCOUNT_ID::$STG_AWS_ACCOUNT_ID"
            echo "::set-output name=STAGE_NAME::stg"
          elif ${{ github.ref == 'refs/heads/main' }}; then
            echo "::set-output name=AWS_ACCOUNT_ID::$PRD_AWS_ACCOUNT_ID"
            echo "::set-output name=STAGE_NAME::prd"
          else
            echo "Invalid branch name."
            exit 1
          fi
      - name: Cache node modules
        uses: actions/cache@v2
        env:
          cache-name: cache-node-modules
        with:
          path: "**/node_modules"
          key: ${{ runner.os }}-${{ env.cache-name }}-${{ hashFiles('**/yarn.lock') }}
      - name: Cache build files
        uses: actions/cache@v2
        env:
          cache-name: cache-build-files
        with:
          path: "**/build"
          key: ${{ runner.os }}-${{ env.cache-name }}-${{ github.sha }}
      - name: Install
        run: make install
      - name: Build
        run: make build
  test:
    name: Test
    needs:
      - setup
    runs-on: ubuntu-latest
    timeout-minutes: 5
    steps:
      - name: Checkout
        uses: actions/checkout@v2
      - name: Use Node
        uses: actions/setup-node@v1
        with:
          node-version: "14.x"
      - name: Restore node modules
        uses: actions/cache@v2
        env:
          cache-name: cache-node-modules
        with:
          path: "**/node_modules"
          key: ${{ runner.os }}-${{ env.cache-name }}-${{ hashFiles('**/yarn.lock') }}
      - name: Lint
        run: make lint
      - name: Unit test
        run: make test-unit
  deploy-1:
    name: Deploy 1
    needs:
      - setup
      - test
    runs-on: ubuntu-latest
    timeout-minutes: 10
    env:
      AWS_DEFAULT_REGION: ap-northeast-1
      AWS_ACCOUNT_ID: ${{ needs.setup.outputs.AWS_ACCOUNT_ID }}
      STAGE_NAME: ${{ needs.setup.outputs.STAGE_NAME }}
    steps:
      - name: Checkout
        uses: actions/checkout@v2
      - name: Use Node
        uses: actions/setup-node@v1
        with:
          node-version: "14.x"
      - 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: ${{ env.AWS_DEFAULT_REGION }}
          role-to-assume: arn:aws:iam::${{ env.AWS_ACCOUNT_ID }}:role/${{ env.STAGE_NAME }}-${{ env.PROJECT_NAME }}-assume-role
          role-duration-seconds: 3600
      - name: Restore node modules
        uses: actions/cache@v2
        env:
          cache-name: cache-node-modules
        with:
          path: "**/node_modules"
          key: ${{ runner.os }}-${{ env.cache-name }}-${{ hashFiles('**/yarn.lock') }}
      - name: Restore build files
        uses: actions/cache@v2
        env:
          cache-name: cache-build-files
        with:
          path: "**/build"
          key: ${{ runner.os }}-${{ env.cache-name }}-${{ github.sha }}
      - name: Deploy
        run: |
          make deploy TARGET=stack-name-1 STAGE=$STAGE_NAME
  deploy-2:
    name: Deploy 2
    needs:
      - setup
      - test
    runs-on: ubuntu-latest
    timeout-minutes: 10
    env:
      AWS_DEFAULT_REGION: ap-northeast-1
      AWS_ACCOUNT_ID: ${{ needs.setup.outputs.AWS_ACCOUNT_ID }}
      STAGE_NAME: ${{ needs.setup.outputs.STAGE_NAME }}
    steps:
      - name: Checkout
        uses: actions/checkout@v2
      - name: Use Node
        uses: actions/setup-node@v1
        with:
          node-version: "14.x"
      - 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: ${{ env.AWS_DEFAULT_REGION }}
          role-to-assume: arn:aws:iam::${{ env.AWS_ACCOUNT_ID }}:role/${{ env.STAGE_NAME }}-${{ env.PROJECT_NAME }}-assume-role
          role-duration-seconds: 3600
      - name: Restore node modules
        uses: actions/cache@v2
        env:
          cache-name: cache-node-modules
        with:
          path: "**/node_modules"
          key: ${{ runner.os }}-${{ env.cache-name }}-${{ hashFiles('**/yarn.lock') }}
      - name: Restore build files
        uses: actions/cache@v2
        env:
          cache-name: cache-build-files
        with:
          path: "**/build"
          key: ${{ runner.os }}-${{ env.cache-name }}-${{ github.sha }}
      - name: Deploy
        run: |
          make deploy TARGET=stack-name-2 STAGE=$STAGE_NAME

解説

ワークフローの実装について、要点を解説していきます。

name: Deploy AWS
env:
  PROJECT_NAME: sample
on:
  push:
    branches:
      - develop
      - release
      - main

ワークフロー名とワークフローの環境変数を定義します。onpushbranchesで指定したブランチにプッシュした際にワークフローが実行されるように設定しています。

  setup:
    name: Setup
    runs-on: ubuntu-latest
    timeout-minutes: 5
    outputs:
      AWS_ACCOUNT_ID: ${{ steps.setenv.outputs.AWS_ACCOUNT_ID }}
      STAGE_NAME: ${{ steps.setenv.outputs.STAGE_NAME }}

Setupジョブの設定を定義しています。コードの記述ミスなどで長時間実行されないように、必ずtimeout-minutesを設定しておきます。他のジョブで値を取り込めるようにoutputsを定義します。(後ほど登場します。)

      - name: Set environment
        id: setenv
        env:
          ITG_AWS_ACCOUNT_ID: 111111111111
          STG_AWS_ACCOUNT_ID: 222222222222
          PRD_AWS_ACCOUNT_ID: 333333333333
        run: |
          if ${{ github.ref == 'refs/heads/develop' }}; then
            echo "::set-output name=AWS_ACCOUNT_ID::$ITG_AWS_ACCOUNT_ID"
            echo "::set-output name=STAGE_NAME::itg"
          elif ${{ github.ref == 'refs/heads/release' }}; then
            echo "::set-output name=AWS_ACCOUNT_ID::$STG_AWS_ACCOUNT_ID"
            echo "::set-output name=STAGE_NAME::stg"
          elif ${{ github.ref == 'refs/heads/main' }}; then
            echo "::set-output name=AWS_ACCOUNT_ID::$PRD_AWS_ACCOUNT_ID"
            echo "::set-output name=STAGE_NAME::prd"
          else
            echo "Invalid branch name."
            exit 1
          fi

プッシュされたブランチに対してデプロイ先のAWSアカウントを設定しています。他のジョブでも設定した値を利用できるように、set-outputを使用します。

      - name: Cache node modules
        uses: actions/cache@v2
        env:
          cache-name: cache-node-modules
        with:
          path: "**/node_modules"
          key: ${{ runner.os }}-${{ env.cache-name }}-${{ hashFiles('**/yarn.lock') }}
      - name: Cache build files
        uses: actions/cache@v2
        env:
          cache-name: cache-build-files
        with:
          path: "**/build"
          key: ${{ runner.os }}-${{ env.cache-name }}-${{ github.sha }}
      - name: Install
        run: make install
      - name: Build
        run: make build

パッケージとビルドしたファイルを他のジョブで利用できるようにキャッシュしています。

  test:
    name: Test
    needs:
      - setup
    runs-on: ubuntu-latest
    timeout-minutes: 5

Testジョブの設定を定義しています。needssetupを指定しているので、Setupジョブが完了後に実行されます。

      - name: Restore node modules
        uses: actions/cache@v2
        env:
          cache-name: cache-node-modules
        with:
          path: "**/node_modules"
          key: ${{ runner.os }}-${{ env.cache-name }}-${{ hashFiles('**/yarn.lock') }}
      - name: Lint
        run: make lint
      - name: Unit test
        run: make test-unit

Setupジョブでキャッシュしたnode_modulesを取得して、テストを実行しています。

  deploy-1:
    name: Deploy 1
    needs:
      - setup
      - test
    runs-on: ubuntu-latest
    timeout-minutes: 10

Deployジョブの設定を定義しています。needssetuptestを指定しているので、2つのジョブが完了後に実行されます。他のDeployジョブでも同様にneedsを設定することで、ジョブを並列に実行することができます。

    env:
      AWS_DEFAULT_REGION: ap-northeast-1
      AWS_ACCOUNT_ID: ${{ needs.setup.outputs.AWS_ACCOUNT_ID }}
      STAGE_NAME: ${{ needs.setup.outputs.STAGE_NAME }}

ジョブの環境変数を定義しています。Setupジョブで出力したAWS_ACCOUNT_IDSTAGE_NAMEを環境変数にセットします。

      - 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: ${{ env.AWS_DEFAULT_REGION }}
          role-to-assume: arn:aws:iam::${{ env.AWS_ACCOUNT_ID }}:role/${{ env.STAGE_NAME }}-${{ env.PROJECT_NAME }}-assume-role
          role-duration-seconds: 3600

指定したAWSアカウントのIAMロールにAssume Roleしています。AWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEYはあらかじめOrganizationもしくはリポジトリのSecretsに登録しておきます。また、Assume Role先のIAM Roleも作成しておく必要があります。これにより、AWS CLIやCDKでAWS環境へデプロイを実行できるようになります。

      - name: Restore node modules
        uses: actions/cache@v2
        env:
          cache-name: cache-node-modules
        with:
          path: "**/node_modules"
          key: ${{ runner.os }}-${{ env.cache-name }}-${{ hashFiles('**/yarn.lock') }}
      - name: Restore build files
        uses: actions/cache@v2
        env:
          cache-name: cache-build-files
        with:
          path: "**/build"
          key: ${{ runner.os }}-${{ env.cache-name }}-${{ github.sha }}
      - name: Deploy
        run: |
          make deploy TARGET=stack-name-1 STAGE=$STAGE_NAME

最後にSetupジョブでキャッシュしたパッケージとビルドファイルを取得して、デプロイを実行しています。デプロイのジョブを並列で実行するために、CloudFormationのスタックはある程度分けています。なお、make deployコマンドではAWS CDKのdeployコマンドなどを実行しています。

ジョブ名はスタック名などにしておくと分かりやすいかもしれません。

まとめ

GitHub Actionsのワークフローではneedsによってジョブの依存関係を定義して、並列実行を分かりやすく実装することが出来ました。また、キャッシュやAWSの資格情報設定などのActionが提供されていて、ワークフローの定義がとても簡単でした。

GitHub Actionsのワークフロー構文やコンテキストについてはドキュメントに分かりやすく記載されているので、一度目を通してから実装に着手するのが良いと思います。

参考資料