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に移行する予定があるので、いい感じで実現できて捗りそうです。

参考資料

CakePHPのschemaからtypescriptのinterfaceを吐きだすプラグインをかいた

小ネタです。

最近は相変わらずCakePHPAPIを書いて、nextjsでフロントのアプリを書くサイトばかり作っているのですが、API側で定義したAPIレスポンスデータをフロント側用にinterfaceを書くのがだるいのでプラグインを書いた話です。

TsExport plugin for CakePHP

TsExport plugin for CakePHPは以下のようにインストールしてください。

composer require --dev kaz29/cakephp-ts-export-plugin

実行は以下のような感じ。

bin/cake export_entity --all

または

bin/cake export_entity モデル名

実際に実行すると、以下のようにinterface定義が標準出力に出力されます。

bin/cake export_entity  Users
/**
 * User entity interface
 */
export interface User {
  id: number
  name: string
  email: string
  password: string
  created?: string
  modified?: string
}

フロント側では、src/types/exported_interfaces.ts のようなファイル名でこのプラグインの出力をそのまま使って、フロント用に変更する場合は、別のファイルでextend して項目を追加したり不要なものをOmitしたりしたものを使ってます。

Azure Web PubSubのnegotiateをPHPで実装してみる

最近書いているとあるサービスでリアルタイム更新をしたいと思い、Azure SignalR ServiceAzure Web PubSubを試してます。

クイックスタートを参考にすれば、Azure Functionsで割と簡単に動作を試せます。

今回のサービスのバックエンドAPIPHPで書かれているため、 negotiate の処理をPHPAPIで実施したいと考えていたのですが、残念ながらAzure PubSubのPHP SDKは現時点で提供されていません。(多分この先も提供はされなそう... (;_; )

ということで、Azure Web PubSub service client library for JavaScript を参考に、negotiate が何をしているか調べてみました。

調べた結果、negotiate のレスポンスは以下のような内容になっていました。

{
  baseUrl: 'wss://[PubSubName].webpubsub.azure.com/client/hubs/[hubname]',
  token: 'JWT token',
  url: 'wss://[PubSubName].webpubsub.azure.com/client/hubs/[hubname]?access_token=[JWT Token]'
}

ふむふむ、JWTで認証しているよう...。生成されるJWTの中身は以下の様な内容でした。

{
  "header": {
    "typ": "JWT",
    "alg": "HS256"
  },
  "claims": {
    "iat": 1623618349,
    "exp": 1623621949,
    "aud": "https://[PubSubName].webpubsub.azure.com/client/hubs/[hubname]"
  },
  "signature": "sigunature...",
  "raw": "eyJ0eXAiOiJ..."
}

要は、接続文字列からこのJWTを生成できれば良さそうです。ということで、gree/joseを使ってざくっと書いてみたのが以下。

<?php
declare(strict_types=1);

class PubSubToken {
    protected $endpoint;
    protected $wssEndpoint;
    protected $accesskey;
    protected $version;
    protected $alg = 'HS256';

    public function __construct($connectionString)
    {
        $params = explode(';', $connectionString);
        foreach ($params as $param) {
            list($k, $v) = explode('=', $param, 2);

            $this->{strtolower($k)} = $v;
        }

        $this->wssEndpoint = preg_replace('/(http)(s?:\/\/)/i', 'ws$2', $this->endpoint);

        if ($this->endpoint === null || $this->accesskey === null || $this->version === null || $this->wssEndpoint === null) {
            throw new \Exception('Parameter error');            
        }
    }

    public function getAuthenticationToken(string $hub, string $userId = null, int $ttl = 3600): array
    {
        $now = time();

        $payload = [
            'iat' => $now,
            'exp' => $now + $ttl,
            'aud' => "{$this->endpoint}/client/hubs/{$hub}",
        ];
        if ($userId !== null) {
            $payload['sub'] = $userId;
        }

        $jwt = new \JOSE_JWT($payload);
        $jwt->header['alg'] = $this->alg;
        $jwt->header['typ'] = 'JWT';
        $jwt->sign($this->accesskey, $this->alg);
        
        $jws = new \JOSE_JWS($jwt);
        $jws = $jws->sign($this->accesskey, $this->alg);
        $token = $jws->toString();
        
        return [
            'baseUrl' => "{$this->wssEndpoint}/client/hubs/{$hub}",
            'token' => $token,
            'url' => "{$this->wssEndpoint}/client/hubs/{$hub}?access_token={$token}",
        ];
    }
}


$pubsub = new PubSubToken('Azure WebPubSubの接続文字列');
$token = $pubsub->getAuthenticationToken('test');

このtoken を使って無事subscribeできました。 ということで、このtokenをAPIで返してあげれば、クライアント側でsubscribeできそうです。

コードは、gistにも上げておきました。

CakePHP4用のOpenApi bake theme pluginを公開しました

最近は久々にガッツリPHPのコードを書いているわたなべです。

このところ、仕事でもプライベートでもPHPAPIを書いて、Next.jsでフロントのWebアプリを書くことがほとんどです。

この場合API仕様は以前ブログにも書きましたが、swagger-phpアノテーションで記述して、Swagger-UIで参照できる様にしています。

kaz29.hatenablog.com

Swagger-UI と swagger-php

最近は使われている方も多いと思いますが、簡単に説明すると、EntityとControllerに以下の様なアノテーションを記述します。

Entity/Article.php

/**
 * Article Entity
 *
 * @OA\Schema(
 *      schema="Article",
 *      title="",
 *      description="Article entity",
 *       @OA\Property(
 *           property="id",
 *           type="integer",
 *           format="int32",
 *           description="",
 *       ),
 *       @OA\Property(
 *           property="user_id",
 *           type="integer",
 *           format="int32",
 *           description="",
 *       ),
 *       @OA\Property(
 *           property="title",
 *           type="string",
 *           description="",
 *       ),
 *       @OA\Property(
 *           property="slug",
 *           type="string",
 *           description="",
 *       ),
 *       @OA\Property(
 *           property="body",
 *           type="string",
 *           description="",
 *       ),
 *       @OA\Property(
 *           property="published",
 *           type="boolean",
 *           description="",
 *       ),
 *       @OA\Property(
 *           property="created",
 *           type="string",
 *           format="datetime",
 *           description="",
 *       ),
 *       @OA\Property(
 *           property="modified",
 *           type="string",
 *           format="datetime",
 *           description="",
 *       ),
 * )

Controller/Api/ArticlesController.php

    /**
     * Index method
     *
     * @OA\Get(
     *     path="/api/articles.json",
     *     summary="Articles index",
     *     description="Articles index",
     *     @OA\Parameter(
     *         name="page",
     *         in="query",
     *         required=false,
     *         @OA\Schema(
     *             type="number",
     *         ),
     *         description=""
     *     ),
     *     @OA\Parameter(
     *         name="limit",
     *         in="query",
     *         required=false,
     *         @OA\Schema(
     *             type="number",
     *         ),
     *         description=""
     *     ),
     *     @OA\Response(
     *         response=200,
     *         description="successful operation",
     *         @OA\JsonContent(
     *              @OA\Property(
     *                  property="success",
     *                  type="boolean",
     *                  default=true,
     *              ),
     *              @OA\Property(
     *                  property="data",
     *                  type="array",
     *                  @OA\Items(
     *                      allOf={
     *                          @OA\Schema(ref="#/components/schemas/Article"),
     *                          @OA\Schema(
     *                              @OA\Property(
     *                                  property="user",
     *                                  ref="#/components/schemas/User",
     *                                  description="User Entity",
     *                              ),
     *                          ),
     *                      },
     *                  ),
     *              ),
     *              @OA\Property(
     *                  property="pagination",
     *                  ref="#/components/schemas/Pagination",
     *              ),
     *         ),
     *     ),
     * )
     * @return \Psr\Http\Message\ResponseInterface
     */

これらのコードを、以下の様なコマンドでswagger-phpを使用してビルドします。

swagger.jsonをビルドするコマンド

#!/usr/local/bin/php -q
<?php
include_once __DIR__.'/../autoload.php';

$app_path = '.';
$openapi = \OpenApi\scan(
    $app_path, 
    [
        'exclude' => [
            'vendor', 
            'tmp', 
            'logs', 
            'tests', 
            'webroot',
        ]
    ]
);
file_put_contents(dirname($app_path).'/docs/swagger.json', $openapi->toJson());

ビルドが成功すると、swagger.jsonが作成されるのでこれをSwagger-UIで読み込むと、以下の様にドキュメントを見ることはもちろん、Swagger-UI上からAPIを呼び出すこともできます。

f:id:kaz_29:20210306062925p:plain
Swagger-UI サンプル

これすごい便利なのでおすすめなのですが、記述するのが結構面倒なのと、記述方法にいろいろ癖があるので書くたびに毎回試行錯誤することになったりします。

前からなんとかしたいなぁと思っていたのですが、現在とあるリプレース案件で大量にAPIを作成する予定で、この作業を少しでも効率化したいと思いCakePHPのbakeテンプレートを書きました。

bakeテンプレートを自作すると、CakePHPを使っている方であればご存知のbakeコマンドで生成される雛形のソースコードをカスタマイズすることができます。

github.com

OpenApiTheme plugin

OpenApiTheme pluginでは、APIを作成する際には定番のfriends od cake CRUD Pluginを使うことを前提で作成しました。

今回は、以下の2つのbakeコマンドを追加しています。

  • open_api_model - モデルのbake時にEntityにOpenApiのSchema定義を自動生成する
  • open_api_controller - コントローラのbake時にCRUDAPI定義を自動生成する

実際には以下のような感じでbakeすることができます。

// モデルのbake
$ bin/cake bake open_api_model Articles

// コントローラのbake
$ bin/cake bake open_api_controller Articles --prefix Api

現在のバージョンでは、EntityのSchameにはアソシエーション先のプロパティはあえて含めないようになっています。 定義すると便利は便利なのですが、実際の利用シーンではどのアソシエーションをContainさせるかはAPIによって変わるケースが多いのでEntity側で定義してしまうと使いにくいことが多いです。 この為、OpenApiTheme pluginではEntityのSchameにはアソシエーションを含めずにControllerのAPI定義の方で複数のSchemaを合成(?)するようにしています。

公式のbakeでは、index actionではBelongsToのみcontainし、view acrionでは全てのアソシエーションをcontainするコードが生成されるので、それに倣って以下のようなレスポンスを定義しています。

index action のレスポンス定義サンプル

     *     @OA\Response(
     *         response=200,
     *         description="successful operation",
     *         @OA\JsonContent(
     *              @OA\Property(
     *                  property="success",
     *                  type="boolean",
     *                  default=true,
     *              ),
     *              @OA\Property(
     *                  property="data",
     *                  type="array",
     *                  @OA\Items(
     *                      allOf={
     *                          @OA\Schema(ref="#/components/schemas/Article"),
     *                          @OA\Schema(
     *                              @OA\Property(
     *                                  property="user",
     *                                  ref="#/components/schemas/User",
     *                                  description="User Entity",
     *                              ),
     *                          ),
     *                      },
     *                  ),
     *              ),
     *              @OA\Property(
     *                  property="pagination",
     *                  ref="#/components/schemas/Pagination",
     *              ),
     *         ),
     *     ),

view action のレスポンス定義サンプル

     *     @OA\Response(
     *         response=200,
     *         description="successful operation",
     *         @OA\JsonContent(
     *              @OA\Property(
     *                  property="success",
     *                  type="boolean",
     *                  default=true,
     *              ),
     *              @OA\Property(
     *                  property="data",
     *                  allOf={
     *                      @OA\Schema(ref="#/components/schemas/Article"),
     *                      @OA\Schema(
     *                          @OA\Property(
     *                              property="user",
     *                              ref="#/components/schemas/User",
     *                              description="User Entity",
     *                          ),
     *                          @OA\Property(
     *                              property="tags",
     *                              type="array",
     *                              @OA\Items(ref="#/components/schemas/Tag"),
     *                              description="Tag Entities",
     *                          ),
     *                      ),
     *                  },
     *              ),
     *         ),
     *     ),

bakeしたままでは実際に作成したいAPIにマッチしないケースも多々あるとは思いますが、これを元に実際のAPI定義を作成することで、記述の手間をだいぶ軽減できると思います。

以下で実際にOpenApiTheme pluginで生成したAPI仕様を確認できますので、ぜひ一度見てみてください。

petstore.swagger.io

開発環境でのSwagger-UIの利用

普段利用している開発環境では、開発中のAPIをSwagger-UIから直接叩けるように、開発環境用のdocker-compose.ymlにSwagger-UIのコンテナも含めるようにしています。

docker hub に上がっている、公式のDockerコンテナを利用しています。

まとめ

現在進行中の実案件にもOpenApiTheme pluginを導入して使い始めていますが、仕様書作成がだいぶ捗ります。

随時フィードバックして改善していくつもりですが、ぜひ使っていただいて、要望などあればIssueなりPRなりいただければと思います。

github.com

ExportしたApp Service 証明書にパスフレーズをつける

管理を手伝っている、友人のサイトでApp Service 証明書を移行する必要があってちょっとハマったので備忘録。

App Service 証明書のExport

CloudShellから以下のコマンドで、Exportできます。

$ az keyvault secret download \
    --file appservicecertificate.pfx \
    --vault-name <key-valut-name> \
    --name <保存先のシークレット名> \
    --encoding base64

vault-name には証明書作成時に設定した、キーコンテナ名を指定します。

f:id:kaz_29:20200627162614p:plain
キーコンテナ

name には証明書が保存されている、シークレットの名前を指定します。

f:id:kaz_29:20200627162651p:plain
kay-vault

このコマンドでExportした証明書には空のパスフレーズで生成されます。

このあたりの詳細は公式ドキュメントにも解説があります。

証明書のImport

で、この証明書をアップロードしようとするとアップロード画面ではパスフレーズが必須になっています。

f:id:kaz_29:20200627163303p:plain
Import Error

pfxファイルにパスワードをつける方法を探すのに少し手間取りましたが、以下で大丈夫でした。

# 一旦pem形式に変換
openssl pkcs12 -in appservicecertificate.pfx -out example.com.pem -nodes

# 再度pfx形式に変換、この際にパスフレーズの入力プロンプトが表示されます。
openssl pkcs12 -export -out example.com.pfx -in example.com.pem

ということで無事移行できました。

CakePHP4でRoutingのテスト

最近React+TypeScriptばかりで、CakePHPのコードはあまり書いていないわたなべです。

Routingのテスト

CakePHP1の頃の新原さんのブログ(なんと2009-05-25の記事、11年前!?)でも書かれているように、routes.phpの設定変更は、思わぬバグを出す可能性があるので、UnitTestでの動作確認は必須だと思っています。

CakePHP3までは、以下のような感じでテストできていましたが、CakePHP4でRoutingがmiddleware化した影響などでそのままでは動作しません。

<?php
declare(strict_types=1);
...
use Cake\Network\Request;
...

    public function testRouting($request, $expected)
    {
        $request = new Request([
            'url'         => '/api/articles.json',
            'environment' => ['REQUEST_METHOD' => 'GET']
        ]),
        $result = Router::parseRequest($request);
        $expected = [
            'pass'       => [],
            'plugin'     => null,
            'controller' => 'Articles',
            'prefix'     => 'api',
            '_ext'       => 'json',
            'action'        => 'index',
        ];
        $this->assertEquals($expected, $result);
    }
...

CakePHP4でのRoutingのテスト

最初書いたテストコードは以下のような感じ。

<?php
declare(strict_types=1);
...
use Cake\Http\ServerRequest;
...

    public function testRouting()
    {
        $request = new ServerRequest([
            'url'         => '/api/users/login.json',
            'environment' => ['REQUEST_METHOD' => 'POST']
        ]);
        $result = Router::parseRequest($request);
        $expected = [
            'pass' => [],
            'plugin' => null,
            'controller' => 'Users',
            'prefix' => 'Api',
            '_ext' => 'json',
            'action' => 'login',
            '_matchedRoute' => '/api/users/login',
            '_method' => ['POST'],
            '_middleware' => ['bodies']
        ];
        $this->assertEquals($expected, $result);
    }
...

これを実行すると、以下のようなエラーになってしまいます。もちろんroutes.phpには正しく設定されていて、Web経由でのアクセスにも問題ありません。

Cake\Routing\Exception\MissingRouteException: A route matching "/api/users/login.json" could not be found.

色々調べてみたところ、Routerがmiddleware化されたため、Applicationにmiddlewareが追加された時点でrouterを初期化するようになったようです。 このため、routingテーブルが空の状態になっていました。

解決方法

ということで、Routingテーブルを初期化する処理をtraitにしました。

<?php
declare(strict_types=1);

namespace App\Test\Utility;

use Cake\Routing\Router;

trait RoutingTestTrait {
    protected function initializeRoute()
    {
        Router::reload();
        $routes = Router::createRouteBuilder('/');
        require CONFIG . 'routes.php';
    }
}

前出のコードをこのtraitを使うようにこんな感じに修正するとroutingのテストができます。

<?php
declare(strict_types=1);
...
use Cake\Http\ServerRequest;
use App\Test\Utility\RoutingTestTrait;    // 追加
...
class UsersControllerTest extends TestCase
{
    use IntegrationTestTrait;
    use RoutingTestTrait;    // 追加
...

    public function testRouting()
    {
        $this->initializeRoute();    // 追加

        $request = new ServerRequest([
            'url'         => '/api/users/login.json',
            'environment' => ['REQUEST_METHOD' => 'POST']
        ]);
        $result = Router::parseRequest($request);
        $expected = [
            'pass' => [],
            'plugin' => null,
            'controller' => 'Users',
            'prefix' => 'Api',
            '_ext' => 'json',
            'action' => 'login',
            '_matchedRoute' => '/api/users/login',
            '_method' => ['POST'],
            '_middleware' => ['bodies']
        ];
        $this->assertEquals($expected, $result);
    }
...

まとめ

routingのテストを書いていたことで、routing変更時に何度も救われたことがあるので是非テストを書くことをお勧めします。 CakePHP4は、色々と改善されていい感じに進化しているので今後も使っていこうと思います。

以上、小ネタでした。

2020/6/25追記

RoutingのMiddleware化は、CakePHP3で実施されていました。具体的に何が理由でRoutingテーブルが初期化されていないかは不明。後日調べてみてわかれば追記します。

Github ActionsでMultiContainerなAzure WebApp for Containersをデプロイする

友達に頼まれて、@kunyamiさんのスライド 堅牢&運用楽々な WordPress を Azure App Service でを参考に、WordpressをMultiContainerなAzure WebApp for Containersで構築してたのですが、いくつかハマったのでメモとして残します。

基本的な設定は、@kunyamiさんの資料の通りに進めたのですが、今回は、一部Wordpress管理外の静的なページを含むため、独自コンテナをビルドする必要があったので、GitHub Actionsを使用して、コンテナイメージのビルド、Azure WebApp for Containersへのデプロイをすることにしました。

リポジトリは以下の様な構成になっています。

  • Dockerfile - デプロイ用Dockerfile
  • docker-compose.yml - デプロイ用 docker-composeファイル
  • 静的ファイル用のディレクト
  • その他設定ファイルなど

ベースイメージは、wordpress公式のものを使用して、静的なページは、ビルド時にコピーする様にしました

Dockerfileサンプル

FROM wordpress:latest

COPY static_webpages /var/www/html/static_webpages

事前準備

Azure WebAppデプロイ用クレデンシャルの作成

いつものですね。以下のコマンドを実行すると表示されるJSONがクレデンシャルです。

$ az ad sp create-for-rbac --name "https://[WebAppの名前].azurewebsites.net" --role contributor \
    --scopes /subscriptions/[サブスクリプションID]/resourceGroups/wetest \
    --sdk-auth

秘匿情報の設定

Github Actions内で利用する秘匿情報は、Githubの対象リポジトリ内の Settings -> Secrets で設定します。

f:id:kaz_29:20200504130051p:plain
github Secrets

今回は、ビルドしたイメージをDocker Hubに保存するので、Docker Hubアカウントパスワードと先ほど作成した、クレデンシャルをSecretsに追加します。

  • AZURE_CREDENTIALS - Azureクレデンシャル
  • DOCKER_PASSWORD - Docker Hubのパスワード

Github Actionsの設定

Github Actionsの設定は、リポジトリ内の .github/workfloes ディレクトリ以下に設定ファイルを配置します。今回作成したのは以下の様なファイルです。

name: Build And Deploy

env:
  DOCKER_USERNAME: [Docker Hubのユーザ名]
  DOCKER_IMAGENAME: [docker image名]
  AZURE_WEBAPP_NAME: [webappの名称]

on: 
  push:
    branches:
      - master
  
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Check out code
        uses: actions/checkout@v2
    
      - name: Azure authentication
        uses: azure/login@v1
        with:
          creds: ${{ secrets.AZURE_CREDENTIALS  }}

      - name: Build & push Docker image
        uses: docker/build-push-action@v1
        with:
          image: ${{ env.DOCKER_USERNAME }}/${{ env.DOCKER_IMAGENAME }}
          repository: ${{ env.DOCKER_USERNAME }}/${{ env.DOCKER_IMAGENAME }}
          dockerfile: Dockerfile
          username: ${{ env.DOCKER_USERNAME }}
          password: ${{ secrets.DOCKER_PASSWORD }}
          tags: ${{ github.sha }} 

      - name: 'Deploy to Azure Web App for Container'   
        uses: azure/webapps-deploy@v2
        with: 
          images: ${{ env.DOCKER_USERNAME }}/${{ env.DOCKER_IMAGENAME }}:${{ github.sha }}
          app-name: ${{ env.AZURE_WEBAPP_NAME }}
          configuration-file: docker-compose-webapp.yml

yamlファイルを見れば一目瞭然ですが、今回作成したJobには以下の4つのStepが定義されています。

  • Check out code - コードをチェックアウト
  • Azure authentication - Azureの認証処理
  • Build & push Docker image - docker imageのビルドとプッシュ
  • Deploy to Azure Web App for Container - Azure WebApp for Containersへのデプロイ

docker imageのビルドとプッシュ

docker imageのビルドとプッシュには、Docker公式のGitHub Actionを使っています。 公式サイトに詳しい解説があるので、そちらを参照してください。

今回は、ビルト時のタグにgit hashを使用しているので以下の様に指定しています。

          tags: ${{ github.sha }} 

Azureの認証とAzure WebApp for Containersへのデプロイ

Azure WebApp for Containersへのデプロイは、Azure 公式のGitHub Actionを使っています。 こちらも、公式サイトに詳しい解説があるので、そちらを参照してください。

MultiContainerなAzure WebApp for Containersのデプロイ方法

今回は、MultiContainerなAzure WebApp for Containersを使用しているのですが、公式ドキュメントにはMultiContainerの場合のデプロイ方法は書かれていないため、GitHub Actionのソースコードを確認したところ、以下の様に設定情報を初期化しているのを発見しました。

...
class ActionParameters {
    constructor(endpoint) {
        this._publishProfileContent = core.getInput('publish-profile');
        this._appName = core.getInput('app-name');
        this._slotName = core.getInput('slot-name');
        this._packageInput = core.getInput('package');
        this._images = core.getInput('images');
        this._multiContainerConfigFile = core.getInput('configuration-file');
        this._startupCommand = core.getInput('startup-command');
        this._endpoint = endpoint;
    }
...

ということで、MultiContainer時のdocker-composeファイルは configuration-file で指定できました。最終的なStepの設定が下記です。

      - name: 'Deploy to Azure Web App for Container'   
        uses: azure/webapps-deploy@v2
        with: 
          images: ${{ env.DOCKER_USERNAME }}/${{ env.DOCKER_IMAGENAME }}:${{ github.sha }}
          app-name: ${{ env.AZURE_WEBAPP_NAME }}
          configuration-file: docker-compose-webapp.yml

docker-compose.yml 内でイメージ名を指定していますが、 images に今回ビルドしたタグを含むイメージ名を指定しています。 これもドキュメントには記載がないのですが、試行錯誤している最中に以下のエラーが発生してエラーメッセージの内容から発見したのですが、MultiContainer時にimage名を指定すると、docker-compose内のイメージ名を上書きしてくれますので、自前でdocker-composeファイルを書き換える様な処理は書かなくても大丈夫でした。

試行錯誤中に発生したエラーメッセージ

Deployment Failed with Error: Error: For single-container, just specify a valid image name. For multi-container specifying a Docker-Compose file is mandatory and specifying image names is optional. Provide image names if the tags in Docker-Compose file need to be substituted.

また、slot-name でデプロイ対象のスロット名も指定できる様なので、B/Gデプロイもできそうですね。

まとめ

マーケットプレイスで公開されているGitHub Actionはソースコードも公開されているので、ドキュメントに記載のない内容はソースコードにあたるとすんなり解決できることも多そうです。 GitHub Actionsかなり便利なので、これからも積極的に使っていきたいです。