コマンド集 / Windows Server / AD

Windows テンプレート構築 コマンド集

Proxmox 上で Windows Server の雛形を autounattend・post-install・cloudbase-init・sysprep で構築し、複製後の初期設定と Azure Arc 接続までを罠つきで引ける。

Proxmox VE 上に Windows Server 2025 / 2022(評価版)の雛形 tpl-win2025 / tpl-win2022人が一度も入らずに組み立て、Linux の cloud image と同じく qm cloneqm set --ciuser ... で払い出せるようにした環境向け。台本は createwatchfinish の 3 段で、途中の状態は C:\lab\stage.txtC:\lab\build.log に残す。

流れ#

3 コマンドで組む#

bash ops/windows/build-template.sh create 2025   # VM を作って無人インストールを始める
bash ops/windows/build-template.sh watch  2025   # いまどの段に居るか
bash ops/windows/build-template.sh finish 2025   # sysprep で止まったのを確かめて変換

中で起きること#

ISO 起動 → autounattend(WinPE で vioscsi を読む → 版を名前で選ぶ → 自動ログオン)
  → post-install.ps1  drivers 段: virtio guest tools・固定 IP・RDP
                      update  段: Windows Update を無くなるまで繰り返す
                      sysprep 段: cloudbase-init → Appx 掃除 → dism → sysprep /generalize /oobe /shutdown
  → 電源が落ちる → finish: CD を外す・cloud-init ドライブを付ける → qm template

⚠️ 組み立ては 1〜2 時間。qm statusrunning でも進んでいるとは限らない(UEFI の「Press any key」で 52 分止まっていた実例あり)。

複製する#

qm clone <テンプレートID> <新VMID> --name <> --full
qm set <新VMID> --ciuser Administrator --cipassword '<パスワード>' --ipconfig0 ip=<IP>/24,gw=<GWのIP> --nameserver <DNSのIP>
qm start <新VMID>

autounattend の要点#

構造#

autounattend.xml.tmpl は 3 つの pass からなる。値は build-template.sh が 1Password(op read "op://<vault>/<item>/password")から取って Python で差し込み、応答 ISO に post-install.ps1net.txt を同梱する。

windowsPE   言語 ja-JP / DriverPaths(D: E: F: の vioscsi と NetKVM)/ EFI 300MB + MSR 128MB + C: / ImageInstall
specialize  ComputerName(@@HOSTNAME@@)/ TimeZone / RDP を開ける(fDenyTSConnections=false)
oobeSystem  OOBE の画面を全部隠す / AdministratorPassword / AutoLogon(50 回)/ FirstLogonCommands で post-install.ps1

⚠️ 印は @@ADMIN_PASSWORD@@ @@DRV@@ @@IMAGE_NAME@@ @@HOSTNAME@@コメントも置換されるので、コメントに印そのものを書くとパスワードがコメントに入る。&< を含む値は XML エスケープしないとファイルごと壊れ、セットアップは「応答ファイルが無い」顔をして対話モードに落ちる。差し込み漏れの @@…@@ も同じ症状。

版は名前で指す#

<InstallFrom>
  <MetaData wcm:action="add"><Key>/IMAGE/NAME</Key><Value>Windows Server 2025 SERVERSTANDARD</Value></MetaData>
</InstallFrom>

⚠️ /IMAGE/INDEX は ISO を差し替えた瞬間に黙って別の版を指す。評価版に ProductKey は要らず、空の <Key> を置くと Server 2022 は「ライセンス条項が見つかりません」で止まる(2025 は通る)。AutoLogonLogonCount は多めに(50)。RunOnce は対話ログオン時にしか発火せず、5 回では更新の再起動で使い切ってログイン画面で止まる。失敗ではないので FAILED.txt も出ない

VirtIO とゲストエージェント#

VM を作る#

既存の Windows ゲストと構成を揃える(OVMF + TPM 2.0 + q35 + virtio-scsi-single)。

qm create <vmid> --name tpl-win2025 --ostype win11 --machine q35 --bios ovmf \
  --cpu x86-64-v2 --cores 4 --memory 6144 --balloon 3072 --scsihw virtio-scsi-single \
  --efidisk0 shared:1,efitype=4m,ms-cert=2023k,pre-enrolled-keys=1 --tpmstate0 shared:1,version=v2.0 \
  --scsi0 shared:48,discard=on,iothread=1,ssd=1 --net0 virtio,bridge=vmbr0,firewall=1 \
  --ide2 local:iso/<Windows ISO>,media=cdrom --ide0 local:iso/virtio-win-<>.iso,media=cdrom \
  --ide1 local:iso/lab-answer-ws2025.iso,media=cdrom --boot order='ide2;scsi0' --agent enabled=1 --onboot 0

⚠️ ostype は Server 2025 が win11、2022 が win10。CPU は x86-64-v2 にすると停止せずにノード間を移せるhost は移せない)。local はノード単位なので、ISO と virtio ISO が同じノードに無いと volume does not exist で落ちる。要るドライバは vioscsi\2k25\amd64viostor は virtio-blk 用で、入れてもディスクは見えない)。

「Press any key」を押す#

qm start <vmid>
for i in $(seq 1 15); do echo 'sendkey ret' | qm monitor <vmid> >/dev/null 2>&1; sleep 1; done
echo 'screendump /tmp/chk.ppm' | qm monitor <vmid>

⚠️ 誰も押さないと空のディスクしか無いためブートマネージャで止まる。応答ファイルが効いていれば余分な Enter は無害。screendump画面の中央を見る。真っ黒は「消えているだけ」のこともあり、sendkey で起きる。

ゲストエージェントを入れる・確かめる#

post-install.ps1 の drivers 段が virtio guest tools(ドライバ + QEMU-GA)を入れる。後から入れるときはこちら。

Start-Process -FilePath C:\Windows\Temp\virtio-win-guest-tools.exe -ArgumentList '/quiet','/norestart' -Wait -PassThru
Get-Service -Name 'QEMU-GA'
qm agent <vmid> ping

⚠️ 中に QEMU-GA が Running でも VM 側に agent デバイスが無ければ応答しない。qm set <vmid> --agent enabled=1再起動まで効かず qm pending にも出ない。Windows Update の最中にエージェントだけ落ちることもあり、「答えない=止まっている」と判断しない。

ゲストへスクリプトを運ぶ#

qm guest exec は引用符付きの引数を通せず、file-write は非 ASCII で落ちる。BOM 付き UTF-8 を base64 で運び、md5 で照合してから実行する

ops/windows/psrun <node-ip> <vmid> <local.ps1> [timeout秒]
qm guest exec <vmid> --timeout 300 -- powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\lab\psrun.ps1

⚠️ qm guest exec ... -- cmd /c echo x >> f使えない>> を PVE 側の bash が食う)。秘密は argv ではなく --pass-stdin 1 で渡す。CD から Copy-Item した物は読み取り専用属性を引き継ぐ。

post-install#

段の進め方#

C:\lab\stage.txt   drivers -> update -> sysprep
C:\lab\build.log   各段のログ
C:\lab\FAILED.txt  失敗の印(走り出しで消し、いまの回のことだけ表す)

⚠️ 段ごとに try/catch で包み、例外はログと FAILED.txt に落とす。PowerShell の変数名は大文字小文字を区別しない$Stage$stage が同じ変数になり、drivers 段が毎回黙って死んだ実例あり)。文字列リテラルは ASCII だけ、日本語はコメントだけ。

drivers 段: ネットワークと RDP#

[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
New-NetIPAddress -InterfaceIndex <ifIndex> -IPAddress <IP> -PrefixLength 24 -DefaultGateway <GWのIP>
Set-DnsClientServerAddress -InterfaceIndex <ifIndex> -ServerAddresses <DNSのIP>
Test-NetConnection -ComputerName cloudbase.it -Port 443 -InformationLevel Quiet
Set-ItemProperty "HKLM:\System\CurrentControlSet\Control\Terminal Server" -Name fDenyTSConnections -Value 0
Enable-NetFirewallRule -Name "RemoteDesktop-UserMode-In-TCP","RemoteDesktop-UserMode-In-UDP"
Enable-PSRemoting -Force

⚠️ 表示名で探さない。日本語 Windows では DisplayGroup が「リモート デスクトップ」なので "Remote Desktop" は一致せず、RDP が開かない。Name は言語に依存しない。既定経路が残っていると New-NetIPAddressInstance DefaultGateway already exists で落ちるので、既に目的の IP が付いていれば何もしない。IP を入れた同じ秒に疎通を測ると偽陰性になる。WinRM はテンプレートでは開けず、払い出した機で必要なら。

update 段: Windows Update API#

PSGallery の PSWindowsUpdate は使わない(PowerShell 5.1 は既定 TLS 1.0/1.1 で NuGet 取得が止まる)。

$session  = New-Object -ComObject Microsoft.Update.Session
$searcher = $session.CreateUpdateSearcher()
$found    = $searcher.Search("IsInstalled=0 and Type='Software' and IsHidden=0")
$found.Updates | ForEach-Object { if (-not $_.EulaAccepted) { $_.AcceptEula() } }

⚠️ 使用条件に同意していない更新は黙って飛ばされるInstall()ResultCode が 4 以上なら sysprep へ進まず FAILED.txt を置く。中途半端に一般化した image は後から追えない。

cloud-init(cloudbase-init)#

入れる#

Invoke-WebRequest -UseBasicParsing -Uri "https://cloudbase.it/downloads/CloudbaseInitSetup_Stable_x64.msi" -OutFile C:\lab\CloudbaseInitSetup.msi
Start-Process msiexec.exe -Wait -PassThru -ArgumentList "/i","C:\lab\CloudbaseInitSetup.msi","/qn","/norestart","RUN_SERVICE_AS_LOCAL_SYSTEM=1"

⚠️ 入らなかったら一般化しない。入っていない雛形は「作れたのに設定が入らない機」を量産する。

設定は書き換える#

cloudbase-init.confcloudbase-init-unattend.conf の両方で、要る鍵だけ差し替える。

username=Administrator
inject_user_password=true
first_logon_behaviour=no
metadata_services=cloudbaseinit.metadata.services.configdrive.ConfigDriveService
plugins=...SetHostNamePlugin,...ExtendVolumesPlugin,...NetworkConfigPlugin,...SetUserPasswordPlugin,...SetUserSSHPublicKeysPlugin,...UserDataPlugin

⚠️ 丸ごと書き直すと版が変わったときに既定を落とす。ExtendVolumesPlugin を入れないと払い出しで広げたディスクが使われないまま残る。

PVE が渡すもの#

PVE は ostype が Windows なら configdrive2 で書く(citype の指定は不要)。渡るのはホスト名・IP・ゲートウェイ・DNS・ユーザーとパスワード・SSH 公開鍵。 ⚠️ cloud-init ドライブ(--ide2 shared:cloudinit)が無いと cloudbase-init は何も読めないfinish で付ける。cloudbase-init は起動から 1〜2 分かけて 2 回走るので、その最中に見ると計算機名が WIN-xxxxxxxx に見える。C:\Program Files\Cloudbase Solutions\Cloudbase-Init\log\cloudbase-init.logPlugins execution done を待つ。複製した機に設定が入らないのは、ドライブが無い/Unattend.xml を渡さずに sysprep した/2 回目の実行前に見た、のどれか。

sysprep とテンプレート化#

一般化する#

cloudbase-init の Unattend.xml を渡す。これが specialize で cloudbase-init を走らせる仕掛け。

Get-AppxProvisionedPackage -Online | Select-Object PackageName
Get-AppxPackage -AllUsers | Select-Object PackageFullName
Remove-AppxPackage -Package <PackageFullName> -AllUsers
dism.exe /Online /Cleanup-Image /StartComponentCleanup /ResetBase
& "$env:SystemRoot\System32\Sysprep\sysprep.exe" /generalize /oobe /shutdown /quiet "/unattend:C:\Program Files\Cloudbase Solutions\Cloudbase-Init\conf\Unattend.xml"

⚠️ 「あるユーザーにだけ入っていて全ユーザーに provision されていない」Appx が 1 つでもあると 0x80073cf2 で止まる。2022 は Windows Update が入れた Edge、クライアントは日本語の Language Experience Packがこれに当たる。止まっても機は落ちず stage.txtsysprep のままなので気づきにくい。

止まったときに読む場所#

Get-Content C:\Windows\System32\Sysprep\Panther\setuperr.log -Tail 30
Stop-Process -Name sysprep -Force

⚠️ pre-validate で落ちた分は rearm を消費しないので何度でもやり直せる。sysprep.exe はエラーダイアログを出したまま常駐するので、やり直す前に止める。qm guest exec から流すときは Start-Process-Wait なし)で投げて qm status で停止を待つ。Get-AppxPackage -AllUsers は重く guest exec がタイムアウトする。

仕上げて変換する#

sysprep は終わると自分で電源を落とす。stopped を確かめてから。

qm status <vmid>
qm set <vmid> --ide0 none --ide1 none --ide2 none
qm set <vmid> --boot order=scsi0
rm -f /var/lib/vz/template/iso/lab-answer-ws2025.iso
qm set <vmid> --ide2 shared:cloudinit
qm template <vmid>

⚠️ 応答 ISO には組み立て用のパスワードが入っているので消す。qm template は戻せない。作り直すなら create から。

EFI / TPM も一緒に移す#

for d in scsi0 efidisk0 tpmstate0; do qm move-disk <vmid> $d shared --format qcow2 --delete; done

⚠️ scsi0 だけ移すと 4MB の EFI と TPM が local-lvm に残り、そのノードでしか複製できない。qcow2 なら実際に使う分(約 16G)しかコピーしない。

複製後の初期設定#

名前と参加#

名前と IP は cloudbase-init が入れる。ドメイン参加は同期スコープの OU を指定して、参加と同時に改名する。

Add-Computer -DomainName ad.lab.example.jp -NewName <名前> -OUPath "OU=HybridJoin,OU=Devices,OU=Example,DC=ad,DC=lab,DC=example,DC=jp" -Credential (Get-Credential) -Restart
Test-ComputerSecureChannel

⚠️ 既定の CN=Computers に落ちると GPO が当たらず同期もされない。ドメイン参加直後に重いインストーラーを走らせない(プロファイル作成中に入れて LocalDB で失敗した実例あり)。

評価版の期限と rearm#

クローンした側もテンプレートの残り日数を引き継ぐ。

Get-CimInstance SoftwareLicensingProduct -Filter "PartialProductKey IS NOT NULL" | Select-Object Description,GracePeriodRemaining,LicenseStatus
(Get-CimInstance SoftwareLicensingService).RemainingWindowsReArmCount
slmgr /rearm

⚠️ GracePeriodRemaining の単位は。rearm 回数は Server 2022 が最大 6 回、2025 は 1 回で、公式の記事に明記は無くサーバーが答える値だけが事実。期限は数週間ずつずらす(同時に切れると DC を 1 台ずつしか入れ替えられず詰む)。

期限通知(Graph sendMail)#

SMTP AUTH の基本認証は停止済みなので Graph で送る。証明書はサーバーごとに 1 枚、CN=notify-<hostname>

Connect-MgGraph -TenantId <tenant-id> -ClientId <app-id> -CertificateThumbprint <thumbprint> -NoWelcome
Send-MgUserMail -UserId <通知先の UPN> -BodyParameter $message
New-ApplicationAccessPolicy -AppId <app-id> -PolicyScopeGroupId <通知先の UPN> -AccessRight RestrictAccess
Test-ApplicationAccessPolicy -Identity <別のメールボックス> -AppId <app-id>

⚠️ Mail.Send のアプリケーション権限は既定でテナント内の全メールボックスとして送信できる。Application Access Policy を権限より先に作り、検証は範囲外のメールボックスで Denied を見る。同名の証明書が 2 枚あると自動解決が未登録の方を掴む。

Azure Arc への接続#

テンプレートは繋がない#

Arc のリソース名と識別子は connect したときに決まる。テンプレートが繋がっていると複製した機が全部同じ 1 台として上書きし合う。sysprep の前に「初回起動で繋ぐ仕掛け」だけを置く。

bash ops/arc/install.sh windows <node-ip> <vmid>          # sysprep 前: LocalScripts に置くだけ
bash ops/arc/install.sh windows <node-ip> <vmid> --now    # 出来た機に今すぐ当てる

⚠️ --now をテンプレートに使わない。資格情報は op://<vault>/<item>/credential から取り、成否によらず初回起動で消す。

エージェントを入れて繋ぐ#

cloudbase-init の LocalScripts に置いた arc-onboard.ps1 が、名前と IP が入った後に一度だけ走る。

Invoke-WebRequest -Uri "https://aka.ms/azcmagent-windows" -OutFile C:\lab\install-azcmagent.ps1 -UseBasicParsing
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\lab\install-azcmagent.ps1
& "$env:ProgramFiles\AzureConnectedMachineAgent\azcmagent.exe" connect --service-principal-id <app-id> --service-principal-secret <secret> --tenant-id <tenant-id> --subscription-id <sub-id> --resource-group <RG> --location <リージョン> --resource-name $env:COMPUTERNAME --tags "lab=proxmox,onboard=auto"
& "$env:ProgramFiles\AzureConnectedMachineAgent\azcmagent.exe" show

⚠️ SP の役割は Azure Connected Machine Onboarding だけ、範囲はリソースグループだけにする。疎通確認で management.azure.com は未認証だと 401 を返すので curl -f を使わず、成功の枝でも「届いた」と判定する(catch の中だけで立てると通ったときに限って待ち続ける)。--access-token で繋ぐときはトークンを argv ではなく標準入力で渡す。

困ったとき#

どの段で止まっているか#

qm status <vmid>
qm guest exec <vmid> -- cmd.exe /c type C:\lab\stage.txt
bash ops/windows/watch-build.sh <node-ip> <vmid>
bash ops/windows/push-script.sh <node-ip> <vmid> [戻す段]

⚠️ 見る順番は qm status/proc/<pid>/stat の CPU → screendump の中央 → ポート 3389 / 5985 → ゲストエージェント。台本を直したら push-script.sh で中身だけ差し替えて続きから走らせる(最初からやり直すと 1〜2 時間)。ISO の参照が残ったまま ISO 名を変えると次の再起動で volume 'local:iso/...' does not exist になる。