Github Actions で Azure Container Apps の B/G Deployを設定する

先日、現在開発をしているサービスのQueue workerの一部をAzure Container Appsに移行しました。とても使いやすいのでメインのAPIの移行準備として、Github Actions使用したB/Gデプロイの実験をしてみました。

サンプルコードの準備

今回実験用にデプロイするのは、クイック スタート: Azure Container Apps にコードをデプロイする で使用している、 Azure Container Apps Album APIを使いたいと思います。

このAPIはnodejsで実装されていて、静的に保持したデータを返すAPIが一つだけ定義されています。まずは手元の環境で実際に実行してみます。

自身の環境でGithub Actionsを実行したいので、 Azure Container Apps Album APIfork してpullします。

Dockerfileが用意されているので、以下のようにビルド・起動します。

$ cd containerapps-albumapi-javascript/src
$ docker build -t kaz29/containerapps-albumapi-javascript .
$ docker run -it --rm -d --name containerapps-albumapi-javascript -p 80:3500 kaz29/containerapps-albumapi-javascript

実際にAPIを叩くとこんな感じです

$ curl http://localhost/albums | jq
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100   751  100   751    0     0  35753      0 --:--:-- --:--:-- --:--:-- 57769
[
  {
    "id": 1,
    "title": "You, Me and an App ID",
    "artist": "Daprize",
    "price": 56.99,
    "image_url": "https://aka.ms/albums-daprlogo"
  },
  {
    "id": 2,
    "title": "Seven Revision Army",
    "artist": "The Blue-Green Stripes",
    "price": 17.99,
    "image_url": "https://aka.ms/albums-containerappslogo"
  },
  {
    "id": 3,
    "title": "Scale It Up",
    "artist": "KEDA Club",
    "price": 39.99,
    "image_url": "https://aka.ms/albums-kedalogo"
  },
  {
    "id": 4,
    "title": "Lost in Translation",
    "artist": "MegaDNS",
    "price": 39.99,
    "image_url": "https://aka.ms/albums-envoylogo"
  },
  {
    "id": 5,
    "title": "Lock Down your Love",
    "artist": "V is for VNET",
    "price": 39.99,
    "image_url": "https://aka.ms/albums-vnetlogo"
  },
  {
    "id": 6,
    "title": "Sweet Container O' Mine",
    "artist": "Guns N Probeses",
    "price": 39.99,
    "image_url": "https://aka.ms/albums-containerappslogo"
  }
]

src/models/Album.js に配列で持っているデータを返すだけのシンプルなAPIです。

リソースの準備

基本的にはここの手順通りなのですが、まずはリソースグループとContainer Registryを作成します。

環境変数を設定

export GITHUB_USERNAME="<YOUR_GITHUB_USERNAME>"

export RESOURCE_GROUP="album-containerapps"
export LOCATION="japaneast"
export ACR_NAME="acaalbums"$GITHUB_USERNAME
export API_NAME="album-api"

リソースグループを作成

az group create \
  --name $RESOURCE_GROUP \
  --location "$LOCATION"

Container Registryを作成

az acr create \
  --resource-group $RESOURCE_GROUP \
  --name $ACR_NAME \
  --sku Basic \
  --admin-enabled true

Github actionの設定

下記で取得したユーザ名・パスワードをGithub Actions Secretに設定

az acr credential show --name $ACR_NAME -g $RESOURCE_GROUP --query "username" --out tsv
az acr credential show --name $ACR_NAME -g $RESOURCE_GROUP --query "passwords[0].value" --out tsv
  • ユーザ名: CONTAINER_REGISTRY_USERNAME

    CONTAINER_REGISTRY_USERNAMEを追加

  • パスワード: CONTAINER_REGISTRY_PASSWORD

    CONTAINER_REGISTRY_PASSWORDを追加

今回は、github container registry ではなく、Azure Container Registryを使うのでGHAの設定を以下のように書き換えます。 また、tagのpushのみデプロイをしたいので main の pushトリガーを削除しています。

環境変数からtag名を取得して、ACRにpush時のtagとして使用しています。

name: Build and Push
on:
  push:
    # Publish semver tags as releases.
    tags: ["v*.*.*"]
  workflow_dispatch:

env:
  ACR_NAME: acaalbumskaz29
  API_NAME: album-api

jobs:
  build:
    runs-on: ubuntu-latest
    permissions: 
      contents: read
      packages: write 
    steps:
      - name: Checkout repository
        uses: actions/checkout@v2

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v1

      - name: Log in to container registry
        uses: docker/login-action@v1
        with:
          registry: ${{ env.ACR_NAME }}.azurecr.io
          username: ${{ secrets.CONTAINER_REGISTRY_USERNAME }}
          password: ${{ secrets.CONTAINER_REGISTRY_PASSWORD }}

      - name: Set tag name to env
        run: | 
          echo "TAG=${GITHUB_REF##*/}" >> $GITHUB_ENV

      - name: Build and push container image to registry
        uses: docker/build-push-action@v2
        with:
          push: true
          tags: ${{ env.ACR_NAME }}.azurecr.io/${{ env.ACR_NAME }}:${{ env.TAG }}
          context: ./src

      - name: Upload artifact
        uses: actions/upload-artifact@v2
        with:
          name: deploy-artifact
          path: bicep/*

修正をcommit/pushした上で、以下の様にタグをつけてpushするとACRにデプロイ用のイメージがpushされます

$ git tag v0.0.1
$ git push origin --tags

Container Apps環境を作成

以下のコマンドを実行し、Container Apps環境を作成します。

export CONTAINERAPPS_ENVIRONMENT="my-containerapps-env"
az containerapp env create \
  --name $CONTAINERAPPS_ENVIRONMENT \
  --resource-group $RESOURCE_GROUP \
  --location $LOCATION

アプリをデプロイ

デプロイの環境ができたので、アプリをデプロイする準備・設定を進めていきます。

サービスプリンシパルを作成

デプロイに使用するサービスプリンシパルを作成します。以下では、1年有効な設定をしていますがこの辺りは適宜修正してください。

$ export RESOURCE_GROUP_ID=$(az group show \
   --name "$RESOURCE_GROUP" \
   --query id --output tsv)

$ az ad sp create-for-rbac \
  --display-name "$RESOURCE_GROUP GHA deploy" \
  --scope $RESOURCE_GROUP_ID \
  --role Contributor \
  --sdk-auth \
  --years 1

上記azコマンドで出力されたjson文字列を、GHA Action Secretに保存します。

AZURE_CREDENTIALSを追加

初回デプロイ

まずは、B/Gデプロイではなく先ほどビルドしたコンテナを単純にデプロイします。

azコマンドでもデプロイはできるのですが、細かな設定ができないので今回はbicepを使用してデプロイします。

この辺りの詳細は、トニー (@TonyTonyKun) / Twitter さんの ブログ - Azure Container Apps で Blue-Green Deployments を試してみたがとても参考になります。

bicep/api.bicep を作成

# bicep/api.bicep

param containerAppName string
param location string = resourceGroup().location
param environmentId string
param imageName string
param tagName string
param revisionSuffix string
param oldRevisionSuffix string
param isExternalIngress bool
param acrUserName string
@secure()
param acrSecret string

@allowed([
  'multiple'
  'single'
])
param revisionMode string = 'single'

resource containerApp 'Microsoft.App/containerApps@2022-03-01' = {
  name: containerAppName
  location: location
  properties: {
    managedEnvironmentId: environmentId
    configuration: {
      activeRevisionsMode: revisionMode
      ingress: {
        external: isExternalIngress
        targetPort: 3500
        transport: 'auto'
        allowInsecure: false
        traffic: ((contains(revisionSuffix, oldRevisionSuffix)) ? [
          {
            weight: 100
            latestRevision: true
          }
        ] : [
          {
            weight: 0
            latestRevision: true
          }
          {
            weight: 100
            revisionName: '${containerAppName}--${oldRevisionSuffix}'
          }
        ])
      }
      dapr:{
        enabled:false
      }
      secrets: [
        {
          name: 'acr-secret'
          value: acrSecret
        }
      ]
      registries: [
        {
            server: '${acrUserName}.azurecr.io'
            username: acrUserName
            passwordSecretRef: 'acr-secret'
        }
      ]
    }
    template: {
      revisionSuffix: revisionSuffix
      containers: [
        {
          image: '${acrUserName}.azurecr.io/${imageName}:${tagName}'
          name: containerAppName
          resources: {
            cpu: any('0.25')
            memory: '0.5Gi'
          }
        }
      ]
      scale: {
        minReplicas: 1
        maxReplicas: 10
        rules: [
          {
            name: 'http-scaling-rule'
            http: {
              metadata: {
                concurrentRequests: '10'
              }
            }
          }
        ]
      }
    }
  }
}

output fqdn string = containerApp.properties.configuration.ingress.fqdn

bicep/deploy.bicep を作成

#bicep/deploy.bicep

param location string = resourceGroup().location
param isExternalIngress bool = true
param revisionMode string = 'multiple'
param environmentName string
param containerAppName string
param imageName string
param tagName string
param revisionSuffix string
param oldRevisionSuffix string
param acrUserName string
@secure()
param acrSecret string

resource environment 'Microsoft.App/managedEnvironments@2022-03-01' existing = {
  name: environmentName
}

module apps 'api.bicep' = {
  name: 'container-apps'
  params: {
    containerAppName: containerAppName
    location: location
    environmentId: environment.id
    imageName: imageName
    tagName: tagName
    revisionSuffix: revisionSuffix
    oldRevisionSuffix: oldRevisionSuffix
    revisionMode: revisionMode
    isExternalIngress: isExternalIngress
    acrUserName: acrUserName
    acrSecret: acrSecret
  }
}

後ほど、B/Gデプロイを実現するために、以下のパラメータを定義しています。

  • revisionSuffix: 新たにデプロイされるリビジョン
  • oldRevisionSuffix: 現在デプロイされているリビジョン

また、初回デプロイ時にはまだ実行中のリビジョンが存在しないため、以下の様にrevisionSuffix / oldRevisionSuffixが同じ場合には最新版のリビジョンに100%トラフィックを流すように設定しています。

        traffic: ((contains(revisionSuffix, oldRevisionSuffix)) ? [
          {
            weight: 100
            latestRevision: true
          }
        ] : [
          {
            weight: 0
            latestRevision: true
          }
          {
            weight: 100
            revisionName: '${containerAppName}--${oldRevisionSuffix}'
          }
        ])
      }

workflowを更新

.github//workflows/build-and-push.yaml に以下のjobを追加します。

  deploy:
    runs-on: ubuntu-latest
    needs: build
    environment:
      name: build
      url: https://${{ steps.fqdn.outputs.fqdn }}
    outputs:
      revision_suffix: ${{ steps.revision_suffix.outputs.revision_suffix }}
      previous_revision_suffix: ${{ steps.previous_revision_suffix.outputs.previous_revision_suffix }}
      fqdn: ${{ steps.fqdn.outputs.fqdn }}
    steps:
      - name: Download artifact
        uses: actions/download-artifact@v2
        with:
          name: deploy-artifact

      - name: Set tag name to env
        run: | 
          echo "TAG=${GITHUB_REF##*/}" >> $GITHUB_ENV

      # タグ名から.(ドット)を除去する
      - name: Set revision suffix name to env
        id: revision_suffix
        run: | 
          echo "REVISION_SUFFIX=${TAG//./}" >> $GITHUB_ENV
          echo "::set-output name=revision_suffix::${TAG//./}"

      - name: Azure Login
        uses: azure/login@v1
        with:
          creds: ${{ secrets.AZURE_CREDENTIALS }}

      - name: Deploy to containerapp
        uses: azure/CLI@v1
        with:
          inlineScript: |
            az extension add --upgrade --name containerapp

            az deployment group create \
                -f ./deploy.bicep \
                -g ${{ env.RESOURCE_GROUP_NAME }} \
                --parameters \
                    environmentName=${{ env.CONTAINER_APPS_ENVIRONMENT }} \
                    containerAppName=${{ env.API_NAME }} \
                    imageName=${{ env.API_NAME }} \
                    tagName=${{ env.TAG }} \
                    revisionSuffix=${{ env.REVISION_SUFFIX }} \
                    oldRevisionSuffix=${{ env.REVISION_SUFFIX }} \
                    acrUserName=${{ secrets.CONTAINER_REGISTRY_USERNAME }} \
                    acrSecret=${{ secrets.CONTAINER_REGISTRY_PASSWORD }}

リビジョン名には . (ドット) は含められないため、 Set revision suffix name to env stepでタグ名のドットを除去しています。

修正をcommit/pushした上で、以下の様にタグをつけてpushするとコンテナアプリが追加され、APIがデプロイされます。

$ git tag v0.0.2
$ git push origin --tags

デプロイが完了したら、Auzre Portalのコンテナアプリのページに表示されているURLをブラウザで開くとAPIの動作を確認できます。

コンテナアプリ画面

B/Gデプロイの設定を追加

では今回の主目的の、B/Gデプロイを実現するための設定を追加していきます。まずは、workflowのdeploy stepを以下の様に修正します

  deploy:
    runs-on: ubuntu-latest
    needs: build
    environment:
      name: build
      url: https://${{ steps.fqdn.outputs.fqdn }}
    outputs:
      revision_suffix: ${{ steps.revision_suffix.outputs.revision_suffix }}
      previous_revision_suffix: ${{ steps.previous_revision_suffix.outputs.previous_revision_suffix }}
      fqdn: ${{ steps.fqdn.outputs.fqdn }}
    steps:
      - name: Download artifact
        uses: actions/download-artifact@v2
        with:
          name: deploy-artifact

      - name: Set tag name to env
        run: | 
          echo "TAG=${GITHUB_REF##*/}" >> $GITHUB_ENV

      # タグ名から.(ドット)を除去する
      - name: Set revision suffix name to env
        id: revision_suffix
        run: | 
          echo "REVISION_SUFFIX=${TAG//./}" >> $GITHUB_ENV
          echo "::set-output name=revision_suffix::${TAG//./}"

      - name: Azure Login
        uses: azure/login@v1
        with:
          creds: ${{ secrets.AZURE_CREDENTIALS }}

      - name: Get Previous revision name
        id: previous_revision_suffix
        uses: azure/CLI@v1
        with:
          inlineScript: |
            az extension add --upgrade --name containerapp
            export REVISIONS=`az containerapp revision list --resource-group ${{ env.RESOURCE_GROUP_NAME }} --name ${{ env.API_NAME }} --query '[].name' --out tsv`
            echo "REVISION_NUM=`az containerapp revision list --resource-group ${{ env.RESOURCE_GROUP_NAME }} --name ${{ env.API_NAME }} --query '[] | length(@)' --out tsv`" >> $GITHUB_ENV
            echo "PREVIOUS_REVISION_NAME=${REVISIONS##*--}" >> $GITHUB_ENV
            echo "::set-output name=previous_revision_suffix::${REVISIONS##*--}"

      - name: Active revision count check
        if: ${{ env.REVISION_NUM != 1 }} 
        uses: actions/github-script@v3
        with:
          script: |
              core.setFailed('Multiple revisions are active!')

      - name: Deploy to containerapp
        uses: azure/CLI@v1
        with:
          inlineScript: |
            az extension add --upgrade --name containerapp

            az deployment group create \
                -f ./deploy.bicep \
                -g ${{ env.RESOURCE_GROUP_NAME }} \
                --parameters \
                    environmentName=${{ env.CONTAINER_APPS_ENVIRONMENT }} \
                    containerAppName=${{ env.API_NAME }} \
                    imageName=${{ env.API_NAME }} \
                    tagName=${{ env.TAG }} \
                    revisionSuffix=${{ env.REVISION_SUFFIX }} \
                    oldRevisionSuffix=${{ env.PREVIOUS_REVISION_NAME }} \
                    acrUserName=${{ secrets.CONTAINER_REGISTRY_USERNAME }} \
                    acrSecret=${{ secrets.CONTAINER_REGISTRY_PASSWORD }}

      - name: Get new revision's fqdn
        id: fqdn
        uses: azure/CLI@v1
        with:
          inlineScript: |
            export FQDN=`az deployment group show \
              -g ${{ env.RESOURCE_GROUP_NAME }} \
              -n ${{ env.DEPLOYMENT_NAME }} \
              --query properties.outputs.fqdn.value \
              --out tsv`
            export BASE_NAME=${FQDN#*.}
            echo "::set-output name=fqdn::${{ env.API_NAME }}--${{ env.REVISION_SUFFIX }}.$BASE_NAME"

各ステップの概要

  • Get Previous revision name

B/Gデプロイを実現する為に、現在実行中のリビジョン名を取得しています。

  • Active revision count check

B/Gデプロイ時に、実行中のリビジョンが複数ある場合はどのようにトラフィックを割り当てるか判断できないので、複数リビジョンが稼働している場合は、エラーになるようにチェックしています。

  • Get new revision's fqdn

次に定義するジョブで、新しいリビジョンのURLを表示するために新しいリビジョンのFQDNを生成しています。


このjobを実行すると、新しいリビジョン(green)がデプロイされますが、トラフィックの割り当ては 0% に設定されているため実際には新しいリビジョンのAPIは呼び出されません。

リビジョン毎にURLが発行されるので、新しいリビジョンのURLを使用して、動作確認を実施しします。

今回は、確認が完了後に、新しいリビジョンに 100%、古いリビジョンに 0%トラフィックの割り当て、Blue / Greenを入れ替えて最新版を反映します。

B / Gを入れ替える

今回は、B/Gの入れ替え時に、承認処理を挟むために Environments 機能 の Environment protection rules を利用します。 残念ながら、現状、Environment protection rulesは publicリポジトリか、Github Enterpriseでのみ利用可能です。

Environmentを追加

リポジトリSettings - Environments で、リビジョン入れ替え用のEnvironment flip を作成します。

以下の様に Required reviewers をチェックし、レビュアーのアカウントを選択します。

flip用 Environmentの作成

B / Gを入れ替えるJobを追加

  flip:
    runs-on: ubuntu-latest
    needs: deploy
    environment:
      name: flip
    steps:
      - name: Azure Login
        uses: azure/login@v1
        with:
          creds: ${{ secrets.AZURE_CREDENTIALS }}
      - name: Flip revisions
        uses: azure/CLI@v1
        with:
          inlineScript: |
            az extension add --upgrade --name containerapp

            az containerapp ingress traffic set \
              -g ${{ env.RESOURCE_GROUP_NAME }} \
              -n ${{ env.API_NAME }} \
              --revision-weight \
                ${{ env.API_NAME }}--${{ needs.deploy.outputs.revision_suffix }}=100 \
                ${{ env.API_NAME }}--${{ needs.deploy.outputs.previous_revision_suffix }}=0

古いリビジョンを非アクティブにする

Environmentを追加

リポジトリSettings - Environments で、非アクティブ用のEnvironment deactivate を作成します。

以下の様に Required reviewers をチェックし、レビュアーのアカウントを選択します。

deactivate 用の Environmentを作成

古いリビジョンを非アクティブにするJobを追加

  deactivate:
    runs-on: ubuntu-latest
    needs: [flip, deploy]
    environment:
      name: deactivate
    steps:
      - name: Azure Login
        uses: azure/login@v1
        with:
          creds: ${{ secrets.AZURE_CREDENTIALS }}
      - name: Deactivate previous revision
        uses: azure/CLI@v1
        with:
          inlineScript: |
            az extension add --upgrade --name containerapp

            az containerapp revision deactivate \
              -g ${{ env.RESOURCE_GROUP_NAME }} \
              -n ${{ env.API_NAME }} \
              --revision \
                ${{ env.API_NAME }}--${{ needs.deploy.outputs.previous_revision_suffix }}

デプロイを実行

修正をcommit/pushした上で、以下の様にタグをつけてpushすると新しいリビジョンがデプロイされます。

$ git tag v0.0.3
$ git push origin --tags

新しいリビジョンがトラフィック 0% でデプロイされている

flip

デプロイ完了後、flipの実行待ち状態でworkflowが止まります。 deploy jobのボックスにはデプロイされた新しいリビジョンのAPIのURLが表示されています。

flip job実行前に停止している様子

Review deployments をクリックして、B / Gを入れ替え(flip)します。

Review pending deployments(flip)

flipが完了すると、トラフィック割り当てが変更され新しいリビジョンを利用する状態になります。

flipが完了してトラフィック割り当てが変更された様子

deactivate

この状態で問題がなければ、古いリビジョンを非アクティブにする必要があります。リビジョンがアクティブな状態だと課金対象になってしまいますので、早めに非アクティブ化したほうが良いでしょう。

workflowは deactivate Jobの実行待ち状態で停止しています。

deactivate job実行待ち状態で停止している様子

Review deployments をクリックして、古いリビジョンを非アクティブ化(deactivate)します。

Review pending deployments (deactivate)

deactivate jobが完了すると古いリビジョンが非アクティブ化されます

古いリビジョンが非アクティブ化されている様子

無事、Github actionsを使用して、Container AppsのB/Gデプロイが実現できました。

まとめ

いかがでしたでしょうか?Container AppsでBlue / Green デプロイを実現するには、現在実行中のリビジョン名が必要なため若干複雑な流れになっています。やっていることは特に難しいことではないですが、調査に少し時間がかかりました。「もっといい方法があるよ!」とかあれば是非教えてほしいです。

現状、Environment protection rulesは publicリポジトリか、Github Enterprise以外では利用できないので、privateリポジトリで開発をしている現場で使うには一部見直しが必要かもしれません。

今回はBlue / Green デプロイを採用しましたが、ちょっと修正すればカナリーリリースとかも実現できると思うので、参考にしてもらえると嬉しいです。参考までに、私が試したforkしたAPIのリポジトリを残しておきます。

私が現在担当している現場でも、今後Container Appsに移行する予定があるので、いい感じで実現できて捗りそうです。

参考資料