凌晨两点,监控告警说某台业务虚拟机掉线了。我远程连上 ESXi 主机,敲了一条esxcli vm process list,列表里干干净净——那台 VM 的进程压根不在。这种情况我遇到过太多次,最后落地的方案都是用 esxcli 配合 vim-cmd 做一套巡检:crond 定时触发脚本,发现虚拟机不在开机状态就拉起来。今天就把这套东西完整拆开讲讲,包括为什么 esxcli 里根本找不到“开机”这个命令、cron 时间为什么总是差 8 小时、以及那些让我返工三次的持久化问题。内容适合手上只有一两台 ESXi 主机、没有上 vCenter 或者不想为这点小事上 vCenter 的朋友,也适合刚接触 ESXi 命令行、想搞清楚esxcli和vim-cmd边界的人。
1. esxcli 管“停”不管“启”,开机的活得换个工具
1.1 为什么esxcli vm process下翻不到 power on
第一次做这个需求的时候,我在esxcli vm process下面翻了好几遍帮助,esxcli vm process --help出来的子命令只有两个:list和kill。当时以为是自己版本老,换了几个版本试,还是一样。
后来想明白了这件事的定位问题:esxcli 是主机级别的管理接口,它眼里的虚拟机是一个个跑在 VMkernel 上的进程(World),所以它能干的事就是“看有哪些进程在跑”和“把某个进程干掉”。而“开机”这个动作,本质是对虚拟机这个对象调用一个电源方法,走的是另一套接口体系。vim-cmd是 vSphere API 在 ESXi 本地的命令行封装,虚拟机的电源操作都在它手里。
记住这条分界线,后面就不会到处乱找命令了:
- esxcli 面向主机:网络、存储、硬件、驱动、VM 进程、维护模式、NTP、防火墙
- vim-cmd 面向虚拟机对象:注册、注销、电源、快照、Tools 状态、配置查询
所以完整的方案必然是两条腿走路:用 vim-cmd 去开机,用 esxcli 去做健康复核和主机侧配置。标题里说“esxcli 定时开启虚拟机”,严格讲是“在 ESXi 上用命令行体系定时开启”,esxcli 负责的是周边的主机环境和状态验证。
1.2 两套命令的职责对照表
我把自己脚本里实际用到的命令整理成一张表,照着查基本不用再翻文档:
| 想做的事 | 命令 | 归属 |
|---|---|---|
| 列出正在运行的 VM 和 World ID | esxcli vm process list | esxcli |
| 强杀某个 VM 进程 | esxcli vm process kill --type=hard --world-id=N | esxcli |
| 列出所有已注册 VM 及 vmid | vim-cmd vmsvc/getallvms | vim-cmd |
| 查电源状态 | vim-cmd vmsvc/power.getstate <vmid> | vim-cmd |
| 开机 | vim-cmd vmsvc/power.on <vmid> | vim-cmd |
| 请求 Guest 正常关机 | vim-cmd vmsvc/power.shutdown <vmid> | vim-cmd |
| 直接断电 | vim-cmd vmsvc/power.off <vmid> | vim-cmd |
| 重置(相当于按 reset 键) | vim-cmd vmsvc/power.reset <vmid> | vim-cmd |
| 挂起 | vim-cmd vmsvc/power.suspend <vmid> | vim-cmd |
| 查 VMware Tools 状态 | vim-cmd vmsvc/tools.status <vmid> | vim-cmd |
| 查数据卷挂载情况 | esxcli storage filesystem list | esxcli |
| 查主机当前时间 | esxcli system time get | esxcli |
| 查硬件时钟 | esxcli hardware clock get | esxcli |
esxcli vm process kill的三个 type 值得单独说一下,很多人用错:soft是向 Guest 发 ACPI 关机请求,需要 Tools 在跑;hard是立刻切断电源,相当于拔插头;force是更强制的终止。生产环境里除非 VM 已经完全无响应,否则别用hard,文件系统大概率要修。
1.3 vmid 不能写死,这是第一个坑
vim-cmd vmsvc/getallvms的输出长这样:
Vmid Name File Guest OS Version Annotation 1 web-prod-01 [ds01] web-prod-01/web-prod-01.vmx rhel8_64Guest vmx-19 7 db-prod-01 [ds01] db-prod-01/db-prod-01.vmx rhel8_64Guest vmx-19左边的1、7就是 vmid。新手最容易犯的错是把这个数字抄进脚本里,觉得它是永久的。实际上 vmid 是 ESXi 在注册虚拟机时分配的顺序号,VM 被注销后重新注册、从模板克隆、从别的卷上重新扫描进来,都会拿到新的 vmid。主机重启后虽然通常保持不变,但只要动过注册关系就会变。
稳妥的做法是按虚拟机名字反查 vmid。写成一个 shell 函数:
get_vmid() { vim-cmd vmsvc/getallvms 2>/dev/null | awk -v n="$1" '$2 == n {print $1; exit}' }这里有个细节要留意:File列的内容是[ds01] web-prod-01/web-prod-01.vmx,中间带一个空格,所以 awk 按空白切分之后,$1是 vmid、$2是 VM 名字、$3是[ds01]、$4才是路径。用$2匹配名字是对的。但如果你的虚拟机名字本身带空格,这套逻辑就会错位,那时候老老实实按$1或者用grep -w处理更省心。我自己的习惯是给虚拟机命名时不带空格,全用短横线,从源头上规避这个问题。
2. ESXi 的定时能力:cron 是临时的,local.sh 才是根
2.1 为什么改完 crontab 重启就没了
ESXi 的根文件系统不是普通硬盘上的可写分区,它更像一个每次启动都从镜像还原出来的内存盘。你在/etc、/tmp、/var下面做的绝大多数修改,重启之后都会被抹掉,回到出厂状态。这个设计是为了让主机保持干净一致,升级和回滚都简单,代价就是你想加点自己的东西必须找到“官方的持久化入口”。
这个入口就是/etc/rc.local.d/local.sh。ESXi 每次启动会执行它,而它本身保存在 bootbank 分区里,改动会被保留(升级大版本时可能被覆盖,这一点后面还会说)。
所以正确的顺序是:先把逻辑写进 local.sh,再由 local.sh 在每次开机时重建 crontab 并拉起 crond。如果你只是手动echo一行到 crontab 里,当次能用,重启就没了。
2.2 local.sh 的正确改法
用vi /etc/rc.local.d/local.sh打开,文件末尾一定会有一行exit 0。所有新增内容必须加在exit 0之前,加在后面等于没加,这是纯手工操作里最高频的失误。
改完大概长这样:
#!/bin/sh # 原有内容保持不动…… # ---- 以下为新增 ---- mkdir -p /var/spool/cron/crontabs cat > /var/spool/cron/crontabs/root <<'CRONEOF' # 每 5 分钟巡检一次,掉线的虚拟机拉起来 */5 * * * * /bin/sh /vmfs/volumes/ds01/scripts/vm-power-guard.sh # 每天 00:00 UTC(北京时间 08:00)批量开机 0 0 * * * /bin/sh /vmfs/volumes/ds01/scripts/vm-boot-all.sh CRONEOF # 重载 crond kill "$(cat /var/run/crond.pid 2>/dev/null)" 2>/dev/null sleep 1 busybox crond -c /var/spool/cron/crontabs exit 0几个细节值得抠一下。heredoc 的结束标记CRONEOF用了单引号包起来,这样里面的*/5不会被 shell 提前当成通配符展开,$也不会被替换——如果写成不带引号的<<CRONEOF,在某些场景下*会被当前目录的文件名替换掉,crontab 就变成一堆乱码了。
再就是busybox这个命令本身。不同版本 ESXi 里它的位置不一样,有的在/bin/busybox,有的在/usr/lib/vmware/busybox/bin/busybox,而且这个目录不一定在 PATH 里。改之前先在 SSH 里敲一下which busybox或者ls /usr/lib/vmware/busybox/bin/,确认路径后再往 local.sh 里写。写不准的话,开机时 crond 根本起不来,你会以为是 cron 表达式写错了,白查半天。
2.3 验证 crond 到底在不在跑
改完之后先别急着等定时触发,手工确认两件事:
ps -c | grep crond cat /var/run/crond.pid正常应该能看到一条busybox crond -c /var/spool/cron/crontabs的进程。如果看不见,说明 local.sh 里那段没执行成功,去tail /var/log/syslog.log里找找有没有相关报错。
还有一个容易被忽略的点:busybox 的 crond 默认不写日志。你在/var/log下面翻不到类似常规 Linux 的/var/log/cron,因为 ESXi 这套东西压根没开日志开关。这意味着如果 cron 没触发,你没有任何线索。解决办法只有一个——让脚本自己写日志,脚本里每一次执行、每一个判断结果都落到文件里。这一条是整篇文章里我认为最值钱的经验,后面第 3 章会详细展开。
3. 巡检脚本:从“本地能跑”到“敢挂在生产上”
3.1power.getstate的输出不是给人看的
先看这条命令的真实输出:
[root@esxi:~] vim-cmd vmsvc/power.getstate 1 Retrieved runtime info Powered on注意它是两行。第一行Retrieved runtime info是提示信息,第二行才是状态。如果你直接拿整段去做字符串比较,永远匹配不上。所以脚本里必须tail -1取最后一行。
取到的状态值有三种:Powered on、Powered off、Suspended。大小写在不同版本里偶尔有差异,我习惯统一转成小写再判断,多写一行tr换来的是脚本在任何版本上都不出错:
state=$(vim-cmd vmsvc/power.getstate "$vmid" 2>/dev/null | tail -1 | tr 'A-Z' 'a-z')还有一种情况:vmid 存在但这台 VM 已经被注销、只剩个空壳,getstate会返回空或者报错。所以脚本里对空值也要有分支,不能默认它一定有值。
3.2 完整脚本与逐段解释
下面这个版本是我在几台主机上跑了很久的,逻辑不复杂但该有的都有:
#!/bin/sh # vm-power-guard.sh —— 运行于 ESXi 主机,由 crond 定时调用 # 作用:巡检指定虚拟机电源状态,关机或挂起则拉起到开机状态 PATH=/sbin:/bin:/usr/sbin:/usr/bin DS=/vmfs/volumes/ds01 DIR=$DS/scripts LOG=$DIR/vm-guard.log LOCK=/var/run/vm-power-guard.lock MAXLOG=1048576 # 1) 存储没挂上就直接退出,等下一轮 [ -d "$DIR" ] || exit 0 # 2) 维护窗口开关:文件存在就跳过本轮 if [ -f "$DIR/MAINTENANCE" ]; then echo "$(date -u '+%F %T') UTC skip: maintenance-flag" >> "$LOG" exit 0 fi # 3) 单实例锁,防止上一轮没跑完下一轮又进来 mkdir "$LOCK" 2>/dev/null || exit 0 trap 'rmdir "$LOCK" 2>/dev/null' EXIT INT TERM # 4) 日志超限就轮转 if [ -f "$LOG" ] && [ "$(wc -c < "$LOG")" -gt $MAXLOG ]; then mv "$LOG" "$LOG.1" fi # 5) 等 hostd 就绪(主机刚启动时 vim-cmd 会连不上) i=0 while [ $i -lt 12 ]; do vim-cmd vmsvc/getallvms >/dev/null 2>&1 && break sleep 10 i=$((i+1)) done get_vmid() { vim-cmd vmsvc/getallvms 2>/dev/null | awk -v n="$1" '$2 == n {print $1; exit}' } for name in web-prod-01 db-prod-01; do vmid=$(get_vmid "$name") if [ -z "$vmid" ]; then echo "$(date -u '+%F %T') UTC name=$name result=not-registered" >> "$LOG" continue fi state=$(vim-cmd vmsvc/power.getstate "$vmid" 2>/dev/null | tail -1 | tr 'A-Z' 'a-z') case "$state" in "powered on"*) echo "$(date -u '+%F %T') UTC name=$name vmid=$vmid result=ok" >> "$LOG" ;; "powered off"*|"suspended"*) vim-cmd vmsvc/power.on "$vmid" >/dev/null 2>&1 rc=$? sleep 3 now=$(vim-cmd vmsvc/power.getstate "$vmid" 2>/dev/null | tail -1) echo "$(date -u '+%F %T') UTC name=$name vmid=$vmid from=$state rc=$rc now=$now" >> "$LOG" ;; *) echo "$(date -u '+%F %T') UTC name=$name vmid=$vmid unknown_state=[$state]" >> "$LOG" ;; esac done第 1 段那个[ -d "$DIR" ] || exit 0看着不起眼,但非常必要。ESXi 开机过程中,local.sh 是在比较早的阶段执行的,那时候VMFS 数据卷不一定全部挂载完成。如果你的脚本正好在这一瞬间被触发,路径不存在,后面所有命令都会报错,日志里刷一堆找不到文件的记录,看着吓人其实啥事没有。
第 2 段的维护标记文件是我强烈建议加的一个东西。做系统升级、打补丁、迁移数据的时候,把MAINTENANCE这个空文件往 scripts 目录里一放,巡检就自动停手,完事删掉就恢复。没有这个开关,你半夜在做维护,五分钟后脚本把机器又拉起来了,那个瞬间很想砸键盘。
第 5 段那个等待循环针对的是主机重启后的场景。vim-cmd依赖hostd服务,主机刚启动时 hostd 还没起来,这时候敲vim-cmd会直接报Failed to connect to the localhost。脚本里给两分钟的重试窗口,基本能覆盖住。
3.3 锁和日志:两个看起来多余但救过命的东西
先说锁。巡检是每 5 分钟一次,正常情况下一次执行两三秒就结束了,理论上不会重叠。但如果你配了几十台虚拟机,或者某台 VM 开机特别慢导致power.on卡住,就会出现上一轮还在跑,下一轮已经进来的情况。两个实例同时去开同一台机器,轻则日志混乱,重则把.vmx锁争起来,报Failed to lock file。
mkdir在文件系统上是原子操作,同一个名字只有一个进程能创建成功,这是 shell 里做单实例锁最省事的办法,不需要额外装flock。锁目录放在/var/run而不是数据卷上,是因为/var/run是 tmpfs,主机重启自动清空——万一某个异常场景下 trap 没执行、锁没删掉,重启之后也不会一直卡着。
再说日志。前面已经强调过 busybox 的 crond 不写系统日志,所以脚本自己记是唯一的信息来源。日志内容我习惯把时间、虚拟机名、vmid、原始状态、返回码、操作后的状态全打进去。这行日志看起来啰嗦,但真出问题时,一眼就能判断到底是没触发、触发了没成功、还是成功了又被别的东西关掉了。
日志轮转那段用的是最简单粗暴的办法——超过 1MB 就把文件改名,保留一份历史。VMFS 卷的空间是宝贵的,谁也不想因为一个巡检日志把数据卷塞满。
3.4 存储没挂载的时候,开机一定是失败的
前面提过存储挂载的时序问题,这里展开说一下为什么它值得单独占一节。
主机重启之后的加载顺序大致是:VMkernel 起来 → 挂载本地和共享存储 → 启动 hostd、vpxa 这些管理服务 → 执行 local.sh 里的用户脚本。看上去存储在前,但光纤通道或者 iSCSI 的 LUN 扫描是异步的,尤其是接了外部存储的情况下,卷的挂载会晚于本地的 bootbank。这时候你去开一台.vmx在外部卷上的虚拟机,power.on会返回一个模棱两可的结果,状态还是关的。
判断存储是否就绪,最直接的是这条:
esxcli storage filesystem list输出里会有Mount Point和Mounted两列。你可以用grep精确判断目标卷是不是true。我脚本里用的是更简单的[ -d "$DIR" ],因为这个目录本身就在目标卷上,目录能访问就说明卷挂上了,省一次命令调用。
还有一种开机失败跟存储无关,是.vmx文件残留了.lck锁目录。主机异常断电、或者上次关机不干净的时候,虚拟机的目录里会留下xxx.vmx.lck这类文件夹,power.on就会以“文件被锁定”为由失败。处理办法是在确认没有任何进程占用的情况下,把.lck目录删掉再开。这个操作有风险,删之前务必esxcli vm process list确认该 VM 的进程确实不在了,不然容易把正在跑的机器弄坏。我个人的原则是:只要能用power.on正常拉起,就绝不去碰.lck;只有反复失败、且进程列表里确实没有它的时候,才考虑手动清锁。
4. 时间对不齐,定时任务就等于随机数
4.1 ESXi 的时间是 UTC,cron 也跟着 UTC 走
这是我自己踩得最深的一个坑,也是很多人配完 cron 之后“任务完全不触发”的头号原因。
你在 vSphere Client 里看到的主机时间可能是对的,SSH 进去敲date显示的也是正常时间,但 ESXi 内部的系统时区默认是UTC。date显示的那个时间字符串后面通常跟着UTC字样,很多人扫一眼没注意。
而 busybox 的 crond 在解析 crontab 时,用的是主机系统时间,也就是 UTC。所以你想让虚拟机在北京时间早上 8 点开机,crontab 里必须写 UTC 的 0 点:
| 想要的北京时间 | 对应的 UTC | crontab 五段 |
|---|---|---|
| 08:00 | 00:00 | 0 0 * * * |
| 08:30 | 00:30 | 30 0 * * * |
| 09:00 | 01:00 | 0 1 * * * |
| 20:00 | 12:00 | 0 12 * * * |
| 23:00 | 15:00 | 0 15 * * * |
| 每周一 08:00 | 周日 00:00 | 0 0 * * 0 |
最后一行那个星期几的换算也容易翻车。UTC 的周日 0 点对应北京时间的周一 8 点,cron 的星期字段里 0 和 7 都表示周日。写0 0 * * 1是 UTC 周一 0 点,也就是北京时间周一早上 8 点——差了一天。这种错配在只有一台机器的时候特别难发现,因为表面上任务确实在跑,只是跑的时间不对。
我个人的建议是:别去改 ESXi 的时区。虽然理论上可以往/etc/localtime塞一个时区文件,但那个改动重启就没了,而且 ESXi 的日志时间戳、vCenter 收集的性能数据、告警时间全都基于 UTC,你单独改了本地时区只会让后续排障更混乱。老老实实按 UTC 写 cron,在日志里同时打印 UTC 时间,需要看北京时间的时候自己心里加 8 小时,是最不容易出错的做法。
4.2 NTP 没配对,主机的钟会越走越偏
即使 cron 表达式写对了,还有一个前提:主机的系统时间本身要准。ESXi 的硬件时钟精度有限,长期不校时会慢慢漂移,漂到几分钟甚至更多的时候,你的“8 点开机”就变成了“8 点 07 分开机”。
先看现状:
esxcli system time get esxcli hardware clock get第一条是系统时间,第二条是硬件时钟(通常都是 UTC)。
配置 NTP 用这几条:
# 打开 NTP 客户端 esxcli system ntp set --enabled=true # 指定时间源,可以给多个 esxcli system ntp set --server=ntp-a.internal --server=ntp-b.internal # 确认配置 esxcli system ntp getesxcli system ntp get的输出里会有NTP Status和Current Time两个关键字段。NTP Status显示Enabled只代表客户端开着,不代表已经同步上了。要看实际同步状态,得敲:
ntpq -p输出里每个服务器前面有个*号,带星的那个就是当前正在同步的源。如果所有行前面都是+或者空白,说明还没同步成功,耐心等几分钟再看。
还有一个隐藏的坑:NTP 走的是 UDP 123 端口,ESXi 的防火墙默认可能没放行。主机自身发起的出站请求一般不受影响,但如果你配的内网时间源反过来要访问主机,就得手动开规则:
esxcli network firewall ruleset list | grep ntp esxcli network firewall ruleset set --enabled=true --ruleset-id=ntpClient前一条是看一眼当前规则叫什么名字,不同版本可能是ntpClient也可能是别的,别照抄。
4.3 用脚本里的时间戳做交叉验证
配置完之后怎么确认真的生效了?我的做法是让巡检脚本在每次执行时把当前时间打进日志。挂上 cron 之后等十几分钟,tail一下日志文件,看时间戳是不是按 5 分钟间隔连续出现的。
如果日志里的时间戳出现跳变,比如 10:05 之后直接跳到 10:25,说明中间有 20 分钟脚本没跑——这时候要么是 crond 挂了,要么是主机时间被 NTP 校正时跳了一下。这两种情况都得查,不能当成偶然。
再补一句:改了 NTP 配置之后最好重启一次主机做验证。因为 NTP 的设置本身也需要持久化,如果没写进 local.sh,重启后又会回到没配的状态。你可以把esxcli system ntp set这几条命令也塞进 local.sh 的开头,每次开机自动配一遍,省心。
5. 不写脚本的几条替代路线,各有各的边界
5.1 vSphere 原生“随主机启动”能覆盖多少场景
vSphere Client 里选中虚拟机 → 编辑设置 → 虚拟机选项 → 电源管理,有三个下拉框:启动时的行为、关闭时的行为、挂起时的行为。把“启动”设成“打开电源”,主机一启动,这台 VM 就会跟着起来。
这条路最大的好处是零维护,不用改任何文件,不存在重启丢失的问题。但它的适用边界非常窄:
- 只在主机启动这个时点生效,主机一直开着、VM 中途挂了,它不会管
- 没有时间控制,主机什么时候起来它就什么时候起
- 多台 VM 同时启动的时候没有顺序控制,容易把存储 IO 打满
- 如果 VM 是被 DRS 迁移走的,或者被手动关掉的,这个选项管不到
所以我一般把它当成保底手段用——真正重要的是巡检脚本,原生选项只是保证主机重启这个场景能兜住。两者不冲突,可以一起开。
5.2 从跳板机远程 SSH 进去执行
手上主机多的时候,每台机器都去改 local.sh 就很烦。更省事的做法是找一台跳板机,用任务计划定时 SSH 到各台 ESXi 上执行命令:
ssh -i /root/.ssh/esxi_key root@esxi-01 \ "vim-cmd vmsvc/getallvms | awk '\$2==\"web-prod-01\"{print \$1}'"这条路的好处是调度逻辑集中在外部,可以写很复杂的判断(先查监控系统,再决定要不要开),日志也统一。但有两个现实问题。
一是ESXi 上 root 的 authorized_keys 存在/etc/ssh/keys-root/authorized_keys,重启会丢。你今天配好免密登录,明天主机重启一次,密钥没了,所有远程任务全部失败。要持久化就得把它写进 local.sh,让每次开机时把公钥重新写回去。这一层不处理,远程方案就是不可靠的。
二是ESXi 的 SSH 默认是关闭的,需要手动在主机上开启,而且开启之后防火墙会放行 22 端口。从安全角度,长期开着 SSH 不是好习惯,一般建议配完之后再关掉,需要的时候临时开。这就跟“定时任务需要长期稳定的通道”产生了矛盾。
折中的办法是不走 SSH,走443 端口的 API。ESXi 从 7.0 开始提供 REST API,用 curl 拿个 session 就能操作虚拟机电源,端口和 vSphere Client 共用,防火墙本来就是开的。这条路的门槛是得会处理 session token,比纯命令行复杂一些,适合已经有自动化平台的场景。
5.3 几种方案的横向对比
| 方案 | 依赖 | 定时能力 | 掉线自动拉起 | 持久化难度 | 适合场景 |
|---|---|---|---|---|---|
| local.sh + crond + 脚本 | 单台 ESXi | 强,任意 cron 表达式 | 强 | 中,需处理 local.sh | 无 vCenter 或主机数量少 |
| vSphere 随主机启动 | ESXi 自身 | 无 | 弱,只管主机启动 | 无 | 兜底,配合其他方案 |
| 跳板机 SSH 调度 | 跳板机 + SSH 通道 | 强 | 强 | 中,需处理密钥持久化 | 多主机统一管理 |
| PowerCLI / REST API | 443 端口 | 强 | 强 | 低,配置在外部门 | 已有自动化平台 |
| vCenter 计划任务 | vCenter 必须在线 | 中,支持重复执行 | 弱 | 低 | 有 vCenter、VM 数量少 |
表格里“持久化难度”这一列是我最看重的。很多人搭方案的时候只看功能,不考虑“主机重启之后这套东西还在不在”,结果第一次计划外重启就把所有配置清零,然后重新搭一遍,非常浪费时间。
6. 排错链路:VM 没起来的时候按这个顺序查
6.1 一次完整的排查过程
分享一段真实经历。有天早上到公司发现测试环境的几台 VM 全是关着的,按说 8 点应该自动开了。我当时的排查顺序是这样的,后来也固化成了标准流程。
第一步,先确认主机活着。ping通不通、SSH 能不能连上。这一步排除掉主机宕机、网络中断这些低概率但致命的问题。
第二步,看时间。esxcli system time get,同时看一眼ntpq -p。那次就是这里发现问题——主机时间比实际时间慢了 40 多分钟,NTP 源被改过之后一直没生效。cron 到点没到,脚本自然没跑。
第三步,看 crond 在不在。ps -c | grep crond。如果进程不在,那就往前推,去