Hub-spoke 構成で Spoke 間はなぜつながらないのか
TL;DR
- Hub-spoke 構成で Spoke 間の通信ができないのの理由をよく聞かれるので説明しておき隊
- 端的に言えば、Spoke にもう片方の Spoke の IP アドレス帯向けのルーティングがないから
- 何かしらのフォワーディングしてくれるインスタンスを Hub に置いて、それに向かって UDR を書けば通信可能になる
はじめに
以下のような、よくある Hub-spoke 構成を考えてみます。
typical hub and spoke architecture
Hub の Azure VM と Spoke の Azure VM は普通に通信が可能ですが、2 つの Spoke にある Azure VM 同士はそのままでは通信ができません。
なんでか、というとルーティングがないから、に尽きるんですが、紹介しておきます。
ネットワーク インターフェース の 有効なルート を見る
effective routes for NIC
これは Spoke #1 に置いた仮想マシンの Network Interface から 有効なルート と進んだ画面です。
10.0.0.0/16
が Hub で、10.10.0.0/16
は所属している VNet 自身です。
ですので、もちろんこれらの IP アドレス帯に対しては通信可能なのですが、Spoke #2 にあたる 10.20.0.0/16
がありません。
前提として、Hub - Spoke の間の VNet Peering では「リモート仮想ネットワークのゲートウェイまたはルート サーバーを使用する」は選択された状態です。
そのため Hub には VPN Gateway がおいてあります。
「リモートゲートウェイ」という用語からするとゲートウェイが Hub に向いていれば折り返しの通信ができそうなもんですが、そういう動きにはなっていません。
Spoke 間の通信を実現するためには
Spoke 間の通信を実現するためにはいくつかの方法がありますが、なんらかパケットをフォワーディングしてくれるインスタンスを Hub に置き、それに向かって User Defined Route (UDR、ユーザ定義ルート) を書く方法が定番です。
このフォワーディングしてくれる何かの例は以下のとおりです。
- Azure Firewall
- Hub-spoke アーキテクチャの紹介ではよく Hub に Azure Firewall がおかれているのでこれに向けて UDR を書く
- Azure Firewall で Spoke 間の通信も制御できるのでよい
- NVA
- Cisco や Juniper、Fortinet などの NVA を配置して、上記同様に UDR を書く
- Azure Firewall より機能的に優れている面も多いが、どう冗長化を実現するかでベンダごとに少し苦労する
あまりおススメはできないが、一応ということで以下の選択肢も書いておきます。
- Azure VM
- あまりおススメできるものではないかもしれないですが、なんでもない Azure VM を PC ルータとして設定する
- 原理上何でもできるかもしれないですが、まぁ運用負荷も高いのであまり見ない
- VNet Gateway
- サポートされなかったかもしれないので書くのが少しはばかられるのですが、一応 VNet Gateway を next-hop とする UDR を書けばうごくっちゃあ動きます
- あまりおススメはしたくない
設定する UDR の経路としては、Spoke #1 から見た Spoke #2 を向けるのが最低限の設定例ですが、0.0.0.0/0
を向けてしまうのも構成によっては可能です。
そのほかの実現例としては、VNet Peering ではなく、VPN Gateway をそれぞれの VNet におき、VPN + BGP で接続する、という方法ですが、VNet 間の上限帯域幅が VPN Gateway の SKU に依存するため、あまりおススメはしていないです。
また、UDR だと Spoke #1 からみた Spoke #2 の IP アドレス空間が増えたときに UDR を修正する必要があるのでいやだなぁ、という時には、Azure Route Server (ARS) を使うこともできます。
ARS を使う場合には、1) ARS 自体を Spoke のそれぞれにデプロイすること、2) Hub に BGP をしゃべれるインスタンスを用意し、各 Spoke へのルーティングを BGP で流し込む、ということになり、これはこれでややハードルが高くなります。
技術的なハードルのほか、ARS のコストがやや高いこと、また ARS 自体がやや新しいこともあり構成としてあまり見たことはないです。
コストは 価格 - Route Server から見られますが、2022年07月 時点だと 61.320円/時間 (44763.6円/月) となっています。
ちなみに ARS は上記でいうところの なんらかパケットをフォワーディングしてくれるインスタンス にはなってくれません。[1]
まとめ
なんでできないんだっけ、とよく聞かれる Hub-spoke 構成における Spoke 間で通信ができない件について説明してみました。
原因と対策を書いてあるので、もし設計上どうしよう、と悩んだ場合に参考になれば幸いです。
Update log
- 全体的に
# (見出し1)
から## (見出し2)
に変更 - 2024/07/14 - TL;DR とかの追加 - 2024/07/14
- 画像の内部的なファイル パスを変更 - 2024/07/14
久々にちゃんと Bicep で検証環境を作りました。(どちらかというとこちらがメインの目的だったりする)
ここに記録がてら張っておきます。
このままだと UDR がないので Spoke 間は通信ができないねぇ、という状況までです。
これがメインの Bicep ファイルです。
もうちょっと外に出したい気もしますが今のところこれくらいで。
lib
にいくつかの Bicep ファイルがおいてあってそれも使ってます。
subnet
を後で参照するため existing
キーワードを使って中間オブジェクトみたいなものを使ってますが、正確に言うと existing
なものではなくて順序的に VNet のほうが先に作られるので一応動く、程度のあまりいい方法ではないと思っていて、そのうち Bicep 側の新機能が出てきていい感じにできるはずです。[2]
もしこれらのファイルをそのまま使うのであれば、adminUsername
を xxxxxxxxxxxx
から適当に変更し、実行時に adminPassword
を与えてあげてください。
実行方法は az deployment group create -g <resource group name> -f main.bicep
です。
main.bicep
param location string = 'eastasia'
resource vnet_hub 'Microsoft.Network/virtualNetworks@2022-01-01' = {
name: 'vnet-hub01'
location: location
properties: {
addressSpace: {
addressPrefixes: [
'10.0.0.0/16'
]
}
subnets: [
{
name: 'default'
properties: {
addressPrefix: '10.0.0.0/24'
}
}
{
name: 'AzureBastionSubnet'
properties: {
addressPrefix: '10.0.100.0/24'
}
}
{
name: 'GatewaySubnet'
properties: {
addressPrefix: '10.0.200.0/24'
}
}
]
}
}
resource defaultsubnet_hub01 'Microsoft.Network/virtualNetworks/subnets@2022-01-01' existing = {
parent: vnet_hub
name: 'default'
}
resource bastionsubnet_hub01 'Microsoft.Network/virtualNetworks/subnets@2022-01-01' existing = {
parent: vnet_hub
name: 'AzureBastionSubnet'
}
resource gatewaysubnet_hub01 'Microsoft.Network/virtualNetworks/subnets@2022-01-01' existing = {
parent: vnet_hub
name: 'GatewaySubnet'
}
resource vnet_spoke01 'Microsoft.Network/virtualNetworks@2022-01-01' = {
name: 'vnet-spoke01'
location: location
properties: {
addressSpace: {
addressPrefixes: [
'10.10.0.0/16'
]
}
subnets: [
{
name: 'default'
properties: {
addressPrefix: '10.10.0.0/24'
}
}
]
}
}
resource defaultsubnet_spoke01 'Microsoft.Network/virtualNetworks/subnets@2022-01-01' existing = {
parent: vnet_spoke01
name: 'default'
}
resource vnet_spoke02 'Microsoft.Network/virtualNetworks@2022-01-01' = {
name: 'vnet-spoke02'
location: location
properties: {
addressSpace: {
addressPrefixes: [
'10.20.0.0/16'
]
}
subnets: [
{
name: 'default'
properties: {
addressPrefix: '10.20.0.0/24'
}
}
]
}
}
resource defaultsubnet_spoke02 'Microsoft.Network/virtualNetworks/subnets@2022-01-01' existing = {
parent: vnet_spoke02
name: 'default'
}
module peering_hub_spoke01 '../lib/vnet-peering.bicep' = {
name: 'peering-hub-spoke01'
params: {
vnet01Name: vnet_hub.name
vnet02Name: vnet_spoke01.name
useRemoteGateways: true
remoteGatewayName: vpngw01.name
}
}
module peering_hub_spoke02 '../lib/vnet-peering.bicep' = {
name: 'peering-hub-spoke02'
params: {
vnet01Name: vnet_hub.name
vnet02Name: vnet_spoke02.name
useRemoteGateways: true
remoteGatewayName: vpngw01.name
}
}
module vpngw01 '../lib/vpngw_act-act.bicep' = {
name: 'vpngw01'
params: {
location: location
gatewaySubnetId: gatewaysubnet_hub01.id
}
}
module bast01 '../lib/bastion.bicep' = {
name: 'bast01'
params: {
location: location
bastionSubnetId: bastionsubnet_hub01.id
}
}
param hubVmName string = 'vm-hub01'
@secure()
param adminPassword string
module vm_hub '../lib/ws2019.bicep' = {
name: hubVmName
params: {
location: location
subnetId: defaultsubnet_hub01.id
vmName: hubVmName
adminPassword: adminPassword
}
}
param spoke01VmName string = 'vm-spoke01'
module vm_spoke01 '../lib/ws2019.bicep' = {
name: spoke01VmName
params: {
location: location
subnetId: defaultsubnet_spoke01.id
vmName: spoke01VmName
adminPassword: adminPassword
}
}
param spoke02VmName string = 'vm-spoke02'
module vm_spoke02 '../lib/ws2019.bicep' = {
name: spoke02VmName
params: {
location: location
subnetId: defaultsubnet_spoke02.id
vmName: spoke02VmName
adminPassword: adminPassword
}
}
ここからが lib
フォルダ配下にある Bicep ファイルたちです。
典型的なデプロイは適当にまとめてあります。
VNet Peering、なんとなく Act-Act の VPN Gateway、Bastion、smalldisk な Windows Server 2019 です。
なるべくデフォルト値を採用し、最小記述量にとどめた予定です。
vnet-peering.bicep
param vnet01Name string
param vnet02Name string
param useRemoteGateways bool = false
param remoteGatewayName string = ''
resource vnet01 'Microsoft.Network/virtualNetworks@2022-01-01' existing = {
name: vnet01Name
}
resource vnet02 'Microsoft.Network/virtualNetworks@2022-01-01' existing = {
name: vnet02Name
}
resource gw 'Microsoft.Network/virtualNetworkGateways@2022-01-01' existing = {
name: remoteGatewayName
}
var vnet01Suffix = replace(vnet01Name, 'vnet-', '')
var vnet02Suffix = replace(vnet02Name, 'vnet-', '')
resource peering_hub_spoke01 'Microsoft.Network/virtualNetworks/virtualNetworkPeerings@2022-01-01' = {
parent: vnet01
name: '${vnet01Suffix}-to-${vnet02Suffix}'
properties: {
allowForwardedTraffic: true
allowGatewayTransit: true
allowVirtualNetworkAccess: true
remoteVirtualNetwork: { id: vnet02.id }
}
}
resource peering_spoke01_hub 'Microsoft.Network/virtualNetworks/virtualNetworkPeerings@2022-01-01' = {
parent: vnet02
name: '${vnet02Suffix}-to-${vnet01Suffix}'
properties: {
allowForwardedTraffic: true
allowVirtualNetworkAccess: true
remoteVirtualNetwork: { id: vnet01.id }
useRemoteGateways: useRemoteGateways
}
dependsOn: useRemoteGateways ? [ gw ] : []
}
vpngw_act-act.bicep
param location string = 'eastasia'
param gatewaySubnetId string
resource pip01 'Microsoft.Network/publicIPAddresses@2021-08-01' = {
name: 'pip01-vpngw01'
location: location
sku: {
name: 'Standard'
tier: 'Regional'
}
zones: [
'1'
'2'
'3'
]
properties: {
publicIPAllocationMethod: 'Static'
}
}
resource pip02 'Microsoft.Network/publicIPAddresses@2021-08-01' = {
name: 'pip02-vpngw01'
location: location
sku: {
name: 'Standard'
tier: 'Regional'
}
zones: [
'1'
'2'
'3'
]
properties: {
publicIPAllocationMethod: 'Static'
}
}
resource vpngw01 'Microsoft.Network/virtualNetworkGateways@2021-08-01' = {
name: 'vpngw01'
location: location
properties: {
sku: {
name: 'VpnGw1AZ'
tier: 'VpnGw1AZ'
}
ipConfigurations: [
{
name: 'ipconfig1'
properties: {
publicIPAddress: { id: pip01.id }
subnet: { id: gatewaySubnetId }
}
}
{
name: 'ipconfig2'
properties: {
publicIPAddress: { id: pip02.id }
subnet: { id: gatewaySubnetId }
}
}
]
gatewayType: 'Vpn'
vpnType: 'RouteBased'
activeActive: true
}
}
bastion.bicep
param location string = 'eastasia'
param bastionSubnetId string
resource pip 'Microsoft.Network/publicIPAddresses@2022-01-01' = {
name: 'pip-bast01'
location: location
sku: {
name: 'Standard'
}
properties: {
publicIPAllocationMethod: 'Static'
}
}
resource bast01 'Microsoft.Network/bastionHosts@2022-01-01' = {
name: 'bast01'
location: location
sku: {
name: 'Standard'
}
properties: {
scaleUnits: 2
ipConfigurations: [
{
name: 'ipconfig1'
properties: {
subnet: { id: bastionSubnetId }
publicIPAddress: { id: pip.id }
}
}
]
}
}
ws2019.bicep
param location string = 'eastasia'
param subnetId string
param vmNameSuffix string
param adminUsername string = 'xxxxxxxxxxxx'
@secure()
param adminPassword string
resource nic 'Microsoft.Network/networkInterfaces@2022-01-01' = {
name: 'nic-${vmNameSuffix}'
location: location
properties: {
ipConfigurations: [
{
name: 'ipconfig1'
properties: {
subnet: {
id: subnetId
}
privateIPAllocationMethod: 'Dynamic'
}
}
]
}
}
resource vm 'Microsoft.Compute/virtualMachines@2022-03-01' = {
name: 'vm-${vmNameSuffix}'
location: location
properties: {
hardwareProfile: {
vmSize: 'Standard_B2ms'
}
storageProfile: {
osDisk: {
createOption: 'FromImage'
managedDisk: {
storageAccountType: 'StandardSSD_LRS'
}
deleteOption: 'Delete'
}
imageReference: {
publisher: 'MicrosoftWindowsServer'
offer: 'WindowsServer'
sku: '2019-datacenter-smalldisk-g2'
version: 'latest'
}
}
networkProfile: {
networkInterfaces: [
{
id: nic.id
properties: {
deleteOption: 'Delete'
}
}
]
}
osProfile: {
computerName: vmNameSuffix
adminUsername: adminUsername
adminPassword: adminPassword
}
diagnosticsProfile: {
bootDiagnostics: {
enabled: true
}
}
}
}
resource shutdown 'Microsoft.DevTestLab/schedules@2018-09-15' = {
name: 'shutdown-computevm-vm-${vmNameSuffix}'
location: location
properties: {
status: 'Enabled'
taskType: 'ComputeVmShutdownTask'
dailyRecurrence: {
time: '00:00'
}
timeZoneId: 'Tokyo Standard Time'
targetResourceId: vm.id
notificationSettings: {
status: 'Disabled'
}
}
}
Discussion