【Packer】日本語化したWindows Server 2022のVMイメージをAzureに作成する

Azureで提供される Windows Server のベースイメージは英語OSのみなので、通常は言語パックをインストールして日本語化し、sysprepしてひな形となるイメージを作成します。今更ですが、このイメージ作成の処理をPackerで自動化してみました。

# Packerとは
Packerは仮想マシンのイメージ作成を自動化するためのIaCツールで、HashiCorp社によって提供されています。一時的に仮想マシンを起動し、指定されたセットアップ処理を実行後、イメージの作成と一時環境の削除までを行ってくれます。

初めて触りましたが、クラウドなどのインフラ構成をコード化するTerraformと似たような構文なので、Terraformに馴染みがある場合は学習コストは低い印象です。

# 環境
ローカルマシンはWindows11ですが、devcontainer上で実行します。
- Ubuntu 22.04.2 LTS
- Packer 1.8.6

```json:devcontainer.json
{
	"name": "Ubuntu",
	"image": "mcr.microsoft.com/devcontainers/base:jammy",
	"features": {
		"ghcr.io/devcontainers/features/azure-cli:1": {},
		"ghcr.io/devcontainers/features/git:1": {},
		"ghcr.io/devcontainers-contrib/features/packer-asdf:2": {}
	},
	"customizations": {
		"vscode": {
			"extensions": [
				"4ops.packer",
				"cweijan.vscode-office"
			]
		}
	}
}
```

# フォルダ構成
フォルダ構成は以下のような形です。
`rejigtry` と `scripts` フォルダは日本語化の処理で使うレジストリとPowerShellスクリプトです。Packer関連のファイルとしては `.hcl` の3ファイルのみです。
```bash
$ tree
.
├── azure-winvm-image.pkr.hcl
├── registry
│   ├── ja-JP-default.reg
│   └── ja-JP-welcome.reg
├── scripts
│   ├── install_language_pack_1.ps1
│   └── install_language_pack_2.ps1
├── variables.auto.pkrvars.hcl
└── variables.pkr.hcl
```

ソースはGitHubにあげています。

https://github.com/m-oka-system/azure-winvm-jp-packer

- **azure-winvm-image.pkr.hcl**
ビルドの処理を記述するメインのファイルです。
Packerのコードは拡張子が `pkr.hcl` のファイルに記述していきます。ビルド処理の内容は後述。

- **variables.pkr.hcl**
変数とビルド処理を一つのファイルにまとめても構いませんが、変数は `variables.pkr.hcl` に分けました。
ここでは変数の名前と型だけを定義しています。デフォルト値も設定できますが、変数の値は後述の `variables.auto.pkrvars.hcl` にまとめて書くことにしました。
```hcl:variables.pkr.hcl
variable "project" {
  type = string
}

variable "location" {
  type = string
}

variable "resource_group_name" {
  type = string
}

variable "vm_size" {
  type = string
}

variable "gallery_name" {
  type = string
}

variable "image_definition" {
  type = string
}

variable "replication_regions" {
  type = list(string)
}

variable "winrm_password" {
  type = string
}

variable "inbound_ip_addresses" {
  type = list(string)
}
```

- **variables.auto.pkrvars.hcl**
変数の値を定義します。`auto.pkrvars.hcl` で終わる名前を付けるとPackerのコマンド実行時に自動的に読み込んでくれます。Terraformで言うところの `*.tfvars` です。書き方も同じでKey=Valueの形式で次のように記述します。
```bash:variables.auto.pkrvars.hcl
project              = "MyProject"
location             = "japaneast"
resource_group_name  = "packer-rg"
vm_size              = "Standard_DS1_v2"
gallery_name         = "windows_sig"
image_definition     = "2022-datacenter-smalldisk-g2"
replication_regions  = ["japaneast"]
winrm_password       = "P@ssw0rd!"
inbound_ip_addresses = ["xxx.xxx.xxx.xxx"]
```

# ビルド処理
続いて、メインの `azure-winvm-image.pkr.hcl` の中身について。
## Source ブロック
Source ブロックでは、起動するVMに関する設定やイメージの保存先について定義します。
[Azureの公式ドキュメント](https://learn.microsoft.com/ja-jp/azure/virtual-machines/windows/build-image-with-packer) を参考にしつつ、いくつか手を加えたところをかいつまんで記載します。

- ローカル変数でタイムスタンプを定義し、タグとリソースグループ名末尾に付与
- 以下、サービスプリンシパルでの認証は使用せず `use_azure_cli_auth = true` にして `az login` でサインインする前提（[GithubActionsとの統合](https://qiita.com/m-oka-system/items/410b8f2c372b6ab31534) を見据えて）
    - tenant_id
    - subscription_id
    - client_id
    - client_secret
- ベースイメージは win2022 の smalldisk
- スポットVMを使用（`spot` を指定）
- `allowed_inbound_ip_addresses` で接続元IPアドレスを制限（NSGが作成される）
- イメージはAzure Compute Galleryに格納（あらかじめ作成しておく）

```hcl:azure-winvm-image.pkr.hcl
locals {
  timestamp = formatdate("YYYYMMDDhhmmss", timeadd(timestamp(), "9h"))
  image_version = formatdate("YYYY.MMDD.hhmm", timeadd(timestamp(), "9h"))
}

source "azure-arm" "windowsserver-2022" {
  azure_tags = {
    project = var.project
    created = local.timestamp
  }
  use_azure_cli_auth       = true
  location                 = var.location
  temp_resource_group_name = "tmp-packer${local.timestamp}"
  communicator             = "winrm"
  image_offer              = "WindowsServer"
  image_publisher          = "MicrosoftWindowsServer"
  image_sku                = "2022-datacenter-smalldisk-g2" # az vm image list-skus --location japaneast --publisher MicrosoftWindowsServer --offer WindowsServer --output table
  os_type                  = "Windows"
  vm_size                  = var.vm_size
  spot {
    max_price       = "-1"
    eviction_policy = "Deallocate"
  }

  allowed_inbound_ip_addresses = var.inbound_ip_addresses

  ## Note: Use the following parameters if you are building using an existing virtual network. In that case, disable the `allowed_inbound_ip_addresses` parameter.
  # private_virtual_network_with_public_ip = true
  # virtual_network_name = var.virtual_network_name
  # virtual_network_subnet_name = var.virtual_network_subnet_name
  # virtual_network_resource_group_name = var.virtual_network_resource_group_name

  winrm_insecure = true
  winrm_timeout  = "5m"
  winrm_use_ssl  = true
  winrm_username = "packer"
  winrm_password = var.winrm_password

  ## Note: If you want to save it as a managed image, specify the following parameters.
  # managed_image_name                = "win-2022-smalldisk-image-ja"
  # managed_image_resource_group_name = var.resource_group_name

  ## Note: If saving to Compute Gallery, specify the parameters in the `shared_image_gallery_destination` block.
  ## If you want to save to both, `specify managed_image_*` parameters for both.
  shared_image_gallery_destination {
    resource_group      = var.resource_group_name
    gallery_name        = var.gallery_name
    image_name          = var.image_definition
    image_version       = local.image_version
    replication_regions = var.replication_regions
  }
}
```

なお、コメントで書いていますが、`virtual_network_*` のパラメータを使えば既存のVnet上にVMを起動してビルドすることもできます。

また、Azure Compute Gallery を使わない場合は `managed_image_*` を指定するとマネージドイメージとして保存できます（両方指定も可能）

## Build ブロック
Build ブロックには、起動したVM上で行う処理を Source ブロックに続けて記述します。

- `file` プロビジョナーで `registry` フォルダのレジストリファイルをVMのCドライブ直下に転送
- `powershell` プロビジョナーで、PowerShellコマンドを実行
    - 第1段階が言語パックのインストール
    - 第2段階がもろもろの言語設定
- 言語パックインストール後、言語設定後にWindows再起動
    - `windows-restart` プロビジョナーを使うとオンラインになるまで待機します（デフォルト5分）
- 最後にSysprep実行
```hcl:azure-winvm-image.pkr.hcl
build {
  sources = ["source.azure-arm.windowsserver-2022"]

  # Transfer the registry files.
  provisioner "file" {
    source      = "${path.root}/registry/"
    destination = "C:/"
  }

  # Download and install language packs.
  provisioner "powershell" {
    scripts = [
      "${path.root}/scripts/install_language_pack_1.ps1",
    ]
  }

  provisioner "windows-restart" {}

  # Language Settings.
  provisioner "powershell" {
    scripts = [
      "${path.root}/scripts/install_language_pack_2.ps1",
    ]
  }

  provisioner "windows-restart" {}

  # Running Sysprep.
  provisioner "powershell" {
    inline = [
      "while ((Get-Service WindowsAzureGuestAgent).Status -ne 'Running') { Start-Sleep -s 5 }",
      "& $env:SystemRoot\\System32\\Sysprep\\Sysprep.exe /generalize /oobe /mode:vm /quiet /quit",
      "while($true) { $imageState = Get-ItemProperty HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Setup\\State | Select ImageState; if($imageState.ImageState -ne 'IMAGE_STATE_GENERALIZE_RESEAL_TO_OOBE') { Write-Output $imageState.ImageState; Start-Sleep -s 10  } else { break } }"
    ]
  }
}
```

# 日本語化の処理
前述の通り、言語パックのインストールと言語設定はPowerShellで実行しています。
合間に再起動させるためにファイルを分けていますが、ひとまとめにすると以下のような流れです。
※Windows Server 2022 前提にしていますが、ベースイメージが Windows Server 2019 の場合でもURLとパスを変えれば良いだけです
```powershell:install_language_pack_*.ps1
Write-Host "Download the languagePack for Windows Server 2022"
$downloadPath = "C:\lang.iso"
$downloadUrl = "https://go.microsoft.com/fwlink/p/?linkid=2195333"

# Windows Server 2019
# $downloadUrl = "https://software-download.microsoft.com/download/pr/17763.1.180914-1434.rs5_release_SERVERLANGPACKDVD_OEM_MULTI.iso"

## Note: Download speed is faster with net.webclient than with the Invoke-WebRequest command.
# Invoke-WebRequest -Uri $downloadUrl -OutFile $downloadPath
$wc = New-Object net.webclient
$wc.Downloadfile($downloadUrl, $downloadPath)

Write-Host "Mount ISO."
$mountResult = Mount-DiskImage $downloadPath -PassThru

Write-Host "Get the drive letter of the mounted ISO."
$driveLetter = ($mountResult | Get-Volume).DriveLetter

Write-Host "Stores paths"
$lppath = $driveLetter + ":\LanguagesAndOptionalFeatures\Microsoft-Windows-Server-Language-Pack_x64_ja-jp.cab"

# Windows Server 2019
# $lppath = $driveLetter + ":\x64\langpacks\Microsoft-Windows-Server-Language-Pack_x64_ja-jp.cab"

Write-Host "Install Japanese languagePack using Lpksetup.exe command."
lpksetup.exe /i ja-JP /s /p $lppath

while((Get-Process "lpksetup" -ErrorAction SilentlyContinue) -ne $null) {
    Write-Host "lpksetup process is running."
    Start-Sleep -Seconds 60
}
Write-Host "The process has been completed."

Write-Host "Unmount disk and delete ISO."
DisMount-DiskImage $downloadPath
Remove-Item $downloadPath

Write-Host "Set the language used by the user to Japanese."
Set-WinUserLanguageList -LanguageList ja-JP,en-US -Force

Write-Host "Overwrites the input language with Japanese."
Set-WinDefaultInputMethodOverride -InputTip "0411:00000411"

### Packerのwindows-restartプロビジョナーを使ってここで再起動 ###

Write-Host "Override the UI language with Japanese."
Set-WinUILanguageOverride -Language ja-JP

Write-Host "Set the time/date format to the same as the Windows language."
Set-WinCultureFromLanguageListOptOut -OptOut $False

Write-Host "Location is set to Japan."
Set-WinHomeLocation -GeoId 0x7A

Write-Host "Set the system locale to Japan."
Set-WinSystemLocale -SystemLocale ja-JP

Write-Host "Modify the registry to change the Welcome screen and default user display language."
$DefaultHKEY = "HKU\DEFAULT_USER"
$DefaultRegPath = "C:\Users\Default\NTUSER.DAT"
reg load $DefaultHKEY $DefaultRegPath
reg import "C:\ja-JP-default.reg"
reg unload $DefaultHKEY
reg import "C:\ja-JP-welcome.reg"
Remove-Item "C:\ja-JP-*.reg"

## Note: Switch the language bar to legacy mode if necessary
# Write-Host "Set MS-IME input method."
# Set-WinLanguageBarOption -UseLegacySwitchMode -UseLegacyLanguageBar

## Note: Time zone settings will initialized by Sysprep
# Write-Host "Set the time zone to Tokyo."
# Set-TimeZone -Id "Tokyo Standard Time"
```
ここでは、GUIで行う処理を一つずつPowerShellで実行しているだけですが、Windows Serverでは以下の画面で行う2つの設定をPowerShellのコマンドで実行することができません。
- Welcomスクリーンの言語設定
- 新しいユーザーの言語設定

![スクリーンショット 2023-03-26 232635 (小).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/402286/8e15f508-2ac8-2175-93af-ac0337833e00.png)

なので、レジストリをインポートしてInternational関連のデフォルト値を書き換えて実現しています。
[sny0421]((https://github.com/sny0421/windows-server-2019-japanese)) さんがGitHubに公開しているものを参考にさせて頂きました（ありがとうございます）

Windows11 であれば [Copy-UserInternationalSettingsToSystem](https://learn.microsoft.com/en-us/powershell/module/international/copy-userinternationalsettingstosystem?view=windowsserver2022-ps) で同等のコピー処理ができるようになっているのですが、早く Windows Server にも対応してほしいですね。
```powershell
Copy-UserInternationalSettingsToSystem -WelcomeScreen $True -NewUser $True
```
# ビルド実行
30分ちょっとくらいで処理は完了しました。
```bash
# *.pkr.hclファイルをフォーマット
packer fmt .

# *.pkr.hclファイルの検証
packer validate .

# ビルド実行
packer build .
```

# デバッグ
```bash
# 各ステップの間で停止し、キーボード入力を待ってから続行する
packer build -debug .

# エラー発生時に処理を継続するか中断するかを選択する
packer build -on-error=ask .
```
NSGにRDP許可を追加すればビルド中のVMに接続して確認することもできます。

# つまづいたところ
先人達のいくつかのコードを助けに実装しましたが、思ってた以上にハマりました。。
まだ全容は理解していませんが、色々と試行錯誤して良い勉強になりました。

## 言語パックのダウンロード
言語パックを含むisoはファイルサイズが数GBと巨大ですが、大きいファイルを `Invoke-WebRequest` でダウンロードすると遅いです。
`Start-BitsTransfer` を使うと速いのですが、WinRM越しでは実行できないようです。
結果、`net.webclient` でのダウンロードにしました。
```powershell
$wc = New-Object net.webclient
$wc.Downloadfile($downloadUrl, $downloadPath)
```

## lpksetupコマンドの挙動
[lpksetup](https://learn.microsoft.com/ja-jp/previous-versions//dn898585(v=vs.85)) は言語パックをコマンドラインでインストールできるツールですが、`-f` オプションの挙動がよくわからず悩みました。

>If the computer must restart, forces a restart even if there are other users logged on to the computer.

これ、インストールが終わったら再起動されるわけではない・・・？
少なくとも、私が試した環境ではいつまで経っても再起動しませんでした。
後で気づいたのですが、ビルド中にOSトリガーで再起動されても困るのでいずれにせよ不要でした。

で、次の処理へ進むためにインストールの処理が終わっているかどうか判定する必要があるので `Wait-Proccess` で lpksetup が終わるまで待機させるようにしたのですが、なぜか Packer では終了を拾うことができませんでした。代わりに `Get-Process` のプロセスがなくなるまで While でループさせるようにしました。

## タイムゾーンは設定しても意味ない
Sysprep すると初期化されて UTC に戻るので意味ないです。凡ミス。

## レジストリファイルのエンコードは UTF-16 LE
UTF-8になっていると一部文字化けします。凡ミス。

## スポット VM の指定方法
リファレンスの [Azure Resource Manager Builder](https://developer.hashicorp.com/packer/plugins/builders/azure/arm) を見ても `Spot` のパラメータが載ってない？
Goのドキュメント [arm](https://pkg.go.dev/github.com/hashicorp/packer-plugin-azure/builder/azure/arm#Spot) に書いてあった。

# まとめ
今回は、Windows Server 2022 を日本語化してイメージ作成する処理を Packer で自動化する手順をご紹介しました。Packer を使うと、Azure VM のイメージ作成を自動化することができます。マルチクラウドのツールなので、AWS の AMI 作成にもよく使われます。

次は GithubActions で Packer を実行できるようにしてみたいと思います。

https://qiita.com/m-oka-system/items/410b8f2c372b6ab31534

# 参考リンク
- [PowerShell:Packer を使用して Azure に仮想マシンのイメージを作成する方法](https://learn.microsoft.com/ja-jp/azure/virtual-machines/windows/build-image-with-packer)
- [[小ネタ]WindowsServer2019を日本語化するPowerShellを作った](https://dev.classmethod.jp/articles/windowsserver2019_to_japanese/)
- [Azure 上に Packer で日本語版 Windows Server の VM イメージを作成してみた](https://blog.devplatform.techmatrix.jp/blog/create-localized-windows-server-vm-image-on-azure-with-packer/)
- [Packer - HCL Templates Overview](https://developer.hashicorp.com/packer/docs/templates/hcl_templates)
- [Github - windows-server-2019-japanese](https://github.com/sny0421/windows-server-2019-japanese)