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 }
データを更新したのになぜか before
が null
になってしまっています。
色々調べた結果、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;"
無事、after
が null
になりました
{ "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_level
を LOGICAL
に変更して、設定を保存します。
次に、今回は手元環境から直接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 APIを fork
して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に保存します。
初回デプロイ
まずは、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
をチェックし、レビュアーのアカウントを選択します。
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
をチェックし、レビュアーのアカウントを選択します。
古いリビジョンを非アクティブにする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
flip
デプロイ完了後、flipの実行待ち状態でworkflowが止まります。 deploy
jobのボックスにはデプロイされた新しいリビジョンのAPIのURLが表示されています。
Review deployments
をクリックして、B / Gを入れ替え(flip)します。
flipが完了すると、トラフィック割り当てが変更され新しいリビジョンを利用する状態になります。
deactivate
この状態で問題がなければ、古いリビジョンを非アクティブにする必要があります。リビジョンがアクティブな状態だと課金対象になってしまいますので、早めに非アクティブ化したほうが良いでしょう。
workflowは deactivate
Jobの実行待ち状態で停止しています。
Review 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を吐きだすプラグインをかいた
小ネタです。
最近は相変わらずCakePHPでAPIを書いて、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 ServiceとAzure Web PubSubを試してます。
クイックスタートを参考にすれば、Azure Functionsで割と簡単に動作を試せます。
今回のサービスのバックエンドAPIはPHPで書かれているため、 negotiate
の処理をPHPのAPIで実施したいと考えていたのですが、残念ながら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のコードを書いているわたなべです。
このところ、仕事でもプライベートでもPHPでAPIを書いて、Next.jsでフロントのWebアプリを書くことがほとんどです。
この場合API仕様は以前ブログにも書きましたが、swagger-phpのアノテーションで記述して、Swagger-UIで参照できる様にしています。
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を呼び出すこともできます。
これすごい便利なのでおすすめなのですが、記述するのが結構面倒なのと、記述方法にいろいろ癖があるので書くたびに毎回試行錯誤することになったりします。
前からなんとかしたいなぁと思っていたのですが、現在とあるリプレース案件で大量にAPIを作成する予定で、この作業を少しでも効率化したいと思いCakePHPのbakeテンプレートを書きました。
bakeテンプレートを自作すると、CakePHPを使っている方であればご存知のbakeコマンドで生成される雛形のソースコードをカスタマイズすることができます。
OpenApiTheme plugin
OpenApiTheme pluginでは、APIを作成する際には定番のfriends od cake CRUD Pluginを使うことを前提で作成しました。
今回は、以下の2つのbakeコマンドを追加しています。
- open_api_model - モデルのbake時にEntityにOpenApiのSchema定義を自動生成する
- open_api_controller - コントローラのbake時にCRUDのAPI定義を自動生成する
実際には以下のような感じで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仕様を確認できますので、ぜひ一度見てみてください。
開発環境でのSwagger-UIの利用
普段利用している開発環境では、開発中のAPIをSwagger-UIから直接叩けるように、開発環境用のdocker-compose.ymlにSwagger-UIのコンテナも含めるようにしています。
docker hub に上がっている、公式のDockerコンテナを利用しています。
まとめ
現在進行中の実案件にもOpenApiTheme pluginを導入して使い始めていますが、仕様書作成がだいぶ捗ります。
随時フィードバックして改善していくつもりですが、ぜひ使っていただいて、要望などあればIssueなりPRなりいただければと思います。
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
には証明書作成時に設定した、キーコンテナ名を指定します。
name
には証明書が保存されている、シークレットの名前を指定します。
このコマンドでExportした証明書には空のパスフレーズで生成されます。
このあたりの詳細は公式ドキュメントにも解説があります。
証明書のImport
で、この証明書をアップロードしようとするとアップロード画面ではパスフレーズが必須になっています。
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テーブルが初期化されていないかは不明。後日調べてみてわかれば追記します。