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月、MCPLinux Foundation傘下のAgentic AI Foundation (AAIF)に寄贈されました。AAIFは、Anthropic、Block、OpenAIが共同で設立した財団で、以下のプロジェクトを中心に据えています:

  • Model Context Protocol (MCP) - Anthropicから寄贈
  • goose - Blockから寄贈
  • AGENTS.md - OpenAIから寄贈

この動きの重要なポイントは:

  1. ベンダーニュートラル性の確保 - 特定企業に依存しない、オープンな標準プロトコルとして位置づけらえれ、中立的な運営体制になります。

  2. 長期的な持続可能性 - Linux Foundationの支援により、長期的な開発とメンテナンスが保証されます。

  3. エンタープライズ採用の促進 - プラチナメンバーには、AWS、Anthropic、Block、Bloomberg、Cloudflare、GoogleMicrosoft、OpenAIなどが参画しており、エンタープライズでの採用が進んでいくことが予想されます。

  4. 技術的自律性の維持 - プロトコルの技術的方向性は、メンテナーとコミュニティがSEP (Specification Enhancement Proposal) プロセスを通じて決定します。

MCPがAI エージェント時代における重要なインフラストラクチャとして位置づけられたことで、今後のエコシステムの発展が非常に楽しみです。

まずはbasic-exampleで仕様を学ぶ

MCPサーバーを実装するにあたって、まずは基本的な仕様と実装方法を理解する必要がありました。そこで、シンプルな機能だけを持つbasic-exampleブランチを作成し、MCPの基本を学ぶことにしました。

basic-exampleでは、以下のシンプルな機能を実装しています:

  1. get_current_time - 現在の日時を取得
  2. calculate - 四則演算(加算、減算、乗算、除算)
  3. 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}`;
  }
}

ポイントは以下の通りです:

  1. @Toolデコレーター - このメソッドをMCPツールとして公開します
  2. name / description - LLMに対してこのツールが何をするかを説明します
  3. parameters - Zodスキーマで型安全なパラメータ定義(今回はパラメータなし)
  4. 依存性注入 - コンストラクタで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つの主要指標です:

  1. デプロイ頻度 (Deployment Frequency) - 本番環境へのデプロイ頻度
  2. リードタイム (Lead Time for Changes) - コード変更から本番反映までの時間
  3. 変更失敗率 (Change Failure Rate) - デプロイ後に障害が発生した割合
  4. 平均復旧時間 (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認証を実装し、以下の情報を収集します:

  • リリース、タグ、GitHub Actionsの実行履歴(デプロイ頻度)
  • プルリクエストの作成〜マージ時間(リードタイム)
  • ホットフィックスPR、インシデントIssue(変更失敗率、MTTR

柔軟なデプロイ検出

チームによってデプロイの運用方法は異なるため、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レベルですが、今後以下のような拡張を考えています:

  1. データベース統合

    • 履歴データの保存とトレンド分析
    • ダッシュボード機能
  2. カスタマイズ性の向上

    • チーム固有のルール設定
    • Slack/Teams連携
  3. 認証・認可

    • マルチテナント対応
    • ロールベースのアクセス制御

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-previewpublicNetworkAccess が追加になっている
    • @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バージョンで追加になった publicNetworkAccessDisable に指定しして、パブリックインターネットからの接続を無効にしています。

// 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のアトリビュート形式で吐き出すように修正しました

最近は、アプリの開発・実行基盤の構築でインフラエンジニアっぽいことばかりやっていて、プロダクションのコードを書いていなくてストレスが溜まりつつある渡辺です

今構築している環境では、PHPAPIを書いていて、swagger-phpAPIドキュメントを作成して、フロントアプリとの連携をしています。以前書いたブログで OpenApi bake theme plugin や swagger-phpを紹介しているので参考にしてみてください。

kaz29.hatenablog.com

kaz29.hatenablog.com

当初、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 でもアトリビュート形式を採用することにしました。

アトリビュート形式のサンプル

CakePHPCMS 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
}

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

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

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

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

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

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

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

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

無事、afternull になりました

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

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

Azure Database for PostgreSQLでの設定

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

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

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

Azure Database for PostgreSQLの作成

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

設定の変更

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

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

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

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

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

test=> 

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

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

CDCの構築

SSL接続設定の準備

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

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

コネクタの追加

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

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

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

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

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

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


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

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

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

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

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

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

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

トピックの確認

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

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

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

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

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

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

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

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

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

まとめ

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

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

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

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

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

関連リンク

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

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

サンプルコードの準備

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

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

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

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

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

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

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

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

リソースの準備

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

環境変数を設定

export GITHUB_USERNAME="<YOUR_GITHUB_USERNAME>"

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

リソースグループを作成

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

Container Registryを作成

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

Github actionの設定

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

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

    CONTAINER_REGISTRY_USERNAMEを追加

  • パスワード: CONTAINER_REGISTRY_PASSWORD

    CONTAINER_REGISTRY_PASSWORDを追加

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

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

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

env:
  ACR_NAME: acaalbumskaz29
  API_NAME: album-api

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

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

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

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

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

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

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

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

Container Apps環境を作成

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

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

アプリをデプロイ

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

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

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

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

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

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

AZURE_CREDENTIALSを追加

初回デプロイ

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

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

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

bicep/api.bicep を作成

# bicep/api.bicep

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

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

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

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

bicep/deploy.bicep を作成

#bicep/deploy.bicep

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

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

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

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

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

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

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

workflowを更新

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

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

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

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

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

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

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

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

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

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

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

コンテナアプリ画面

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

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

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

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

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

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

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

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

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

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

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

各ステップの概要

  • Get Previous revision name

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

  • Active revision count check

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

  • Get new revision's fqdn

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


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

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

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

B / Gを入れ替える

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

Environmentを追加

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

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

flip用 Environmentの作成

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

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

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

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

Environmentを追加

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

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

deactivate 用の Environmentを作成

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

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

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

デプロイを実行

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

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

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

flip

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

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

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

Review pending deployments(flip)

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

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

deactivate

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

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

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

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

Review pending deployments (deactivate)

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

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

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

まとめ

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

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

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

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

参考資料

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

小ネタです。

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

TsExport plugin for CakePHP

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

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

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

bin/cake export_entity --all

または

bin/cake export_entity モデル名

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

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

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