はじめに
私は普段AWSだけでなく、Azureについてもアカウントを保有しており、過去に色々環境作ったり、Terraformでコード書いたりした際の記事を投稿してきました。
先日、AWSの最新を追求かつベストプラクティスを意識したTerraformコード生成スキルをIBM Bobで作成してみた際のスキル作成手順及び使用感を纏めた記事を投稿しましたが、同じ考え方でAzureのTerraformコードも書けるといいなと考え、横展開的にAzure版のTerraformコード生成スキルをIBM Bobで作成した際の手順、及び、使用感を纏めました。
MCPサーバーの登録
今回のスキル作成においては、TerraformのMCPサーバーと共に、AzureのMCPサーバーを活用することにしました。
Azure MCPサーバー
AWSと同じように、Azureのドキュメントにアクセスして、各々のAzureサービスに関する情報を取得したり、Azureのベストプラクティスやサービスガイドを提供してくれるMCPサーバーです。また、自然言語を使用して、Azureのリソースと対話することが可能です。
こちらもAWS同様、アクティブなサブスクリプションをもつAzureアカウントが必要になります。
VS Codeから使用する場合は、GitHub Copilotが使えることが前提のようですが、IBM Bobから使用する分には、IBM BobのMCP.jsonに登録すれば稼働できました。
Terraform MCPサーバー
現行のTerraformプロバイダーが提供しているTerraformレジストリー(今回はAzure用)から、現行のドキュメント・モジュール・ポリシーをリアルタイムでアクセスし、最新情報をAIに提供してくれるMCPサーバーです。
両者のMCPサーバーを、IBM Bobのmcp.jsonファイルに下記のように登録します。
{
"mcpServers": {
"Azure MCP Server": {
"command": "npx",
"args": [
"-y",
"@azure/mcp@latest",
"server",
"start"
]
},
"terraform": {
"command": "C:/Users/userid/terraform-mcp-server.exe",
"transport": "stdio"
}
}
}
AzureのMCPサーバー登録の際は、「npx」コマンドが必要となるので、事前にNode.jsをインストールしておいてください。npx単体のインストールは不要であり、Node.jsをインストールすれば自動的にインストールされます。
時折バージョンが高いNode.jsを使うと、Node.jsのバグで、npxコマンドが必要なMCPサーバーの接続ができなくなることがあります。筆者の環境では、安定板のNode.js V22.23.2で問題なく動作している為、こちらのバージョンをご利用いただくのがお奨めです。
その後、IBM BobのMCP設定パネルで、AWSのMCPサーバー(Azure MCP Server)とTerraformのMCPサーバー(terraform)のステータスが「接続済み」になっていることを確認してください。
IBM Bobを活用したスキル作成
基本的に前回のAWSのTerraformコード生成スキル作成とやり方は同じです。
スキルはグローバル設定とし、スキル名は「terraform-azure」としました。ファイル構造は下記の通りです。
C:.bob
└─skills
├─terraform-azure
│ SKILL.md
ある程度AIに実施させたい作業方針を自分なりに整理した上で、今回はIBM Bobに下記のようなプロンプトを入力して、スキルを作成してもらいました。
TerraformでAzureの環境プロビジョニングを行う際、Azureのベストプラクティスに則ったコーディングができるように、以下のスキルを作成してください。
・スキル名:terraform-azure
・既存の「terraform」MCPサーバーにアクセスし、TerraformのAzureプロバイダーの最新バージョンのモジュールを確認して、コーディングを行う。
・既存の「Azure-MCP-Server」MCPサーバーにアクセスし、Azureのベストプラクティスに則ったコーディングを意識する。
・パーツ化を意識し、コードの構成はmodule構造とする。
・可読性を意識し、適宜コメント文を記述する。
・Azureの他のIaCサービスであるBicepやARM(Azure Resource Manager)とTerraformを比較して機能の差異がある場合には、それを考慮したコーディングにする。
一番最後の要件を追加したのは、AWS同様にAzureにも純正のBicepやARM(Azure Resource Manager)というIaCを提供するサービスがあります。こちらについても、純正サービスであればネイティブサポートされている依存関係やドリフト検出等の機能が、Terraformでは明示指定しないと上手く動作しなかったりするので、そういった処理への対応を追加しました。
スキルの稼働確認、生成されたTerraformコードの稼働確認完了後の最終的なSKILL.mdファイルは下記のGitHubリポジトリに公開していますので、ご参照ください。
スキルの稼働検証
IBM Bobのチャットウィンドウ上で「/」コマンドを入力し、「terraform-azure」スキルを選択して、今回は下記のようなAzure構成をプロビジョニングするTerraformコードを作成してもらいました。

- 東京リージョンにVNETを1個作成、AZは1個とする。(本来は複数AZが望ましい)
- VNET内にプライベートサブネットを用意し、そこにVMインスタンスを1個稼働する。
- 外部のStorage Account(Blob Storage)に対して、プライベートエンドポイント経由でプライベート接続を行う。
Bob君のチャットウィンドウで下記のようなプロンプトを入力し実行します。
以下の構成を満たすTerraformコードを作成してください。
・東京リージョンにVNETを1個用意。(AZは1個)
・VNET内にプライベートサブネットを1個用意し、VMインスタンス(Ubuntu 24.04LTS)を1個稼働。
・外部にBlobストレージを作成し、VMインスタンスからプライベート接続できるようにする。
・VMインスタンスに安全に接続できるようにする。
Bob君はAzure MCPサーバーとTerraform MCPサーバーにアクセスし、AzureのTerraformモジュールの最新バージョンの取得、ベストプラクティスの取得に成功しました。

※本記事執筆時点でのプロバイダーモジュールの最新版は5.2.0です。

最終的にBob君が生成したTerraformコードのファイル構成は下記の通りです。

今回ベストプラクティスを考慮して設計してくれたポイントは下記の通りです。

Azureでは、プライベートサブネット内のVMインスタンスに安全に接続するための方法として、Azure Bastionという踏み台サーバーからRDP/SSH経由でアクセスする方法が提供されています。
プライベートサブネット内のVMインスタンスから、外部のBlobストレージ上へのアクセスする際に必要な認証は、マネージドIDで行われます。今回のようなユーザーにアサインするタイプのマネージドIDでも可能です。AWSのEC2からS3へのアクセスの際にも、IAMロールやバケットポリシーの設定をやりますが、それと同じような概念です。
Terraformコード実行後のAzure環境構成確認
TerraformでAzure環境をプロビジョニングする際には、事前にAzureとの認証を終えておく必要があります。対応方法は過去に投稿した下記の記事をご参照ください。
紆余曲折はありましたが、何とかBob君とトラブルシューティングしながら、最後まで「terraform apply」コマンドで全リソースのプロビジョニングを完了できました。AzureのTerraformはAWSよりも大変でした。。
Apply complete! Resources: xx added, 0 changed, 0 destroyed.
Outputs:
bastion_fqdn = "bst-4c37fc34-f552-4fd0-83db-51699406ff71.bastion.azure.com"
bastion_ssh_command_hint = "az network bastion ssh --name <bastion-name> --resource-group <rg-name> --target-resource-id /subscriptions/c85cc798-90f7-455f-a58e-eff75bb9f090/resourceGroups/myapp-dev-rg-compute/providers/Microsoft.Compute/virtualMachines/myapp-dev-vm-main --auth-type ssh-key --username azureuser --ssh-key ~/.ssh/id_rsa"
blob_container_name = "data"
managed_identity_client_id = "54e5b02a-86eb-4615-ac49-5e60cd37792c"
primary_blob_endpoint = "https://myappdevblob.blob.core.windows.net/"
private_endpoint_ip = "10.0.1.4"
private_subnet_id = "/subscriptions/c85cc798-90f7-455f-a58e-eff75bb9f090/resourceGroups/myapp-dev-rg-networking/providers/Microsoft.Network/virtualNetworks/myapp-dev-vnet-main/subnets/myapp-dev-snet-private"
storage_account_name = "myappdevblob"
vm_name = "myapp-dev-vm-main"
vm_private_ip = "10.0.1.5"
vnet_id = "/subscriptions/c85cc798-90f7-455f-a58e-eff75bb9f090/resourceGroups/myapp-dev-rg-networking/providers/Microsoft.Network/virtualNetworks/myapp-dev-vnet-main"
稼働確認が完了したBob君作成terraformコードの主なサンプルファイルを紹介します。いずれもBicep/ARMとの機能差異であったり、ベストプラクティスを取得して情報記載してくれています。
- ルート配下のmain.tf
##############################################################################
# ルートモジュール
# 概要 : networking / storage / compute の各モジュールを呼び出す
# モジュール間の依存関係を output → variable で明示的に管理する
# 最終更新 : 2025-07-01
#
# 構成概要:
# - 東京リージョン (japaneast) に VNET 1 個 (AZ1)
# - プライベートサブネット 1 個 に Ubuntu 24.04 LTS VM 1 台
# - Blob Storage をプライベートエンドポイント経由で接続
# - Azure Bastion で VM に安全に SSH 接続
##############################################################################
# ─── ローカル値(共通タグ) ───────────────────────────────────────────────
# [Bicep/ARM 差異] Bicep の inheritTags に相当。
# Terraform では locals で共通タグを定義し merge() で各モジュールに渡す。
locals {
common_tags = {
ManagedBy = "Terraform"
Environment = var.environment
Project = var.project_name
Owner = var.owner
}
}
# ─── networking モジュール ────────────────────────────────────────────────
# VNET / プライベートサブネット / NSG / Azure Bastion を作成する
module "networking" {
source = "./modules/networking"
project_name = var.project_name
environment = var.environment
location = var.location
vnet_address_space = var.vnet_address_space
private_subnet_prefix = var.private_subnet_prefix
bastion_subnet_prefix = var.bastion_subnet_prefix
tags = local.common_tags
}
# ─── storage モジュール ───────────────────────────────────────────────────
# Blob Storage Account と Private Endpoint を作成する。
# compute モジュールより先に作成し storage_account_id を compute へ渡す。
module "storage" {
source = "./modules/storage"
project_name = var.project_name
environment = var.environment
location = var.location
account_tier = var.storage_account_tier
replication_type = var.storage_replication_type
tags = local.common_tags
# networking モジュールの出力を受け取る
private_subnet_id = module.networking.private_subnet_id
vnet_id = module.networking.vnet_id
}
# ─── compute モジュール ───────────────────────────────────────────────────
# VM を作成し、Managed Identity で Storage へのアクセス権限を付与する。
# [Bicep/ARM 差異] モジュール間依存は depends_on を明示的に記述する。
# (networking と storage が完全に作成されてから VM をプロビジョニングする)
module "compute" {
source = "./modules/compute"
project_name = var.project_name
environment = var.environment
location = var.location
vm_size = var.vm_size
admin_username = var.admin_username
admin_ssh_public_key_filepath = var.admin_ssh_public_key_filepath
tags = local.common_tags
# networking モジュールの出力を受け取る
private_subnet_id = module.networking.private_subnet_id
# storage モジュールの出力を受け取る(RBAC ロール割り当てに使用)
storage_account_id = module.storage.storage_account_id
# [Bicep/ARM 差異] Bicep では暗黙的に推論されるが、
# Terraform ではモジュール間依存を明示する必要がある。
depends_on = [module.networking, module.storage]
}
- ルート配下のversions.tf
##############################################################################
# バージョン制約
# AzureRM プロバイダーは terraform MCP で確認した最新バージョン 5.2.0 に固定する。
# 最終更新: 2025-07-01
##############################################################################
terraform {
required_version = ">= 1.6.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 5.2.0"
}
}
# リモートバックエンド(本番環境では必須)
# [Bicep/ARM 差異] ARM は Azure 側で state を管理するが、
# Terraform は tfstate を明示的に管理する必要がある。
# 以下のコメントアウトを解除して Storage Account 名を設定すること。
# backend "azurerm" {
# resource_group_name = "<tfstate-rg-name>"
# storage_account_name = "<tfstate-storage-name>"
# container_name = "tfstate"
# key = "vm-blob/terraform.tfstate"
# }
}
provider "azurerm" {
features {
# VM 削除時に OS ディスクも同時削除する(コスト最適化)
virtual_machine {
delete_os_disk_on_deletion = true
}
# Key Vault を誤削除から保護する(本番では必須)
key_vault {
purge_soft_delete_on_destroy = false
recover_soft_deleted_key_vaults = true
}
}
# [AzureRM v5.0 破壊的変更] skip_provider_registration は廃止済み。
# resource_provider_registrations + resource_providers_to_register で代替する。
resource_provider_registrations = "none"
resource_providers_to_register = [
"Microsoft.Network", # VNET, NSG, Bastion, Private Endpoint
"Microsoft.Compute", # Virtual Machine, OS Disk
"Microsoft.Storage", # Blob Storage
"Microsoft.ManagedIdentity", # User-Assigned Managed Identity
]
# [既知の落とし穴] shared_access_key_enabled = false の Storage Account を
# Terraform で操作する場合は storage_use_azuread = true が必須。
# 本構成では shared_access_key_enabled = false を設定するため有効化する。
# 要件: 実行プリンシパルに Storage Blob Data Contributor ロールが必要。
storage_use_azuread = true
}
- Storageモジュールのmain.tf
##############################################################################
# モジュール名: storage
# 概要 : Blob Storage Account と Private Endpoint を作成する
# VM からプライベートネットワーク経由でのみアクセスできる構成にする
# 依存モジュール: networking モジュール
# 最終更新 : 2025-07-01
##############################################################################
# ─── ローカル値(タグ共通化) ──────────────────────────────────────────────
locals {
common_tags = merge(var.tags, { Module = "storage" })
}
# ─── リソースグループ ──────────────────────────────────────────────────────
resource "azurerm_resource_group" "storage" {
name = "${var.project_name}-${var.environment}-rg-storage"
location = var.location
tags = local.common_tags
}
# ─── Storage Account ──────────────────────────────────────────────────────
# Azure ベストプラクティス:
# - shared_access_key_enabled = false で Shared Key 認証を無効化する
# - min_tls_version = "TLS1_2" で古い TLS を拒否する
# - public_network_access_enabled = false でパブリックアクセスを遮断する
# - enable_https_traffic_only = true で HTTP を拒否する(デフォルト true)
# [既知の落とし穴] shared_access_key_enabled = false の場合、
# versions.tf の provider ブロックに storage_use_azuread = true が必要。
# 実行プリンシパルに Storage Blob Data Contributor ロールも必要。
resource "azurerm_storage_account" "main" {
name = "${replace(var.project_name, "-", "")}${var.environment}blob"
resource_group_name = azurerm_resource_group.storage.name
location = azurerm_resource_group.storage.location
account_tier = var.account_tier
account_replication_type = var.replication_type
account_kind = "StorageV2"
# セキュリティ設定
min_tls_version = "TLS1_2"
shared_access_key_enabled = false # Shared Key 認証を無効化(Managed Identity のみ許可)
public_network_access_enabled = false # パブリックネットワークからのアクセスを全て遮断
allow_nested_items_to_be_public = false # コンテナ・Blob の匿名アクセスを禁止
# HTTPS 強制(デフォルト true だが明示的に記述する)
https_traffic_only_enabled = true
# Blob Storage の設定
blob_properties {
# 論理削除でデータの誤削除を保護する(7日間保持)
delete_retention_policy {
days = 7
}
container_delete_retention_policy {
days = 7
}
# バージョニングを有効化してデータ履歴を保持する
versioning_enabled = true
}
tags = local.common_tags
}
# ─── Blob コンテナ ────────────────────────────────────────────────────────
# デフォルトのデータ保存用コンテナを作成する
resource "azurerm_storage_container" "data" {
name = "data"
storage_account_id = azurerm_storage_account.main.id
# 匿名アクセスを禁止する(Managed Identity 認証のみ許可)
container_access_type = "private"
# [Bicep/ARM 差異] ARM の dependsOn に相当。
# Private Endpoint が確立されてからコンテナを作成する。
depends_on = [azurerm_private_endpoint.storage_blob]
}
# ─── Private DNS Zone ────────────────────────────────────────────────────
# Azure Private Link で Blob Storage に接続するための Private DNS Zone を作成する。
# privatelink.blob.core.windows.net を使用することで、
# VM から storage-account-name.blob.core.windows.net の名前解決がプライベート IP を返す。
resource "azurerm_private_dns_zone" "blob" {
name = "privatelink.blob.core.windows.net"
resource_group_name = azurerm_resource_group.storage.name
tags = local.common_tags
}
# DNS Zone を VNET にリンクする(VM からの名前解決に必要)
# AzureRM v5.x では resource_group_name / private_dns_zone_name の組み合わせが廃止され、
# private_dns_zone_id による指定に変更された。
resource "azurerm_private_dns_zone_virtual_network_link" "blob" {
name = "${var.project_name}-${var.environment}-dns-link-blob"
private_dns_zone_id = azurerm_private_dns_zone.blob.id
virtual_network_id = var.vnet_id
registration_enabled = false # VM の DNS 自動登録は不要
tags = local.common_tags
}
# ─── Private Endpoint ────────────────────────────────────────────────────
# Storage Account にプライベート接続するための Private Endpoint を作成する。
# VM はこのエンドポイント経由でプライベート IP を使って Blob にアクセスする。
# [Bicep/ARM 差異] ARM の condition プロパティに相当。
# Terraform では Private Endpoint リソースとして明示的に定義する。
resource "azurerm_private_endpoint" "storage_blob" {
name = "${var.project_name}-${var.environment}-pe-storage-blob"
location = azurerm_resource_group.storage.location
resource_group_name = azurerm_resource_group.storage.name
subnet_id = var.private_subnet_id
tags = local.common_tags
private_service_connection {
name = "${var.project_name}-${var.environment}-psc-blob"
private_connection_resource_id = azurerm_storage_account.main.id
subresource_names = ["blob"] # Blob サービスへの接続
is_manual_connection = false
}
# Private Endpoint の DNS を Private DNS Zone に自動登録する
private_dns_zone_group {
name = "blob-dns-zone-group"
private_dns_zone_ids = [azurerm_private_dns_zone.blob.id]
}
}
- networkingモジュールのmain.tf
##############################################################################
# モジュール名: networking
# 概要 : VNET / プライベートサブネット / NSG / Azure Bastion を作成する
# 依存モジュール: ルートモジュール (main.tf) から呼び出される
# 最終更新 : 2025-07-01
##############################################################################
# ─── ローカル値(タグ共通化) ──────────────────────────────────────────────
# [Bicep/ARM 差異] Bicep の inheritTags に相当。
# Terraform では locals で共通タグを定義し merge() で各リソースに付与する。
locals {
common_tags = merge(var.tags, { Module = "networking" })
}
# ─── リソースグループ ──────────────────────────────────────────────────────
# ネットワーク関連リソースをまとめるリソースグループ
resource "azurerm_resource_group" "networking" {
name = "${var.project_name}-${var.environment}-rg-networking"
location = var.location
tags = local.common_tags
}
# ─── Virtual Network ──────────────────────────────────────────────────────
# 東京リージョン (japaneast) に VNET を 1 個作成する
resource "azurerm_virtual_network" "main" {
name = "${var.project_name}-${var.environment}-vnet-main"
location = azurerm_resource_group.networking.location
resource_group_name = azurerm_resource_group.networking.name
address_space = var.vnet_address_space
tags = local.common_tags
}
# ─── プライベートサブネット ────────────────────────────────────────────────
# VM を配置するプライベートサブネット。
# Private Endpoint 用に private_endpoint_network_policies を Disabled に設定する。
resource "azurerm_subnet" "private" {
name = "${var.project_name}-${var.environment}-snet-private"
resource_group_name = azurerm_resource_group.networking.name
virtual_network_name = azurerm_virtual_network.main.name
address_prefixes = [var.private_subnet_prefix]
# Private Endpoint を配置するサブネットでは network policies を無効化する
private_endpoint_network_policies = "Disabled"
}
# ─── Azure Bastion 専用サブネット ──────────────────────────────────────────
# Azure Bastion は "AzureBastionSubnet" という名称が必須。
# /27 以上の CIDR が必要(公式要件)。
resource "azurerm_subnet" "bastion" {
name = "AzureBastionSubnet"
resource_group_name = azurerm_resource_group.networking.name
virtual_network_name = azurerm_virtual_network.main.name
address_prefixes = [var.bastion_subnet_prefix]
}
# ─── NSG (プライベートサブネット用) ───────────────────────────────────────
# 最小権限の原則: 必要最小限のルールのみ許可する。
# SSH (22) / RDP (3389) はインターネットからの直接アクセスを拒否する。
# Bastion 経由でのみ VM にアクセスする構成にする。
resource "azurerm_network_security_group" "private" {
name = "${var.project_name}-${var.environment}-nsg-private"
location = azurerm_resource_group.networking.location
resource_group_name = azurerm_resource_group.networking.name
tags = local.common_tags
}
# Bastion から VM への SSH (22) / RDP (3389) を許可するルール
# source_address_prefix にはサービスタグか CIDR のみ有効。
# サブネット名 "AzureBastionSubnet" は無効なため VirtualNetwork サービスタグを使用する。
# description フィールドは英数字・記号のみ(日本語不可)
resource "azurerm_network_security_rule" "allow_bastion_ssh" {
name = "AllowBastionSSH"
resource_group_name = azurerm_resource_group.networking.name
network_security_group_name = azurerm_network_security_group.private.name
priority = 100
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "22"
# VirtualNetwork サービスタグで VNET 内(Bastion サブネット含む)からの通信を許可する
source_address_prefix = "VirtualNetwork"
destination_address_prefix = "VirtualNetwork"
# Allows inbound SSH from VirtualNetwork (includes AzureBastionSubnet)
description = "Allow SSH from VirtualNetwork to VirtualNetwork"
}
# VirtualNetwork 内の通信を許可するルール
resource "azurerm_network_security_rule" "allow_vnet_inbound" {
name = "AllowVNetInbound"
resource_group_name = azurerm_resource_group.networking.name
network_security_group_name = azurerm_network_security_group.private.name
priority = 200
direction = "Inbound"
access = "Allow"
protocol = "*"
source_port_range = "*"
destination_port_range = "*"
source_address_prefix = "VirtualNetwork"
destination_address_prefix = "VirtualNetwork"
# Allows inbound traffic within the virtual network
description = "Allow all inbound traffic within VirtualNetwork"
}
# インターネットからの全受信トラフィックを拒否するルール(明示的 Deny)
resource "azurerm_network_security_rule" "deny_internet_inbound" {
name = "DenyInternetInbound"
resource_group_name = azurerm_resource_group.networking.name
network_security_group_name = azurerm_network_security_group.private.name
priority = 4000
direction = "Inbound"
access = "Deny"
protocol = "*"
source_port_range = "*"
destination_port_range = "*"
source_address_prefix = "Internet"
destination_address_prefix = "*"
# Denies all inbound traffic from the Internet
description = "Deny all inbound traffic from Internet"
}
# NSG をプライベートサブネットに関連付ける
resource "azurerm_subnet_network_security_group_association" "private" {
subnet_id = azurerm_subnet.private.id
network_security_group_id = azurerm_network_security_group.private.id
}
# ─── Azure Bastion ────────────────────────────────────────────────────────
# VM への安全なアクセス手段として Azure Bastion を使用する。
# SSH キーをインターネットに晒さずブラウザから SSH 接続できる。
# Bastion には Public IP が必要(Bastion サービス自体は PIP を持つが VM は不要)。
resource "azurerm_public_ip" "bastion" {
name = "${var.project_name}-${var.environment}-pip-bastion"
location = azurerm_resource_group.networking.location
resource_group_name = azurerm_resource_group.networking.name
allocation_method = "Static"
sku = "Standard"
zones = ["1"] # 東京リージョン (japaneast) は AZ1 のみ使用
tags = local.common_tags
}
resource "azurerm_bastion_host" "main" {
name = "${var.project_name}-${var.environment}-bastion-main"
location = azurerm_resource_group.networking.location
resource_group_name = azurerm_resource_group.networking.name
sku = "Standard" # Standard SKU で SSH/RDP のネイティブクライアント接続が可能
ip_configuration {
name = "bastion-ipconf"
subnet_id = azurerm_subnet.bastion.id
public_ip_address_id = azurerm_public_ip.bastion.id
}
# Standard SKU で利用可能な機能
tunneling_enabled = true # ネイティブ SSH クライアント経由の接続を許可
shareable_link_enabled = false
tags = local.common_tags
}
- computeモジュールのmain.tf
##############################################################################
# モジュール名: compute
# 概要 : プライベートサブネットに Ubuntu 24.04 LTS VM を作成する
# Managed Identity を付与し Storage への RBAC アクセスを可能にする
# 依存モジュール: networking モジュール
# 最終更新 : 2025-07-01
##############################################################################
# ─── ローカル値(タグ共通化) ──────────────────────────────────────────────
locals {
common_tags = merge(var.tags, { Module = "compute" })
}
# ─── リソースグループ ──────────────────────────────────────────────────────
resource "azurerm_resource_group" "compute" {
name = "${var.project_name}-${var.environment}-rg-compute"
location = var.location
tags = local.common_tags
}
# ─── User-Assigned Managed Identity ──────────────────────────────────────
# Azure ベストプラクティス: VM には Managed Identity を使用し、
# 認証情報をコードやディスクに保存しない。
# [Bicep/ARM 差異] ARM の dependsOn に相当する依存を明示する。
resource "azurerm_user_assigned_identity" "vm" {
name = "${var.project_name}-${var.environment}-id-vm"
location = azurerm_resource_group.compute.location
resource_group_name = azurerm_resource_group.compute.name
tags = local.common_tags
}
# ─── ネットワークインターフェース ─────────────────────────────────────────
# VM をプライベートサブネットに配置する。パブリック IP は不要。
# Bastion 経由でのみアクセスする構成にする。
resource "azurerm_network_interface" "vm" {
name = "${var.project_name}-${var.environment}-nic-vm"
location = azurerm_resource_group.compute.location
resource_group_name = azurerm_resource_group.compute.name
tags = local.common_tags
ip_configuration {
name = "internal"
subnet_id = var.private_subnet_id
private_ip_address_allocation = "Dynamic"
# public_ip_address_id を指定しない = パブリック IP なし(セキュリティ要件)
}
}
# ─── Virtual Machine (Ubuntu 24.04 LTS) ──────────────────────────────────
# パスワード認証を無効化し SSH 公開鍵認証のみを許可する(セキュリティ要件)。
# Bastion Standard SKU により、ブラウザ or ネイティブ SSH クライアント経由でアクセス可能。
resource "azurerm_linux_virtual_machine" "main" {
name = "${var.project_name}-${var.environment}-vm-main"
location = azurerm_resource_group.compute.location
resource_group_name = azurerm_resource_group.compute.name
size = var.vm_size
# OS ディスクは VM 削除時に自動削除する (versions.tf の features ブロックで制御)
os_disk {
name = "${var.project_name}-${var.environment}-osdisk-vm"
caching = "ReadWrite"
storage_account_type = "Premium_LRS"
}
# Ubuntu 24.04 LTS (Noble Numbat) ARM64 版イメージを指定する。
# Standard_B2ps_v2 は ARM アーキテクチャ (Ampere Altra) のため arm64 offer が必要。
# x86_64 版 (offer: ubuntu-24_04-lts / sku: server) は ARM VM では起動不可。
source_image_reference {
publisher = "Canonical"
offer = "ubuntu-24_04-lts"
sku = "server-arm64"
version = "latest"
}
# [Bicep/ARM 差異] ARM の condition プロパティとは異なり、
# Terraform では disable_password_authentication を明示的に指定する。
admin_username = var.admin_username
disable_password_authentication = true
# SSH 公開鍵認証のみを許可する(セキュリティベストプラクティス)
# file() はリソース定義内でのみ呼び出し可能。
# ファイルパスを変数で受け取り、ここで内容を読み込む。
admin_ssh_key {
username = var.admin_username
public_key = file(var.admin_ssh_public_key_filepath)
}
# User-Assigned Managed Identity を VM に付与する
# Azure SDK での Blob Storage 操作に使用する
identity {
type = "UserAssigned"
identity_ids = [azurerm_user_assigned_identity.vm.id]
}
network_interface_ids = [azurerm_network_interface.vm.id]
# 高可用性: 東京リージョン (japaneast) の AZ1 に配置
zone = "1"
tags = local.common_tags
# [Bicep/ARM 差異] ARM の dependsOn に相当。
# Managed Identity が完全に作成されてから VM をプロビジョニングする。
depends_on = [azurerm_user_assigned_identity.vm]
}
# ─── RBAC: VM の Managed Identity に Storage Blob Data Contributor を付与 ──
# Azure ベストプラクティス: Managed Identity に必要最小限のロールを付与する。
# [Bicep/ARM 差異] ARM の 組み込みロール参照を data ソースで取得する。
data "azurerm_role_definition" "storage_blob_data_contributor" {
name = "Storage Blob Data Contributor"
}
# Storage Account スコープで Blob Data Contributor ロールを付与する
# storage モジュールの Storage Account ID をここで受け取る
resource "azurerm_role_assignment" "vm_to_storage" {
scope = var.storage_account_id
role_definition_name = "Storage Blob Data Contributor"
principal_id = azurerm_user_assigned_identity.vm.principal_id
# [Bicep/ARM 差異] ARM の dependsOn に相当。
# Managed Identity が Azure AD に伝播されてからロール割り当てを実行する。
depends_on = [azurerm_user_assigned_identity.vm]
}
Azure Bastionを経由したプライベートVMインスタンスへの接続確認
Azure CLIを使って下記のようなコマンドを実行します。
az network bastion ssh `
--name myapp-dev-bastion-main `
--resource-group myapp-dev-rg-networking `
--target-resource-id /subscriptions/c85cc798-90f7-455f-a58e-eff75bb9f090/resourceGroups/myapp-dev-rg-compute/providers/Microsoft.Compute/virtualMachines/myapp-dev-vm-main `
--auth-type ssh-key `
--username azureuser `
--ssh-key C:/Users/userid/.ssh/ubuntu-key
初めて接続を試みるとき、下記のようなメッセージが出力されました。
Preview version of extension is disabled by default for extension installation, enabled for modules without stable versions.
Please run 'az config set extension.dynamic_install_allow_preview=true or false' to config it specifically.
The command requires the extension bastion. Do you want to install it now? The command will continue to run after the extension is installed. (Y/n): y
「y」を応答しないと、Azure Bastion経由でプライベートVMインスタンスに接続するための拡張機能が使えないので、「y」を応答します。
何故かここで「y」を返すと下記のようなエラーになり、どうやら「az extension add -n ssh」というコマンドを使用しないと拡張機能がインストールできませんでした。初めてこの機能をインストールする方は必ず遭遇する可能性がありますので、ご注意ください。
Run 'az config set extension.use_dynamic_install=yes_without_prompt' to allow installing extensions without prompt.
The command failed with an unexpected error. Here is the traceback:
The extension ssh is not installed. Please install the extension via `az extension add -n ssh`.
拡張機能インストール後、再度下記のコマンドを実行します。
az network bastion ssh `
--name myapp-dev-bastion-main `
--resource-group myapp-dev-rg-networking `
--target-resource-id /subscriptions/c85cc798-90f7-455f-a58e-eff75bb9f090/resourceGroups/myapp-dev-rg-compute/providers/Microsoft.Compute/virtualMachines/myapp-dev-vm-main `
--auth-type ssh-key `
--username azureuser `
--ssh-key C:/Users/userid/.ssh/ubuntu-key
その後、プライベートVMインスタンス(Ubuntu 24.04)に無事アクセスすることができました。
Welcome to Ubuntu 24.04.4 LTS (GNU/Linux 6.17.0-1022-azure aarch64)
* Documentation: https://help.ubuntu.com
* Management: https://landscape.canonical.com
* Support: https://ubuntu.com/pro
System information as of Sat Aug 22 01:20:56 UTC 2026
System load: 0.08 Processes: 118
Usage of /: 5.8% of 28.02GB Users logged in: 0
Memory usage: 3% IPv4 address for eth0: 10.0.1.5
Swap usage: 0%
Expanded Security Maintenance for Applications is not enabled.
0 updates can be applied immediately.
Enable ESM Apps to receive additional future security updates.
See https://ubuntu.com/esm or run: sudo pro status
The list of available updates is more than a week old.
To check for new updates run: sudo apt update
The programs included with the Ubuntu system are free software;
the exact distribution terms for each program are described in the
individual files in /usr/share/doc/*/copyright.
Ubuntu comes with ABSOLUTELY NO WARRANTY, to the extent permitted by
applicable law.
To run a command as administrator (user "root"), use "sudo <command>".
See "man sudo_root" for details.
azureuser@myapp-dev-vm-main:~$
プライベートVMインスタンスから、Blobストレージへのアクセス認証確認
プライベートVMインスタンスにAzure CLIをインストールした後、Terraform ApplyのOutputに出力されているマネージドID(managed_identity_client_id値)を指定して、下記のコマンドを実行し、Blobストレージへのアクセスを行います。
azureuser@myapp-dev-vm-main:~$ az login --identity --client-id 54e5b02a-86eb-4615-ac49-5e60cd37792c
[
{
"environmentName": "AzureCloud",
"homeTenantId": "0dd352d2-ac90-403b-88b8-82857631c6df",
"id": "c85cc798-90f7-455f-a58e-eff75bb9f090",
"isDefault": true,
"managedByTenants": [],
"name": "Azure subscription 1",
"state": "Enabled",
"tenantId": "0dd352d2-ac90-403b-88b8-82857631c6df",
"user": {
"assignedIdentityInfo": "MSIClient-54e5b02a-86eb-4615-ac49-5e60cd37792c",
"name": "userAssignedIdentity",
"type": "servicePrincipal"
}
}
]
結果は問題ありませんが、下記のディスプレイコマンドも実行しておきます。
azureuser@myapp-dev-vm-main:~$ az account show
{
"environmentName": "AzureCloud",
"homeTenantId": "0dd352d2-ac90-403b-88b8-82857631c6df",
"id": "c85cc798-90f7-455f-a58e-eff75bb9f090",
"isDefault": true,
"managedByTenants": [],
"name": "Azure subscription 1",
"state": "Enabled",
"tenantId": "0dd352d2-ac90-403b-88b8-82857631c6df",
"user": {
"assignedIdentityInfo": "MSIClient-54e5b02a-86eb-4615-ac49-5e60cd37792c",
"name": "userAssignedIdentity",
"type": "servicePrincipal"
}
}
この時点で、BlobストレージにプライベートVMインスタンスがアクセスするための認証が完了しています。続いて、Blobストレージに対してファイルのアップロードやダウンロードを行います。
azureuser@myapp-dev-vm-main:~$ echo "Hello from VM" > test.txt
azureuser@myapp-dev-vm-main:~$ ls
test.txt
azureuser@myapp-dev-vm-main:~$ az storage blob upload \
--account-name myappdevblob \
--container-name data \
--name test.txt \
--file test.txt \
--auth-mode login
Finished[#############################################################] 100.0000%
{
"client_request_id": "446798eb-9def-11f1-b825-7c1e524cc221",
"content_md5": "N8Ogvf4EE+Za2NbEjNlKIQ==",
"date": "2026-08-22T06:03:59+00:00",
"encryption_key_sha256": null,
"encryption_scope": null,
"etag": "\"0x8DF001328EB4A00\"",
"lastModified": "2026-08-22T06:04:00+00:00",
"request_id": "de47cefc-c01e-00b0-7ffc-310244000000",
"request_server_encrypted": true,
"structured_body": null,
"version": "2026-04-06",
"version_id": "2026-08-22T06:04:00.0786944Z"
}
azureuser@myapp-dev-vm-main:~$ ls
test.txt
azureuser@myapp-dev-vm-main:~$ rm test.txt
azureuser@myapp-dev-vm-main:~$ ls
azureuser@myapp-dev-vm-main:~$ az storage blob list \
--account-name myappdevblob \
--container-name data \
--auth-mode login \
--output table
Name Blob Type Blob Tier Length Content Type Last Modified Snapshot
-------- ----------- ----------- -------- -------------- ------------------------- ----------
test.txt BlockBlob Hot 14 text/plain 2026-08-22T06:04:00+00:00
azureuser@myapp-dev-vm-main:~$ az storage blob download \
--account-name myappdevblob \
--container-name data \
--name test.txt \
--file downloaded.txt \
--auth-mode login
Finished[#############################################################] 100.0000%
{
"container": "data",
"content": "",
"contentMd5": null,
"deleted": false,
"encryptedMetadata": null,
"encryptionKeySha256": null,
"encryptionScope": null,
"hasLegalHold": null,
"hasVersionsOnly": null,
"immutabilityPolicy": {
"expiryTime": null,
"policyMode": null
},
"isAppendBlobSealed": null,
"isCurrentVersion": true,
"lastAccessedOn": null,
"metadata": {},
"name": "test.txt",
"objectReplicationDestinationPolicy": null,
"objectReplicationSourceProperties": [],
"properties": {
"appendBlobCommittedBlockCount": null,
"blobTier": null,
"blobTierChangeTime": null,
"blobTierInferred": null,
"blobType": "BlockBlob",
"contentLength": 14,
"contentRange": "bytes 0-13/14",
"contentSettings": {
"cacheControl": null,
"contentDisposition": null,
"contentEncoding": null,
"contentLanguage": null,
"contentMd5": "N8Ogvf4EE+Za2NbEjNlKIQ==",
"contentType": "text/plain"
},
"copy": {
"completionTime": null,
"destinationSnapshot": null,
"id": null,
"incrementalCopy": null,
"progress": null,
"source": null,
"status": null,
"statusDescription": null
},
"creationTime": "2026-08-22T06:04:00+00:00",
"deletedTime": null,
"etag": "\"0x8DF001328EB4A00\"",
"lastModified": "2026-08-22T06:04:00+00:00",
"lease": {
"duration": null,
"state": "available",
"status": "unlocked"
},
"pageBlobSequenceNumber": null,
"pageRanges": null,
"rehydrationStatus": null,
"remainingRetentionDays": null,
"serverEncrypted": true
},
"rehydratePriority": null,
"requestServerEncrypted": true,
"snapshot": null,
"tagCount": null,
"tags": null,
"versionId": "2026-08-22T06:04:00.0786944Z"
}
azureuser@myapp-dev-vm-main:~$ ls
downloaded.txt
azureuser@myapp-dev-vm-main:~$ cat downloaded.txt
Hello from VM
いずれも問題なく処理できています。
Blobストレージへのプライベート接続確認
最後にnslookupコマンドを実行して、Blobストレージへのアクセスが本当にプライベート接続になっているかを確認します。
azureuser@myapp-dev-vm-main:~$ nslookup myappdevblob.blob.core.windows.net
Server: 127.0.0.53
Address: 127.0.0.53#53
Non-authoritative answer:
myappdevblob.blob.core.windows.net canonical name = myappdevblob.privatelink.blob.core.windows.net.
Name: myappdevblob.privatelink.blob.core.windows.net
Address: 10.0.1.4
Blobストレージのエンドポイント(terraformのoutputにある「primary_blob_endpoint」の値)の名前解決がプライベートIP(terraformのoutputにある「private_endpoint_ip」となっていることから、プライベートエンドポイントとプライベートDNSゾーンが正しく機能していることが確認できました。
Terraformコードの稼働確認で得られた主な対応項目
今回、いくつかコードの稼働確認の中でエラー事象が発生し、トラブルシューティングを行いました。その中でも一番インパクトの強かったエラー事象を紹介します。
Azureのリソースに対するリソース。プロバイダーが自動登録されなくなった。
これまで使っていたAzureのTerraformモジュールのバージョンが古いものだったので、今まで全く意識することもなかったのですが、今回最新バージョンのモジュールを利用することにしたことで、Terraform Apply時に過去に1度も目にしたことのない、下記のようなエラーメッセージが出力されました。(出力内容は1例)
│ Error: creating User Assigned Identity (Subscription: "c85cc798-90f7-455f-a58e-eff75bb9f090"
│ Resource Group Name: "myapp-dev-rg-compute"
│ Name: "myapp-dev-id-vm"): unexpected status 409 (409 Conflict) with error: MissingSubscriptionRegistration: The subscription is not registered to use namespace 'Microsoft.ManagedIdentity'. See https://aka.ms/rps-not-found for how to register subscriptions.
Azureには、ストレージに関するサービスをプロビジョニングする場合には「Microsoft.Storage」という名前のリソースプロバイダーを、ネットワークに関するサービスをプロビジョニングする場合には「Microsoft.Network」という名前のリソースプロバイダーといったように、各々のサービスに対するリソースプロバイダーを登録する必要があるようです。昔のTerraformのモジュールでは、これらのリソースプロバイダーは特に指定しなくても自動でTerraform Applyの中で登録処理が行われていたようですが、最新バージョンのモジュールからは、下記の理由から個別登録することを強く推奨する形に変えたようです。
上記のリンク先には下記のような記載があります。
Previously, the provider would automatically attempt to register a large set of Azure Resource Providers (~60 RPs) when initializing. This could:
- Add delay to provider startup due to sequential RP registration checks
- Cause permission errors for users with restricted access to their subscription
- Register RPs that users may not need or want
In v5.0, no Resource Providers are registered by default. This gives users full control over RP registration and avoids potential permission issues.
リソースのプロビジョニングに本当に必要なもののみを登録するようにすることで、登録にかかる所要時間の短縮化、権限の適正化を推奨するようにされたように見えます。
従来の動きに戻すパラメータも提供されているようですが、要らないものを登録するのも時間の無駄ですし、セキュリティー面でも不必要なリソースプロバイダー登録を行うのもよろしくないと考えましたので、次回からはterraformのルート配下のversions.tfファイルの中に下記のような登録を行うように、SKILL.mdにBob君に追記をお願いしました。(詳細は前述のGitHubリポジトリ内のSKILL.mdを参照)
provider "azurerm" {
features {}
# [AzureRM v5.0 破壊的変更] skip_provider_registration は廃止済み。
# resource_provider_registrations + resource_providers_to_register で代替する。
resource_provider_registrations = "none"
resource_providers_to_register = [
"Microsoft.Network", # VNET, NSG, Bastion, Private Endpoint
"Microsoft.Compute", # Virtual Machine, OS Disk
"Microsoft.Storage", # Blob Storage
"Microsoft.ManagedIdentity", # User-Assigned Managed Identity
]
}
おわりに
今回は、AzureのMCPサーバーとTerraformのMCPサーバーを活用して、Azureの最新を追求しつつ、ベストプラクティスも意識したTerraformコードを生成するスキルをIBM Bobで作成してみた際の手順と使用感について紹介しました。
AWS版の記事でも記載していますが、スキルは1度作成したら終わりではありません。Bob君に作成してもらったTerraformコードを使ってみて、不具合等が発生してトラブル対応の上解決できたものについては、該当スキルのSKILL.mdファイルに経験情報を蓄積していくプロセスを回し、より価値のあるスキルに成長させていこうと思います。

