CakePHP OpenTelemetry Pluginを作った話
最近(というかここ1,2年はずっと)私の部署では、Azure Container Apps+StaticWebAppでマイクロサービスっぽい感じでいろんなアプリやAPIを書いています。 まだ一部のサービス以外は開発中なのですが、今後増えていくAPIを運用していくにあたってオブザーバビリティを高めないと破綻しそうな予感しかないので、APMを導入したいと考えていました。
そんな中、コミュニティイベントなどでOpenTelemetryの話を色々聞く機会があったのと、Azure Container Appsにマネージド OpenTelemetry エージェントが追加されたこともあり、CakePHP用の汎用プラグインとして実装してみることにしました。その経緯と実装についてまとめます。
コードは以下のリポジトリで公開しています。
なぜOpenTelemetryなのか
マイクロサービスや複数のバックエンドサービスが絡む構成では、障害調査やパフォーマンス分析の際に「どのリクエストがどこで何をしていたか」を追跡できることが非常に重要です。
OpenTelemetryは、分散トレーシング・メトリクス・ログを統一的に扱うためのオブザーバビリティフレームワークで、CNCF(Cloud Native Computing Foundation)のプロジェクトとして広く採用が進んでいます。
バックエンド非依存のOTLP(OpenTelemetry Protocol)でテレメトリを送信するため、Datadog、New Relic、Grafana Tempo、Azure Application Insightsなど、OTLP対応の任意のバックエンドで利用できます。
プラグインの概要
今回作成した kaz29/cakephp-otel-plugin は、CakePHP 5アプリケーションにコード変更なしでOpenTelemetryの自動トレースを追加するプラグインです。
PHPのext-opentelemetry PECL拡張が提供するzend_observerフックを使い、Controller / ORM操作のスパンを自動生成します。アプリケーションのコードを一切変更することなく、プラグインを追加するだけでトレースが取れるようになります。
トレース対象
以下の操作に対して自動的にスパンが生成されます。
| フック対象 | スパン名の例 | SpanKind |
|---|---|---|
Controller::invokeAction |
App\Controller\UsersController::index |
SERVER |
Table::find |
Users.find(all) |
CLIENT |
Table::save |
Users.save |
CLIENT |
Table::delete |
Users.delete |
CLIENT |
Controllerのスパンが親スパンとなり、その中で実行されるORMの各操作が子スパンとして記録されるため、1つのリクエストの中でどのDBクエリにどれだけ時間がかかっているかが一目でわかります。
その他の機能
- OtelErrorLoggingMiddleware — 5xxエラーをOpenTelemetryのログとして送信するPSR-15ミドルウェア
- TraceAwareLogger — 既存のPSR-3ロガーをラップし、ログにtrace_id / span_idを自動付与するデコレーター
なぜログを全部送らないのか
うちの環境ではすでにAzure Monitorでアプリケーションログを収集しています。OpenTelemetry経由でログも全部送ると二重取りになりますし、ログの量に応じてそれなりのコストが発生します。
そこで、通常のログは既存のログ基盤にそのまま流しつつ、TraceAwareLoggerで trace_id を埋め込んでトレースとの紐付けだけできるようにしました。障害調査のときはトレースからtrace_idを拾って、ログ基盤側で検索すれば関連ログを追えます。
一方、5xxエラーだけはトレース画面からすぐに確認できると調査が格段に楽になるので、OtelErrorLoggingMiddlewareで例外ログだけをOpenTelemetryに送るようにしています。全部送るか・何を送るかはコストとのバランスなので、このあたりは環境に合わせて調整してもらえればと思います。
セットアップ
必要な環境
- PHP 8.1以上
- CakePHP 5.x
ext-opentelemetryPECL拡張
インストール
composer require kaz29/cakephp-otel-plugin
プラグインの有効化
Application.php の bootstrap() メソッドで、プラグインを追加します。
public function bootstrap(): void
{
parent::bootstrap();
$this->addPlugin('OtelInstrumentation');
}
エラーログをOTelで送信したい場合は、ミドルウェアも追加します。ErrorHandlerMiddleware の後に追加してください。
public function middleware(MiddlewareQueue $middlewareQueue): MiddlewareQueue
{
$middlewareQueue
->add(new ErrorHandlerMiddleware(Configure::read('Error'), $this))
->add(new OtelErrorLoggingMiddleware())
// ... 他のミドルウェア
;
return $middlewareQueue;
}
環境変数の設定
OpenTelemetry PHP SDKの設定は環境変数で行います。
OTEL_PHP_AUTOLOAD_ENABLED=true OTEL_SERVICE_NAME=my-cakephp-app OTEL_TRACES_EXPORTER=otlp OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
これだけで、アプリケーションのControllerアクションやDBクエリが自動的にトレースされるようになります。
実装の詳細
Controllerのフック
Controller::invokeAction に zend_observer フックを設定し、リクエスト処理全体をひとつのスパンとして記録します。
\OpenTelemetry\API\Instrumentation\hook(
Controller::class,
'invokeAction',
pre: static function (Controller $controller, array $params, ...) use ($instrumentation) {
$request = $controller->getRequest();
$spanBuilder = $instrumentation->tracer()
->spanBuilder(get_class($controller) . '::' . $request->getParam('action'))
->setSpanKind(SpanKind::KIND_SERVER)
->setAttribute('http.method', $request->getMethod())
->setAttribute('http.url', (string)$request->getUri())
->setAttribute('cake.controller', get_class($controller))
->setAttribute('cake.action', $request->getParam('action'));
$span = $spanBuilder->startSpan();
Context::storage()->attach($span->storeInContext(Context::getCurrent()));
},
post: static function (Controller $controller, array $params, $returnValue, ?Throwable $exception) {
$scope = Context::storage()->scope();
$span = Span::fromContext($scope->context());
if ($exception !== null) {
$span->recordException($exception);
$span->setStatus(StatusCode::STATUS_ERROR, $exception->getMessage());
} else {
$span->setStatus(StatusCode::STATUS_OK);
}
$span->end();
$scope->detach();
}
);
SpanKind::KIND_SERVER を設定しているため、トレースのビューアでHTTPリクエストとして認識されます。
ORMのフック
Table::find / Table::save / Table::delete にフックを設定し、DB操作を SpanKind::KIND_CLIENT のスパンとして記録します。
ポイントは Table::save() のエラーハンドリングです。CakePHPの save() はバリデーション失敗時に例外を投げずに false を返す仕様のため、戻り値のチェックも行っています。
post: static function (Table $table, array $params, $returnValue, ?Throwable $exception) { // ... if ($exception !== null) { $span->recordException($exception); $span->setStatus(StatusCode::STATUS_ERROR, $exception->getMessage()); } elseif ($returnValue === false) { $span->setStatus(StatusCode::STATUS_ERROR, 'save() returned false'); } else { $span->setStatus(StatusCode::STATUS_OK); } // ... }
DbSystemResolver
ORMのフックでは、OpenTelemetryのセマンティック規約に準拠した db.system 属性を設定する必要があります。CakePHPのドライバークラス名からこの値を解決するユーティリティを用意しました。
public static function resolveFromDriverClass(string $driverClass): string
{
return match (true) {
str_contains($driverClass, 'Mysql') => 'mysql',
str_contains($driverClass, 'Postgres') => 'postgresql',
str_contains($driverClass, 'Sqlite') => 'sqlite',
str_contains($driverClass, 'Sqlserver') => 'mssql',
default => 'other_sql',
};
}
DB接続が取得できない場合は other_sql にフォールバックし、トレース処理自体が失敗しないようにしています。
TraceAwareLogger
既存のPSR-3ロガーをラップし、アクティブなスパンがあれば trace_id と span_id をログのコンテキストに自動付与します。
public function log($level, \Stringable|string $message, array $context = []): void { $spanContext = Span::getCurrent()->getContext(); $traceId = $spanContext->getTraceId(); if ($traceId !== '00000000000000000000000000000000') { $context['trace_id'] = $traceId; $context['span_id'] = $spanContext->getSpanId(); } $this->logger->log($level, $message, $context); }
ログは OTel で送信するのではなく、trace_idをログに埋め込んでログ基盤側でトレースと突合する方式を採用しています。これにより、既存のログ基盤をそのまま使いつつ、トレースとの紐付けが可能になります。
テスト
テストでは InMemoryExporter + SimpleSpanProcessor を使い、実際にエクスポートされるスパンを検証しています。
# Unitテスト(DB不要) vendor/bin/phpunit --testsuite unit # Integrationテスト(PostgreSQL必要) vendor/bin/phpunit --testsuite integration
CIはGitHub Actionsで PHP 8.3 / 8.4 / 8.5 のマトリクステストを実行しています。PostgreSQLサービスコンテナを使ってIntegrationテストも回しているので、実際のDB接続を含めた動作を継続的に検証できています。
テストアプリ
プラグインの動作確認用に、Posts / Commentsの CRUD を持つテストアプリも含めています。
cd testapp docker compose up -d docker compose exec app composer install docker compose exec app bin/cake migrations migrate
- アプリ: http://localhost:8080
- Jaeger UI: http://localhost:16686
Jaeger UIで実際のトレースを確認できるので、プラグインの動作を試すのに便利です。
フロントエンドとの分散トレーシング
OpenTelemetry PHP SDKはデフォルトでW3C TraceContextプロパゲーターを使用するため、フロントエンド側でW3C対応のOpenTelemetry SDK(opentelemetry-jsなど)を使えば、追加設定なしでフロントエンドからバックエンドまでの分散トレーシングが実現できます。
おわりに
CakePHP OpenTelemetry Pluginを作成しました。ext-opentelemetry の zend_observer フックを使うことで、アプリケーションコードを変更せずにControllerとORM操作の自動トレースが実現できています。
CakePHPでOpenTelemetryを導入したいけどフックのコードを自分で書くのは面倒、という方はぜひ試してみてください。フィードバックやPRもお待ちしています!
また、Azure Container AppsからTTelで、Application Insghtsに連携する設定なども、若干はまりどころありつつ完了しているので、別の記事を書く予定です。
参考リンク
MCPを理解するために、MCPサーバーをNestJSで実装してみた話
最近は個人ではClaude Code/Github Copilot、会社ではGithub Copilotをがっつり使って開発しています。その中で、Model Context Protocol (MCP) の実装に興味を持って、色々と実験してみたので、その経過をまとめました
MCPは、LLMが外部のデータやツールにアクセスするための標準化されたプロトコルです。元々はAnthropic社が提唱したものでしたが、2025年12月にLinux Foundation傘下のAgentic AI Foundation (AAIF)に寄贈され、よりオープンでベンダーニュートラルなプロトコルとして発展していくことになりました。
今回は、NestJSを使ってMCPサーバーを実装し、最終的にFour Keys (DORA Metrics) の集計機能を実装してみました。
MCPで実装することの大きなメリット
従来、今回作成したような分析ツールを作る場合、以下が必要でした:
- バックエンドAPI - データ収集・集計ロジック
- フロントエンドUI - ダッシュボード、グラフ、チャート、テーブル等
- データの可視化 - D3.js、Chart.js等を使った実装
しかし、MCPとして実装することで、UIの開発が不要になります:
- LLMが自然言語インターフェースになる - 「先月のデプロイ頻度は?」と聞くだけ
- データの解釈もLLMが支援 - 数値だけでなく、傾向分析や改善提案も自動で行われる
- 開発コストの大幅削減 - バックエンドロジックだけに集中できる
- 柔軟な分析が可能 - ユーザーが自由に質問を変えて、多角的に分析できる
例えば、従来のダッシュボードでは事前に決められたグラフやメトリクスしか見られませんが、MCPを通じてLLMと対話すれば、「デプロイ頻度が低い週はどんな特徴があった?」「変更失敗率が高かった時期のPRを分析して」といった柔軟な問いかけを可能にできそうです。
この「UI不要」という点は、特に社内ツールや検証的なプロジェクトにおいて非常に大きなメリットだと思います。
MCPの最新動向 - Agentic AI Foundationへの寄贈
本題に入る前に、MCPの最新動向について触れておきます。
2025年12月、MCPはLinux Foundation傘下のAgentic AI Foundation (AAIF)に寄贈されました。AAIFは、Anthropic、Block、OpenAIが共同で設立した財団で、以下のプロジェクトを中心に据えています:
- Model Context Protocol (MCP) - Anthropicから寄贈
- goose - Blockから寄贈
- AGENTS.md - OpenAIから寄贈
この動きの重要なポイントは:
ベンダーニュートラル性の確保 - 特定企業に依存しない、オープンな標準プロトコルとして位置づけらえれ、中立的な運営体制になります。
長期的な持続可能性 - Linux Foundationの支援により、長期的な開発とメンテナンスが保証されます。
エンタープライズ採用の促進 - プラチナメンバーには、AWS、Anthropic、Block、Bloomberg、Cloudflare、Google、Microsoft、OpenAIなどが参画しており、エンタープライズでの採用が進んでいくことが予想されます。
技術的自律性の維持 - プロトコルの技術的方向性は、メンテナーとコミュニティがSEP (Specification Enhancement Proposal) プロセスを通じて決定します。
MCPがAI エージェント時代における重要なインフラストラクチャとして位置づけられたことで、今後のエコシステムの発展が非常に楽しみです。
まずはbasic-exampleで仕様を学ぶ
MCPサーバーを実装するにあたって、まずは基本的な仕様と実装方法を理解する必要がありました。そこで、シンプルな機能だけを持つbasic-exampleブランチを作成し、MCPの基本を学ぶことにしました。
basic-exampleでは、以下のシンプルな機能を実装しています:
- get_current_time - 現在の日時を取得
- calculate - 四則演算(加算、減算、乗算、除算)
- save_note / get_note / list_notes - メモの保存・取得・一覧表示
この段階では、外部API連携やデータベースなどの複雑な要素は一切含めず、MCPプロトコルとNestJSの連携方法に集中しました。
プロジェクト構造
basic-exampleのプロジェクト構造は以下のようになっています:
src/ ├── main.ts # エントリーポイント ├── app.module.ts # ルートモジュール ├── mcp/ # MCPモジュール │ ├── mcp.module.ts # MCP設定・プロバイダー登録 │ ├── tools/ # ツール層(MCPインターフェース) │ │ ├── time.tool.ts # 時刻ツール │ │ ├── calculator.tool.ts # 計算ツール │ │ └── notes.tool.ts # メモツール │ └── services/ # サービス層(ビジネスロジック) │ ├── time.service.ts │ ├── calculator.service.ts │ └── notes.service.ts
レイヤーを分離することで、MCPプロトコルに関する部分(ツール層)とビジネスロジック(サービス層)を明確に分けています。
basic-exampleの実装 - 時刻取得を例に
それでは、実際のコードを見ながら、MCPサーバーの実装方法を解説します。最もシンプルな「現在時刻の取得」機能を例にします。
サービス層の実装
まず、サービス層で実際のビジネスロジックを実装します:
// src/mcp/services/time.service.ts import { Injectable } from '@nestjs/common'; @Injectable() export class TimeService { /** * 現在の日時を取得 */ getCurrentTime(): string { const now = new Date(); return now.toLocaleString('ja-JP', { timeZone: 'Asia/Tokyo' }); } }
@Injectable()デコレーターを付けることで、NestJSの依存性注入システムで管理されるようになります。このサービス自体はMCPプロトコルについて何も知らず、純粋なビジネスロジックだけを持っています。
ツール層の実装
次に、MCPプロトコルとのインターフェースとなるツール層を実装します:
// src/mcp/tools/time.tool.ts import { Injectable } from '@nestjs/common'; import { Tool } from '@rekog/mcp-nest'; import { z } from 'zod'; import { TimeService } from '../services/time.service'; @Injectable() export class TimeTool { constructor(private readonly timeService: TimeService) {} @Tool({ name: 'get_current_time', description: '現在の日時を取得します', parameters: z.object({}), }) async getCurrentTime() { const time = this.timeService.getCurrentTime(); return `現在の日時: ${time}`; } }
ポイントは以下の通りです:
@Toolデコレーター - このメソッドをMCPツールとして公開します- name / description - LLMに対してこのツールが何をするかを説明します
- parameters - Zodスキーマで型安全なパラメータ定義(今回はパラメータなし)
- 依存性注入 - コンストラクタで
TimeServiceを注入し、ビジネスロジックを呼び出します
この構造により、ビジネスロジックとMCPプロトコルが疎結合になり、テストや再利用が容易になります。
モジュール設定
最後に、これらをNestJSモジュールとして登録します:
// src/mcp/mcp.module.ts import { Module } from '@nestjs/common'; import { McpModule, McpTransportType } from '@rekog/mcp-nest'; import { TimeService } from './services/time.service'; import { TimeTool } from './tools/time.tool'; // ... 他のインポート @Module({ imports: [ McpModule.forRoot({ name: 'nestjs-mcp-server', version: '1.0.0', transport: McpTransportType.STREAMABLE_HTTP, mcpEndpoint: '/mcp', }), ], providers: [ // サービス層 TimeService, // ツール層 TimeTool, ], }) export class McpToolsModule {}
McpModule.forRoot()で、MCPサーバーの基本設定を行います。今回は最新のSTREAMABLE_HTTPトランスポートを使用しています。
実際に動かしてみる
実装したMCPサーバーを実際に動かしてみましょう。
サーバーの起動
npm install npm run build npm start
サーバーが起動すると、以下のように表示されます:
🚀 NestJS MCP Server running on http://localhost:3000 📡 MCP Endpoint: http://localhost:3000/mcp
テストクライアントで動作確認
basic-exampleには、MCPクライアントを使ったテストスクリプトが含まれています:
node test/e2e/test-client.mjs
このスクリプトは、以下のような処理を実行します:
// 1. サーバーに接続 const client = new Client({ name: "test-client", version: "1.0.0" }, { capabilities: {} }); const transport = new StreamableHTTPClientTransport(new URL("http://localhost:3000/mcp")); await client.connect(transport); // 2. 利用可能なツール一覧を取得 const toolsResult = await client.listTools(); // 3. get_current_time ツールを呼び出し const timeResult = await client.callTool({ name: "get_current_time", arguments: {}, }); console.log("結果:", timeResult.content[0].text);
実行すると、以下のような出力が得られます:
MCPサーバーのテストを開始します... サーバーに接続中... ✓ サーバーに接続しました 利用可能なツールを取得中... ✓ ツールリスト取得成功: - get_current_time: 現在の日時を取得します - calculate: 簡単な計算を実行します(加算、減算、乗算、除算) - save_note: キーと値のペアでメモを保存します - get_note: 保存されたメモを取得します - list_notes: 保存されているすべてのメモのキーを一覧表示します テスト1: get_current_time を実行 ✓ 結果: 現在の日時: 2025/1/12 14:30:45 テスト2: calculate (123 + 456) を実行 ✓ 結果: 計算結果: 123 + 456 = 579 ... ✅ すべてのテストが成功しました!
このように、MCPプロトコルを通じてツールが正しく呼び出せることが確認できます。
LLMから使う場合
Claude DesktopなどのMCPクライアントから使う場合は、設定ファイルにサーバー情報を追加します。
今回はStreamable HTTPトランスポートを使用しているため、HTTP経由で接続します:
{ "servers": { "github-metrics": { "type": "http", "url": "http://localhost:3000/mcp" } } }
サーバーを起動してから、Claudeと会話する中で自然にツールが呼び出されます:
ユーザー: 「今何時?」
Claude: (get_current_timeツールを呼び出し)
「現在の日時は2025年1月12日 14時30分45秒です。」
なぜNestJSでMCPサーバーなのか
basic-exampleで基本を理解したところで、なぜNestJSを選んだのかについて説明します。
MCPサーバーは公式のTypeScript SDK(@modelcontextprotocol/sdk)で実装できますが、今回はNestJSフレームワークと組み合わせることで、保守性と拡張性を高められる構造を持たせることにしました。@rekog/mcp-nestというライブラリが、公式SDKとNestJSの橋渡しをしてくれます。
NestJSを採用した理由は以下の通りです:
1. テストしやすく保守しやすい設計
- 依存性注入(DI) - コンストラクタインジェクションで疎結合な設計を実現。モックに置き換えやすく、ユニットテストが書きやすい
- レイヤー分離 - ツール層(MCPインターフェース)とサービス層(ビジネスロジック)を明確に分離。MCPプロトコルの変更がビジネスロジックに影響しない
- モジュールシステム - 機能ごとにモジュール化することで、コードの見通しが良くなり、変更の影響範囲を限定できる
2. 将来の機能追加がしやすい
basic-exampleで基本を理解した後、Four Keysのような複雑な機能を追加する際に、NestJSのエコシステムを活用できます:
- データベース統合 - TypeORM、Prismaなどを標準的な方法で追加できる
- 外部API連携 - HttpModuleを使った統一的な実装パターン
- 認証・認可 - Guardやミドルウェアを使った標準的な実装
- 横断的関心事 - キャッシング、ロギング、エラーハンドリングをインターセプターで実装
3. ビジネスロジックの再利用性
ツール層とサービス層を分離することで、サービス層は他の用途でも使えます:
- REST APIとしても公開可能 - 同じサービス層を使ってHTTP APIを提供できる
- CLI ツールでも利用可能 - コマンドラインツールからも同じロジックを呼び出せる
- テストの容易さ - 各層を独立してテストでき、テストコードの保守性も向上
Four Keysの実装へ
basic-exampleでMCPの基本を理解した後、より実践的な題材としてFour Keys (DORA Metrics) の計測機能を実装しました(mainブランチ)。
Four Keysは、DevOpsのパフォーマンスを測定するための4つの主要指標です:
- デプロイ頻度 (Deployment Frequency) - 本番環境へのデプロイ頻度
- リードタイム (Lead Time for Changes) - コード変更から本番反映までの時間
- 変更失敗率 (Change Failure Rate) - デプロイ後に障害が発生した割合
- 平均復旧時間 (MTTR) - 障害発生から復旧までの平均時間
アーキテクチャの拡張
Four Keys実装では、basic-exampleの構造を拡張し、以下のような階層構造にしました:
src/mcp/
├── tools/four-keys/ # Four Keysツール層
│ ├── deployment-frequency.tool.ts
│ ├── lead-time.tool.ts
│ ├── change-failure-rate.tool.ts
│ ├── mttr.tool.ts
│ └── summary.tool.ts # 4つのメトリクスをまとめて取得
├── services/
│ ├── github/ # GitHub統合サービス
│ │ ├── github-auth.service.ts # GitHub App認証
│ │ └── github-api.service.ts # GitHub APIクライアント
│ └── four-keys/ # Four Keys計算サービス
│ ├── deployment-frequency.service.ts
│ ├── lead-time.service.ts
│ ├── change-failure-rate.service.ts
│ └── mttr.service.ts
└── types/ # 型定義
├── github.types.ts
└── four-keys.types.ts
実装の特徴
GitHub API連携
Four Keysの計測には、GitHub APIからのデータ取得が必要です。GitHub App認証を実装し、以下の情報を収集します:
柔軟なデプロイ検出
チームによってデプロイの運用方法は異なるため、3つの検出方法をサポート:
- GitHub Releases - リリースベースのデプロイ
- Git Tags - タグベースのデプロイ(正規表現フィルタリング可能)
- GitHub Actions Workflow - 特定のワークフロー実行履歴
DORAレベルの自動評価
各メトリクスについて、DORAが定義するパフォーマンスレベル(Elite/High/Medium/Low)を自動評価します。例えば、デプロイ頻度の場合:
- Elite: 1日に複数回
- High: 週に1回〜月に1回
- Medium: 月に1回〜半年に1回
- Low: 半年に1回未満
使用例
LLMと会話しながら、チームのDevOpsパフォーマンスを分析できます:
ユーザー: 「my-org/my-repoリポジトリの過去1ヶ月のFour Keysメトリクスを教えて」 Claude: (get_four_keys_summaryツールを呼び出し) 「過去1ヶ月のFour Keysメトリクスは以下の通りです: デプロイ頻度: 15回/月 (0.5回/日) - Performance Level: High リードタイム: 平均 12.5時間 - Performance Level: Elite 変更失敗率: 6.7% - Performance Level: Elite MTTR: 平均 2.3時間 - Performance Level: Elite 総合評価: Elite デプロイ頻度をもう少し上げることで、Eliteレベルに到達できそうです。」
テスト戦略
Four Keys実装では、95%以上のテストカバレッジを達成しています。Vitestを使用し、GitHub APIはモックで置き換えることで、高速で信頼性の高いテストを実現しています。
GitHub Actionsでは、テスト実行とカバレッジレポートの自動投稿を設定しており、継続的に品質を担保しています。
学んだこと・工夫したこと
1. 段階的な学習アプローチ
basic-exampleで基本を理解してから、Four Keysという実践的な題材に進むという段階的なアプローチは非常に効果的でした。MCPの仕様を理解してから、実際のビジネスロジックの実装に集中できました。
2. レイヤー分離の重要性
ツール層とサービス層を明確に分離したことで:
特に、Four Keys実装では外部API連携やデータ処理などが絡むため、この分離が非常に役立ちました。
3. ブランチ戦略
学習曲線を考慮した2つのブランチ構成:
- basic-exampleブランチ: MCPの基本を学ぶ
- mainブランチ: 実践的な実装を学ぶ
これにより、初めてMCPを触る人でも段階的に学べるリポジトリになりました。
4. 型安全性の追求
Zodによるパラメータバリデーションと、TypeScriptの型定義を組み合わせることで、実行時と開発時の両方で型安全性を確保できました。
今後の展望
現在の実装は試験実装のためMVPレベルですが、今後以下のような拡張を考えています:
データベース統合
- 履歴データの保存とトレンド分析
- ダッシュボード機能
カスタマイズ性の向上
- チーム固有のルール設定
- Slack/Teams連携
認証・認可
- マルチテナント対応
- ロールベースのアクセス制御
NestJSの構造により、これらの拡張も体系的に進められると考えています。
おわりに
NestJSでMCPサーバーを実装することで、保守性と拡張性の高いツールの基礎を作ることができました。
特に、basic-exampleで基本を学び、Four Keysという実践的な題材で応用するという段階的なアプローチは、MCPの理解を深める上で非常に効果的でした。レイヤー分離や依存性注入といった設計により、テストもしやすく、将来の機能追加にも対応しやすい構造になっています。
また、MCPとLLMを組み合わせることで、「チームのデプロイ頻度は?」「先月のリードタイムは?」といった質問に自然言語で答えてもらえるのは非常に便利です。DevOpsメトリクスの可視化と分析が、より身近になると感じました。
MCPがAgentic AI Foundationに寄贈されたことで、今後さらにエコシステムが発展していくことが期待されます。このような標準プロトコルを使った開発が、より一般的になっていくと思います。
コードは以下のリポジトリで公開していますので、ぜひ試してみてください!
https://github.com/kaz29/mcp-server-example https://github.com/kaz29/mcp-server-example/tree/basic-example
参考リンク
Azure Container AppsのPrivate Endpoint + Azure Front Doorをbicepで構築してみた(プレビュー)
最近bicepばかり書いていて、コード書けてないのでちょっとストレス溜まっているわたなべです。
昨年11月頃にAzure Container Apps 環境でプライベート エンドポイントを使用するを参考にbicepでPrivate EndpointありのAzure Container Appsをbicepでの構築を試していました。
この時点で構築はできたのですが、以下のような状況を確認して止まっていました。
- 2024/11時点ではパブリックプレビュー
- 本番利用非推奨
- プレビュー中は無料、GA時の料金未定
Microsoft.App/managedEnvironments@2024-08-02-previewでpublicNetworkAccessが追加になっている@2024-03-01で実行するとporalで指定したpublicNetworkAccessが更新されてしまう- まだドキュメントは更新されていない
- zoneRedundantを指定するにはVnet統合を有効にしないといけないので、現状指定できなかった(対応してほしいなぁ...)
年明け後のバタバタが落ち着いて、久々に調べてみたのですが、新しいバージョンのMicrosoft.App/managedEnvironments@2024-10-02-previewが公開されていたり、そもそもやりたかったFront Door経由で利用するサンプルが、Azure Front Door を使用して Azure Container App へのプライベート リンクを作成する (プレビュー)として公開されていました。ということで、このサンプルを参考にbicepでの構築を試してみました。
2025/2時点ではパブリックプレビューなので実験用途以外では利用しないようにしてください
構築
構築の流れ
今回の構築の流れはざっくり以下のようになります
- パブリックインターネットからの接続を無効なContainer Apps Environmentを作成
- Azure Front Doorを作成
- Front Door Endpointを作成
Origin Groupを作成
Originを追加
originの接続先に、Container Appsを指定する(この時点では接続がリクエストされた状態)
routesを作成
- プライベートエンドポイント接続を承認する
実際には関連するリソースなどいくつかありますが、今回のメインであるContainer Apps EnvironmentとFront Door Endpointをメインに解説を進めます。
Container Apps Environmentの構築
環境に関連する情報を受け取って、Container Apps Environmentを生成するbicepファイル(./modules/ca.bicep)を以下のような形で呼び出します。指定しているのは作成するリソース名と、内部で利用するログ関連のリソース名です。
var caeName = 'cae-kaz29-labo'
var logAnalyticsWorkspaceName = 'log-kaz29-labo'
var applicationInsightsName = 'appinsights-kaz29-labo'
//...
module cae './modules/cae.bicep' = {
name: 'provision-cae'
params: {
caeName: caeName
logAnalyticsWorkspaceName: logAnalyticsWorkspaceName
applicationInsightsName: applicationInsightsName
}
dependsOn: [
log
]
}
//...
modules/ca.bicep は以下のようになります。新しいAPIバージョンで追加になった publicNetworkAccess を Disable に指定しして、パブリックインターネットからの接続を無効にしています。
// modules/ca.bicep
param location string = resourceGroup().location
param caeName string
param logAnalyticsWorkspaceName string
param applicationInsightsName string
resource logAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2020-08-01' existing = {
name: logAnalyticsWorkspaceName
}
resource applicationInsights 'Microsoft.Insights/components@2020-02-02' existing = {
name: applicationInsightsName
}
resource environment 'Microsoft.App/managedEnvironments@2024-10-02-preview' = {
name: caeName
location: location
properties: {
appLogsConfiguration: {
destination: 'log-analytics'
logAnalyticsConfiguration: {
customerId: logAnalyticsWorkspace.properties.customerId
sharedKey: logAnalyticsWorkspace.listKeys().primarySharedKey
}
}
daprAIInstrumentationKey: applicationInsights.properties.InstrumentationKey
zoneRedundant: false
peerAuthentication: {
mtls: {
enabled: false
}
}
workloadProfiles: [{
name: 'Consumption'
workloadProfileType: 'Consumption'
}]
peerTrafficConfiguration: {
encryption: {
enabled: false
}
}
publicNetworkAccess: 'Disabled' // 追加になっているプロパティ
}
}
output managedEnvironmentsId string = environment.id
Front Doorの構築
Front Doorの構築はとても簡単で、リソース名とSkuを指定して、./modules/afd.bicepを呼び出します。
//...
module afd './modules/afd.bicep' = {
name: 'provision-afd'
params: {
frontDoorProfileName: frontDoorProfileName
frontDoorSkuName: frontDoorSkuName
}
dependsOn: [
ca
]
}
module/afd.bicep はこんな感じ。
//
param location string = 'global'
param frontDoorProfileName string
@allowed([
'Standard_AzureFrontDoor'
'Premium_AzureFrontDoor'
])
param frontDoorSkuName string
resource frontDoorProfile 'Microsoft.Cdn/profiles@2024-06-01-preview' = {
name: frontDoorProfileName
location: location
sku: {
name: frontDoorSkuName
}
}
Front Door Endpointの構築
次にFront Door Endpointの作成です。下記では、リソース名、前の手順で作成した情報をいくつか渡しています。
module fde './modules/fde.bicep' = {
name: 'provision-fde'
params: {
frontDoorProfileName: frontDoorProfileName
prefix: 'test-app'
originHostName: ca.outputs.fqdn
originHostHeader: ca.outputs.fqdn
managedEnvironmentsId: cae.outputs.managedEnvironmentsId
}
dependsOn: [
afd
]
}
modules/fde.bicepではendpoint/originGroup/origin/routesなどを定義しています。今回の肝はオリジン作成時に sharedPrivateLinkResource を指定し、Container Appsに接続をリクエストする箇所(*1)です。
// modules/fde.bicep
param location string = 'global'
param frontDoorProfileName string
param prefix string
param originHostName string
param originHostHeader string
param probePath string = '/'
param managedEnvironmentsId string
var endpointName = '${prefix}-endpoint'
var routeName = '${prefix}-route'
var originGroup = {
name: '${prefix}-origingroup'
probePath: probePath
}
var origins = [{
name: '${prefix}-origin'
hostName: originHostName
originHostHeader: originHostHeader
priority: 1
weight: 1000
// -- (*1)ここでContainer Appsに接続をリクエストする --
sharedPrivateLinkResource: {
privateLink: {
id: managedEnvironmentsId
}
groupId: 'managedEnvironments'
privateLinkLocation: 'japaneast'
requestMessage: 'AFD Private Link Request'
}
// -----------------------------------------------
}]
resource frontDoorProfile 'Microsoft.Cdn/profiles@2024-09-01' existing = {
name: frontDoorProfileName
}
resource endpoint 'Microsoft.Cdn/profiles/afdEndpoints@2024-09-01' = {
parent: frontDoorProfile
name: endpointName
location: location
properties: {
enabledState: 'Enabled'
}
}
resource originGroupResource 'Microsoft.Cdn/profiles/originGroups@2024-09-01' = {
parent: frontDoorProfile
name: originGroup.name
properties: {
loadBalancingSettings: {
sampleSize: 4
successfulSamplesRequired: 3
}
healthProbeSettings: {
probePath: originGroup.probePath
probeRequestType: 'HEAD'
probeProtocol: 'Https'
probeIntervalInSeconds: 100
}
}
}
resource originResources 'Microsoft.Cdn/profiles/originGroups/origins@2024-09-01' = [for (origin, index) in origins: {
parent: originGroupResource
name: origin.name
properties: {
hostName: origin.hostName
httpsPort: 443
originHostHeader: origin.originHostHeader
priority: origin.priority
weight: origin.weight
sharedPrivateLinkResource: origin.sharedPrivateLinkResource
enforceCertificateNameCheck: true
}
dependsOn: index == 0 ? [] : [origins[index - 1]]
}]
resource routeResource 'Microsoft.Cdn/profiles/afdEndpoints/routes@2024-09-01' = {
parent: endpoint
name: routeName
dependsOn: [
originResources
]
properties: {
originGroup: {
id: originGroupResource.id
}
supportedProtocols: [
'Https'
]
patternsToMatch: [
'/*'
]
forwardingProtocol: 'HttpsOnly'
linkToDefaultDomain: 'Enabled'
httpsRedirect: 'Enabled'
}
}
output endpointId string = endpoint.id
プロビジョニング
Azure CLIでログインしている状態で、以下のコマンドを実行し、プロビジョニングします。 今回はリソースグループは事前に作成しておきました。
$ az deployment group create \ --template-file ./bicep/provision.bicep \ --name "provision-from-local" \ --mode Complete \ --resource-group rg-labo
実行の状況はリソースグループの、デプロイセクションで確認することができます。何か問題があってエラーが発生した場合にもここでエラーの詳細を確認できます。

プライベートエンドポイント接続の承認
現状この承認をbicepで自動で処理する方法がわからないため、手動で実行する方法を紹介します。 Front Doorからのプライベートリンク接続は作成しましたが、Pending状態なので以下の手順で承認します。
まず、以下のコマンドでプライベートエンドポイント接続の一覧を取得します。
$ az network private-endpoint-connection list \
--name 'cae-kaz29-labo' \
--resource-group 'rg-labo' \
--type Microsoft.App/managedEnvironments \
--query "[].{id:id, status: properties.privateLinkServiceConnectionState.status}"
[
{
"id": "/subscriptions/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/resourceGroups/rg-labo/providers/Microsoft.App/managedEnvironments/cae-kaz29-labo/privateEndpointConnections/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"status": "Approved"
}
]
上記コマンドで表示される id を使用して以下のコマンドを実行しプライベートエンドポイント接続を承認します。
az network private-endpoint-connection approve --id $ID
これで下記に表示されるURLを開くと、いつものnginxの初期画面が表示されるはずです。
現状把握している問題点
- 承認作業をbicepで自動化する方法が未確立
- プライベートエンドポイントが複数作成される(これは今後解決する?)
まとめ
いかがでしたか?プライベートエンドポイント接続は、待望の機能だったのでGAに向けた準備として実験をしてみました。 いつGAするかはまだ不明ですが、楽しみにして色々実験を進めていきたいと思います。
関連情報
OpenApi bake theme pluginをswagger-phpのアトリビュート形式で吐き出すように修正しました
最近は、アプリの開発・実行基盤の構築でインフラエンジニアっぽいことばかりやっていて、プロダクションのコードを書いていなくてストレスが溜まりつつある渡辺です
今構築している環境では、PHPでAPIを書いていて、swagger-phpでAPIドキュメントを作成して、フロントアプリとの連携をしています。以前書いたブログで OpenApi bake theme plugin や swagger-phpを紹介しているので参考にしてみてください。
当初、OpenApi bake theme pluginを使ってアノテーション形式で記載しようと思っていたのですが、構築するにあたりswagger-phpの開発状況を確認したところ、かなりアップデートされていて、より便利に使えそうなのでほとんどメンテナンスしていなかった、 OpenApi bake theme plugin を修正することにしました。
OpenApiTheme plugin for CakePHP
swagger-php
swagger-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="", * ), * )
- アトリビュート形式のサンプル
#[OA\Schema(
schema: 'Article',
title: 'Article',
description: 'Article Entity',
required: ['id', 'user_id', 'title', 'slug', 'published', 'user', 'tags'],
properties: [
new OA\Property(
property: 'id',
type: 'integer',
format: 'int32',
description: '',
),
new OA\Property(
property: 'user_id',
type: 'integer',
format: 'int32',
description: '',
),
new OA\Property(
property: 'title',
type: 'string',
description: '',
),
new OA\Property(
property: 'slug',
type: 'string',
description: '',
),
new OA\Property(
property: 'body',
type: 'string',
description: '',
),
new OA\Property(
property: 'published',
type: 'boolean',
description: '',
),
new OA\Property(
property: 'created',
type: 'string',
format: 'datetime',
description: '',
),
new OA\Property(
property: 'modified',
type: 'string',
format: 'datetime',
description: '',
),
]
)]
アノテーションでの記述でも便利なのですが、所詮コメントの中に記載する形なので、書き心地はあまり良いとはいえない状況でした。 アトリビュート形式に変更になったことで、IDEの補完などの恩恵を受けられるようになり、かなり改善された印象です。
実際の開発現場では、このプラグインで自動生成したものをカスタマイズして利用することになると思うのでこの改善はかなり有益だと考え、OpenApi bake theme plugin でもアトリビュート形式を採用することにしました。
アトリビュート形式のサンプル
CakePHPのCMS Tutorialを題材にいくつか記述例を紹介します
一覧取得API
以下が一覧取得処理をAPI化した場合の記述です。Queryは paramaters に OA\Parameterを利用して記述します
#[OA\Get(
path: '/api/articles.json',
summary: 'Articles index',
parameters: [
new OA\Parameter(
name: 'page',
in: 'query',
required: false,
schema: new OA\Schema(type: 'number'),
description: 'Page number to be get',
),
new OA\Parameter(
name: 'limit',
in: 'query',
required: false,
schema: new OA\Schema(type: 'number'),
description: 'Number of elements per page',
),
],
responses: [
new OA\Response(
response: 201,
description: 'OK',
content: new OA\JsonContent(ref: '#components/schemas/Article'),
),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 403, description: 'Forbidden'),
]
)]
public function index()
新規作成API
新規作成APIは、JSON形式のデータをbodyに入れる形になるので、 OA\RequestBody / OA\JsonContent を使用して記述しています
#[OA\Post(
path: '/api/articles/add.json',
summary: 'Add article',
description: 'Add article',
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['user_id', 'title', 'slug', 'published', 'user', 'tags'],
properties: [
new OA\Property(
property: 'user_id',
type: 'integer',
format: 'int32',
description: '',
),
new OA\Property(
property: 'title',
type: 'string',
description: '',
),
new OA\Property(
property: 'slug',
type: 'string',
description: '',
),
new OA\Property(
property: 'body',
type: 'string',
description: '',
),
new OA\Property(
property: 'published',
type: 'boolean',
description: '',
),
]
)
),
responses: [
new OA\Response(
response: 201,
description: 'OK',
content: new OA\JsonContent(ref: '#components/schemas/Article'),
),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 403, description: 'Forbidden'),
new OA\Response(
response: 422,
description: 'Validation Error',
content: new OA\JsonContent(ref: '#components/schemas/Application'),
),
]
)]
public function add()
詳細取得API
OpenApi bake theme plugin では詳細取得APIはアソシエーションがあるModelの場合、アソシエーション内容も含むレスポンスのドキュメントを生成するように実装しています。
また、 /api/articles/{id}.json のようにidをpathに含む形式なので OA\Parameter の in を path に設定しています。
#[OA\Get(
path: "/api/articles/{id}.json",
summary: "Get Article",
description: "Get Article",
parameters: [
new OA\Parameter(
name: 'id',
in: 'path',
required: true,
schema: new OA\Schema(type: 'integer', format: 'int32'),
description: 'Article id',
),
],
responses: [
new OA\Response(
response: 200,
description: 'OK',
content: new OA\JsonContent(
type: 'object',
allOf: [
new OA\Schema(ref: '#components/schemas/Article'),
new OA\Schema(
properties: [
new OA\Property(
property: "user",
ref: "#/components/schemas/User",
description: "User Entity",
),
new OA\Property(
property: "tags",
type: "array",
items: new OA\Items(ref: "#/components/schemas/Tag"),
description: "Tag Entities",
),
],
),
],
),
),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 403, description: 'Forbidden'),
new OA\Response(response: 404, description: 'Not Found'),
]
)]
public function view($id = null)
edit / delete はこれらの応用で記述できるので省略します。
まとめ
ということで、簡単にswagger-phpのアトリビュート形式での記載方法を紹介しました。 このプラグインを使わないにしても、詳細取得APIでアソシエーションを含むレスポンス形式は、定義済みのOA\Schemaを利用して別形式のレスポンスを生成する記述は参考になるのではないかなと思います。
参考リンク
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 }
データを更新したのになぜか 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に移行する予定があるので、いい感じで実現できて捗りそうです。