PVE + CloudBaseInit/CloudInit + Windows 全宇宙最全最完善解决方案

相信用过 PVE 的同学都知道 Cloudinit 怎么爽,但是在 Windows 下使用会有一些问题,本文将详细介绍如何在 Windows 下使用 Cloudinit。请注意,本文并不是你找到的一些需要修改 PVE 源码的教程。官方已在最新版本中修复了此问题。

直接上结论:如果你的 PVE 在使用 Windows 的 Cloudinit 时遇到了比如无法自动设置 IP、无法自动设置密码等问题。那么请升级你的 PVE 版本 , 需要大于等于 8.2.4

聪明的你就要问了,为什么需要升级版本,究竟发生了什么?

因为在旧版本中,PVE 通过 cloudinit 创建的镜像写入的信息密码是加密的。而我们在 windows 上使用的客户端是 cloudbase-init,它无法解密这个密码,所以就会导致无法自动设置密码的问题。而你抱着这个问题去全网搜索解决方案,你会发现大部分的解决方案都是修改 PVE 源码,这样做的确可以解决问题,但是这样做的后果是你的 PVE 就不是官方的了,你需要自己维护,而且每次升级都需要重新修改源码。所以这种方法并不是最好的。

于是机智的网友向 PVE 提出了这个问题,终于在 8.2.4 版本中,PVE 官方修复了这个问题,现在我们可以直接使用官方的 PVE 来解决这个问题。 点我直达

升级 8.2.4 版本之后,你不需要修改任何内容。在选择创建 vm 的时候,make os type is any windows version. And boot the vm , install the cloudbase-init. you can click this link to get the latest stable version of cloudbase-init. 对不起,懒得切中文了

然后你需要去修改默认的配置文件,是的。爸爸给你把配置文件准备好了,直接抄吧。 修改的配置文件是 C:\Program Files\Cloudbase Solutions\Cloudbase-Init\conf\cloudbase-init.conf
此配置文件在包含 PVE 文档的基本功能后也自行配置了一些插件。
实现的功能是修改默认的管理员账号,设置密码,设置主机名,设置网络配置,扩展磁盘。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
[DEFAULT]
username=administrator
groups=Administrators
inject_user_password=true
first_logon_behaviour=no
rename_admin_user=true
bsdtar_path=C:\Program Files\Cloudbase Solutions\Cloudbase-Init\bin\bsdtar.exe
mtools_path=C:\Program Files\Cloudbase Solutions\Cloudbase-Init\bin\
verbose=true
debug=true
log_dir=C:\Program Files\Cloudbase Solutions\Cloudbase-Init\log\
log_file=cloudbase-init.log
default_log_levels=comtypes=INFO,suds=INFO,iso8601=WARN,requests=WARN
mtu_use_dhcp_config=false
ntp_use_dhcp_config=false
local_scripts_path=C:\Program Files\Cloudbase Solutions\Cloudbase-Init\LocalScripts\
check_latest_version=false
metadata_services=cloudbaseinit.metadata.services.configdrive.ConfigDriveService
plugins=cloudbaseinit.plugins.common.networkconfig.NetworkConfigPlugin,cloudbaseinit.plugins.windows.extendvolumes.ExtendVolumesPlugin,cloudbaseinit.plugins.common.setuserpassword.SetUserPasswordPlugin,cloudbaseinit.plugins.common.sethostname.SetHostNamePlugin,cloudbaseinit.plugins.windows.createuser.CreateUserPlugin
[config_drive]
cdrom=true

这时候,可以把 vm 关机。在 pvm 界面配置 cloudinit,且挂在到 vm 上。启动 vm 就可以看到 cloudinit 生效啦!
有机智的宝宝就要问了,那怎么把自动挂在的 cloudinit 在配置完毕后自动取消挂载呢。这里就需要你修改 cloudinit 的 py 文件了

C:\Program Files\Cloudbase Solutions\Cloudbase-Init\Python\Lib\site-packages\cloudbaseinit\metadata\services\configdrive.py

把这个文件的一整坨都替换成下面的内容,这样就可以在配置完毕后自动取消挂载了。

重新优化版本,适配 Windows Server 2025 版本,并且向下兼容至 Windows Server 2012 版本,支持自动弹出 config-2 驱动器。
感谢 Claude Sonnet 4

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
# Copyright 2020 Cloudbase Solutions Srl
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.

from oslo_log import log as oslo_logging
from cloudbaseinit import conf as cloudbaseinit_conf
from cloudbaseinit.metadata.services import baseconfigdrive
from cloudbaseinit.metadata.services import baseopenstackservice
import os
import shutil
import ctypes

CONF = cloudbaseinit_conf.CONF
LOG = oslo_logging.getLogger(__name__)


class ConfigDriveService(baseconfigdrive.BaseConfigDriveService,
baseopenstackservice.BaseOpenStackService):

def __init__(self):
super(ConfigDriveService, self).__init__(
'config-2', 'openstack\\latest\\meta_data.json')

def cleanup(self):
LOG.debug('Deleting metadata folder: %r', self._mgr.target_path)
shutil.rmtree(self._mgr.target_path, ignore_errors=True)
self._metadata_path = None

# 动态获取 config-2 驱动器的盘符
drive_letter = self._get_config_drive_letter()

if drive_letter:
LOG.debug('Found config-2 drive at: %s', drive_letter)
success = self._eject_drive(drive_letter)
if success:
LOG.info('Successfully ejected config-2 drive: %s', drive_letter)
else:
LOG.warning('Failed to eject config-2 drive: %s', drive_letter)
else:
LOG.warning('No config-2 drive found to eject')

def _get_config_drive_letter(self):
"""动态获取 config-2 驱动器的盘符,优先使用现代方法,兼容旧系统"""

# 方法1:现代 PowerShell CIM 方法 (Windows Server 2016+)
try:
LOG.debug('Attempting to find config-2 drive using PowerShell CIM')
ps_cmd = 'powershell "Get-CimInstance -ClassName Win32_LogicalDisk | Where-Object {$_.VolumeName -eq \'config-2\'} | Select-Object -ExpandProperty DeviceID"'
result = os.popen(ps_cmd).read().strip()

if result and ':' in result:
LOG.debug('Found drive using PowerShell CIM: %s', result)
return result
except Exception as e:
LOG.debug('PowerShell CIM method failed: %s', e)

# 方法2:兼容旧系统的 wmic 方法 (Windows Server 2012/2019)
try:
LOG.debug('Attempting to find config-2 drive using wmic (legacy)')
result = os.popen('wmic logicaldisk where VolumeName="config-2" get Caption | findstr /I ":"').read().strip()

if result and ':' in result:
LOG.debug('Found drive using wmic: %s', result)
return result
except Exception as e:
LOG.debug('WMIC method failed: %s', e)

# 方法3:PowerShell WMI 方法 (中等兼容性)
try:
LOG.debug('Attempting to find config-2 drive using PowerShell WMI')
ps_cmd = 'powershell "Get-WmiObject -Class Win32_LogicalDisk | Where-Object {$_.VolumeName -eq \'config-2\'} | Select-Object -ExpandProperty DeviceID"'
result = os.popen(ps_cmd).read().strip()

if result and ':' in result:
LOG.debug('Found drive using PowerShell WMI: %s', result)
return result
except Exception as e:
LOG.debug('PowerShell WMI method failed: %s', e)

LOG.warning('All methods failed to find config-2 drive')
return None

def _eject_drive(self, drive_letter):
"""弹出指定的驱动器,优先使用现代方法,兼容旧系统"""

# 方法1:PowerShell Shell.Application(已验证可行,Windows Server 2016+)
try:
LOG.debug('Attempting PowerShell Shell.Application eject for: %s', drive_letter)
ps_cmd = f'powershell "(New-Object -comObject Shell.Application).Namespace(17).ParseName(\'{drive_letter}\').InvokeVerb(\'Eject\')"'
result = os.system(ps_cmd)

if result == 0:
LOG.info('Successfully ejected %s using PowerShell Shell.Application', drive_letter)
return True
else:
LOG.debug('PowerShell Shell.Application eject failed with code: %d', result)
except Exception as e:
LOG.debug('PowerShell Shell.Application eject exception: %s', e)

# 方法2:传统 MCI 命令(兼容旧系统,Windows Server 2012/2019)
try:
LOG.debug('Attempting MCI eject for: %s (legacy compatibility)', drive_letter)
result1 = ctypes.windll.WINMM.mciSendStringW(f"open {drive_letter} type cdaudio alias d_drive", None, 0, None)
result2 = ctypes.windll.WINMM.mciSendStringW("set d_drive door open", None, 0, None)
result3 = ctypes.windll.WINMM.mciSendStringW("close d_drive", None, 0, None)

LOG.debug('MCI eject results: open=%d, eject=%d, close=%d', result1, result2, result3)

if result1 == 0 and result2 == 0:
LOG.info('Successfully ejected %s using MCI (legacy method)', drive_letter)
return True
else:
LOG.debug('MCI eject failed: open=%d, eject=%d', result1, result2)
except Exception as e:
LOG.debug('MCI eject exception: %s', e)

# 方法3:PowerShell WMI 卸载方法(中等兼容性)
try:
LOG.debug('Attempting PowerShell WMI dismount for: %s', drive_letter)
ps_cmd = f'powershell "(Get-WmiObject -Class Win32_Volume | Where-Object {{$_.DriveLetter -eq \'{drive_letter}\'}}).Dismount($true, $false)"'
result = os.system(ps_cmd)

if result == 0:
LOG.info('Successfully dismounted %s using PowerShell WMI', drive_letter)
return True
else:
LOG.debug('PowerShell WMI dismount failed with code: %d', result)
except Exception as e:
LOG.debug('PowerShell WMI dismount exception: %s', e)

LOG.error('All eject methods failed for drive: %s', drive_letter)
LOG.info('Please manually eject the drive from Windows Explorer if needed')
return False

下面是原版代码,已知在 Windows Server 2025 版本中不可用,供参考:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
# Copyright 2020 Cloudbase Solutions Srl
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.

from oslo_log import log as oslo_logging
from cloudbaseinit import conf as cloudbaseinit_conf
from cloudbaseinit.metadata.services import baseconfigdrive
from cloudbaseinit.metadata.services import baseopenstackservice
import os
import shutil
import ctypes

CONF = cloudbaseinit_conf.CONF
LOG = oslo_logging.getLogger(__name__)


class ConfigDriveService(baseconfigdrive.BaseConfigDriveService,
baseopenstackservice.BaseOpenStackService):

def __init__(self):
super(ConfigDriveService, self).__init__(
'config-2', 'openstack\\latest\\meta_data.json')

def cleanup(self):
LOG.debug('Deleting metadata folder: %r', self._mgr.target_path)
shutil.rmtree(self._mgr.target_path, ignore_errors=True)
self._metadata_path = None
drive_letter = os.popen('wmic logicaldisk where VolumeName="config-2" get Caption | findstr /I ":"').read().strip()
if drive_letter:
LOG.debug('Ejecting metadata drive: %s', drive_letter)
ctypes.windll.WINMM.mciSendStringW(f"open {drive_letter} type cdaudio alias d_drive", None, 0, None)
ctypes.windll.WINMM.mciSendStringW("set d_drive door open", None, 0, None)