はじめに
BicepでPrivate Endpointを含む構成を作ると、次の障害が起きがちです。
- 空のResource Groupへの初回デプロイだけ
ResourceNotFoundで失敗する - 同じデプロイを再実行すると成功する
- Private Endpointは
Succeededなのに名前解決できない - Private DNS Zone Groupだけが権限エラーになる
- モジュール分割後、実行タイミングによって結果が変わる
- 同じスコープへの並行デプロイで、ネストされたデプロイが干渉する
本質は、Bicepファイルの記述順ではありません。Azure Resource Managerが評価する依存関係グラフです。
本記事では、Storage AccountのBlobサービスだけをPrivate Endpoint化する最小構成を使い、次を解説します。
-
resourceId()とシンボリック参照の違い -
parentによる子リソースの依存関係 - モジュールOutputと固定モジュール名の罠
- Private DNSとRBACの責任分界
- Azure DevOps/GitHub Actionsでの安全なデプロイ設計
- 公開前に必要な15項目の実測
障害事例について
本文の体験談は、複数のAzure案件で起きやすい事象を技術解説用に再構成したものです。単一案件の実録ではありません。
対象範囲:Blobサービスだけを扱う
Storage Accountは、サービスごとにPrivate EndpointのサブリソースとPrivate DNS Zoneが異なります。
| サービス | groupIds |
Private DNS Zone |
|---|---|---|
| Blob | blob |
privatelink.blob.core.windows.net |
| Data Lake Storage Gen2 | dfs |
privatelink.dfs.core.windows.net |
| Azure Files | file |
privatelink.file.core.windows.net |
| Queue | queue |
privatelink.queue.core.windows.net |
| Table | table |
privatelink.table.core.windows.net |
| Static Website | web |
privatelink.web.core.windows.net |
本記事が作成するのはblob用Private Endpointだけです。1個のPrivate EndpointでStorage Accountの全サービスが閉域化されるわけではありません。
ADLS Gen2では、一部処理がBlobエンドポイントへリダイレクトされるため、通常はdfsとblobの両方を設計対象にします。
RA-GRSまたはRA-GZRSでセカンダリリージョンを明示的に読み取る場合は、blob_secondaryを対象にした別のPrivate Endpointが必要です。本記事では対象外とします。
掲載コードは依存関係を確認する最小構成です。Diagnostic Settings、Resource Lock、データ保護、データプレーンRBAC、DNS Private Resolver、Azure Policyとの責任分界は別途設計します。
先に結論
| No. | 原則 |
|---|---|
| 1 | Bicepの記述順は作成順を保証しない |
| 2 | 同一デプロイ内ではstorageAccount.idのようなシンボリック参照を使う |
| 3 |
resourceId()だけの式には対象リソースへのシンボリック参照がないため、その式だけでは暗黙的依存関係が生成されない |
| 4 | 子リソースはparentで表現する |
| 5 | モジュール間は前段モジュールのoutputsを後段へ渡す |
| 6 | モジュールのnameは原則省略し、固定名の並行実行を避ける |
| 7 | 過剰なdependsOnで並列性を潰さない |
| 8 | ARM、Private Link、DNS、データプレーンを別々に検証する |
| 9 | RBAC表をそのままカスタムロール定義として使わない |
| 10 | CI/CDは認証、What-If、承認、Artifact固定、排他制御まで試験する |
複数案件を基に再構成した障害事例
空のResource GroupへStorage AccountとPrivate Endpointをデプロイしたところ、Storage Accountは成功し、Private Endpointだけが失敗しました。
ResourceNotFound
The Resource 'Microsoft.Storage/storageAccounts/...' was not found.
同じパイプラインを再実行すると成功しました。
現場では、次の会話でクローズされがちです。
「Storage Accountはできています」
「再実行したら通りました」
「Azure側の反映待ちだったのでしょう」
しかし、本番の新規Resource Groupで同じ失敗が再発しました。1回目でStorage Accountだけが残り、2回目は接続先がすでに存在していただけです。
原因は、次のようなコードでした。
❌ このコードは使用しないでください
以下はBicepとして構文上は有効ですが、同一デプロイ内のリソースシンボルを参照していないため、依存関係の設計が誤っています。
var storageAccountId = resourceId(
'Microsoft.Storage/storageAccounts',
storageAccountName
)
resource privateEndpoint 'Microsoft.Network/privateEndpoints@2025-07-01' = {
name: privateEndpointName
location: location
properties: {
subnet: {
id: privateEndpointSubnetId
}
privateLinkServiceConnections: [
{
name: 'blob-connection'
properties: {
privateLinkServiceId: storageAccountId
groupIds: [
'blob'
]
}
}
]
}
}
resourceId()が既存の依存関係を削除するわけではありません。上の式にはBicep上のstorageAccountリソースシンボルがないため、この式だけではStorage Accountへの暗黙的依存関係が生成されません。
Storage Account作成 ──────────┐
├─ 並列開始する可能性
Private Endpoint作成 ─────────┘
同一デプロイ内では、リソースシンボルを直接参照します。
✅ 推奨する依存関係の表現
privateLinkServiceId: storageAccount.id
Subnetも同様です。
subnet: {
id: privateEndpointSubnet.id
}
これにより、Private EndpointはStorage AccountとSubnetの両方へ暗黙的に依存します。
依存関係を確認するための最小構成
⚠️ 実機検証予定コード:
main.bicep
targetScope = 'resourceGroup'
@description('Azureリージョン')
param location string = resourceGroup().location
@description('リソース名のプレフィックス')
@minLength(2)
@maxLength(12)
param prefix string = 'peblob'
param vnetAddressPrefix string = '10.20.0.0/16'
param privateEndpointSubnetAddressPrefix string = '10.20.1.0/24'
param tags object = {
workload: 'bicep-private-endpoint-lab'
environment: 'lab'
managedBy: 'bicep'
}
var suffix = take(uniqueString(subscription().subscriptionId, resourceGroup().id), 6)
var baseName = '${prefix}-${suffix}'
var storageAccountName = 'st${uniqueString(subscription().subscriptionId, resourceGroup().id)}'
var privateDnsZoneName = 'privatelink.blob.core.windows.net'
resource storageAccount 'Microsoft.Storage/storageAccounts@2025-06-01' = {
name: storageAccountName
location: location
tags: tags
kind: 'StorageV2'
sku: {
name: 'Standard_LRS'
}
properties: {
accessTier: 'Hot'
allowBlobPublicAccess: false
allowSharedKeyAccess: false
minimumTlsVersion: 'TLS1_2'
publicNetworkAccess: 'Disabled'
supportsHttpsTrafficOnly: true
}
}
resource virtualNetwork 'Microsoft.Network/virtualNetworks@2025-07-01' = {
name: '${baseName}-vnet'
location: location
tags: tags
properties: {
addressSpace: {
addressPrefixes: [
vnetAddressPrefix
]
}
}
}
resource privateEndpointSubnet 'Microsoft.Network/virtualNetworks/subnets@2025-07-01' = {
parent: virtualNetwork
name: 'snet-private-endpoint'
properties: {
addressPrefix: privateEndpointSubnetAddressPrefix
privateEndpointNetworkPolicies: 'Disabled'
}
}
resource privateDnsZone 'Microsoft.Network/privateDnsZones@2024-06-01' = {
name: privateDnsZoneName
location: 'global'
tags: tags
}
resource privateDnsZoneVnetLink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = {
parent: privateDnsZone
name: '${baseName}-vnet-link'
location: 'global'
tags: tags
properties: {
registrationEnabled: false
resolutionPolicy: 'Default'
virtualNetwork: {
id: virtualNetwork.id
}
}
}
resource privateEndpoint 'Microsoft.Network/privateEndpoints@2025-07-01' = {
name: '${baseName}-blob-pe'
location: location
tags: tags
properties: {
subnet: {
id: privateEndpointSubnet.id
}
privateLinkServiceConnections: [
{
name: 'blob-connection'
properties: {
privateLinkServiceId: storageAccount.id
groupIds: [
'blob'
]
requestMessage: 'Private connection for Azure Storage Blob.'
}
}
]
}
}
resource privateDnsZoneGroup 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2025-07-01' = {
parent: privateEndpoint
name: 'default'
properties: {
privateDnsZoneConfigs: [
{
name: 'blob'
properties: {
privateDnsZoneId: privateDnsZone.id
}
}
]
}
}
output storageAccountName string = storageAccount.name
output storageAccountId string = storageAccount.id
output privateEndpointName string = privateEndpoint.name
output privateEndpointId string = privateEndpoint.id
output privateEndpointSubnetId string = privateEndpointSubnet.id
output privateDnsZoneName string = privateDnsZone.name
output privateDnsZoneId string = privateDnsZone.id
output privateDnsZoneGroupId string = privateDnsZoneGroup.id
必要な依存関係は次のとおりです。
Storage Account ──────────────┐
├──> Private Endpoint ──> DNS Zone Group
Private Endpoint Subnet ──────┘ ^
|
Private DNS Zone ────────────────────────────────────────────+
|
+──> VNet Link <── Virtual Network
Private EndpointとVNet Linkは互いに依存しないため、並列に作成できます。
Private DNS Zone Groupでは、次の2点が重要です。
parent: privateEndpoint
privateDnsZoneId: privateDnsZone.id
parentで親子関係を表現し、name: '${privateEndpointName}/default'のような文字列連結だけで親を表現しません。
過剰なdependsOnを避ける
❌ 過剰な依存関係
dependsOn: [
storageAccount
virtualNetwork
privateEndpointSubnet
privateDnsZone
privateDnsZoneVnetLink
]
Private Endpointが待つ必要があるのは、接続先PaaSリソースと配置先Subnetです。Private DNS ZoneやVNet Linkまで待たせると、不要に直列化されます。
dependsOnは、DNS反映、Private Endpointの手動承認、データプレーン疎通、別パイプラインの完了を待つ機能ではありません。
モジュール分割時の罠
❌ IDを再計算し、固定モジュール名を使う
以下はBicepとして構文上は有効ですが、Module Outputによる依存関係がなく、固定されたネストデプロイ名も並行実行時の干渉要因になります。
var storageAccountId = resourceId(
'Microsoft.Storage/storageAccounts',
storageAccountName
)
module storageModule './modules/storage.bicep' = {
name: 'storage-deployment'
params: {
storageAccountName: storageAccountName
location: location
}
}
module privateEndpointModule './modules/private-endpoint.bicep' = {
name: 'private-endpoint-deployment'
params: {
targetResourceId: storageAccountId
subnetId: privateEndpointSubnetId
}
}
問題は2つあります。
- Output参照がないため、モジュール間依存が生成されない
- 固定
nameが、同一スコープへの並行デプロイで干渉し得る
モジュールのnameはネストされたデプロイ名です。同じResource Groupへ固定名で同時にデプロイすると、別実行のOutputを参照する可能性があります。
✅ モジュール名を省略し、Outputを渡す
module networkModule './modules/network.bicep' = {
params: {
location: location
prefix: prefix
}
}
module storageModule './modules/storage.bicep' = {
params: {
location: location
storageAccountName: storageAccountName
}
}
module privateEndpointModule './modules/private-endpoint.bicep' = {
params: {
location: location
privateEndpointName: privateEndpointName
targetResourceId: storageModule.outputs.storageAccountId
subnetId: networkModule.outputs.privateEndpointSubnetId
privateDnsZoneId: networkModule.outputs.privateDnsZoneId
}
}
nameを省略すると、ネストされたデプロイ名としてGUIDが生成されます。さらに、次のOutput参照によってモジュール間依存が生成されます。
targetResourceId: storageModule.outputs.storageAccountId
storageModule ─────────┐
├──> privateEndpointModule
networkModule ─────────┘
storage.bicepでは、作成したリソースIDを出力します。
output storageAccountId string = storageAccount.id
明示名が必要な場合は、親デプロイ名を含めます。親デプロイ名自体も実行ごとに一意にします。
⚠️ 明示名が必要な場合だけ使用
module storageModule './modules/storage.bicep' = {
name: '${deployment().name}-storage'
params: {
location: location
storageAccountName: storageAccountName
}
}
Linterで再発を防ぐ
bicepconfig.jsonで、明示的なモジュール名と不要なdependsOnを検出します。
{
"analyzers": {
"core": {
"enabled": true,
"rules": {
"no-module-name": {
"level": "error"
},
"no-unnecessary-dependson": {
"level": "error"
},
"use-parent-property": {
"level": "warning"
},
"use-resource-symbol-reference": {
"level": "warning"
}
}
}
}
}
az bicep lint --file .\main.bicep
Private DNSで見落としやすい制約
DNS Zone Groupの制約
| 制約 | 内容 |
|---|---|
| 1 Private Endpointあたり | 複数のDNS Zone Groupはサポートされない |
| 1 Zone Groupあたり | Private DNS Zoneは最大5個 |
| 同じゾーン名 | 同一Zone Groupへ同名ゾーンの別リソースを複数登録できない |
複数ゾーンを扱うモジュールへ拡張する場合は、配列長とゾーン名重複を検証します。
共有Private DNS Zoneとexisting
Hub側の別SubscriptionにDNS Zoneを集約する場合は、existingで参照できます。
param dnsSubscriptionId string
param dnsResourceGroupName string
resource sharedPrivateDnsZone 'Microsoft.Network/privateDnsZones@2024-06-01' existing = {
scope: resourceGroup(
dnsSubscriptionId,
dnsResourceGroupName
)
name: 'privatelink.blob.core.windows.net'
}
existingは参照先リソースを再作成せず、権限も付与しません。DNS Zone GroupやAレコード更新など、参照先を使用して行う操作に必要なActionは、Bicep実行主体またはAzure PolicyのマネージドIDへ別途付与します。
DNS Zone Groupの管理主体は、次のどちらかに統一します。
方式A:BicepがPrivate EndpointとDNS Zone Groupを管理
方式B:BicepはPrivate Endpointまで、DNS Zone GroupはAzure Policyが管理
NxDomainRedirectの適用条件
VNet LinkのresolutionPolicyには、DefaultまたはNxDomainRedirectを指定できます。
properties: {
registrationEnabled: false
resolutionPolicy: 'NxDomainRedirect'
virtualNetwork: {
id: virtualNetwork.id
}
}
| 項目 | 条件 |
|---|---|
| 対象 | Private Link用Private DNS Zone |
| 設定単位 | Virtual Network Link単位 |
| 対象クエリ | A、AAAA、CNAME |
| API |
2024-06-01以降 |
NxDomainRedirectは、Private DNS ZoneがNXDOMAINを返した場合にパブリック名前解決へフォールバックします。採用時は次を一体で判定します。
NxDomainRedirect
+ Public Network Access
+ PaaSのFirewall
+ パブリック経路を許可する対象
DNSだけをパブリックへフォールバックさせ、PaaS側の公開アクセスを残すと、意図しない公開経路を許す可能性があります。
RBACは「主体・スコープ・Action」で分ける
重要
以下は、Private Endpoint構成で必要になる権限を整理するための説明です。表やコードブロックを、そのままカスタムロールの完全な
Actions一覧としてコピーしないでください。Microsoft公式の権限表は、少なくとも次の二つを別々に示しています。
- Private Endpointをデプロイするための粒度別権限
- Private Endpoint接続を自動承認または手動承認するための権限
実際のロール定義では、Azure Private Link RBAC permissions、対象Resource Providerの権限一覧、実際のARMエラーに表示される不足Actionを基準にします。
1.Private Endpointをデプロイするための基本権限
Microsoft公式がPrivate Endpointのデプロイに必要な粒度別権限として示す基本セットは、次のとおりです。
Microsoft.Resources/deployments/*
Microsoft.Resources/subscriptions/resourceGroups/read
Microsoft.Network/virtualNetworks/read
Microsoft.Network/virtualNetworks/subnets/read
Microsoft.Network/virtualNetworks/subnets/join/action
Microsoft.Network/privateEndpoints/read
Microsoft.Network/privateEndpoints/write
Microsoft.Network/locations/availablePrivateEndpointTypes/read
SubnetのPrivate Endpoint Network Policyも同じデプロイで変更する場合は、次も必要です。
Microsoft.Network/virtualNetworks/subnets/write
この基本セットだけでは、次の操作をすべてカバーするとは限りません。
- Private DNS ZoneとAレコードの管理
- Private DNS ZoneのVNet Link作成
- DNS Zone Groupの作成
- 接続先PaaSリソース側の自動承認または手動承認
- 更新、削除、Diagnostic Settings、Resource Lockなど、本記事の最小構成外の操作
2.Storage Account接続を自動承認する場合の公式権限表
自動承認は、前述の基本セットへnetworkInterfacesと承認Actionの二つだけを足せばよい、という意味ではありません。Microsoft公式の自動承認欄は、Storage Accountを接続先とする場合、次のワイルドカードを含む権限パターンを示しています。
Microsoft.Network/virtualNetworks/**
Microsoft.Network/virtualNetworks/subnets/**
Microsoft.Network/privateEndpoints/**
Microsoft.Network/networkInterfaces/**
Microsoft.Network/locations/availablePrivateEndpointTypes/read
Microsoft.Storage/storageAccounts/PrivateEndpointConnectionsApproval/action
これは「自動承認経路に必要な公式の最小権限パターン」であり、ARM/Bicepデプロイ全体に必要な権限をすべて表す完全なロール定義ではありません。デプロイ操作には、前節のMicrosoft.Resources/deployments/*なども別途考慮します。
ワイルドカードは権限範囲が広いため、次を検討します。
- Subscription全体へ安易に割り当てず、対象Resource Groupや個別リソースにスコープを限定する
- ネットワーク作成主体とPaaS承認主体を分離する
- 最小権限を優先する場合は、Private Endpoint接続を
Pendingで作成し、PaaS所有者が手動承認する - カスタムロールを採用する場合は、実際の作成・更新・削除・再デプロイを検証する
3.Storage Account接続を手動承認する場合
Private Endpoint接続をPendingで作成し、Storage Account所有者が後から承認する経路では、公式表は次の権限を示しています。
Microsoft.Storage/storageAccounts/privateEndpointConnections/read
Microsoft.Storage/storageAccounts/privateEndpointConnections/write
自動承認と手動承認は、運用責任も異なります。
自動承認
PE作成主体がネットワーク作成とPaaS側承認に必要な権限を持つ
手動承認
PE作成主体は接続要求を作成し、PaaS所有者が別操作で承認する
4.主体と付与スコープを分解する
次の表は、誰に、どのスコープで権限を割り当てるかを整理する代表例です。カスタムロールの完全定義ではありません。
| 主体 | 付与スコープ | 代表的なAction/権限群 | 補足 |
|---|---|---|---|
| Bicep実行主体 | デプロイ先Resource Group | Microsoft.Resources/deployments/* |
配下で作成・更新する各Resource ProviderのActionも必要 |
| PE作成主体 | 初回作成時はPrivate Endpoint配置先Resource Group |
Microsoft.Network/privateEndpoints/read、Microsoft.Network/privateEndpoints/write
|
作成前のPEリソース自体には事前割り当てできない |
| PE後続操作主体 | 既存PEリソース、または配置先Resource Group | 必要なread、write、delete
|
作成後はPEリソーススコープまで絞り込める |
| Subnet参加主体 | 対象Subnet、VNet、またはそれらを含むResource Group |
Microsoft.Network/virtualNetworks/read、Microsoft.Network/virtualNetworks/subnets/read、Microsoft.Network/virtualNetworks/subnets/join/action
|
Subnet Policy管理時はsubnets/writeも必要 |
| DNS Zone Group作成主体 | 初回作成時はPrivate Endpoint配置先Resource Group |
Microsoft.Network/privateEndpoints/privateDnsZoneGroups/read、Microsoft.Network/privateEndpoints/privateDnsZoneGroups/write
|
BicepかAzure Policyか、管理主体を一つに決める |
| DNS管理主体 | Private DNS Zone |
Microsoft.Network/privateDnsZones/read、Microsoft.Network/privateDnsZones/A/read、Microsoft.Network/privateDnsZones/A/write、必要に応じてA/delete
|
Private DNS Zone Contributorまたは検証済みカスタムロール |
| VNet Link作成主体 | Private DNS Zone |
Microsoft.Network/privateDnsZones/virtualNetworkLinks/read、write、必要に応じてdelete
|
DNS Zone側の権限 |
| VNet Link作成主体 | リンク先VNet |
Microsoft.Network/virtualNetworks/read、Microsoft.Network/virtualNetworks/join/action
|
VNet側にも権限が必要 |
| Storage自動承認主体 | VNet/Subnet、PE、NIC、Storage Accountを包含する適切な各スコープ | 前節の自動承認用公式権限群 | 「基本セット+2 Action」と単純化しない |
| Storage手動承認者 | Storage Account |
Microsoft.Storage/storageAccounts/privateEndpointConnections/read、write
|
Pending接続をPaaS所有者が承認する |
初回作成時の重要点は、まだ存在しないPrivate Endpoint自体をRBACスコープにできないことです。配置先Resource Groupへ必要権限を付与し、作成後の限定運用だけをPEリソーススコープへ絞り込みます。
また、カスタムロールのAssignableScopesへスコープを書くだけでは権限は付与されません。作成したロールを、実行主体へ適切なスコープでロール割り当てする必要があります。
az role assignment list `
--assignee-object-id '<実行主体のObject ID>' `
--scope '<確認対象のリソースID>' `
--include-inherited `
--output table
アプリ用Resource GroupのContributorだけでは、共有DNS Zone、別チーム管理VNet、別SubscriptionのPaaS承認権限まで満たせません。Bicep実行主体、Azure PolicyのマネージドID、自動承認主体、手動承認者を分けて設計します。
ローカル検証とCI/CDを分離する
ローカル検証:対話操作を許可する
az loginと--confirm-with-what-ifは、人が画面を見ている手動検証向けです。
$SubscriptionId = '<サブスクリプションID>'
$Location = 'japaneast'
$RunId = Get-Date -Format 'yyyyMMddHHmmss'
$ResourceGroupName = "rg-pe-dependency-$RunId"
$DeploymentName = "pe-dependency-$RunId"
$ExpectedAzureCliVersion = '2.88.0'
$ExpectedBicepVersion = '0.45.15'
az login
az account set --subscription $SubscriptionId
$ActualAzureCliVersion = az version --query '"azure-cli"' --output tsv
if ($LASTEXITCODE -ne 0 -or $ActualAzureCliVersion -ne $ExpectedAzureCliVersion) {
throw "Azure CLI version mismatch. expected=$ExpectedAzureCliVersion actual=$ActualAzureCliVersion"
}
az config set bicep.use_binary_from_path=false
if ($LASTEXITCODE -ne 0) {
throw 'Failed to set bicep.use_binary_from_path=false.'
}
az bicep install --version "v$ExpectedBicepVersion"
if ($LASTEXITCODE -ne 0) {
throw 'Failed to install the requested Bicep version.'
}
$BicepVersionText = az bicep version
if ($LASTEXITCODE -ne 0 -or $BicepVersionText -notmatch "Bicep CLI version\s+$([regex]::Escape($ExpectedBicepVersion))(\s|\()") {
throw "Bicep version mismatch. expected=$ExpectedBicepVersion actual=$BicepVersionText"
}
az version
$BicepVersionText
az group create `
--name $ResourceGroupName `
--location $Location
az bicep lint --file .\main.bicep
az bicep build --file .\main.bicep --outfile .\main.json
Select-String -Path .\main.json -Pattern '"dependsOn"' -Context 0,8
az deployment group validate `
--resource-group $ResourceGroupName `
--name "$DeploymentName-validate" `
--template-file .\main.json `
--parameters location=$Location prefix=peblob
az deployment group what-if `
--resource-group $ResourceGroupName `
--name "$DeploymentName-whatif" `
--template-file .\main.json `
--parameters location=$Location prefix=peblob `
--result-format ResourceIdOnly
az deployment group create `
--resource-group $ResourceGroupName `
--name $DeploymentName `
--template-file .\main.json `
--parameters location=$Location prefix=peblob `
--confirm-with-what-if
--confirm-with-what-ifは確認入力を要求するため、無人Pipelineには入れません。
本記事の仕様確認日は2026年7月31日です。再現性のため、次を検証対象版として固定します。読者が実行する時点の最新版を意味しません。
| 項目 | 検証対象版 |
|---|---|
| Azure CLI | 2.88.0 |
| Bicep CLI | 0.45.15 |
各CI/CDジョブでAzure CLIとBicepの版を固定し、表示だけでなく期待値との完全一致を検査します。
Azure DevOps:WIF、Artifact、承認、排他制御
Azure Resource Manager Service ConnectionにはWorkload Identity Federationを使います。AzureCLI@2が認証するため、スクリプト内でaz loginは実行しません。
固定済みAgent
↓
Lint / Build / Validate / What-If
↓
main.json + What-If + SHA256をArtifact化
↓
Environment承認
↓
Exclusive lock
↓
同一main.jsonを非対話Deploy
本例では、What-If用とDeploy用のService Connectionを分けます。
sc-azure-prod-plan-wif
sc-azure-prod-deploy-wif
ただし、Service Connectionを分けただけでは、What-If主体からAzureのデプロイ権限を必ず除去できるとは限りません。ARM What-Ifはデプロイと同等の権限を要求します。ID分離の主な効果は、監査、Pipeline利用制限、条件付きアクセス、資格情報の境界を分けることです。
What-If用IDに本番書き込み権限を持たせない設計を採用する場合は、実際のテンプレートで必要権限を検証し、What-Ifが成功することを確認してください。Pipelineコード変更によるDeployコマンドへの差し替えも考慮し、Branch Protection、Service ConnectionのPipeline認可、Environment Checkを併用します。
Microsoft-hostedのubuntu-latestはAzure CLIの厳密な版固定になりません。本例ではAzure CLI 2.88.0を組み込んだ固定済みセルフホステッドAgentを使用します。
⚠️ 実機検証予定のAzure DevOps例
trigger: none
pr: none
lockBehavior: sequential
variables:
resourceGroupName: 'rg-app-prod'
templateFile: 'infra/main.bicep'
expectedAzureCliVersion: '2.88.0'
expectedBicepVersion: '0.45.15'
stages:
- stage: WhatIf
jobs:
- job: Validate
pool:
name: 'azure-cli-2-88-0'
steps:
- checkout: self
- task: AzureCLI@2
displayName: 'Lint, Build, Validate and What-If'
inputs:
azureSubscription: 'sc-azure-prod-plan-wif'
scriptType: bash
scriptLocation: inlineScript
inlineScript: |
set -euo pipefail
actual_az_version="$(az version --query '"azure-cli"' --output tsv)"
az version --output json
if [[ "$actual_az_version" != "$(expectedAzureCliVersion)" ]]; then
echo "Azure CLI version mismatch: expected=$(expectedAzureCliVersion) actual=$actual_az_version" >&2
exit 1
fi
az config set bicep.use_binary_from_path=false --only-show-errors
az bicep install --version 'v$(expectedBicepVersion)' --only-show-errors
bicep_version_text="$(az bicep version)"
printf '%s\n' "$bicep_version_text"
actual_bicep_version="$(printf '%s\n' "$bicep_version_text" | sed -nE 's/^Bicep CLI version ([0-9]+\.[0-9]+\.[0-9]+).*/\1/p')"
if [[ "$actual_bicep_version" != "$(expectedBicepVersion)" ]]; then
echo "Bicep version mismatch: expected=$(expectedBicepVersion) actual=$actual_bicep_version" >&2
exit 1
fi
out='$(Build.ArtifactStagingDirectory)'
mkdir -p "$out"
az bicep lint --file '$(templateFile)'
az bicep build \
--file '$(templateFile)' \
--outfile "$out/main.json"
az deployment group validate \
--resource-group '$(resourceGroupName)' \
--name 'pe-$(Build.BuildId)-$(System.JobAttempt)-validate' \
--template-file "$out/main.json" \
--parameters location=japaneast prefix=peblob \
--no-prompt true --output none
az deployment group what-if \
--resource-group '$(resourceGroupName)' \
--name 'pe-$(Build.BuildId)-$(System.JobAttempt)-whatif' \
--template-file "$out/main.json" \
--parameters location=japaneast prefix=peblob \
--no-pretty-print --no-prompt true --output json \
> "$out/what-if.json"
(
cd "$out"
sha256sum main.json > main.json.sha256
)
printf '%s\n' '$(Build.SourceVersion)' > "$out/source-version.txt"
env:
AZURE_CORE_OUTPUT: none
- publish: '$(Build.ArtifactStagingDirectory)'
artifact: 'bicep-what-if'
- stage: Deploy
dependsOn: WhatIf
jobs:
- deployment: DeployInfrastructure
pool:
name: 'azure-cli-2-88-0'
environment: 'prod-infra'
strategy:
runOnce:
deploy:
steps:
- download: current
artifact: 'bicep-what-if'
- task: AzureCLI@2
displayName: 'Deploy approved ARM JSON'
inputs:
azureSubscription: 'sc-azure-prod-deploy-wif'
scriptType: bash
scriptLocation: inlineScript
inlineScript: |
set -euo pipefail
actual_az_version="$(az version --query '"azure-cli"' --output tsv)"
az version --output json
if [[ "$actual_az_version" != "$(expectedAzureCliVersion)" ]]; then
echo "Azure CLI version mismatch: expected=$(expectedAzureCliVersion) actual=$actual_az_version" >&2
exit 1
fi
az config set bicep.use_binary_from_path=false --only-show-errors
az bicep install --version 'v$(expectedBicepVersion)' --only-show-errors
bicep_version_text="$(az bicep version)"
printf '%s\n' "$bicep_version_text"
actual_bicep_version="$(printf '%s\n' "$bicep_version_text" | sed -nE 's/^Bicep CLI version ([0-9]+\.[0-9]+\.[0-9]+).*/\1/p')"
if [[ "$actual_bicep_version" != "$(expectedBicepVersion)" ]]; then
echo "Bicep version mismatch: expected=$(expectedBicepVersion) actual=$actual_bicep_version" >&2
exit 1
fi
artifact='$(Pipeline.Workspace)/bicep-what-if'
approved_source_version="$(cat "$artifact/source-version.txt")"
if [[ "$approved_source_version" != "$(Build.SourceVersion)" ]]; then
echo 'Approved artifact does not match Build.SourceVersion.' >&2
exit 1
fi
(
cd "$artifact"
sha256sum --check main.json.sha256
)
az deployment group create \
--resource-group '$(resourceGroupName)' \
--name 'pe-$(Build.BuildId)-$(System.JobAttempt)-deploy' \
--template-file "$artifact/main.json" \
--parameters location=japaneast prefix=peblob \
--no-prompt true --output json
env:
AZURE_CORE_OUTPUT: none
lockBehavior: sequentialだけでなく、Environment側のExclusive lock checkも設定します。Service Connectionの「Grant access permission to all pipelines」は避け、利用Pipelineを限定します。
GitHub Actions:2026年7月15日以降のOIDC Subjectに対応する
Subject形式はリポジトリによって異なる
GitHub.comでは、2026年7月15日より後に作成されたリポジトリの既定OIDC Subjectに、Owner IDとRepository IDを含むimmutable subjectが使われます。
2026年7月15日以前に作成されたリポジトリは、明示的にオプトインしない限り従来形式を維持します。ただし、同日以降のリポジトリ名変更や移管でimmutable形式へ移行する場合があります。GitHub Enterprise Serverではimmutable subjectを利用できません。
Environmentを使う場合の例は次のとおりです。
# 従来形式
repo:ORG/REPO:environment:production
# immutable形式
repo:ORG@OWNER_ID/REPO@REPO_ID:environment:production
OrganizationまたはRepositoryでSubject Claimをカスタマイズしている場合は、上のどちらとも異なる場合があります。
したがって、Microsoft Entra IDのFederated Credentialを作成する前に、実際に発行されるsubを確認します。記事中のSubject文字列をそのまま使用してはいけません。
実際のsubだけを確認する一時ワークフロー
次のワークフローはJWT全体をログへ出さず、必要なClaimだけを表示します。production EnvironmentにRequired reviewersがある場合、そのジョブは承認後に実行されます。
⚠️ Federated Credential作成前の確認用。確認後は削除してください
name: Discover OIDC Subjects
on:
workflow_dispatch:
permissions:
id-token: write
contents: read
jobs:
show-subject:
strategy:
matrix:
target_environment:
- what-if
- production
runs-on: ubuntu-latest
environment: ${{ matrix.target_environment }}
steps:
- name: Print selected OIDC claims
shell: bash
run: |
set -euo pipefail
oidc_url="${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=api%3A%2F%2FAzureADTokenExchange"
jwt="$(
curl --fail --silent --show-error \
-H "Authorization: Bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \
"$oidc_url" \
| jq -r '.value'
)"
JWT="$jwt" python3 - <<'PY'
import base64
import json
import os
token = os.environ['JWT']
payload = token.split('.')[1]
payload += '=' * (-len(payload) % 4)
claims = json.loads(base64.urlsafe_b64decode(payload))
for key in (
'sub',
'aud',
'repository_id',
'repository_owner_id',
'environment',
'job_workflow_ref',
):
print(f'{key}={claims.get(key)}')
PY
unset jwt JWT
表示されたsubを、What-If用とProduction用のFederated Credentialへ正確に登録します。
| 用途 | GitHub Environment | Federated Credential | Client ID |
|---|---|---|---|
| What-If | what-if |
実測したsubを登録 |
AZURE_CLIENT_ID_WHATIF |
| Production Deploy | production |
実測したsubを登録 |
AZURE_CLIENT_ID_PRODUCTION |
Issuerはhttps://token.actions.githubusercontent.com、Audienceは通常api://AzureADTokenExchangeです。本例では別のMicrosoft Entraアプリ登録またはユーザー割り当てマネージドIDを使い、IDとRBACを分離します。
Actionは完全長コミットSHAへ固定する
GitHub公式は、Actionを不変の参照として利用する方法として、完全長コミットSHAへの固定を推奨しています。組織またはRepositoryで、完全長SHAを必須にするポリシーも設定できます。
次の例は、2026年7月31日に確認したリリースコミットへ固定しています。コメントに人が読めるリリース番号を残し、Dependabotなどで更新します。
| Action | リリース | 固定SHA |
|---|---|---|
actions/checkout |
v7.0.1 |
3d3c42e5aac5ba805825da76410c181273ba90b1 |
azure/login |
v3.0.0 |
532459ea530d8321f2fb9bb10d1e0bcf23869a43 |
azure/cli |
v3.0.0 |
9eb25b8360668fb0ecbafa808d40e2197b2f5f52 |
actions/upload-artifact |
v7.0.1 |
043fb46d1a93c77aae656e7c1c64a875d1fc6a0a |
actions/download-artifact |
v8.0.1 |
3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c |
What-IfとDeployで同じARM JSONを使う
同一Azureスコープへの実行はConcurrency Groupで直列化します。GitHub Enterprise Cloudでqueue: maxを使用すると、最大100実行を待機させられます。Concurrency GroupはRepository単位であり、複数Repository間の排他にはなりません。
⚠️ 実機検証予定のGitHub Actions例
name: Deploy Bicep
on:
workflow_dispatch:
permissions:
id-token: write
contents: read
concurrency:
group: azure-${{ vars.AZURE_SUBSCRIPTION_ID }}-${{ vars.AZURE_RESOURCE_GROUP }}
queue: max
env:
RG: ${{ vars.AZURE_RESOURCE_GROUP }}
TEMPLATE: infra/main.bicep
EXPECTED_AZURE_CLI_VERSION: '2.88.0'
EXPECTED_BICEP_VERSION: '0.45.15'
AZURE_CORE_OUTPUT: none
jobs:
what-if:
runs-on: ubuntu-latest
environment: what-if
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Azure login for What-If
uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 # v3.0.0
with:
client-id: ${{ vars.AZURE_CLIENT_ID_WHATIF }}
tenant-id: ${{ vars.AZURE_TENANT_ID }}
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
- name: Lint, Build, Validate and What-If
uses: azure/cli@9eb25b8360668fb0ecbafa808d40e2197b2f5f52 # v3.0.0
with:
azcliversion: 2.88.0
inlineScript: |
set -euo pipefail
actual_az_version="$(az version --query '"azure-cli"' --output tsv)"
az version --output json
if [[ "$actual_az_version" != "$EXPECTED_AZURE_CLI_VERSION" ]]; then
echo "Azure CLI version mismatch: expected=$EXPECTED_AZURE_CLI_VERSION actual=$actual_az_version" >&2
exit 1
fi
az config set bicep.use_binary_from_path=false --only-show-errors
az bicep install --version "v${EXPECTED_BICEP_VERSION}" --only-show-errors
bicep_version_text="$(az bicep version)"
printf '%s\n' "$bicep_version_text"
actual_bicep_version="$(printf '%s\n' "$bicep_version_text" | sed -nE 's/^Bicep CLI version ([0-9]+\.[0-9]+\.[0-9]+).*/\1/p')"
if [[ "$actual_bicep_version" != "$EXPECTED_BICEP_VERSION" ]]; then
echo "Bicep version mismatch: expected=$EXPECTED_BICEP_VERSION actual=$actual_bicep_version" >&2
exit 1
fi
mkdir -p out
az bicep lint --file "$TEMPLATE"
az bicep build --file "$TEMPLATE" --outfile out/main.json
az deployment group validate \
--resource-group "$RG" \
--name 'pe-${{ github.run_id }}-${{ github.run_attempt }}-validate' \
--template-file out/main.json \
--parameters location=japaneast prefix=peblob \
--no-prompt true --output none
az deployment group what-if \
--resource-group "$RG" \
--name 'pe-${{ github.run_id }}-${{ github.run_attempt }}-whatif' \
--template-file out/main.json \
--parameters location=japaneast prefix=peblob \
--no-pretty-print --no-prompt true --output json \
> out/what-if.json
(
cd out
sha256sum main.json > main.json.sha256
)
printf '%s\n' "$GITHUB_SHA" > out/source-sha.txt
- name: Upload approved deployment inputs
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: bicep-what-if
path: out
if-no-files-found: error
retention-days: 7
deploy:
needs: what-if
runs-on: ubuntu-latest
environment: production
steps:
- name: Download approved deployment inputs
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: bicep-what-if
path: out
- name: Verify approved artifact
shell: bash
run: |
set -euo pipefail
approved_sha="$(cat out/source-sha.txt)"
if [[ "$approved_sha" != "$GITHUB_SHA" ]]; then
echo 'Approved artifact SHA does not match GITHUB_SHA.' >&2
exit 1
fi
(
cd out
sha256sum --check main.json.sha256
)
- name: Azure login for Production
uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 # v3.0.0
with:
client-id: ${{ vars.AZURE_CLIENT_ID_PRODUCTION }}
tenant-id: ${{ vars.AZURE_TENANT_ID }}
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
- name: Deploy approved ARM JSON
uses: azure/cli@9eb25b8360668fb0ecbafa808d40e2197b2f5f52 # v3.0.0
with:
azcliversion: 2.88.0
inlineScript: |
set -euo pipefail
actual_az_version="$(az version --query '"azure-cli"' --output tsv)"
az version --output json
if [[ "$actual_az_version" != "$EXPECTED_AZURE_CLI_VERSION" ]]; then
echo "Azure CLI version mismatch: expected=$EXPECTED_AZURE_CLI_VERSION actual=$actual_az_version" >&2
exit 1
fi
az config set bicep.use_binary_from_path=false --only-show-errors
az bicep install --version "v${EXPECTED_BICEP_VERSION}" --only-show-errors
bicep_version_text="$(az bicep version)"
printf '%s\n' "$bicep_version_text"
actual_bicep_version="$(printf '%s\n' "$bicep_version_text" | sed -nE 's/^Bicep CLI version ([0-9]+\.[0-9]+\.[0-9]+).*/\1/p')"
if [[ "$actual_bicep_version" != "$EXPECTED_BICEP_VERSION" ]]; then
echo "Bicep version mismatch: expected=$EXPECTED_BICEP_VERSION actual=$actual_bicep_version" >&2
exit 1
fi
az deployment group create \
--resource-group "$RG" \
--name 'pe-${{ github.run_id }}-${{ github.run_attempt }}-deploy' \
--template-file out/main.json \
--parameters location=japaneast prefix=peblob \
--no-prompt true --output json
What-IfとDeployは別Runnerで実行されますが、両方でAzure CLIとBicepの版を固定・照合します。DeployではWhat-If時に生成した同じmain.jsonを使い、コミットSHAとSHA-256を検証します。
複数Repositoryから同じResource Groupへデプロイする場合は、単一のデプロイ経路、外部ロック、またはAzure側の運用制御を追加します。
サービスプリンシパルのシークレットは例外用途
OIDCまたはWIFを利用できない場合はaz login --service-principalを使用できますが、シークレット保管、期限監視、ローテーションが必要です。ユーザーIDによる無人ログインは避けます。
az login --service-principal `
--username $env:AZURE_CLIENT_ID `
--password $env:AZURE_CLIENT_SECRET `
--tenant $env:AZURE_TENANT_ID
デプロイ後は4層で確認する
Private EndpointのprovisioningStateがSucceededでも、通信成功とは限りません。
| 層 | 確認内容 |
|---|---|
| ARM | リソースのprovisioningState
|
| Private Link | 接続状態がApprovedか |
| DNS | 通常のサービスFQDNがPrivate IPへ解決されるか |
| データプレーン | 認証付きの実通信が成功するか |
$StorageAccountName = az deployment group show `
--resource-group $ResourceGroupName `
--name $DeploymentName `
--query 'properties.outputs.storageAccountName.value' `
--output tsv
$PrivateEndpointName = az deployment group show `
--resource-group $ResourceGroupName `
--name $DeploymentName `
--query 'properties.outputs.privateEndpointName.value' `
--output tsv
$PrivateDnsZoneName = az deployment group show `
--resource-group $ResourceGroupName `
--name $DeploymentName `
--query 'properties.outputs.privateDnsZoneName.value' `
--output tsv
Private Link接続状態
az network private-endpoint show `
--resource-group $ResourceGroupName `
--name $PrivateEndpointName `
--query '{
provisioningState:provisioningState,
connectionState:privateLinkServiceConnections[0].privateLinkServiceConnectionState.status,
nicId:networkInterfaces[0].id
}' `
--output jsonc
期待値はSucceededかつApprovedです。手動承認方式ではPendingになり得ます。
Private IPとAレコード
$NicId = az network private-endpoint show `
--resource-group $ResourceGroupName `
--name $PrivateEndpointName `
--query 'networkInterfaces[0].id' `
--output tsv
az network nic show `
--ids $NicId `
--query 'ipConfigurations[].privateIPAddress' `
--output table
az network private-endpoint dns-zone-group list `
--resource-group $ResourceGroupName `
--endpoint-name $PrivateEndpointName `
--output jsonc
az network private-dns record-set a list `
--resource-group $ResourceGroupName `
--zone-name $PrivateDnsZoneName `
--output table
AレコードとPrivate Endpoint NICのIPが一致することを確認します。
VNet内または接続済みネットワーク内から確認する
次のコマンドは、インターネット上の管理端末やGitHub-hosted Runnerから実行しても、Private Endpoint経由の確認になりません。
実行元は次のいずれかにします。
- 対象Private DNS ZoneとリンクしたVNet内のVM
- 対象VNetとピアリングされ、Private DNSの解決経路を持つVNet内のVM
- VPNまたはExpressRouteとDNSフォワーダーを通じて接続された管理端末
- 対象VNet内、または同等の閉域到達性を持つセルフホステッドRunner
nslookup "$StorageAccountName.blob.core.windows.net"
az storage container list `
--account-name $StorageAccountName `
--auth-mode login `
--output table
接続先には<storage>.blob.core.windows.netを使います。privatelinkサブドメインをアプリ設定へ直接書かず、通常のサービスFQDNをDNSでPrivate Endpointへ誘導します。
az storage container listには、ネットワーク到達性に加えてStorage Blob Data Readerなどのデータプレーンロールが必要です。
空環境の初回デプロイを再試験する
既存リソースは依存漏れを隠します。Resource Group削除では、--no-waitの後に削除完了を待ちます。
az group delete `
--name $ResourceGroupName `
--yes `
--no-wait
az group wait `
--name $ResourceGroupName `
--deleted `
--interval 30 `
--timeout 3600
再試験では同じResource Group名を再利用せず、毎回新しい名前を使います。
$NextRunId = Get-Date -Format 'yyyyMMddHHmmss'
$NextResourceGroupName = "rg-pe-dependency-$NextRunId"
Storage Account名はSubscription IDとResource Group IDから生成されるため、新しいResource Groupで名前再利用の影響を切り離します。
2回目のWhat-If
初回成功後に再度What-Ifし、意図しない差分がないことを確認します。
az deployment group what-if `
--resource-group $ResourceGroupName `
--name "$DeploymentName-second-whatif" `
--template-file .\main.json `
--parameters location=$Location prefix=peblob `
--result-format ResourceIdOnly
毎回差分が出る場合は、ARMの既定値、Azure Policy、APIバージョン、BicepとPolicyの二重管理を確認します。
公開前の15項目実測ゲート
この改稿環境からはAzure Subscription、Azure DevOps、GitHub Environment、Microsoft Entra Federated Credentialへ接続できないため、実測結果を作成していません。現時点の実測達成数は0/15であり、公開判定はNO-GOです。
以下は結果を捏造せずに引き継ぐための証跡台帳です。実測時は、単に成功と書くのではなく、ログ、Artifact、Resource ID、実行日時、コミットSHAなど、第三者が追跡できる証跡を記録します。途中でコードを修正した場合は、修正後の版で15項目を最初から再実施します。
| No. | 試験 | 期待値 | 実測値 | 証跡 | 状態 |
|---|---|---|---|---|---|
| 1 | Azure CLI実行版 |
2.88.0と完全一致 |
未取得 |
az versionログ |
未実施・公開保留 |
| 2 | Bicep CLI実行版 |
0.45.15と完全一致 |
未取得 |
az bicep versionログ |
未実施・公開保留 |
| 3 | Bicep Lint | Error 0件 | 未取得 | Lint標準出力/終了コード | 未実施・公開保留 |
| 4 | Bicep Build | Error 0件 | 未取得 | Buildログ、生成main.json、SHA-256 |
未実施・公開保留 |
| 5 | 生成ARMのdependsOn
|
PE→Storage/Subnet、Zone Group→PE/DNS | 未取得 |
main.jsonまたはDeployment Graph |
未実施・公開保留 |
| 6 | 空の新規RGへの初回デプロイ | Succeeded |
未取得 | Deployment ID、Operationログ | 未実施・公開保留 |
| 7 | Private Endpoint接続 | Approved |
未取得 | PE接続状態のJSON | 未実施・公開保留 |
| 8 | DNS Zone GroupとAレコード | Aレコードが自動生成され、PE NICのPrivate IPと一致 | 未取得 | NIC IP、Zone Group、AレコードJSON | 未実施・公開保留 |
| 9 | 閉域接続元からの名前解決 | 通常FQDNがPEのPrivate IPへ解決 | 未取得 | 実行元を特定できるnslookupログ |
未実施・公開保留 |
| 10 | 閉域接続元からのデータプレーン | Container一覧取得成功 | 未取得 | 実行元、認証主体、CLIログ | 未実施・公開保留 |
| 11 | 2回目What-If | 意図しない変更なし | 未取得 | What-If JSON | 未実施・公開保留 |
| 12 | 所要時間比較 | 標準版と過剰dependsOn版を同一条件で複数回計測 |
未取得 | 各回の秒数、中央値、条件 | 未実施・公開保留 |
| 13 | Azure DevOps E2E | WIF→Build→What-If Artifact→承認→同一JSON Deployが成功 | 未取得 | Run URL、承認履歴、Artifact SHA-256 | 未実施・公開保留 |
| 14 | GitHub Actions E2E | 実際のsub、2 Federated Credentials、2 Environments、同一JSON Deployを確認 |
未取得 | Run URL、sub、Federated Credential設定、Artifact SHA-256 |
未実施・公開保留 |
| 15 | 並行実行制御 | 同一スコープへ2実行し、Exclusive lockまたはqueue: maxで直列化 |
未取得 | 2実行の開始・待機・完了ログ | 未実施・公開保留 |
所要時間は、同じリージョン、同じSKU、同等の新規Resource Group、同じCLI/Bicep版で複数回計測し、中央値を記録します。
$Elapsed = Measure-Command {
az deployment group create `
--resource-group $ResourceGroupName `
--name $DeploymentName `
--template-file .\main.json `
--parameters location=$Location prefix=peblob `
--output none
}
$Elapsed.TotalSeconds
CI/CD試験では、成功画面だけでなく次を証跡として保存します。
- 実際のOIDC
sub - Federated CredentialのIssuer/Subject/Audience
- Azure CLI/Bicep版
- Build済み
main.jsonのSHA-256 - What-If Artifact
- 承認履歴
- Deployが同一Artifactを使った記録
- 2実行時の待機・直列化ログ
障害時の確認表
| 症状 | 主な確認箇所 |
|---|---|
| 初回だけ失敗し再実行で成功 |
.idまたはModule Outputによる依存関係があるか |
ResourceNotFound |
接続先PaaS、Subnetの作成順序 |
ParentResourceNotFound |
子リソースでparentを使っているか |
AuthorizationFailed |
実行主体、Action、スコープ |
LinkedAuthorizationFailed |
別VNet、共有DNS Zone、別Subscriptionの権限 |
接続がPending
|
PaaS所有者による承認 |
| Aレコードがない | DNS Zone Group、ゾーン名、DNS管理主体の権限 |
| パブリックIPへ解決 | VNet Link、カスタムDNS、DNSフォワーダー |
| Private IPへ解決するが403 | データプレーンRBAC、Storage Firewall、認証方式 |
| GitHub OIDCログイン失敗 | 実際のsubとEntra Federated CredentialのSubjectが一致するか |
| 承認した内容とDeployが違う | ArtifactのSource SHA、SHA-256、Deploy入力を確認 |
失敗したDeployment Operationは次で確認します。
az deployment operation group list `
--resource-group $ResourceGroupName `
--name $DeploymentName `
--query "[?properties.provisioningState=='Failed'].{
resource:properties.targetResource.resourceName,
type:properties.targetResource.resourceType,
error:properties.statusMessage.error
}" `
--output jsonc
まとめ
Private Endpoint込みのBicepでは、次を守ります。
- 同一デプロイの参照は
.id、子リソースはparent、モジュール間はoutputsを使う - モジュール
nameは原則省略し、固定名の並行デプロイを避ける - RBACは主体・スコープ・Actionに分解し、代表例の表を完全なカスタムロールと誤認しない
- GitHub OIDCのSubjectは、従来形式とimmutable形式を決め打ちせず、実際の
subを確認する - GitHub Actionは完全長コミットSHAへ固定する
- What-Ifで生成したARM JSONをArtifact化し、承認後のDeployでも同じファイルを使う
- ARM、接続承認、DNS、データプレーン、再What-If、CI/CD排他制御を別々に試験する
「再実行したら成功した」は正常性の証明ではありません。空環境の初回デプロイ、閉域ネットワーク内からの実通信、CI/CDの認証と排他制御まで確認して初めて、公開・本番採用できるコードになります。
参考資料
- Resource dependencies in Bicep
- Resource functions for Bicep
- Use Bicep to deploy resources to resource groups
- Bicep modules
- Linter rule - no module name
- Linter rule - no unnecessary dependsOn entries
- Bicep releases
- Azure CLI release notes
- Bicep What-If: Preview changes before deployment
- Microsoft.Network/privateEndpoints
- Microsoft.Network/privateEndpoints/privateDnsZoneGroups
- Microsoft.Network/privateDnsZones/virtualNetworkLinks
- Azure Private Endpoint DNS Integration Scenarios
- Use private endpoints for Azure Storage
- Azure Private Link RBAC permissions
- Azure permissions for Storage
- Azure permissions for Networking
- Troubleshoot AuthorizationFailed errors for Azure private endpoints
- Private DNS internet fallback
- Use an Azure Resource Manager service connection
- Pipeline deployment approvals and checks
- OpenID Connect reference - GitHub Docs
- Configuring OpenID Connect in cloud providers
- Use the Azure Login action with OpenID Connect
- Configure an app to trust an external identity provider
- Secure use reference - Pin actions to a full-length commit SHA
- Managing GitHub Actions settings for a repository
- Control workflow concurrency
- Azure Login Action releases
- Azure CLI Action releases
- actions/checkout releases
- actions/upload-artifact releases
- actions/download-artifact releases
- Azure CLI issue #32098: Bicep binary selection in CI
- Sign in with Azure CLI
- az deployment group
- az group wait
