🪢

ARM template の `reference` 関数は入れ子にできない

2022/07/17に公開

TL;DR

  • Bicep で 2 つの VPN Gateway を接続したいのに、どうもエラーになるのでどうにかし隊
  • reference 関数が入れ子になるとエラーになるので、別の deployment に分けることで回避した
  • Bicep 一発で VPN 環境が作れるようになったのでうれしい

はじめに

ARM template には reference 関数というのがあって、Resource ID を指定するとそのプロパティがとれるようになります。
今回やりたかったシナリオでは、シンプルに実装すると reference 関数が入れ子になることによりエラーとなりデプロイできないため、それをどうにか回避した、という話です。

やりたかったこと

Bicep を最近また書いています。

検証環境として、VNet を 2 つ用意して、それらにそれぞれ VPN Gateway を用意して、それらを Internet VPN で接続することで片方の VNet をオンプレミスに見立てる、というのをよくやります。
その上で、(1) static route で設定するか、(2) BGP を有効化するか、の選択肢がありますが、今回は (1) でやります。
VPN Gateway #1 の対向を示す Local Network Gateway #1 は VPN Gateway #2 の Public IP アドレスを指定すればよいわけで、結局は VPN Gateway #1 と #2 を指定すればあとはその間を VPN でつないでくれ、という気持ちになります。
それを Bicep の module として一番シンプルに表現するとこうなるわけです。
VPN Gateway #1 と #2 の名前と、あとは一応 psk (preshared key) を与えます。

module connection '../lib/connection-vpngw-static-route.bicep' = {
  name: 'connection-${vpngw01Name}-${vpngw02Name}'
  params: {
    vpnGateway01Name: vpngw01.name
    vpnGateway02Name: vpngw02.name
    psk: psk
  }
}

ただ、これをそのまま実装したところ、内部的 (Bicep から ARM template への暗黙的な transpile の結果として) に reference 関数が入れ子になってしまい、InvalidTemplate エラーが出ました。

どうすれば回避できるか

いろいろ調べてみたところ、この reference 関数の制限は、別の deployment に分ければ回避できそうな雰囲気がわかりました。
やや面倒ですが、Bicep ファイルを 2 つに分割し、さらに入れ子になった Bicep ファイル側で実際の connection リソース (VPN Gateway と Local Network Gateway を関連付けるリソース) の作成を行います。
まずは 1 つめの入れ子の Bicep ファイルですが、Bicep の便利な機能である existing キーワードを使いますが、これを使って vnet02.name などと properties にアクセスする際に、内部的には reference 関数が使われています。

connection-vpngw-static-route.bicep
connection-vpngw-static-route.bicep
param vpnGateway01Name string = 'vpngw01'
param vpnGateway02Name string = 'vpngw02'
param connection01Name string = ''
param connection02Name string = ''
@secure()
param psk string

var _connection01Name = !empty(connection01Name) ? connection01Name : 'conn-${vpnGateway01Name}'
var _connection02Name = !empty(connection02Name) ? connection02Name : 'conn-${vpnGateway02Name}'

resource vnet01 'Microsoft.Network/virtualNetworks@2022-01-01' existing = {
  name: split(vpngw01.properties.ipConfigurations[0].properties.subnet.id, '/')[8]
}

resource pip_vpngw01 'Microsoft.Network/publicIPAddresses@2022-01-01' existing = {
  name: split(vpngw01.properties.ipConfigurations[0].properties.publicIPAddress.id, '/')[8]
}

resource vpngw01 'Microsoft.Network/virtualNetworkGateways@2022-01-01' existing = {
  name: vpnGateway01Name
}

resource vnet02 'Microsoft.Network/virtualNetworks@2022-01-01' existing = {
  name: split(vpngw02.properties.ipConfigurations[0].properties.subnet.id, '/')[8]
}

resource pip_vpngw02 'Microsoft.Network/publicIPAddresses@2022-01-01' existing = {
  name: split(vpngw02.properties.ipConfigurations[0].properties.publicIPAddress.id, '/')[8]
}

resource vpngw02 'Microsoft.Network/virtualNetworkGateways@2022-01-01' existing = {
  name: vpnGateway02Name
}

module connection01 'connection-vpngw-static-route-helper.bicep' = {
  name: _connection01Name
  params: {
    location: vpngw01.location
    connection01Name: _connection01Name
    vnet02Name: vnet02.name
    pipGateway02Name: pip_vpngw02.name
    vpnGateway01Name: vpnGateway01Name
    vpnGateway02Name: vpnGateway02Name
    psk: psk
  }
}

module connection02 'connection-vpngw-static-route-helper.bicep' = {
  name: _connection02Name
  params: {
    location: vpngw02.location
    connection01Name: _connection02Name
    vnet02Name: vnet01.name
    pipGateway02Name: pip_vpngw01.name
    vpnGateway01Name: vpnGateway02Name
    vpnGateway02Name: vpnGateway01Name
    psk: psk
  }
}

ここから呼び出される helper 側は以下のとおりです。

connection-vpngw-static-route-helper.bicep
connection-vpngw-static-route-helper.bicep
param location string = 'eastasia'
param vnet02Name string = 'vnet02'
param pipGateway02Name string = 'pip-vpngw02'
param vpnGateway01Name string = 'vpngw01'
param vpnGateway02Name string = 'vpngw02'
param connection01Name string = 'conn-vpngw01'
@secure()
param psk string

resource vnet02 'Microsoft.Network/virtualNetworks@2022-01-01' existing = {
  name: vnet02Name
}

resource vpngw01 'Microsoft.Network/virtualNetworkGateways@2022-01-01' existing = {
  name: vpnGateway01Name
}

resource pip_vpngw02 'Microsoft.Network/publicIPAddresses@2022-01-01' existing = {
  name: pipGateway02Name
}

resource lng_vpngw02 'Microsoft.Network/localNetworkGateways@2022-01-01' = {
  name: 'lng-${vpnGateway02Name}'
  location: location
  properties: {
    gatewayIpAddress: pip_vpngw02.properties.ipAddress
    localNetworkAddressSpace: {
      addressPrefixes: vnet02.properties.addressSpace.addressPrefixes
    }
  }
}

resource connection01 'Microsoft.Network/connections@2022-01-01' = {
    name: connection01Name
    location: location
    properties: {
      connectionType: 'IPsec'
      virtualNetworkGateway1: {
        id: vpngw01.id
      }
      connectionProtocol: 'IKEv2'
      localNetworkGateway2: {
        id: lng_vpngw02.id
      }
      sharedKey: psk
    }
  }

まとめ

なんとなくまぁ reference したものの reference という意味で 2 回評価しなきゃいけないのは仕様上の制限としてわからなくはないです。
最初は 1 つ目の入れ子の module のみで解決しようと思いましたが、そうすると main.bicep からの呼び出しの際にすら VNet の名前やら Public IP の名前やらを与えなければならず、だいぶ面倒になってしまいました。
ベッドでうんうん考えていたらこの方法を思いつき、入れ子になってしまったため deployment の履歴としてはやや複雑にはなりますが、main.bicep からの呼び出しの際の引数を最小限に抑えることができたため、満足度は非常に高いです。
プログラミングの方法論でよく言われる、インターフェースを単純化して水面下で頑張るか、そうじゃないか、みたいな話かとは思います。


詳細な解説 (Public IP 編)

ここから詳細な説明に入っていきますが、私自身 2 週間後に読んで理解できる自信がないほど、やや複雑です。

まずは VPN Gateway 側の JSON を示します。
少しだけ要素は消しましたが、大体こんな感じです。

{
    "name": "vpngw01",
    "id": "/subscriptions/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/resourceGroups/s2svpn/providers/Microsoft.Network/virtualNetworkGateways/vpngw01",
    "type": "Microsoft.Network/virtualNetworkGateways",
    "location": "eastasia",
    "properties": {
        "ipConfigurations": [
            {
                "name": "ipconfig1",
                "id": "/subscriptions/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/resourceGroups/s2svpn/providers/Microsoft.Network/virtualNetworkGateways/vpngw01/ipConfigurations/ipconfig1",
                "type": "Microsoft.Network/virtualNetworkGateways/ipConfigurations",
                "properties": {
                    "publicIPAddress": {
                        "id": "/subscriptions/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/resourceGroups/s2svpn/providers/Microsoft.Network/publicIPAddresses/pip-vpngw01"
                    },
                    "subnet": {
                        "id": "/subscriptions/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/resourceGroups/s2svpn/providers/Microsoft.Network/virtualNetworks/vnet-ea01/subnets/GatewaySubnet"
                    }
                }
            }
        ],

「VPN Gateway #1 の対向を示す Local Network Gateway #1 は VPN Gateway #2 の Public IP アドレスを指定すればよい」と書きましたが、VPN Gateway #2 のリソースにおいて、properties.ipConfigurations[0].properties.publicIPAddress.id を見れば、紐づいている Public IP アドレスリソースがわかります。

次に Public IP アドレスのリソースの JSON を示します。

{
    "name": "pip-vpngw02",
    "id": "/subscriptions/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/resourceGroups/s2svpn/providers/Microsoft.Network/publicIPAddresses/pip-vpngw02",
    "location": "southeastasia",
    "properties": {
        "ipAddress": "x.x.x.x",

ということで、この Public IP アドレスリソースの properties.ipAddress を見ればいいわけです。

ただ、これを ARM template で実装すると、

"[reference(resourceId('Microsoft.Network/publicIPAddresses', split(reference(resourceId('Microsoft.Network/virtualNetworkGateways', parameters('vpnGateway02Name')), '2022-01-01').ipConfigurations[0].properties.publicIPAddress.id, '/')[8]), '2022-01-01').ipAddress]"

という感じになり、reference 関数が 2 回登場してしまっています。

順を追って説明すると、以下のとおりです。

  • VPN Gateway のリソース名が、module の呼び出し元側から parameters('pipGateway02Name') として渡されます
  • resourceId('Microsoft.Network/virtualNetworkGateways', parameters('vpnGateway02Name')) で VPN Gateway #2 の Resource ID (/subscriptions/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/resourceGroups/s2svpn/providers/Microsoft.Network/virtualNetworkGateways/vpngw01) を取得 (これを _1_ とします)
  • reference(_1_, '2022-01-01') でそのリソースを参照 (1 回目の reference 関数)
  • reference(_1_, '2022-01-01').ipConfigurations[0].properties.publicIPAddress.id で Public IP の Resouce ID (/subscriptions/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/resourceGroups/s2svpn/providers/Microsoft.Network/publicIPAddresses/pip-vpngw01) がとれる (これを _2_ とします)
  • split(_2_, '/')[8] で Public IP のリソース名 (pip-vpngw01) を取得 (これを _3_ とします)
  • resourceId('Microsoft.Network/publicIPAddresses', _3_) で Public IP の Resource ID がとれる (これを _4_ とします。これは Bicep から変換した都合上であり、省略は可能です)
  • reference(_4_, '2022-01-01') で Public IP のリソースを参照 (2 回目の reference 関数、ここがエラーになるっぽい、たぶん)
  • reference(_4_, '2022-01-01').ipAddress でその Public IP の実際の IP アドレスがとれる (はずだった)

deployment を分ければいい、の方針のもと、Bicep ファイルも 2 つに分けていきます。
その結果、上記の Bicep ファイルのように helper としての Bicep ファイルを用意して、reference 関数が 1 つの deployment 上では入れ子にならないようにしています。
実際、deploy されている ARM template 側を見るとうまく回避できています。

{
    "parameters": {
        "location01": {
            "defaultValue": "eastasia",
            "type": "String"
        },
        "location02": {
            "defaultValue": "southeastasia",
            "type": "String"
        },
        "psk": {
            "type": "SecureString"
        }
    },
    "resources": [
        {
            "type": "Microsoft.Resources/deployments",
            "name": "[format('connection-{0}-{1}', variables('vpngw01Name'), variables('vpngw02Name'))]",
            "properties": {
                "parameters": {
                    "vpnGateway01Name": {
                        "value": "[variables('vpngw01Name')]"
                    },
                    "vpnGateway02Name": {
                        "value": "[variables('vpngw02Name')]"
                    },
                    "psk": {
                        "value": "[parameters('psk')]"
                    }
                },
                "template": {
                    "resources": [
                        {
                            "type": "Microsoft.Resources/deployments",
                            "name": "[variables('_connection01Name')]",
                            "properties": {
                                "parameters": {
                                    "vnet02Name": {
                                        "value": "[split(reference(resourceId('Microsoft.Network/virtualNetworkGateways', parameters('vpnGateway02Name')), '2022-01-01').ipConfigurations[0].properties.subnet.id, '/')[8]]"
                                    },
                                    "pipGateway02Name": {
                                        "value": "[split(reference(resourceId('Microsoft.Network/virtualNetworkGateways', parameters('vpnGateway02Name')), '2022-01-01').ipConfigurations[0].properties.publicIPAddress.id, '/')[8]]"
                                    },
                                    "vpnGateway02Name": {
                                        "value": "[parameters('vpnGateway02Name')]"
                                    }
                                },
                                "template": {
                                    "resources": [
                                        {
                                            "type": "Microsoft.Network/localNetworkGateways",
                                            "name": "[format('lng-{0}', parameters('vpnGateway02Name'))]",
                                            "properties": {
                                                "gatewayIpAddress": "[reference(resourceId('Microsoft.Network/publicIPAddresses', parameters('pipGateway02Name')), '2022-01-01').ipAddress]",
                                                "localNetworkAddressSpace": {
                                                    "addressPrefixes": "[reference(resourceId('Microsoft.Network/virtualNetworks', parameters('vnet02Name')), '2022-01-01').addressSpace.addressPrefixes]"
                                                }
                                            }
                                        }
                                    ]
                                }
                            }
                        },

Microsoft.Resources/deployments が入れ子になっていますが、2 つめの入れ子に入る際に、parameter として [split(reference(resourceId('Microsoft.Network/virtualNetworkGateways', parameters('vpnGateway02Name')), '2022-01-01').ipConfigurations[0].properties.publicIPAddress.id, '/')[8]] が入っています。
2 つめの入れ子側で [reference(resourceId('Microsoft.Network/publicIPAddresses', parameters('pipGateway02Name')), '2022-01-01').ipAddress] として reference 関数に渡されており、parameters('pipGateway02Name') 自体が呼び出し元側で reference 関数が利用されているため reference 関数の入れ子になってしまうはずですが、deployment が分かれているせいか回避できています。

詳細な解説 (VNet 編)

同様の動作を VNet に対しても行っていきます。

VPN Gateway の対向を示す Local Network Gateway について、BGP を使わない場合には properties.localNetworkAddressSpace.addressPrefixes を指定しないと VPN 接続が開始されません。
properties.localNetworkAddressSpace.addressPrefixes は VPN 接続における static route の意味であり、対向側にどのようなネットワークアドレスがあるかを明示的に指定するものです。
これも Public IP 同様、自動的に設定するために以下の内容を考えていきます。

  • VPN Gateway #1 の対向は VPN Gateway #2
  • VPN Gateway #2 は VNet #2 の GatewaySubnet に所属している
  • 所属している subnet の情報から VNet #2 は参照可能
  • 参照した VNet の properties.addressSpace.addressPrefixes にその IP アドレス空間が記載されているため、これを Local Network Gateway の properties.localNetworkAddressSpace.addressPrefixes とすればよい

実装した結果は上の Bicep ファイルにそのまま記載されていますが、reference 関数が 2 回必要になるのは Public IP アドレスの時と同様のため、同じ入れ子構造の中でこの問題が解決できています。


Update log

  • 全体的に # (見出し1) から ## (見出し2) に変更 - 2024/07/14
  • TL;DR とかの追加 - 2024/07/14
  • tag の修正 - 2024/07/14
Microsoft (有志)

Discussion