はじめに
前回の続きAzure Container Apps学び直し-エンタープライズ構成【前編】の後編の前に、アプリケーションとプラットフォームの全体感や仕様を示した方が良いのでは、と今更ながら思い立ち補足としてまとめてみました。
Azure Container Appsで、複数テナントのAzureの状態を一覧表示する運用者向けのWebアプリと定期的にデータを集めAzure Cosmos DBに保存するジョブ、およびそれらの実行環境を構成するためのプラットフォームの説明になります。
仕様書としては過不足があるかも知れませんが、プロンプトに渡すことでGPTやClaude Codeによって、実行環境とアプリケーションの類似環境を再現できるように構成したつもりです。
ファイル構成
Webアプリ/
├── infra/
│ ├── main.bicep
│ └── <モジュール>.bicep
├── collect/
│ ├── collection.py
│ └── job.py
└── tenant-monitor/
├── Dockerfile
├── requirements.txt
├── app/
│ └── main.py
├── static/
│ ├── index.html
│ ├── app.js
│ └── styles.css
└── tests/
| パス | 役割 |
|---|---|
infra/main.bicep |
環境全体を定義するBicep。ほかのBicepはここから呼ばれる |
collect/ |
ジョブの資材。収集処理(collection.py)とジョブの起動点(job.py) |
tenant-monitor/app/ |
WebアプリのPythonコード(FastAPI) |
tenant-monitor/static/ |
画面のHTML、JavaScript、CSS |
tenant-monitor/Dockerfile、requirements.txt
|
コンテナー イメージの定義と依存パッケージ |
ファイル構成について:
現時点でソースコードとBicepは公開しておらず、再現するときに必要となるファイルの一覧を示す。
Bicepはリソースごとにモジュールへ切り出してmainから呼ぶ構成が一般的で <モジュール>.bicepはその構成単位を示している。
プラットフォームの構築
作成の順序
基盤リソースとアプリ層は、次の順で作る。
Bicepは依存関係のないリソースは並行して作るが、本構成において順序を考慮し明示的に依存関係を設定するのは以下の箇所である。
- ロール割り当ては、対象のリソースができた後にしか作れない。Azure Cosmos DB のデータロールは、コンテナーができた後にそのコンテナーを範囲として割り当てる
- コンテナー アプリとジョブは、Container registry のpull権限、プライベート エンドポイント、データへのロールが揃った後に作る。Container Appsはコンテナーを起動するたびにContainer registryからイメージを取得するため、権限とネットワークが先に必要になる
- アプリ層(コンテナー アプリ、ジョブ、Application gateway)は、Container registryにイメージを置いた後に作る。詳細は「アプリケーション層の作成」で説明する
ネットワーク
2つの仮想ネットワークを作る。
サブネットの「最小サイズ」は、この構成(Application gatewayは最大2台、コンテナー アプリは最大3レプリカ、ビルド用仮想マシンは1台)が動く最小のプレフィックス長。
| 仮想ネットワーク | サブネット | 役割 | 最小CIDRサイズ |
|---|---|---|---|
| DMZ | Application gateway用 | Application gatewayを専用で置く。ネットワーク セキュリティ グループで受信を制御する | /28 |
| DMZ | Container Apps 環境用のプライベート エンドポイント | Container Apps 環境のプライベート エンドポイントを置く | /29 |
| バックエンド | Container Apps 環境の統合用 | Container Apps 環境を統合する。Microsoft.App/environments に委任し、NAT ゲートウェイを関連付ける |
/27 |
| バックエンド | データ用のプライベート エンドポイント | Container registry、Azure Cosmos DB アカウント、キー コンテナーのプライベート エンドポイントを置く | /28 |
| バックエンド | ビルド用仮想マシン | ビルド用仮想マシンを置く。NAT ゲートウェイとネットワーク セキュリティ グループを関連付ける | /29 |
| バックエンド | Bastion用 | 名前は AzureBastionSubnet に固定する |
/26 |
仮想ネットワーク全体では、DMZは /28 と /29 が入る /27、バックエンドは /26、/27、/28、/29 を境界に合わせて並べて収める /25 が最小である。
ネットワークに付随するリソース
| リソース | 設定 |
|---|---|
| NAT ゲートウェイ | 静的なパブリック IP アドレスを1つ使う。Container Apps 環境の統合用サブネットと、ビルド用仮想マシンのサブネットに関連付ける |
| ネットワーク セキュリティ グループ(Application gateway用) | 許可する送信元からのHTTP(80)、Application gateway v2の管理通信(GatewayManagerからの 65200-65535)、ロードバランサーの正常性確認を許可し、それ以外の受信を拒否する |
| ネットワーク セキュリティ グループ(ビルド用仮想マシン) | Bastionのサブネットからの接続(RDP、3389)だけを許可し、それ以外の受信を拒否する |
| Bastion | Basic SKU。Standard SKUのパブリック IP アドレスを使う |
| ビルド用の仮想マシン | Windows Server 2022 Datacenter。プライベートIPアドレスのみで、システム割り当てマネージド IDを持つ。Docker EngineはWSL2のLinux環境で動かす |
ネットワーク設計のポイントを以下に記す。
- 本構成以外にHubネットワークがあり、オンプレミスとExpressRoute やVPN と接続、またAzure Firewall によるインバウンド・アウトバウンド制御を行っている場合はNAT ゲートウェイとBastion は必要ないため、全体的な構成に合わせて見直す必要がある
- DMZとバックエンドの仮想ネットワークは、ピアリングをしていない。両者をつなぐのは、DMZのContainer Apps 環境用プライベート エンドポイントだけである。Application gatewayはこのプライベート エンドポイントを通してContainer Apps 環境へ届く。バックエンドのビルド用仮想マシンから、DMZのリソースへ直接は届かない
- 外向きの通信は、サブネットの既定のアウトバウンド(
defaultOutboundAccess)を無効にし、NAT ゲートウェイだけを出口にしている。Container Apps 環境やビルド用仮想マシンが外へ出る経路を1か所に絞るためである。ジョブが他テナントのAPIを呼ぶ通信も、この出口を通る - Container registryがパブリックアクセスを無効にしているため、イメージのpushは仮想ネットワーク内から行う
- 最小サイズの根拠は次の通り。(Azureは各サブネットにおいて予約アドレスが5つ必要である)
- Container Apps 環境の仮想ネットワーク統合サブネットは、ワークロード プロファイル環境の最小が
/27と定められている - Bastion用は
/26以上と定められている - Application gatewayは、v2で
/24が推奨されているが必須ではない。この構成は最大2台なので、インスタンス2つと予約5つの合計7アドレスに、更新時の余裕を加えて/28とする - プライベート エンドポイントは、1つにつき1アドレスを使う。Container Apps 環境用は1つのためAzureの最小サブネットである
/29とする。データ用は、Azure Cosmos DB 2つ(グローバルとリージョン)、Container registry 2つ(レジストリとデータ エンドポイント)、キー コンテナー1つの合計5つであり/28とする - ビルド用仮想マシンは、ネットワーク インターフェース1つと予約5つで6アドレスなので
/29とする。ただしプライベート エンドポイントのサブネットと同居することは可能である。
- Container Apps 環境の仮想ネットワーク統合サブネットは、ワークロード プロファイル環境の最小が
- 最新の仕様、制約は以下を参照。
基盤サービス
本構成で必要なリソースを下表にまとめる。
| リソース | 設定 |
|---|---|
| Log Analytics ワークスペース |
PerGB2018、保持30日。ログの参照はリソースの権限で制御する |
| Application Insights | Log Analytics ワークスペースと連携。ローカル認証を無効にし、Entra認証のみで取り込む |
| Container registry | Premium。管理者ユーザー無効、パブリックアクセス無効、信頼されたサービスのバイパス無効 |
| ストレージ アカウント(任意) |
deployTransferStorage で作成。共有キーの認可は無効、Blobコンテナーは非公開。資材の受け渡し用 |
| マネージド ID | ユーザー割り当てマネージド IDを2つ作る。1つはコンテナー アプリ用、もう1つはコンテナー アプリ ジョブ用で、別々にする |
| キー コンテナー | Standard。ロールベースのアクセス制御で認可、論理削除90日、パブリックアクセス無効、AzureServices のバイパスを設定 |
| Azure Cosmos DB アカウント | Serverless、単一リージョン、整合性はSession、自動フェールオーバー有効。ローカル認証を無効にし、パブリックアクセス無効 |
| Azure Cosmos DB のデータベース、コンテナー | パーティションキーは /tenantId(詳細は「データの持ち方」) |
| Container Apps 環境 | Consumptionのワークロード プロファイル。バックエンドの統合用サブネットに統合。internal=false、パブリックネットワークアクセス無効。ログとメトリックをLog Analytics ワークスペースへ送る |
サービスごとの設計の考え方は以下の通りである。
- 認証はマネージド IDで行うため、パスワードやキーで接続できる方法はあらかじめ無効にしている。Container registryは管理者ユーザーを無効にし、Azure Cosmos DB アカウントはアカウントキーを無効にし、キー コンテナーはアクセスポリシーではなくロールベースのアクセス制御で認可する
- Container registry、Azure Cosmos DB アカウント、キー コンテナー、Container Apps 環境はパブリックアクセスを無効にし、後述のプライベート エンドポイントからだけ接続できる。パブリックに公開するのは入口のApplication gatewayのみ
- Application Insightsだけは、データの送信と参照をパブリックネットワークから受け付ける設定にしている(AMPLSを未使用)。ただしローカル認証を無効にしているので、Entra認証がなければ使えない
- コンテナー アプリのIngressは
external=trueにするが、Container Apps 環境側のパブリックネットワークアクセスが無効のため、インターネットから直接は届かない。環境側とアプリ側は別の設定で、両方を組み合わせてプライベート エンドポイント経由に限定している
プライベート エンドポイントとプライベート DNS ゾーン
本構成で必要なプライベート エンドポイントまたDNSゾーンを下表にまとめる。
| 対象 | 置くサブネット | グループID | プライベート DNS ゾーン |
|---|---|---|---|
| Container Apps 環境 | DMZのContainer Apps 環境用 | managedEnvironments |
privatelink.<リージョン>.azurecontainerapps.io |
| Container registry | バックエンドのデータ用 | registry |
privatelink.azurecr.io |
| Azure Cosmos DB アカウント | バックエンドのデータ用 | Sql |
privatelink.documents.azure.com |
| キー コンテナー | バックエンドのデータ用 | vault |
privatelink.vaultcore.azure.net |
プライベート DNS ゾーンは、プライベート エンドポイントを置いている仮想ネットワークにだけリンクする。
Container Apps 環境のゾーンはDMZに、Container registry・Azure Cosmos DB アカウント・キー コンテナーのゾーンはバックエンドにリンクする。
プライベート エンドポイント使用時の考慮点:
- Container Appsはコンテナーを起動するたびに、Container registryのプライベート エンドポイントへ到達してイメージを取得する。スケールアウト、ジョブの実行、リビジョンの作成のたびに必要になるため、Container Apps 環境からContainer registryのプライベート エンドポイントへ到達できることが前提である
- プライベート DNS ゾーンをリンクしても、通信の経路ができるわけではない。名前解決でプライベート エンドポイントのIPアドレスが返ることと、そのIPアドレスへ到達できることは、別々に確認する
- ピアリングがないため、ゾーンをリンクしていない仮想ネットワークからは、その名前をプライベート エンドポイントのIPアドレスに解決できない。たとえばDMZからキー コンテナーの名前を引くと、プライベート エンドポイントではなく公開エンドポイントに解決される。これを利用して、Application gatewayが証明書をキー コンテナーから取得する方法を「参考:HTTPS対応のApplication gateway」で示す
- Container registryには、レジストリ本体のFQDNとは別に、リージョンのデータエンドポイントのFQDNがある。どちらもプライベート エンドポイントのIPアドレスに解決される必要がある
ロール割り当て
本構成で作成するマネージドIDおよび付与するロールを下表にまとめる。
| 付与先 | 対象 | ロール | 目的 |
|---|---|---|---|
| コンテナー アプリのマネージド ID | Container registry | AcrPull | コンテナー アプリのイメージ取得 |
| コンテナー アプリのマネージド ID | Azure Cosmos DB のコンテナー | Cosmos DB組み込みのData Contributor | スナップショットの読み書き |
| コンテナー アプリ ジョブのマネージド ID | Container registry | AcrPull | ジョブのイメージ取得 |
| コンテナー アプリ ジョブのマネージド ID | Azure Cosmos DB のコンテナー | Cosmos DB組み込みのData Contributor | スナップショットの保存 |
| コンテナー アプリと、コンテナー アプリ ジョブのマネージド ID | キー コンテナー | Key Vault Secrets User | 他テナント用の認証情報の取得 |
| ビルド用仮想マシンのマネージド ID | Container registry | AcrPush | イメージのpush |
| WebアプリのアプリケーションID | 管理対象のサブスクリプション | Reader、Cost Management Reader、Monitoring Reader | 監視データの参照 |
| WebアプリのアプリケーションID | Log Analytics ワークスペース | Log Analytics Reader | ログの参照 |
Azure Cosmos DB のデータロールは、Azureのリソース管理のロールとは別の仕組みで、Azure Cosmos DB自身が管理する。
対象のコンテナーを範囲にして割り当てるため、コンテナーを作った後に付ける。
Webアプリに付けるロールは、管理対象のテナントすべてに対して参照したいデータを取得できる権限を割り当てる。
アプリケーションのデプロイ
コンテナー イメージ
コンテナー アプリとジョブは、同じDockerfileから作る1つのイメージを使う。
FROM python:3.12-slim
WORKDIR /app
COPY tenant-monitor/requirements.txt ./requirements.txt
RUN pip install --no-cache-dir -r requirements.txt
COPY tenant-monitor/app ./app
COPY tenant-monitor/static ./static
COPY collect ./collect
ENV PORT=8000
EXPOSE 8000
CMD ["sh", "-c", "uvicorn app.main:app --host 0.0.0.0 --port ${PORT}"]
fastapi==0.115.6
uvicorn[standard]==0.34.0
gunicorn==23.0.0
azure-identity==1.19.0
azure-cosmos==4.9.0
azure-keyvault-secrets==4.9.0
azure-mgmt-resourcegraph==8.0.0
azure-mgmt-advisor==9.0.0
requests==2.32.3
pydantic-settings==2.7.1
six==1.17.0
| パッケージ | 用途 |
|---|---|
fastapi、uvicorn
|
WebアプリのAPIとサーバー |
azure-identity |
マネージド IDを含むAzureの認証 |
azure-cosmos、azure-keyvault-secrets
|
Azure Cosmos DB の読み書き、キー コンテナーからのシークレット取得 |
azure-mgmt-resourcegraph、azure-mgmt-advisor
|
Azureのリソース情報と、Advisorの推奨事項の取得 |
requests |
Cost ManagementとMicrosoft GraphのREST API呼び出し |
pydantic-settings |
環境変数から設定を読み込む |
Dockerfileの確認ポイントを以下に示す。
-
collect/を、コンテナー アプリ用のイメージにも入れている(COPY collect ./collect)。collect/はジョブの収集処理だが、コンテナー アプリも、画面に表示するデータをAzure Cosmos DBから読む処理と、画面の「今すぐ取得」ボタンによるその場での収集に、同じコードを使うためである。コンテナー アプリとジョブで別々のイメージは作らず、1つのイメージを共用する - ビルドの基準ディレクトリは
閉域構成にする。Dockerfileはtenant-monitor/の下にあるが、隣のフォルダーのcollect/もCOPYするため、--fileでDockerfileを指定し、末尾の.で基準ディレクトリを渡す -
requirements.txtを先にコピーしてpip installする。アプリのコードを変えても、依存パッケージのレイヤーはキャッシュが効く -
0.0.0.0は、すべてのネットワークインターフェースで待ち受ける指定である。特定のIPアドレスではない - ジョブは同じイメージで、起動コマンドを
python -m collect.jobに上書きして使う
イメージのbuildとpush
ビルド用仮想マシンで、閉域構成 を基準ディレクトリにしてbuildし、Container registryへpushする。
cd Webアプリ
docker build --platform linux/amd64 --file tenant-monitor/Dockerfile \
--tag <Container registry名>.azurecr.io/<イメージ名>:<imageTag> .
docker push <Container registry名>.azurecr.io/<イメージ名>:<imageTag>
Container registryはパブリックアクセスを無効にしているため、Container registryのタスクを使う az acr build は使えず、仮想ネットワーク内のビルド用仮想マシンでDockerのbuildとpushを行っている。
Container registryへのログインはビルド用仮想マシンのマネージド ID(AcrPush)で行い、パスワードを発行しない。
アプリケーション層の作成
アプリ層(コンテナー アプリ、ジョブ、Application gateway)は、基盤と、Container registryに置いたイメージが揃った後で、追加で次のような環境構成を行う。
- コンテナー アプリとジョブのイメージに、
<Container registryのログインサーバー>/tenant-monitor:<imageTag>を設定して、コンテナー アプリとジョブを作る。portalImageNameを指定した場合は、その名前を使う。ログインサーバーは、基盤で作ったContainer registryから取得する - コンテナー アプリとジョブは、プライベート エンドポイントとロール割り当て(AcrPull、Azure Cosmos DB のData Contributor、Key Vault Secrets User)が済んでから作る。Container Apps 環境は、このイメージをContainer registryから取得してコンテナーを起動する
- Application gatewayは、コンテナー アプリの作成と、Container Apps 環境用プライベート エンドポイントの作成が済んでから作る。バックエンドには、コンテナー アプリのFQDN(
<コンテナー アプリ名>.<Container Apps 環境の既定ドメイン>)を設定する - Application gatewayの接続先(パブリック IP アドレス、または
portalHostName)を、出力のportalEndpointとして返す
アプリ層のうち、コンテナー アプリとジョブは後続の章で説明する。Application gatewayは次の設定で作る。
| 項目 | 設定 |
|---|---|
| パブリック IP アドレス | Standard SKU、静的 |
| SKU |
enableWaf が無効の場合はStandard_v2、有効の場合はWAF_v2 |
| スケール | 最小1台、最大は gatewayMaxInstances
|
| フロントエンド | HTTP(/HTTPS)。portalHostName を指定した場合は、そのホスト名のリスナーにする |
| バックエンド | Container Apps 環境のFQDN、HTTPS(443)。バックエンドのFQDNをHostヘッダーとSNIに使う |
| ヘルスプローブ | HTTPSで /healthz。30秒間隔、3回連続で失敗すると異常。200〜399を正常とする |
| タイムアウト | バックエンドへの要求は1800秒 |
| Web アプリケーション ファイアウォール | 有効にした場合はOWASP 3.2、防止モード |
コンテナー アプリは、作成時にContainer registryのイメージを必要とする。イメージをpushしていない状態でコンテナー アプリを作ると、イメージを取得できずに失敗することがある。
イメージをpushできるのは、Container registryとビルド用仮想マシンができた後であるため、基盤、イメージのpush、アプリ層の順に進める。
コンテナー アプリ
コンテナー アプリは、利用者に画面を返す常時稼働のWebアプリである。FastAPIが画面とJSONのAPIを提供し、表示するデータはAzure Cosmos DBに保存された最新のスナップショットを読む。他テナントへの問い合わせはジョブが行うため、Webアプリは問い合わせない。
リクエストのシーケンス
このシーケンスのポイント:
- 利用者の通信は、Application gatewayから入り、DMZのプライベート エンドポイントを通ってコンテナー アプリに届く。コンテナー アプリは、バックエンドのプライベート エンドポイントを通してAzure Cosmos DB アカウントを読む
- 画面のHTML、JavaScript、CSSは
static/の静的ファイルで、Webアプリがそのまま返す。ブラウザーのJavaScript(app.js)が/api/summaryを呼び、返ってきたJSONから対象ごとのカードを描画する。カードを選んだときの詳細は、読み込み済みのデータで表示する - 画面の「今すぐ取得」ボタンは、
POST /api/collectを呼んで収集を実行し、結果を再読み込みする
コンテナー アプリの設定
| 項目 | 設定 |
|---|---|
| Container Apps 環境 | Consumptionのワークロード プロファイル |
| イメージ | <Container registry名>.azurecr.io/<イメージ名>:<imageTag> |
| マネージド ID | コンテナー アプリ用のユーザー割り当てマネージド ID。Container registryからのイメージ取得にもこのマネージド IDを使う |
| Ingress | 外部(external=true)、ターゲットポート8000 |
| リビジョン | Singleモード |
| スケール | 最小1、最大3レプリカ |
| リソース | 0.5 vCPU、1 GiB |
| 依存関係 | プライベート エンドポイント、必要なロール割り当て(AcrPull、Azure Cosmos DB のData Contributor、Key Vault Secrets User) |
環境変数はBicepから渡す。
| 変数 | 役割 |
|---|---|
DEMO_MODE |
false で本番の収集・表示。true ではAzureへ接続せず、サンプルデータを表示する |
TENANTS_JSON |
監視対象の一覧(JSON配列) |
COSMOS_ENDPOINT、COSMOS_DATABASE、COSMOS_CONTAINER
|
保存先のAzure Cosmos DB |
APPLICATIONINSIGHTS_CONNECTION_STRING |
Application Insightsへ送る接続設定 |
MONITORING_CLIENT_ID |
他テナントを監視するアプリのアプリケーションID |
MONITORING_KEY_VAULT_URI、MONITORING_CLIENT_SECRET_NAME
|
他テナント用の認証情報を置いたキー コンテナーと、そのシークレット名 |
AZURE_CLIENT_ID |
コンテナー アプリ用マネージド IDのクライアントID |
環境変数のポイント:
- 環境変数に渡すのは、シークレットの名前とキー コンテナーのURIだけで、シークレットの値は渡さない。値はキー コンテナーにだけ置き、コードがマネージド IDで取りに行く
- ユーザー割り当てマネージド IDだけを付けたContainer Appsでは、
AZURE_CLIENT_IDにそのマネージド IDのクライアントIDを指定する。指定しないと、DefaultAzureCredentialがどのIDを使うか決められず、トークンの取得に失敗する -
AZURE_CLIENT_IDは、マネージド IDのクライアントIDである。監視アプリのアプリケーションIDや、対象テナントのサービスプリンシパルのオブジェクトIDとは別の値になる
HTTP API
| メソッド | パス | 用途 |
|---|---|---|
| GET | / |
画面(static/index.html) |
| GET | /healthz |
Application gatewayのヘルスプローブ |
| GET | /api/summary |
対象ごとのスナップショットと、合計値 |
| GET | /api/tenants/{tenant_id} |
1つの対象のスナップショット |
| POST | /api/collect |
その場で収集してAzure Cosmos DBへ保存する |
app = FastAPI(title=settings.app_title)
app.mount("/static", StaticFiles(directory="static"), name="static")
@app.get("/healthz")
def healthz() -> dict[str, str]:
return {"status": "ok"}
@app.get("/api/summary")
def summary() -> dict[str, Any]:
snapshots = load_snapshots()
return {"generatedAt": utc_now(), "tenants": snapshots, "totals": {"tenants": len(snapshots)}}
def load_snapshots() -> list[dict[str, Any]]:
container = cosmos_container(CollectionSettings(**settings.model_dump()))
query = "SELECT * FROM c WHERE c.documentType = 'latest' OR NOT IS_DEFINED(c.documentType)"
snapshots = container.query_items(query=query, enable_cross_partition_query=True)
allowed = {t.tenant_id for t in configured_tenants(CollectionSettings(**settings.model_dump()))}
return unique_latest_snapshots([s for s in snapshots if s.get("tenantId") in allowed])
管理対象データの読み込み処理の仕様
-
documentTypeがlatestのデータだけを読む。documentTypeを持たない古い形式のデータも含める - 設定された監視対象(
TENANTS_JSON)に含まれるテナントだけを表示する。監視をやめた対象のデータが残っていても、画面には出ない - 同じテナントのデータが複数あった場合は、
unique_latest_snapshotsがlastCollectedAtの新しい1件だけを残す -
/api/collectは、画面からの手動操作用である。定期収集の主体はジョブで、Webアプリは保存済みのデータを読む
コンテナー アプリ ジョブ
コンテナー アプリ ジョブは、スケジュールで起動し、他テナントの情報を集めてAzure Cosmos DBへ保存し、終わったら停止するバッチ処理である。コンテナー アプリとはContainer registryのイメージを共有し、起動コマンドを python -m collect.job に上書きして収集処理だけを動かす。
実行のシーケンス
このシーケンスのポイント:
- データの流れは「ジョブ → Azure Cosmos DB → コンテナー アプリ」の一方向である。画面を開くたびに他テナントへ問い合わせることはなく、コンテナー アプリは保存済みのデータを読んで返す。そのため、他テナントのAPIが遅くても画面の表示は影響を受けない
- ジョブは、バックエンドのプライベート エンドポイントを通してAzure Cosmos DB アカウント、Container registry、キー コンテナーに接続する。他テナントのAPIへ出る通信だけは、NAT ゲートウェイを経由する
- ジョブは、まず自分のマネージド IDでキー コンテナーから認証情報を取り、その認証情報で対象テナントごとにトークンを取得してAPIを呼ぶ。マネージド IDは管理テナント内のリソース(Container registry、Azure Cosmos DB アカウント、キー コンテナー)への接続に使い、他テナントへの接続には、監視アプリとしての認証情報を使う。この使い分けと、シークレットを使わない方法は、後編で説明する
ジョブの設定
| 項目 | 設定 |
|---|---|
| Container Apps 環境 | Consumptionのワークロード プロファイル |
| イメージ | コンテナー アプリと共通。<Container registry名>.azurecr.io/<イメージ名>:<imageTag>
|
| 起動コマンド | python -m collect.job |
| トリガー | Schedule。cronは collectionSchedule
|
| 並列数、完了数 | 1、1 |
| 再試行、タイムアウト | 再試行は1回まで、レプリカのタイムアウトは1800秒 |
| リソース | 0.5 vCPU、1 GiB |
| マネージド ID | ジョブ用のユーザー割り当てマネージド ID。Container registryからのイメージ取得にもこのマネージド IDを使う |
| 環境変数 | コンテナー アプリと同じ収集設定。AZURE_CLIENT_ID はジョブ用マネージド IDのクライアントID。APPLICATIONINSIGHTS_CONNECTION_STRING は渡さない |
| 依存関係 | プライベート エンドポイント、必要なロール割り当て(AcrPull、Azure Cosmos DB のData Contributor、Key Vault Secrets User) |
cronとジョブの停止
cron式はUTCで評価されるため、既定の 0 0 * * * は日本時間の9時に起動する。
起動したい時刻に合わせて、UTCとの時差を考えて決める。また、コンテナー アプリ ジョブは長期間トリガーされないと停止した状態になることがある。その場合は再開してから実行する必要がある。
ジョブのコード
from collect.collection import Settings, collect_all_snapshots
def main() -> None:
settings = Settings()
if settings.demo_mode:
return
collect_all_snapshots(settings)
if __name__ == "__main__":
main()
収集処理は、設定を環境変数から読み込む。
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
demo_mode: bool = True
tenants_json: str = "[]"
cosmos_endpoint: str | None = None
cosmos_database: str = "<データベース名>"
cosmos_container: str = "<コンテナー名>"
azure_cost_api_version: str = "2023-11-01"
monitoring_client_id: str | None = None
monitoring_client_secret_name: str | None = None
monitoring_key_vault_uri: str | None = None
class TenantTarget(BaseModel):
id: str
display_name: str
tenant_id: str
subscription_id: str
region: str | None = None
TENANTS_JSON には、TenantTarget に合う配列を渡す。
[
{
"id": "<識別子>",
"display_name": "<表示名>",
"tenant_id": "<対象テナントID>",
"subscription_id": "<対象サブスクリプションID>",
"region": "<リージョン>"
}
]
認証は、管理テナント内への接続と、対象テナントへの接続で分けている。
def credential() -> DefaultAzureCredential:
return DefaultAzureCredential(exclude_interactive_browser_credential=True)
def target_credential(target: TenantTarget, settings: Settings) -> TokenCredential:
secret = monitoring_client_secret(settings)
if settings.monitoring_client_id and secret:
return ClientSecretCredential(
tenant_id=target.tenant_id,
client_id=settings.monitoring_client_id,
client_secret=secret,
)
return credential()
monitoring_client_secret は、キー コンテナーから、credential() のマネージド IDでシークレットを読む関数である。
1つの対象を集める処理は、収集の項目ごとに関数を呼び、結果を1つのドキュメントにまとめる。
def collect_snapshot(target: TenantTarget, settings: Settings) -> dict[str, Any]:
access_credential = target_credential(target, settings)
inventory = resource_inventory(target.subscription_id, access_credential)
status, partial_errors = "正常", []
try:
cost = cost_summary(target.subscription_id, settings, access_credential)
except Exception as exc:
cost = {"current": None}
status = "要確認"
partial_errors.append(f"利用料の取得に失敗しました: {exc}")
# Advisor、ライセンスなど、ほかの項目も同じ形で取得する
return {
"id": target.id,
"tenantId": target.tenant_id,
"displayName": target.display_name,
"subscriptionId": target.subscription_id,
"status": status,
**inventory,
"cost": cost,
"lastCollectedAt": utc_now(),
"collectionError": "; ".join(partial_errors) or None,
}
全対象の処理は、対象ごとに例外を捕まえて、次の対象へ進む。
def collect_all_snapshots(settings: Settings) -> list[dict[str, Any]]:
results = []
for target in configured_tenants(settings):
try:
snapshot = collect_snapshot(target, settings)
except Exception as exc:
snapshot = {
"id": target.id,
"tenantId": target.tenant_id,
"status": "取得不可",
"lastCollectedAt": utc_now(),
"collectionError": str(exc),
}
snapshot["documentType"] = "latest"
save_snapshot(snapshot, settings)
results.append(snapshot)
return results
データ収集処理の仕様
- 認証は2段。ジョブのマネージド IDで管理テナントのキー コンテナーからシークレットを読み、そのシークレットで、対象テナントを指定した
ClientSecretCredentialを作る - あるテナントの収集に失敗しても、ほかのテナントの収集は続ける。たとえば権限が足りない場合でも、その対象だけを「取得不可」としてエラー内容を保存し、次の対象へ進む
- リソース数や料金など、項目ごとの取得に失敗した場合は、その項目だけを空にして状態を「要確認」にする。取得できたほかの項目はそのまま保存する
- 監視対象の一覧が読み込めない場合(
TENANTS_JSONの形式が違うなど)、収集する対象が0件になり、ジョブはエラーなしで終わる。ジョブの完了状態だけでなく、Azure Cosmos DBに保存された結果も確認する - 現在のBicepは、キー コンテナーのURIとシークレット名を常に環境変数へ渡す。そのため、シークレットを登録していないと、キー コンテナーからの取得で失敗して収集できない
外部データの取得
何を収集するかは、運用者の目的によって変わる。
例えば次のデータを取得したい場合に使用するAPIを示す。
各APIの要求の形式、バージョン、必要な権限は、公式資料を基準に決める。
| データ | 使うAPI | トークンのスコープ | 必要な権限 | 公式資料 |
|---|---|---|---|---|
| Azureのリソース | Azure Resource Graph(KQL)。SDKは ResourceGraphClient
|
https://management.azure.com/.default |
対象サブスクリプションの Reader | Azure Resource Graph の概要 - Azure Resource Graph、ResourceGraphClient Class |
| 利用料 | Cost Management の Query REST API | https://management.azure.com/.default |
Cost Management Reader | Query - Usage |
| Advisorの推奨事項 | Azure Advisor管理SDK。AdvisorManagementClient
|
https://management.azure.com/.default |
Reader(Advisorの参照を含む) | AdvisorManagementClient Class |
| ライセンス | Microsoft Graph REST API。subscribedSkus
|
https://graph.microsoft.com/.default |
アプリケーションの許可 LicenseAssignment.Read.All。対象テナントごとの管理者同意が必要 |
subscribedSkus を一覧表示する - Microsoft Graph v1.0 |
Azure Resource Graphの呼び出しは、KQLを渡す形である。次の例は、リソースの種類ごとの件数を数える。
client = ResourceGraphClient(access_credential)
response = client.resources(
QueryRequest(
subscriptions=[subscription_id],
query="Resources | summarize resourceCount = count() by type",
)
)
rows = list(response.data or [])
収集項目の選び方:
- Resource Graph、Cost Management、AdvisorはAzureのリソース管理(
management.azure.com)の認証で呼び、権限はAzureのロールベースのアクセス制御で決まる。Microsoft Graphは別の認証の仕組みで、権限もEntraのアプリケーションの許可で決まる。収集する項目を増やすときは、使うAPIがどちらの仕組みかを先に確認する - Cost Management APIは回数制限(HTTP 429)に当たりやすい。応答の
Retry-Afterを待って数回まで再試行する - 収集した項目はドキュメントの項目として追加するだけで、Azure Cosmos DBの設計は変わらない(スキーマレス)
データの持ち方(Azure Cosmos DB)
Azure Cosmos DB for NoSQLにデータベースとコンテナーを1つずつ作り、パーティションキーを /tenantId にする。監視対象の最新の状態を、対象ごとに1つのドキュメントで持つ。
{
"id": "<監視対象の識別子>",
"tenantId": "<対象テナントID>",
"subscriptionId": "<対象サブスクリプションID>",
"displayName": "<表示名>",
"documentType": "latest",
"status": "<状態>",
"lastCollectedAt": "<UTCの日時>",
"collectionError": null
}
ジョブは、同じ id と tenantId のドキュメントを upsert_item で上書きする。
def save_snapshot(snapshot: dict[str, Any], settings: Settings) -> None:
container = cosmos_container(settings)
if container:
container.upsert_item(snapshot)
Cosmos DB設計のポイント:
-
idに監視対象の識別子を使うと、同じ対象が必ず同じドキュメント(Cosmos DB for NoSQL におけるJSON 形式の1件分のデータ)に上書きされ、最新の1件だけが残る - パーティションキーは
tenantIdである。対象テナントごとにパーティションが分かれ、監視対象が増えても1つのパーティションに偏らない。idとtenantIdの2つが分かれば、検索条件を書かずに1件を直接読める(Azure Cosmos DBで「ポイント読み取り」と呼ぶ、最もコストの小さい読み方) - 画面は全対象をまとめて読むため、複数のパーティションを横断して検索するクエリを使う。パーティションごとに問い合わせるため、対象が数十件程度であれば、このクエリのコストは小さい
- 定期的な書き込みと閲覧が中心であり、Serverlessとし使った分だけの課金モデルを選択している
履歴が必要になった場合に、データの持ち方として次の案がある。
| 案 | データの持ち方 | 長所 | 短所 |
|---|---|---|---|
| A. 最新のみ(現在の構成) | 対象ごとに1ドキュメント。毎回上書き | 単純で、読み取りが1クエリで済む | 履歴が残らない |
| B. 同じコンテナーに履歴を追加 |
documentType を history にして、id に収集日時を含める。latest も更新する |
パーティション内で最新と履歴を取れる。表示側のクエリは変えずに済む | データが増え続ける。TTLなどで古いデータを消す必要がある |
| C. 履歴用のコンテナーを分ける | 履歴は別のコンテナーへ書く。TTLで保持期間を決める | 表示用と分析用を分離できる | コンテナーとロールの管理が増える |
案の選び方:
現在の読み取りは、documentType が latest のものだけを対象にしている。(案A)
このため、案Bに拡張しても、表示側のクエリを変えずに使える。推移のグラフや過去の比較が必要になったときに、BかCを選ぶ。履歴を持つ場合は、古いデータを自動で消すTTLの設定も合わせて決める。
参考:パーティション分割と水平スケーリング - Azure Cosmos DB、クイックスタート - Azure SDK for Python - Azure Cosmos DB、TTLを用いてデータを有効期限切れにする - Azure Cosmos DB
動作確認
次の順で、環境が再現できたことを確認する。
| 手順 | 操作 | 期待する結果 |
|---|---|---|
| 1 | ジョブを手動で起動する。az containerapp job start --name <ジョブ名> --resource-group <リソース グループ名>(ジョブ名は出力 collectionJobName の値) |
実行が完了する。az containerapp job execution list で状態を見られる |
| 2 | Azure Cosmos DB のコンテナーを確認する | 監視対象ごとに1つのドキュメントが作られ、lastCollectedAt が更新されている |
| 3 | ドキュメントの status と collectionError を見る |
取得できた対象は collectionError が空である。権限不足などの対象は、エラー内容が入っている |
| 4 |
portalEndpoint に、ブラウザーで接続する |
一覧が表示され、対象ごとのカードが出る |
| 5 |
/healthz に接続する |
{"status":"ok"} が返る |
ジョブが成功で終わっても、すべての対象の収集が成功したとは限らない。
収集はある対象で失敗しても次の対象へ進む作りにする必要があり、一部の対象で権限不足のエラーが出てもジョブは成功として終わらせる。
このため確認はジョブの状態だけでなく、画面やAzure Cosmos DBで、対象ごとの最終取得日時とエラーも見ておく必要がある。
おわりに
パーツでの紹介になっておりますが、Azure Container Appsのエンタープライズ構成を構築する際の参考になれば幸いです。
次回、Azure Container Apps学び直し-エンタープライズ構成【後編】を投稿します。
ここまでお読み頂きありがとうございました。
