本記事は GitHub Copilot CLI による自動検証ワークフローのレポートです。人間のレビューを経て公開しています。コマンド出力やリソース ID は検証環境のもの(機密値はマスキング済み)です。
本検証は dev 環境です。本番採用時は Bastion / Jumpbox の運用、監査要件、AMPLS の DNS 設計(複数 VNet・ハブスポーク)など追加考慮が必要です。
TL;DR (3 行)
-
AMPLS
PrivateOnlyで Application Insights / Log Analytics を完全閉域化、Functions のテレメトリを VNet 経由で ロスなく 到達確認(Jumpbox 8 calls = AppI 8 件 = LAW 8 件) -
三層閉域反証 に成功: Function App inbound / AppI ingestion / LAW query すべて Public IP から
HTTP 403で拒否(実コマンド + レスポンス本文付き) - EP1 + 閉域化の現実的な罠(
allowSharedKeyAccess=falseGA 未対応、AppServiceHTTPLogs非対応、AMPLS Diagnostic Settings 非対応、az monitor private-link-scope showの CLI バグ等)を整理
背景・目的
Azure Functions を「Application Insights / Log Analytics への テレメトリ送信を VNet 内 Private Endpoint 経由のみ に限定」できるかを 1 ResourceGroup / 1 VNet で検証した。具体的に確認したいのは次の 4 点。
- AMPLS の
ingestionAccessMode = PrivateOnly/queryAccessMode = PrivateOnlyで Public ingestion / query が確実に拒否 されること - Functions の VNet integration (
WEBSITE_VNET_ROUTE_ALL=1) からのテレメトリが AMPLS PE → AppI(workspace-based) → LAW に到達すること - Workspace-based Application Insights における Private Link 配線(
amplsScopedResources)の正しい書き方 - Functions inbound の Private Endpoint と組み合わせた 完全閉域(Public IP からアクセス不可)が実現すること
アーキテクチャ
要点:
-
テレメトリ閉域経路 (青): Function App → VNet integration →
pep-ampls(groupIdazuremonitor) → AMPLS → AppI → LAW -
inbound 閉域経路 (緑): Developer → Bastion → Jumpbox →
pep-func→ Function App - 拒否経路 (赤): Public Internet → 公開 URL → 403(Function inbound / AppI ingestion / LAW query の 三層)
-
Private DNS Zone 9 個 vnet linked:
azurewebsites.net,monitor.azure.com,ods.opinsights.azure.com,oms.opinsights.azure.com,agentsvc.azure-automation.net,blob/file/queue/table.core.windows.net -
AAD 認証:
APPLICATIONINSIGHTS_AUTHENTICATION_STRING=ClientId=<UAMI>;Authorization=AADで UAMI 経由送信
構築のポイント(Bicep)
AMPLS モジュール(ここが今回の主役)
Microsoft.Insights/privateLinkScopes@2021-07-01-preview で accessModeSettings を指定。API バージョンが古いと PrivateOnly が指定できないので注意。
resource ampls 'Microsoft.Insights/privateLinkScopes@2021-07-01-preview' = {
name: amplsName
location: 'global'
tags: tags
properties: {
accessModeSettings: {
ingestionAccessMode: 'PrivateOnly'
queryAccessMode: 'PrivateOnly'
}
}
}
resource scopedLaw 'Microsoft.Insights/privateLinkScopes/scopedResources@2021-07-01-preview' = {
parent: ampls
name: 'scoped-law'
properties: { linkedResourceId: logAnalyticsWorkspaceId }
}
resource scopedAppI 'Microsoft.Insights/privateLinkScopes/scopedResources@2021-07-01-preview' = {
parent: ampls
name: 'scoped-appi'
properties: { linkedResourceId: appInsightsId }
}
Workspace-based Application Insights
LAW / AppI 両方で publicNetworkAccessForIngestion=Disabled / publicNetworkAccessForQuery=Disabled を必ず指定する。これがないと AMPLS PrivateOnly にしても「保険なし」状態になる。
resource logAnalytics 'Microsoft.OperationalInsights/workspaces@2023-09-01' = {
name: logAnalyticsName
location: location
tags: tags
properties: {
sku: { name: 'PerGB2018' }
retentionInDays: 30
publicNetworkAccessForIngestion: 'Disabled'
publicNetworkAccessForQuery: 'Disabled'
}
}
resource appInsights 'Microsoft.Insights/components@2020-02-02' = {
name: appInsightsName
location: location
tags: tags
kind: 'web'
properties: {
Application_Type: 'web'
WorkspaceResourceId: logAnalytics.id
IngestionMode: 'LogAnalytics'
publicNetworkAccessForIngestion: 'Disabled'
publicNetworkAccessForQuery: 'Disabled'
}
}
Functions の VNet integration と AppI 接続
properties: {
virtualNetworkSubnetId: snetFunctionsId
// ...
appSettings: union(baseSettings, {
WEBSITE_VNET_ROUTE_ALL: '1'
APPLICATIONINSIGHTS_CONNECTION_STRING: appInsightsConnectionString
APPLICATIONINSIGHTS_AUTHENTICATION_STRING: 'ClientId=${managedIdentityClientId};Authorization=AAD'
})
}
WEBSITE_VNET_ROUTE_ALL=1 で 全 outbound を VNet 経由 にすることで、AppI への送信が pep-ampls に向かう。
検証結果サマリ (14 シナリオ / すべて PASS)
| ID | シナリオ | 結果 |
|---|---|---|
| S1 | 全リソース provisioningState=Succeeded
|
✅ |
| S2 | Function App が VNet 統合 | ✅ |
| S3 | AMPLS PE が Approved | ✅ |
| S4 | AMPLS scopedResources に AppI/LAW | ✅ |
| S5 | AMPLS access mode = PrivateOnly (両方) | ✅ |
| S6 | Functions に AppI 接続設定 (AAD) | ✅ |
| S7 | AppI が Workspace-based | ✅ |
| S8 | Functions テレメトリが閉域経路で AppI/LAW 到達 | ✅ |
| S9 | Public network access=Disabled (4 項目) | ✅ |
| S10 | Public ingestion 拒否 (403) | ✅ |
| S11 | Public query 拒否 (403) | ✅ |
| S12 | Function inbound 公開 URL 403 | ✅ |
| S13 | Diagnostic Settings → LAW 流入 | ✅ |
| S14 | Storage 閉域 (PE 4 + publicAccess=Disabled) | ✅ |
S8: テレメトリ閉域到達(★最重要)
- Jumpbox から
Invoke-WebRequest https://<func>.azurewebsites.net/api/hello?name=verify-S8を 8 回 流す(Private DNS でpep-funcの PE IP に解決され HTTP 200) - ingestion latency 5 分待機
- Jumpbox MI に Monitoring Reader を付与し、AppI / LAW Query API を private 経路 から実行
--- QUERY: requests | where timestamp > ago(45m) | summarize cnt=count() by resultCode
rows: [["200", 8]]
--- QUERY: AppRequests | where TimeGenerated > ago(1h) | summarize cnt=count() by ResultCode
rows: [["200", 8]]
--- QUERY: customEvents | where name == "public-probe-S10" | summarize cnt=count()
rows: [[0]]
→ 8 calls = AppI 8 件 = LAW 8 件で完全一致。同時に「公開経路で送ろうとした public-probe-S10 イベントは AppI に 0 件」も独立確認。「閉域化したらテレメトリが消えるのでは?」という不安をエビデンスで払拭できた。
S10: Public ingestion 拒否 (403)
curl -i -X POST https://japaneast-1.in.applicationinsights.azure.com/v2/track \
-H "Content-Type: application/json" \
-d '[{"name":"Microsoft.ApplicationInsights.Event","time":"...","iKey":"<INSTRUMENTATION_KEY>","data":{"baseType":"EventData","baseData":{"ver":2,"name":"public-probe-S10"}}}]'
レスポンス:
{
"itemsReceived": 1,
"itemsAccepted": 0,
"appId": null,
"errors": [
{
"index": 0,
"statusCode": 403,
"message": "Component has publicNetworkAccessForIngestion=Disabled"
}
]
}
S11: Public query 拒否 (403)
TOKEN=$(az account get-access-token --resource https://api.loganalytics.io --query accessToken -o tsv)
curl -i -H "Authorization: Bearer $TOKEN" \
"https://api.loganalytics.io/v1/workspaces/<LAW_CUSTOMER_ID>/query?query=AppRequests%20%7C%20take%201"
レスポンス(clientIp がエラーに反射されており IP ベース拒否 の確証となる):
{
"error": {
"code": "InsufficientAccessError",
"innererror": {
"code": "NspValidationFailedError",
"message": "Access to workspace 'log-pappi-dev-jpe' from '<DEVELOPER_PUBLIC_IP>' is denied. ..."
}
}
}
S12: Function inbound 公開 URL 403
curl -i https://func-pappi-dev-jpe.azurewebsites.net/api/hello
# HTTP/1.1 403 (resolved to App Service shared front-door IP)
→ Function inbound / AppI ingestion / LAW query の三層すべてが Public IP から拒否される ことを実コマンドで実証完了。
S5: AMPLS access mode の確認は REST 必須
az monitor private-link-scope show は CLI バグで accessModeSettings を返さない(後述 H2)。REST 直叩きで確認:
az rest --method get \
--url "https://management.azure.com/subscriptions/<SUBSCRIPTION_ID>/resourceGroups/rg-private-app-insights-dev/providers/microsoft.insights/privateLinkScopes/ampls-pappi-dev-jpe?api-version=2021-07-01-preview"
"accessModeSettings": {
"exclusions": null,
"ingestionAccessMode": "PrivateOnly",
"queryAccessMode": "PrivateOnly"
}
ハマったポイント(★ 強調)
H1: AMPLS は Diagnostic Settings をサポートしない
ERROR: (ResourceTypeNotSupported) The resource type
'microsoft.insights/privatelinkscopes' does not support diagnostic settings.
AMPLS 自体のアクセスログは取得不可。Activity Log / NSG flow logs / scoped 先(AppI・LAW)側のログで間接観測する設計にする。「AMPLSAccess テーブル」は現状存在しない。
H2: az monitor private-link-scope show が accessModeSettings を返さない
az CLI が 2019-10-17-preview 等の古い API version を内部で使っているため accessModeSettings が null になる。accessModeSettings は 2021-07-01-preview 以降のフィールド。REST 直叩き (az rest) を CI に組み込む のが安全。
H3: EP1 + allowSharedKeyAccess=false は GA 未対応
identity-based content share(AzureWebJobsStorage__blobServiceUri の triple 構成)を EP1 で組むと Function ホストが起動しない。Functions Premium plan では content share の identity-based access が GA 未対応(Flex Consumption / 一部 Linux Consumption は対応)。
→ 検証スコープが「AppI / LAW の閉域化」だったので、Storage は allowSharedKeyAccess=true + connection string で妥協。Storage 自体は PE 4 種で閉域(S14)。本気で keyless にしたい場合は Flex Consumption への移行を検討。
H4: kind=functionapp,linux の Diagnostic 対応カテゴリ
AppServiceHTTPLogs を Function App の Diagnostic Settings に含めるとデプロイ失敗する。kind=functionapp,linux は FunctionAppLogs のみ対応(Web App 用カテゴリは選べない)。Bicep では categoryGroup ではなく category: 'FunctionAppLogs' を明示指定。
H5: azd --no-prompt で secure parameter が未供給だと停止
adminPassword(Jumpbox 用 secure string)を hook 側で生成する設計だと azd up --no-prompt が value not provided で止まる。azd は secure parameter は必ず env から読む ため、hooks/preprovision.sh の冒頭で azd env set ADMIN_PASSWORD ... を呼んでおく必要がある。
#!/usr/bin/env bash
set -euo pipefail
if ! azd env get-values | grep -q '^ADMIN_PASSWORD='; then
azd env set ADMIN_PASSWORD "$(openssl rand -base64 24)Aa1!"
fi
コスト(連続稼働、停止せず)
| リソース | SKU | 日額目安 |
|---|---|---|
| Functions Premium plan | EP1 (1 instance) | ≈ ¥1,800 |
| Azure Bastion | Basic | ≈ ¥800 |
| Jumpbox VM | Standard B2ms (Windows) | ≈ ¥600 |
| Log Analytics ingestion | PerGB2018 (< 1 GB/日) | ≈ ¥400 |
| Private Endpoint × 6 | ¥1.2/h × 6 | ≈ ¥200 |
| Storage Account | Standard LRS + 4 PE | ≈ ¥150 |
| Public IP (Bastion) | Standard | ≈ ¥30 |
| AMPLS | 課金なし(PE のみ課金) | ¥0 |
| 合計 | — | ≈ ¥4,800 / 日 |
EP1 + Bastion + Jumpbox + 6 PE の連続稼働で月 14 万円規模になる。検証完了後は必ず azd down --purge --force か、最低でも Bastion 削除 + VM Stop + Plan を Free に切替する。
まとめ
- AMPLS PrivateOnly + workspace-based AppI + Functions VNet integration + 6 PE の組合せで、テレメトリ閉域到達と Public 拒否の両方を 1 RG で実証できた
- 三層閉域反証(Function inbound / AppI ingestion / LAW query すべて 403)と、テレメトリ件数の完全一致(8 = 8 = 8)が同時に取れたのが今回の最大成果
- 一方で EP1 + 閉域化 には GA 未対応の機能や CLI バグといった現実的な罠が多い。本記事の H1〜H5 を「踏まないためのチェックリスト」として活用してほしい
展開: 主要リソース命名(CAF 準拠)
| リソース | 名前 |
|---|---|
| Function App | func-pappi-dev-jpe |
| App Insights | appi-pappi-dev-jpe |
| Log Analytics | log-pappi-dev-jpe |
| AMPLS | ampls-pappi-dev-jpe |
| Storage | st<project>dev<unique> |
| UAMI | id-pappi-dev-jpe |
| Bastion | bas-pappi-dev-jpe |
| Jumpbox | vm-jump-pappi-dev |
参考リンク
- Azure Monitor Private Link Scope (AMPLS) — Microsoft Learn
- Workspace-based Application Insights resources
- Application Insights authentication (Microsoft Entra)
- Functions networking - VNet integration
- AMPLS access modes (Open / PrivateOnly)
- Azure REST: PrivateLinkScopes Get (api-version=2021-07-01-preview)