Azure Container Appsの従量課金ワークロード プロファイルをbicepでプロビジョニング

最近は今後の開発作業を効率的に進めるために、CI/CD/IaC用の基盤(サンプル?)的なものを作ったしてます。今までやりたいなと思っていても、様々な理由で取り組めていなかったことも色々盛り込んでいるので、諸々知見が溜まってきました。今回は、その中からContainer Appsを取り上げようと思います。

Container Appsの実行環境

Container Appsは元々従量課金プランのみでの提供でしたが、2023年8月dedicated plan(専用プラン)がGAしました。

従来の従量課金プランでは、CPU/Memoryの組み合わせで最小 0.25 / 0.5Gi から 最大 2vCPU / 4.0Giのサイズが用意されていましたが、dedicated planでは、従来の従量課金プランとよく似た、"従量課金ワークロード プロファイル"が用意されておりこちらでは 最大 4vCPU / 8Gi まで拡張されていいます。

また、必要に応じて専用ワークロードプロファイルを追加することで、専用のハードウェア上でContainer Appを実行することができるようになりました。参考

今回は、この新しいdedicated planに対応したContainer App Environmentをbicepでプロビジョニングする方法を調査した結果をまとめようと思います。

dedicated plan環境の作成

dedicated plan版のContainer Apps Environmentを作成するには以下のようなbicepファイルを用意します

param logAnalyticsWorkspaceName string = 'example-log'
param appInsightsName string = 'example-app-insights'
param environmentName string = 'example-container-apps-env-test'

param location string = resourceGroup().location

// Create a Log Analytics workspace
resource logAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2022-10-01' = {
  name: logAnalyticsWorkspaceName
  location: location
  properties: any({
    retentionInDays: 30
    features: {
      searchVersion: 1
      legacy: 0
      enableLogAccessUsingOnlyResourcePermissions: true
    }
    sku: {
      name: 'PerGB2018'
    }
  })
}

// Create an Application Insights resource
resource appInsights 'Microsoft.Insights/components@2020-02-02' = {
  name: appInsightsName
  location: location
  kind: 'web'
  properties: { 
    Application_Type: 'web'
    WorkspaceResourceId:logAnalyticsWorkspace.id
  }
}

// Create a managed environment
resource environment 'Microsoft.App/managedEnvironments@2023-05-01' = {
  name: environmentName
  location: location
  properties: {
    appLogsConfiguration: {
      destination: 'log-analytics'
      logAnalyticsConfiguration: {
        customerId: logAnalyticsWorkspace.properties.customerId
        sharedKey: logAnalyticsWorkspace.listKeys().primarySharedKey
      }
    }
    daprAIInstrumentationKey: appInsights.properties.InstrumentationKey
    zoneRedundant: false
    peerAuthentication: {
      mtls: {
        enabled: false
      }
    }
    workloadProfiles: [{
      name: 'Consumption'
      workloadProfileType: 'Consumption'
    }]
  }
}

今回の肝になるのは、以下の部分です

...
    workloadProfiles: [{
      name: 'Consumption'
      workloadProfileType: 'Consumption'
    }]
...

追加された workloadProfiles プロパティを指定することで、"従量課金ワークロード プロファイル"を作成することができます。また、ここに以下のように独自のワークロードを定義することで、専用サーバー上にアプリを配置することができるようになります。

    {
      name: 'myworkload'
      maximumCount: 10
      minimumCount: 3
      workloadProfileType: 'D4'
    }

現時点で指定可能なworkloadProfileTypeは以下に記載があります

Azure Container Apps のワークロード プロファイル - プロファイルの種類

(minimumCount は可用性を保証するために3以上を指定することが推奨されています)

プロビジョニング

以下のコマンドでプロビジョンングすることができます

$ export RESOURCE_GROUP=[リソースグループ名]
$ az deployment group create \
    --resource-group $RESOURCE_GROUP \
    --template-file bicep/container-apps-env.bicep

正常に処理が完了すると、以下のようにportalでも設定を確認することができます

dedicated plan環境にアプリをデプロイ

dedicated plan版のContainer Apps Environmentにアプリをデプロイするには以下のようなbicepファイルを用意します

param location string = resourceGroup().location
param environmentName string = 'example-container-apps-env-test'
param containerAppName string = 'example-app'

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

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

resource containerApp 'Microsoft.App/containerApps@2023-05-01' = {
  name: containerAppName
  location: location
  identity: {
    type: 'SystemAssigned'
  }
  properties: {
    workloadProfileName: 'Consumption'
    managedEnvironmentId: environment.id
    configuration: {
      activeRevisionsMode: revisionMode
      dapr:{
        enabled:false
      }
      ingress: {
        external: true
        targetPort: 80
        transport: 'auto'
        allowInsecure: false
        traffic: [
          {
            weight: 100
            latestRevision: true
          }
        ]
      }
    }
    template: {
      containers: [
        {
          image: 'nginx:latest'
          name: containerAppName
          resources: {
            cpu: any('4.0')
            memory: '8Gi'
          }
        }
      ]
      scale: {
        minReplicas: 0
        maxReplicas: 10
        rules: [
          {
            name: 'http-scaling-rule'
            http: {
              metadata: {
                concurrentRequests: '10'
              }
            }
          }
        ]
      }
    }
  }
}

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

従来の従量課金プランと異なるのは、プロパティの以下の部分です

...
  properties: {
    workloadProfileName: 'Consumption'
...

workloadProfileName にワークロードプロファイル名を指定することで、実行環境を指定することができます。

デプロイ

以下のコマンでデプロイできます

$ az deployment group create \
      --resource-group $RESOURCE_GROUP \
      --template-file bicep/app.bicep

無事作成が完了すると、portalでも以下のように設定内容を確認することができます

おまけ

先日まで存在を知らなかったのですが、az deployment group には what-if というコマンドが存在します。このコマンドを利用することで、bicepファイルと実際のリソースの差分を表示することができます

例えば、上記アプリのCPU / メモリを以下のように修正します

...
          resources: {
            cpu: any('2.0')
            memory: '4Gi'
          }
...

この状態で下記のコマンドを実行すると、以下のように差分が表示されます

$ az deployment group what-if \
      --resource-group $RESOURCE_GROUP \
      --template-file bicep/app.bicep

Note: The result may contain false positive predictions (noise).
You can help us improve the accuracy of the result by opening an issue here: https://aka.ms/WhatIfIssues

Resource and property changes are indicated with these symbols:
  - Delete
  ~ Modify
  * Ignore

The deployment will update the following scope:

Scope: /subscriptions/ac25fc44-3b4d-4b2f-917e-672509e414ca/resourceGroups/kaz29-key-vault-test-rg

  ~ Microsoft.App/containerApps/example-app [2023-05-01]
    - properties.configuration.ingress.exposedPort: 0
    - properties.runningStatus:                     "Running"
    ~ properties.template.containers: [
      ~ 0:

        ~ resources.cpu:    4.0 => 2.0
        ~ resources.memory: "8Gi" => "4Gi"

      ]

  * Microsoft.App/managedEnvironments/example-container-apps-env-test
  * Microsoft.Insights/components/example-app-insights
  * Microsoft.OperationalInsights/workspaces/example-log
  * microsoft.alertsmanagement/smartDetectorAlertRules/Failure Anomalies - example-app-insights

この機能を使えば、stateを持たないbicepでもIaC的なことを比較的安心に組めそうです。 例えば、PR作成時にdiffを取って自動で差分をPRコメントに投稿するようにすれば、bicepファイルの変更と実際の差分を確認して安心してレビューができそうです

まとめ

いかがでしたか、dedicated planプランを利用すると従量課金ワークロード プロファイルでも、以前より豊富なリソースサイズの中からアプリの特性に合わせたものを選択できるようになりますし、専用ワークロードプロファイルを作ればさらに自由度が高い環境を構築することもできます。

Container Appsはとても便利なので、ぜひ試してみてください。

参考リンク

Azure Database for PostgreSQLでCDCを試してみる

最近開発しているサービスがだんだん成長してきて、先々を考えるといくつかのサービスに分離したいなーと思いChange Data Capture (CDC)について色々と調べていました。

MySQLでの構築については、この記事DebeziumでCDCを構築してみたがとても丁寧に解説されているのでお薦めです。この記事の解説を参考にしてMySQL+Kafka+Debeziumで動作してお試しできる環境ができたので、色々と挙動を確認できました。

PostgreSQLでCDC

MySQLでの実験環境は簡単に構築できたのですが、今回導入を検討しているサービスではPostgreSQLを使用しています。 ということで、まずは手元でPostgreSQL + Kafka + DebeziumでCDC環境を構築してみます。

Kafkaの構築

こちらは前出のブログの記載とほぼ同じで、Docker hubにある公式イメージから構築します。

version: "3.8"
services:
  pg-debezium-zookeeper:
    container_name: pg-debezium-zookeeper
    image: "bitnami/zookeeper:latest"
    ports:
      - "2181:2181"
    environment:
      - ALLOW_ANONYMOUS_LOGIN=yes
  pg-debezium-kafka:
    container_name: pg-debezium-kafka
    image: "bitnami/kafka:latest"
    ports:
      - "9092:9092"
      - "29092:29092"
    environment:
      - KAFKA_CFG_BROKER_ID=1
      - ALLOW_PLAINTEXT_LISTENER=yes
      - KAFKA_CFG_ZOOKEEPER_CONNECT=pg-debezium-zookeeper:2181
      - KAFKA_CFG_LISTENER_SECURITY_PROTOCOL_MAP=PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT
      - KAFKA_CFG_LISTENERS=PLAINTEXT://:9092,PLAINTEXT_HOST://:29092
      - KAFKA_CFG_ADVERTISED_LISTENERS=PLAINTEXT://pg-debezium-kafka:9092,PLAINTEXT_HOST://127.0.0.1:29092
    depends_on:
      - pg-debezium-zookeeper

PostgreSQLの構築

MySQLでの構築と同様に、PostgreSQLを利用する場合にも設定を変更する必要があります。 デフォルトでは、wal_level = REPLICATION になっているのですが、これを wal_level = LOGICAL に変更しより詳細なログを出力する必要があります。LOGICALに設定変更するとログのサイズが増えるようなので注意が必要かもしれません。

今回は設定ファイルを変更せずにコンテナ起動時のコマンドで設定を変更することにします。 docker-compose.ymlはこんな感じ

version: "3.8"
services:
... 省略
  pg-debezium-postgres:
    container_name: pg-debezium-postgres
    image: postgres:14.7-alpine
    command: [ "postgres", "-c", "wal_level=logical" ]
    volumes:
      - pg-debezium-postgres-data:/var/lib/postgresql/data:cached
    environment:
      POSTGRES_PASSWORD: "Passw0rd"
      POSTGRES_INITDB_ARGS: "--encoding=UTF-8 --locale=ja_JP.UTF-8"
      POSTGRES_USER: "test"
      POSTGRES_DB: test
    healthcheck:
      test: [ "CMD-SHELL", "pg_isready" ]

テーブルの作成

テストに使用するテーブルを作っておきます

$ docker exec -it pg-debezium-postgres psql -U test test \
    -c "CREATE TABLE test (id SERIAL PRIMARY KEY, subject text NOT NULL, created timestamptz NOT NULL);"

Debeziumの構築

Debeziumも、公式のイメージがあるのでそれを利用して、上記のKafkaと接続するように設定します。

version: "3.8"
services:
... 省略
  pg-debezium:
    container_name: pg-debezium
    image: "debezium/connect:2.0"
    ports:
      - "8083:8083"
    environment:
      - BOOTSTRAP_SERVERS=pg-debezium-kafka:9092
      - GROUP_ID=1
      - CONFIG_STORAGE_TOPIC=_kafka_connect_configs
      - OFFSET_STORAGE_TOPIC=_kafka_connect_offsets
      - STATUS_STORAGE_TOPIC=_kafka_connect_statuses
    depends_on:
      - pg-debezium-zookeeper
      - pg-debezium-kafka
      - pg-debezium-postgres

最終的なdocker-compose.yml

これまでの解説分を全て含んだdocker-compose.ymlはこんな感じです

version: "3.8"
services:
  pg-debezium-zookeeper:
    container_name: pg-debezium-zookeeper
    image: "bitnami/zookeeper:latest"
    ports:
      - "2181:2181"
    environment:
      - ALLOW_ANONYMOUS_LOGIN=yes
  pg-debezium-kafka:
    container_name: pg-debezium-kafka
    image: "bitnami/kafka:latest"
    ports:
      - "9092:9092"
      - "29092:29092"
    environment:
      - KAFKA_CFG_BROKER_ID=1
      - ALLOW_PLAINTEXT_LISTENER=yes
      - KAFKA_CFG_ZOOKEEPER_CONNECT=pg-debezium-zookeeper:2181
      - KAFKA_CFG_LISTENER_SECURITY_PROTOCOL_MAP=PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT
      - KAFKA_CFG_LISTENERS=PLAINTEXT://:9092,PLAINTEXT_HOST://:29092
      - KAFKA_CFG_ADVERTISED_LISTENERS=PLAINTEXT://pg-debezium-kafka:9092,PLAINTEXT_HOST://127.0.0.1:29092
    depends_on:
      - pg-debezium-zookeeper

  pg-debezium-postgres:
    container_name: pg-debezium-postgres
    image: postgres:14.7-alpine
    command: [ "postgres", "-c", "wal_level=logical" ]
    volumes:
      - pg-debezium-postgres-data:/var/lib/postgresql/data:cached
    environment:
      POSTGRES_PASSWORD: "Passw0rd"
      POSTGRES_INITDB_ARGS: "--encoding=UTF-8 --locale=ja_JP.UTF-8"
      POSTGRES_USER: "test"
      POSTGRES_DB: test
    healthcheck:
      test: [ "CMD-SHELL", "pg_isready" ]
      
  pg-debezium:
    container_name: pg-debezium
    image: "debezium/connect:2.0"
    ports:
      - "8083:8083"
    environment:
      - BOOTSTRAP_SERVERS=pg-debezium-kafka:9092
      - GROUP_ID=1
      - CONFIG_STORAGE_TOPIC=_kafka_connect_configs
      - OFFSET_STORAGE_TOPIC=_kafka_connect_offsets
      - STATUS_STORAGE_TOPIC=_kafka_connect_statuses
    depends_on:
      - pg-debezium-zookeeper
      - pg-debezium-kafka
      - pg-debezium-postgres

networks:
  internal:
    driver: bridge
    internal: true
  external:
    driver: bridge
    internal: false
    name: pg_debezium_external_network

volumes:
  pg-debezium-postgres-data:

こんな感じで起動してみます

$ docker compose up -d
[+] Running 4/4
 ⠿ Container pg-debezium-postgres   Started          0.4s
 ⠿ Container pg-debezium-zookeeper  Started          0.4s
 ⠿ Container pg-debezium-kafka      Started          0.6s
 ⠿ Container pg-debezium            Started          0.9s

これで、PostgreSQL + Kafka + Debezium でのCDCの基盤が動作している状態になります。

CDCの構築

基盤の構築が終わったので、Debeziumコンテナ内にコネクタを作成します。

以下が今回の調査で作成した設定内容です。 sample.json という名称で保存しておきます。

{
  "name": "postgres-connector",
  "config": {
    "connector.class": "io.debezium.connector.postgresql.PostgresConnector",
    "tasks.max": "1",
    "database.hostname": "pg-debezium-postgres",
    "database.port": "5432",
    "database.user": "test",
    "database.password": "Passw0rd",
    "database.dbname" : "test",
    "database.server.name": "pg-debezium-postgres",
    "table.whitelist": "public.test",
    "plugin.name": "pgoutput",
    "topic.prefix": "test_topic",

    "key.converter": "org.apache.kafka.connect.json.JsonConverter",
    "key.converter.schemas.enable": false,
    "value.converter": "org.apache.kafka.connect.json.JsonConverter",
    "value.converter.schemas.enable": false,

    "database.connectionTimeZone": "UTC",
    "include.schema.changes": "false"
  }
}

PostgreSQLコネクタの設定内容は、公式ドキュメントを参照してください。

設定ファイルができたので以下のコマンドでコネクタを作成します。(出力されているJSON文字列は整形しています)

$ curl -i -X POST -H "Accept:application/json" -H \
    "Content-Type:application/json" \
    http://localhost:8083/connectors/ \
    -d @./sample.json
    
HTTP/1.1 201 Created
Date: Sat, 06 May 2023 05:36:54 GMT
Location: http://localhost:8083/connectors/postgres-connector
Content-Type: application/json
Content-Length: 733
Server: Jetty(9.4.48.v20220622)

{
  "name": "postgres-connector",
  "config": {
    "connector.class": "io.debezium.connector.postgresql.PostgresConnector",
    "tasks.max": "1",
    "database.hostname": "pg-debezium-postgres",
    "database.port": "5432",
    "database.user": "test",
    "database.password": "Passw0rd",
    "database.dbname": "test",
    "database.server.name": "pg-debezium-postgres",
    "table.whitelist": "public.test",
    "plugin.name": "pgoutput",
    "topic.prefix": "test_topic",
    "key.converter": "org.apache.kafka.connect.json.JsonConverter",
    "key.converter.schemas.enable": "false",
    "value.converter": "org.apache.kafka.connect.json.JsonConverter",
    "value.converter.schemas.enable": "false",
    "database.connectionTimeZone": "UTC",
    "include.schema.changes": "false",
    "name": "postgres-connector"
  },
  "tasks": [],
  "type": "source"
}

トピックの確認

以下のコマンドで、トピックの一覧を表示することができます。まだレコードが存在しないので、先ほど作成したコネクタで定義したトピックはまだ存在しません。

$ docker-compose exec pg-debezium-kafka kafka-topics.sh --list --bootstrap-server pg-debezium-kafka:9092
__consumer_offsets
_kafka_connect_configs
_kafka_connect_offsets
_kafka_connect_statuses

レコードを追加してみます。レコードを追加するとトピックが作成されていることが確認できます。

$ docker exec -it pg-debezium-postgres psql -U test test \
    -c "INSERT INTO test (subject, created) values ( 'Test 1', NOW());"
INSERT 0 1

$ docker-compose exec pg-debezium-kafka kafka-topics.sh --list --bootstrap-server pg-debezium-kafka:9092
__consumer_offsets
_kafka_connect_configs
_kafka_connect_offsets
_kafka_connect_statuses
test_topic.public.test

では、kafkaコンテナに用意されている、kafka-console-consumer.sh を使用して購読してみます。

$ docker-compose exec pg-debezium-kafka kafka-console-consumer.sh \
        --bootstrap-server 127.0.0.1:9092 \
        --from-beginning --topic test_topic.public.test

{"before":null,"after":{"id":1,"subject":"Test 1","created":"2023-05-06T05:44:15.096521Z"},"source":{"version":"2.0.0.Final","connector":"postgresql","name":"test_topic","ts_ms":1683351855101,"snapshot":"false","db":"test","sequence":"[null,\"24280176\"]","schema":"public","table":"test","txId":738,"lsn":24280176,"xmin":null},"op":"c","ts_ms":1683351855591,"transaction":null}

無事、先ほど追加したレコードの内容は取得できています。では、別のターミナルを開いてデータベースを更新してみます。

$ docker exec -it pg-debezium-postgres psql -U test test \
    -c "INSERT INTO test (subject, created) values ( 'Test 2', NOW());"
$ docker exec -it pg-debezium-postgres psql -U test test \
    -c "UPDATE test SET subject = 'Test 2 updated' WHERE id = 2;"

コンシューマには以下のように表示されました。

{
  "before": null,
  "after": {
    "id": 2,
    "subject": "Test 2",
    "created": "2023-05-06T05:46:17.579336Z"
  },
  "source": {
    "version": "2.0.0.Final",
    "connector": "postgresql",
    "name": "test_topic",
    "ts_ms": 1683351977580,
    "snapshot": "false",
    "db": "test",
    "sequence": "[\"24280464\",\"24280856\"]",
    "schema": "public",
    "table": "test",
    "txId": 739,
    "lsn": 24280856,
    "xmin": null
  },
  "op": "c",
  "ts_ms": 1683351977937,
  "transaction": null
}
{
  "before": null,
  "after": {
    "id": 2,
    "subject": "Test 2 updated",
    "created": "2023-05-06T05:46:17.579336Z"
  },
  "source": {
    "version": "2.0.0.Final",
    "connector": "postgresql",
    "name": "test_topic",
    "ts_ms": 1683352381043,
    "snapshot": "false",
    "db": "test",
    "sequence": "[\"24282144\",\"24282200\"]",
    "schema": "public",
    "table": "test",
    "txId": 741,
    "lsn": 24282200,
    "xmin": null
  },
  "op": "u",
  "ts_ms": 1683352381077,
  "transaction": null
}

データを更新したのになぜか beforenull になってしまっています。 色々調べた結果、Debeziumのユーザガイド内に記載がありました。 7.3.2. Debezium PostgreSQL 変更イベントの値 こちらの解説を参考に以下のように設定を変更します。

$ docker exec -it pg-debezium-postgres psql -U test test \
    -c "ALTER TABLE public.test REPLICA IDENTITY FULL;"
ALTER TABLE

設定の変更ができたので、再度データを更新してみます。

$ docker exec -it pg-debezium-postgres psql -U test test \
    -c "UPDATE test SET subject = 'Test 2 updated 2' WHERE id = 2;"
UPDATE 1

無事、before に変更前のデータが入るようになりました。

{
  "before": {
    "id": 2,
    "subject": "Test 2 updated",
    "created": "2023-05-06T05:46:17.579336Z"
  },
  "after": {
    "id": 2,
    "subject": "Test 2 updated 2",
    "created": "2023-05-06T05:46:17.579336Z"
  },
  "source": {
    "version": "2.0.0.Final",
    "connector": "postgresql",
    "name": "test_topic",
    "ts_ms": 1683352906329,
    "snapshot": "false",
    "db": "test",
    "sequence": "[\"24284944\",\"24285000\"]",
    "schema": "public",
    "table": "test",
    "txId": 743,
    "lsn": 24285000,
    "xmin": null
  },
  "op": "u",
  "ts_ms": 1683352906769,
  "transaction": null
}

では最後に削除を試してみます。

$ docker exec -it pg-debezium-postgres psql -U test test \
    -c "DELETE FROM test WHERE id = 2;"    

無事、afternull になりました

{
  "before": {
    "id": 2,
    "subject": "Test 2 updated 2",
    "created": "2023-05-06T05:46:17.579336Z"
  },
  "after": null,
  "source": {
    "version": "2.0.0.Final",
    "connector": "postgresql",
    "name": "test_topic",
    "ts_ms": 1683353081106,
    "snapshot": "false",
    "db": "test",
    "sequence": "[\"24285496\",\"24285552\"]",
    "schema": "public",
    "table": "test",
    "txId": 744,
    "lsn": 24285552,
    "xmin": null
  },
  "op": "d",
  "ts_ms": 1683353081505,
  "transaction": null
}

これで、コンシューマを作成すれば、変更データをもとにコピーを作成したりデータを集計したりなど色々と処理ができますね!前出のブログでも紹介されている、KafkaJSを使ってコンシューマを作るのが良さそうです。

Azure Database for PostgreSQLでの設定

ここまで、手元環境のdoker上のPostgreSQLでDebeziumを試してみましたが、Azure Database for PostgreSQLで動作するか検証することにしてみます。

Azureの公式ドキュメントには、変更データ キャプチャ用に Azure Event Hubs の Apache Kafka Connect のサポートを Debezium と統合するという記事があり、Azure Event Hubsを利用した構築方法が解説されています。

このページにも 警告 として記載がありますが、Event Hubを利用する場合イベントの保存期間が制限されるという制約があるようです。これが実運用時に問題になるかはまだ把握できていないのですが、まずは自前でCDCを構築してみようと思います。

Azure Database for PostgreSQLの作成

今回は試験用なので、以下のように小さめ(お安い)で作成しています。 (フレキシブル サーバーでないと14などの新しいバージョンが利用できないので、フレキシブル サーバーを使っています)

設定の変更

まずは、 wal_levelを変更します。サーバーパラメータ編集ページで以下のようにwal_levelLOGICAL に変更して、設定を保存します。

次に、今回は手元環境から直接PostgreSQLに接続をする必要があるので、ネットワーク編集ページで 現在のクライアントIPを追加する を選択して、接続元IPアドレスを追加して、設定を保存します。

今回は実験用なのでこのように外部からの接続を許可する設定をしましたが、実運用時の設定は各環境に合わせて適切に設定してください

これで、手元環境から接続できるようになったので、以下のコマンドで接続確認をします。

$ docker exec -it pg-debezium-postgres psql -h ホスト名.postgres.database.azure.com -U test test
Password for user test: 
psql (14.7)
SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, bits: 256, compression: off)
Type "help" for help.

test=> 

無事接続できましたので、先ほどと同様にテーブルを作成します。また、先ほど追加で設定したレプリケーションの設定なども更新しておきます。

$ docker exec -it pg-debezium-postgres psql -h ホスト名 -U test test \
    -c "CREATE TABLE test (id SERIAL PRIMARY KEY, subject text NOT NULL, created timestamptz NOT NULL");
$ docker exec -it pg-debezium-postgres psql -h ホスト名 -U test test \
    -c "ALTER TABLE public.test REPLICA IDENTITY FULL;"
$ docker exec -it pg-debezium-postgres psql -h ホスト名 -U test test \
    -c "ALTER ROLE test WITH REPLICATION;"

CDCの構築

SSL接続設定の準備

Azure Database for PostgreSQLへの接続は、TLSを使用して接続する必要があるため、Debeziumからの接続に少し準備が必要です。 公式ドキュメントのAzure Database for PostgreSQL - フレキシブル サーバーでのトランスポート層セキュリティを使用した暗号化された接続の解説を参考にして、ルート証明書をダウンロードします。

docker exec -it pg-debezium curl -O https://dl.cacerts.digicert.com/DigiCertGlobalRootCA.crt.pem

コネクタの追加

準備ができたので、以下のような設定でコネクタを追加します。SSLでの接続が必要なため、ローカル用の設定に database.sslmode / database.sslrootcert の2つを追加しています。 今回は、azure~sample.jsonという名前で保存します。

{
    "name": "azure-postgres-connector",
    "config": {
        "connector.class": "io.debezium.connector.postgresql.PostgresConnector",
        "tasks.max": "1",
        "database.hostname": "データベースのホスト名",
        "database.port": "5432",
        "database.user": "test",
        "database.password": "データベースのパスワード",
        "database.dbname" : "test",
        "database.server.name": "データベースのホスト名",
        "database.sslmode": "verify-full",
        "database.sslrootcert": "/kafka/DigiCertGlobalRootCA.crt.pem",
        "table.whitelist": "public.test",
        "plugin.name": "pgoutput",
        "topic.prefix": "azure_pgsql_topic",

        "key.converter": "org.apache.kafka.connect.json.JsonConverter",
        "key.converter.schemas.enable": false,
        "value.converter": "org.apache.kafka.connect.json.JsonConverter",
        "value.converter.schemas.enable": false,

        "database.connectionTimeZone": "UTC",
        "include.schema.changes": "false"
    }
  }

先ほどと同様に以下のコマンドでコネクタを追加します。

$ curl -i -X POST -H "Accept:application/json" -H \
    "Content-Type:application/json" \
    http://localhost:8083/connectors/ \
    -d @./azure-sample.json


HTTP/1.1 201 Created
Date: Sat, 06 May 2023 06:49:08 GMT
Location: http://localhost:8083/connectors/azure-postgres-connector
Content-Type: application/json
Content-Length: 924
Server: Jetty(9.4.48.v20220622)

{
  "name": "azure-postgres-connector",
  "config": {
    "connector.class": "io.debezium.connector.postgresql.PostgresConnector",
    "tasks.max": "1",
    "database.hostname": "ホスト名",
    "database.port": "5432",
    "database.user": "test",
    "database.password": "データベースのパスワード",
    "database.dbname": "webapp",
    "database.server.name": "ホスト名",
    "database.sslmode": "verify-full",
    "database.sslrootcert": "/kafka/DigiCertGlobalRootCA.crt.pem",
    "table.whitelist": "public.test",
    "plugin.name": "pgoutput",
    "topic.prefix": "azure_pgsql_topic",
    "key.converter": "org.apache.kafka.connect.json.JsonConverter",
    "key.converter.schemas.enable": "false",
    "value.converter": "org.apache.kafka.connect.json.JsonConverter",
    "value.converter.schemas.enable": "false",
    "database.connectionTimeZone": "UTC",
    "include.schema.changes": "false",
    "name": "azure-postgres-connector"
  },
  "tasks": [],
  "type": "source"
}

以下のコマンドで、コネクタが追加されているか確認します

$ curl -i -X GET -H "Accept:application/json" \
    -H  "Content-Type:application/json" \
    http://localhost:8083/connectors/

HTTP/1.1 200 OK
Date: Sat, 06 May 2023 06:51:04 GMT
Content-Type: application/json
Content-Length: 49
Server: Jetty(9.4.48.v20220622)

["postgres-connector","azure-postgres-connector"]

問題なく追加されているようです。

トピックの確認

準備ができたので、先ほどと同様に kafka-console-consumer.sh を使用して購読してみます。

$ curl -i -X POST -H "Accept:application/json" -H \
    "Content-Type:application/json" \
    http://localhost:8083/connectors/ \
    -d @./azure-sample.json

HTTP/1.1 201 Created
Date: Sat, 06 May 2023 06:49:08 GMT
Location: http://localhost:8083/connectors/azure-postgres-connector
Content-Type: application/json
Content-Length: 924
Server: Jetty(9.4.48.v20220622)

{
  "name": "azure-postgres-connector",
  "config": {
    "connector.class": "io.debezium.connector.postgresql.PostgresConnector",
    "tasks.max": "1",
    "database.hostname": "ホスト名",
    "database.port": "5432",
    "database.user": "test",
    "database.password": "パスワード",
    "database.dbname": "webapp",
    "database.server.name": "ホスト名",
    "database.sslmode": "verify-full",
    "database.sslrootcert": "/kafka/DigiCertGlobalRootCA.crt.pem",
    "table.whitelist": "public.test",
    "plugin.name": "pgoutput",
    "topic.prefix": "azure_pgsql_topic",
    "key.converter": "org.apache.kafka.connect.json.JsonConverter",
    "key.converter.schemas.enable": "false",
    "value.converter": "org.apache.kafka.connect.json.JsonConverter",
    "value.converter.schemas.enable": "false",
    "database.connectionTimeZone": "UTC",
    "include.schema.changes": "false",
    "name": "azure-postgres-connector"
  },
  "tasks": [],
  "type": "source"
}

準備ができたので、別ターミナルからデータを追加・更新・削除してみます。

$ docker exec -it pg-debezium-postgres psql -h ホスト名 -U test test \
    -c "INSERT INTO test (subject, created) values ( 'Test 1', NOW());"    
$ docker exec -it pg-debezium-postgres psql -h ホスト名 -U test test \
    -c "INSERT INTO test (subject, created) values ( 'Test 2', NOW());"    
$ docker exec -it pg-debezium-postgres psql -h ホスト名 -U test test \
    -c "UPDATE test SET subject = 'Test 2 updated' WHERE id = 2;"    
$ docker exec -it pg-debezium-postgres psql -h ホスト名 -U test test \
    -c "DELETE FROM test WHERE id = 2;"    

コンシューマでは、問題なく以下のように変更を取得できました。

{"before":null,"after":{"id":1,"subject":"Test 1","created":"2023-05-06T02:28:46.075454Z"},"source":{"version":"2.0.0.Final","connector":"postgresql","name":"azure_pgsql_topic","ts_ms":1683355748922,"snapshot":"first","db":"webapp","sequence":"[null,\"20568873528\"]","schema":"public","table":"test","txId":48642,"lsn":20568873528,"xmin":null},"op":"r","ts_ms":1683355749255,"transaction":null}
{"before":null,"after":{"id":2,"subject":"Test 2","created":"2023-05-06T02:28:46.084254Z"},"source":{"version":"2.0.0.Final","connector":"postgresql","name":"azure_pgsql_topic","ts_ms":1683355748922,"snapshot":"true","db":"webapp","sequence":"[null,\"20568873528\"]","schema":"public","table":"test","txId":48642,"lsn":20568873528,"xmin":null},"op":"r","ts_ms":1683355749257,"transaction":null}
{"before":{"id":2,"subject":"Test 2","created":"2023-05-06T02:28:46.084254Z"},"after":{"id":2,"subject":"Test 2 updated","created":"2023-05-06T02:28:46.084254Z"},"source":{"version":"2.0.0.Final","connector":"postgresql","name":"azure_pgsql_topic","ts_ms":1683356320172,"snapshot":"false","db":"webapp","sequence":"[\"20602423752\",\"20602427936\"]","schema":"public","table":"test","txId":48718,"lsn":20602427936,"xmin":null},"op":"u","ts_ms":1683356320309,"transaction":null}
{"before":{"id":2,"subject":"Test 2 updated","created":"2023-05-06T02:28:46.084254Z"},"after":null,"source":{"version":"2.0.0.Final","connector":"postgresql","name":"azure_pgsql_topic","ts_ms":1683356329235,"snapshot":"false","db":"webapp","sequence":"[\"20602428392\",\"20602428648\"]","schema":"public","table":"test","txId":48720,"lsn":20602428648,"xmin":null},"op":"d","ts_ms":1683356329507,"transaction":null}

これで、Azure Database for PostgreSQLでもDebeziumを使用したCDCが構築できることが確認できました。

まとめ

今回は、Azure Database for PostgreSQL + DebeziumでCDCを構築するための準備として、ローカル環境 + Azure Database for PostgreSQLでCDCを構築するところまでを解説しました。 実際に利用するためには、まだまだ調査しないといけないことは多いですが、ひとまず以降の実験・調査をする土台までは検証できました。

さらっと書いていますが、概念を把握するのに色々実験したり、PostgreSQL・Azure Database for PostgreSQLで動作させるために何度も構築し直したり、色々と試行錯誤が必要でした。

この後は、以下のような残った課題を順次調査を進めていこうと思います

  • Kafka + zookeeperをどこに構築するか検討して構築
  • コネクタのパラメータの調整
  • ログの管理や監視
  • Azure Event Hubsを利用する形での実験と検討

また知見が溜まったら、ブログにまとめようと思います。

関連リンク

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

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