1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

PR: アディクシィ株式会社
ADiXiのコーポレートサイト

BicepでStorage BlobのPrivate Endpoint構成を作る:依存関係と並行デプロイの罠

1
Last updated at Posted at 2026-08-04

はじめに

BicepでPrivate Endpointを含む構成を作ると、次の障害が起きがちです。

  • 空のResource Groupへの初回デプロイだけResourceNotFoundで失敗する
  • 同じデプロイを再実行すると成功する
  • Private EndpointはSucceededなのに名前解決できない
  • Private DNS Zone Groupだけが権限エラーになる
  • モジュール分割後、実行タイミングによって結果が変わる
  • 同じスコープへの並行デプロイで、ネストされたデプロイが干渉する

本質は、Bicepファイルの記述順ではありません。Azure Resource Managerが評価する依存関係グラフです。

本記事では、Storage AccountのBlobサービスだけをPrivate Endpoint化する最小構成を使い、次を解説します。

  1. resourceId()とシンボリック参照の違い
  2. parentによる子リソースの依存関係
  3. モジュールOutputと固定モジュール名の罠
  4. Private DNSとRBACの責任分界
  5. Azure DevOps/GitHub Actionsでの安全なデプロイ設計
  6. 公開前に必要な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つあります。

  1. Output参照がないため、モジュール間依存が生成されない
  2. 固定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公式の権限表は、少なくとも次の二つを別々に示しています。

  1. Private Endpointをデプロイするための粒度別権限
  2. 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の認証と排他制御まで確認して初めて、公開・本番採用できるコードになります。

参考資料

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?