与图形界面应用相比,命令行脚本(Shell、PowerShell、Batch、Python、Node.js 等))相对灵活、可组合性较强,很适合自动化、批处理任务。但对普通用户来说,命令行工具往往过于“硬核”。对普通用户,更合理的交付方式是将脚本封装成原生应用,只需要双击图标即可运行,无需打开终端。

汇总一下Windows/macOS/Linux将命令行脚本打包为原生应用的工具。

阅读全文 »

---
name: esxi-vm-support-analysis
description: >-
Analyze a VMware ESXi vm-support / vm-support bundle directory (or .tgz
extract) to diagnose host crashes, PSODs, hardware faults
(memory/CPU/PSU/fan/storage), performance freezes, networking issues, and
per-VM power-off/reboot or vMotion questions. Use whenever the user provides a
vm-support folder containing README, action.log, commands/ and var/run/log/,
or asks "分析崩溃原因 / 分析 ESXi 支持包 / vm-support 里怎么看 XX / why did the host or VM
reboot".
---

# ESXi vm-support 支持包分析

对 VMware ESXi(`vm-support` 打包)诊断包做系统化分析的标准流程与判据。
目标:**从一堆日志中给出可复现、可举证、可行动的结论**(发生了什么 → 什么时候 → 为什么 → 怎么办)。

---

## 0. 使用前提与起步动作

- 输入通常是一个目录(或解压后的 .tgz),根下有:`README`、`action.log`、`error.log`、
`errors-ignored.log`,以及镜像目录 `commands/ var/ etc/ json/ bootbank/ altbootbank/ lib64/ usr/ vmfs/`。
- **只看 `commands/` 和 `var/`,绝大多数问题就够了**;其余目录是"需要深挖时才翻"的镜像。
- 只在只读日志上分析,**不要**假设主机当前状态;一切以包内时间戳为准。
- 命令一律用 bash/POSIX 语法(Windows 下也用 Git Bash)。路径含中文(如 VM 名 `脚本服务01`)时加引号。

**开场三条命令**(先建立时空坐标系,再谈其他):

```bash
cat README # Captured on = 采集时刻
cat commands/date.txt # 确认时区(ESXi 常为 UTC)
cat var/run/log/vmksummary.log # 启动/关机历史 + uptime + VM 数
```

---

## 1. 支持包结构全解

| 目录/文件 | 是什么 | 看什么 |
|---|---|---|
| `README` | 采集元信息 | `Captured on`(采集时间)、命令行、选项 —— **第一眼看它** |
| `action.log` | 采集时执行过的所有命令及耗时 | 判断某条命令是否超时/未收集到(对应 `error.log`)|
| `error.log` / `errors-ignored.log` | 采集过程报错 | 某个文件缺失/命令失败的原因 |
| `commands/` | 按命令名生成的**纯文本输出**(`lsusb.txt`、`esxcfg-info.txt`…)| **信息密度最高**,采集时现场跑一遍并落盘 |
| `json/` | 与 `commands/` 同源,但为 JSON 格式 | 交给脚本/自动化解析时用 |
| `var/run/log/` | **运行时日志主战场** | 见 §1.1 |
| `var/log/` | `boot.gz`、`sysboot.log`、`kickstart.log` 等引导期日志 | 启动报错、历史启动参数 |
| `var/core/` | 内核转储(PSOD) | 紫屏日志 `vmkernel-zdump.*`;对照 `commands/localcli_system-coredump-file-*` |
| `etc/` | 主机配置文件全量复制 | `ntp.conf`/`ntp.drift`、网络、认证、服务配置 |
| `bootbank/` | **活动分区**软链接 | 正在运行的内核模块与 VIB |
| `altbootbank/` | **备用分区**软链接 | 升级/新装先写入此分区,重启后切换;主分区故障时回滚 |
| `lib64/` | 64 位共享库与内核模块(PAM 等)| ESXi 优先从 `/lib64` 加载 |
| `usr/` | 可执行文件、脚本 | `vm-support` 脚本本体在 `/usr/lib/vmware/vm-support/` |
| `vmfs/` | 挂载的 VMFS 数据存储镜像 | `vmfs/volumes/<uuid>/<vm name>/` 下是 VM 磁盘与配置 |

### 1.1 `var/run/log/` 关键日志

| 文件 | 用途 |
|---|---|
| `vmkernel.log` | **内核主日志**:崩溃、MCA、存储 IO、调度、驱动程序全在此 |
| `vmkwarning.log` | 警告/ALERT 汇总:NMI、Heartbeat、PageRetire、性能退化 |
| `vmksummary.log` | **启动/关机历史**(uptime、VM 数、`Host has booted`)——**做时间线起点** |
| `vobd.log` | 硬件/网络/存储事件(VMFS 心跳、`vmnic linkstate is`)|
| `hostd.log` | 主机服务、任务、重启原因、卷挂载/丢失 |
| `syslog.log` | 用户态聚合日志 |
| `localcli.log` / `esxcli.log` / `esxtokend.log` | 命令与认证审计 |
| `cmmdsd/clomd/clusterAgent/vsan*` | vSAN / 集群栈 |

---

## 2. 分析总流程(故障定责主线)

1. **采集时间与版本**
- `README` → `Captured on`;`commands/date.txt` → 确认时区
- `commands/localcli_system-version-get.txt`、`uname_-a.txt`、`vmware_-vl.txt`
2. **建立重启/关机时间线**
```bash
grep -n "Host has booted\|Host is rebooting\|Host is powering off" var/run/log/vmksummary.log
```
- 有 `booted` 却无 `powering off/rebooting` → **非正常重启**(崩溃/掉电)
- `hostd.log` 的 `Last reboot reason is unknown` 为佐证
- `vmkernel.log` 用启动分隔符切片:`grep -n "Boot Successful\|Initializing InitVMKernel" ...`
3. **崩溃 / PSOD 排查**
```bash
grep -n -iE "panic|BUG:|Assert|MCE|MCA|SRAR|Uncorrectable|NMI|trap|not responding|lost access|APD|PDL|deadlock|backtrace" var/run/log/vmkernel.log
```
- 转储核对:`commands/localcli_system-coredump-file-list.txt` `-file-get.txt`
`-partition-get/-list.txt`、`localcli_--plugin-dir-usrlibvmwareesxcliint-systemInternal-coredump-configuration-check.txt`、`var/core/`
- 无 panic 文本 + 日志戛然而止 + 无平滑关机 = **硬复位/掉电**
4. **硬件告警(BMC / IPMI)—— 崩溃类必查**
- `commands/localcli_hardware-ipmi-sel-get*.txt`(SEL 概要:`Last Added` / `Last Cleared` / 条数)
- `commands/localcli_hardware-ipmi-sel-list*.txt`(**逐条事件 + 时间戳**)
- `commands/localcli_hardware-ipmi-sdr-list*.txt`(**传感器当前状态**:内存槽/温度/电源/风扇)
- `commands/localcli_hardware-ipmi-bmc-get.txt`、`...fru-*`
5. **内存故障定位**(本类问题最常见,见 §4.1)
6. **CPU / 调度**:`vmkwarning.log` 的 `Heartbeat: PCPU x didn't have a heartbeat` + `ALERT: NMI ... RIPOFF`
7. **存储 / 网络 / VM 层**:按 §3 的四大方向表逐项查
8. **交叉比对历史包**:换件/变更前后的两份包对比(见 §5)

---

## 3. 四条巡检主线(不问具体故障时用)

> 拿到包不知道从哪下手时,按 **VM → 主机 → 网络 → 存储** 四方向各走一遍。

### 3.1 方向一:虚拟机 VM 的问题

VM 日志路径:`vmfs/volumes/<datastore-uuid>/<vm name>/vmware.log`(**每个 VM 独立一份**)

| 细分 | 路径 | 搜索关键字 | 含义 |
|---|---|---|---|
| VM 数量/清单 | `etc/vmware/hostd/vmInventory.xml` | — | 该 ESXi 上所有登记的 VM |
| VM 配置 | `vmfs/volumes/<uuid>/<vm>/<vm>.vmx` | `numvcpus` | vCPU 数 |
| | 同上 | `memSize` | 内存(MB) |
| | 同上 | `guestOS` | Guest 系统类型 |
| 开机/关机原因 | `vmware.log` | `CPU reset: soft` | Guest OS 内主动重启 |
| | | `PM Soft Off` | Guest OS 内主动关机 |
| | | `CPU reset: hard` | 外部(vSphere API)重启 |
| | | `MKS local poweroff` | 外部强制关机(类似拔电)|
| | | `Tools: sending 'OS_Halt' (state = 1)` | 外部通过 Tools 关机 |
| | | `PM Soft Off. Good-bye.` | 外部 API 关机 |
| vMotion | `vmware.log` | `mid id` | 取 migration id |
| | `vmware.log` / `hostd.log` | 上一步的 `mid id` | 看源/目标、迁移过程 |

> 注:`mid id` 仅在该 VM 发生过 vMotion 时存在;无命中不等于异常。

### 3.2 方向二:ESXi 主机本身

| 细分 | 路径 | 搜索关键字 | 含义 |
|---|---|---|---|
| 采集时刻 | `commands/date.txt` | — | 本次 vm-support 的时间 |
| 机型/厂商/SN | `commands/esxcfg-info_-a.txt.FRAG-00000` | `Product Name` | 服务器型号 |
| | 同上 | `Vendor Name` | 服务器厂商 |
| | 同上 | `Serial Number` | 主机序列号 |
| 主机名 | `commands/python_usrlibvmwarevm-supportbinsystemStorageDebugpyc.txt` | `Hostname` | ESXi 主机名 |
| 升级/补丁 | `commands/localcli_software-profile-get.txt` | — | 已应用的升级配置文件 |
| VIB 清单 | `commands/localcli_software-vib-list.txt` | — | 已安装的所有 VIB |
| 内核模块 | `commands/esxcfg-module_-q.txt` | 如 `migrate` | 模块是否加载 |
| uptime/VM 数 | `var/run/log/vmksummary.log` | `Host has booted` | 运行时长与 VM 数量 |

### 3.3 方向三:网络

| 细分 | 路径 | 搜索关键字 | 含义 |
|---|---|---|---|
| 物理网口 | `commands/nicinfo.sh.txt` | `vmnic` | 有几个物理口(vmnic0/1/2/3…)|
| | 同上 | `Firmware Version` | NIC 固件版本 |
| | 同上 | `Driver Info` | 驱动名(如 `qfle3`)|
| 驱动版本 | `commands/localcli_software-vib-list.txt` | 上一步的驱动名 | 驱动对应的 VIB 版本 |
| vmkernel 端口 | `commands/esxcfg-vmknic_-l.txt` | — | 管理 IP、vMotion IP、MTU |
| 虚拟交换机 | `commands/esxcfg-vswitch_-l.txt` | — | vSwitch / 上行链路 / 端口组 |
| 链路状态日志 | `var/run/log/vobd.log` | `linkstate is` | 物理口 up/down 记录 |
| 内核侧异常 | `var/run/log/vmkernel.log` | 故障时刻附近 | 网卡相关错误 |

### 3.4 方向四:存储

**先看配置:**

| 细分 | 路径 | 搜索关键字 | 含义 |
|---|---|---|---|
| HBA 清单 | `commands/localcli_storage-core-adapter-list.txt` | — | 有哪些 HBA |
| SAS HBA | `commands/localcli_storage-san-sas-list.txt` | — | SAS 卡固件版本与驱动名(如 `smartpqi`)|
| SAS 驱动版本 | `commands/localcli_software-vib-list.txt` | 上一步的驱动名 | 驱动 VIB 版本 |
| FC SAN HBA | `commands/localcli_storage-san-fc-list.txt` | — | FC 卡固件版本与驱动名(如 `qedf`)|
| FC 驱动版本 | `commands/localcli_software-vib-list.txt` | 上一步的驱动名 | 驱动 VIB 版本 |
| 空间占用 | `commands/df.txt` | — | Datastore 使用率 % |
| 文件系统 | `commands/localcli_storage-filesystem-list--i.txt` | — | Datastore UUID、挂载点 UUID |
| Datastore↔设备 | `commands/esxcfg-scsidevs_-m.txt` | — | 找到 datastore 底层设备的 `naa.id` |
| 设备详情 | `commands/localcli_storage-core-device-list.txt` | 上一步的 `naa.600508b1...` | vendor、RAID level、是否 SSD、是否 offline、VAAI、RDM |
| 多路径 | `commands/esxcfg-mpath_-b.txt` | — | SAN 多路径配置 |

**再看日志:**

| 路径 | 搜索关键字 | 含义 |
|---|---|---|
| `var/run/log/vmkernel.log` | `ScsiDeviceIO` | SCSI 层 IO 异常汇总 |
| | `CmdSN` | 关联命令序列号,定位单条超时 |
| | `All Paths Down state` | 存储 APD(全路径丢失)|
| | `0x28` | SCSI sense 码(见 t10.org status 表)|
| | `TimedOut` | 存储心跳超时 |
| `var/run/log/vmkwarning.log` | `performance has deteriorated` | 存储性能下降 |
| `var/run/log/hostd.log` | `Lost access to volume` | 存储卷断开 |
| | `Successfully restored access to volume` | 存储卷恢复 |
| `var/run/log/vobd.log` | `esx.problem.vmfs.heartbeat.timedout` | VMFS 心跳超时 |
| | `esx.problem.vmfs.heartbeat.recovered` | 心跳恢复 |
| | `connection failure` | 存储网络连接失败 |

---

## 4. 关键判据(速查)

| 现象 | 判据 | 含义 |
|---|---|---|
| 非正常重启 | `vmksummary` 无 `powering off/rebooting`,但有 `booted` | 崩溃/掉电 |
| 重启原因未知 | `hostd.log` `Last reboot reason is unknown` | 非平滑关机 |
| 内存 CE | `PageRetire: ... retirement is 1` 周期性刷屏 | 某 DIMM 持续可纠正错误 |
| 内存 UCE | `ALERT: MCA ... SRAR` + 杀 VM | 硬件不可纠正错误 |
| 具体坏槽 | SEL `Memory (Un)Correctable ECC` + SDR `Memory Device ...` | 定位到 DIMM 槽 |
| 盘慢 vs 主机卡 | 延迟尖峰与 CPU 冻结窗口逐秒重合 | 主机卡死拖累 IO |
| 全核冻结 | 多次 `Heartbeat` + 同一 `NMI` 栈 | 全局锁竞争 / fork 风暴 |
| 卷掉线 | `vobd` heartbeat timedout + `hostd` Lost access | 存储心跳/链路问题 |

### 4.1 内存故障定位细则

- 内核侧:`PageRetire`(可纠正 CE 反复退役页)、`ALERT: MCA`(机器检查异常)
- BMC 侧:SEL 的 `Memory Uncorrectable ECC` / `Memory Transition to Critical`
- 槽位状态:`sdr-list` 中 `Memory Device <n> <bank> <slot>` 的 Computed Reading:
`Presence Detected`(正常)/ `Correctable ECC Logging Limit Reached` / `Uncorrectable ECC`
- 物理信息:`commands/smbiosDump.txt` → `Memory Device (Type 17)` 的
`Location / Serial / Asset Tag / Part Number / Rank / Speed / Size`
- **交叉比对**:SEL/传感器报错槽 ↔ SMBIOS 已插槽 ↔ 历史包(换件前后)

---

## 5. 陷阱与注意事项

- **BMC 传感器状态可能是锁存值**:换件后若未清 SEL/未重新 POST,仍可能显示旧错误。
→ 结论前先确认"是否清过 SEL / 是否换件后重启"。
- **单包无法证明"已修复/未修复"**:必须结合**换件前后的多份包对比**。
- **对比两份包的正确姿势**:比较 SMBIOS `Serial`/`Asset Tag`(是否真换件)、
`Location`(槽位是否变化)、SDR 传感器状态、SEL 事件时间。
- **命令输出常被分片截断**为 `*.FRAG-000NN`,需拼接多片后再 grep。
- **时钟跳变**:部分日志有 `esx.problem.clock.correction.delta.allowed`,跳变前的绝对时间不可信;
`vobd.log` 里出现 `2001-01-01` 即为未校时阶段的伪时间。
- JSON 优先:`json/` 目录或 `localcli --formatter=json` 更易自动解析。
- 中文 VM 名/路径 grep 时加引号;`awk` 处理 CRLF 注意 `\r`。
- 文件名中的连字符易抄错(例如文件系统清单是 `localcli_storage-filesystem-list--i.txt`,
两个连字符),**用 `ls commands | grep -i <关键词>` 确认实际文件名**,不要凭记忆敲。

---

## 6. 报告输出模板

```
# <主机名/机型> 故障分析报告
## 1. 结论摘要(1-3 句,先说结论)
## 2. 环境概况(机型/CPU/内存/版本/存储/采集包信息)
## 3. 事件时间线(表:UTC 时间 | 事件 | 证据文件)
## 4. 证据链(逐条:现象 + 原始日志引用)
## 5. 根因分析(现象 → 机理 → 根因)
## 6. 建议(按 P0/P1/P2 优先级,含验证实验)
## 7. 局限性与未确认项
## 附录:证据文件清单(相对路径)
```

**报告原则**:每个结论都要能指到具体文件名 + 行号/时间戳;区分"已证实"与"推测";
给出可执行的**决定性实验**(如"两槽对调看错误是否跟条走")。

---

## 7. 一键排查命令片段

```bash
# 采集时间 / 时区 / 版本
cat README | grep "Captured on"; cat commands/date.txt
cat commands/localcli_system-version-get.txt; cat commands/uname_-a.txt

# 非正常重启检测
grep -n "Host has booted\|Host is rebooting\|Host is powering off" var/run/log/vmksummary.log

# 崩溃/硬件异常全景
grep -n -iE "panic|MCA|SRAR|Uncorrectable|NMI|panic|APD|PDL|deadlock" var/run/log/vmkernel.log var/run/log/vmkwarning.log

# BMC 事件与传感器
grep -n "Memory\|CPU\|Power\|Fan\|Temperature" commands/localcli_hardware-ipmi-sel-list*.txt
grep -n "Memory Device" commands/localcli_hardware-ipmi-sdr-list*.txt

# 内存条物理信息
grep -n -A 20 "Memory Device (Type 17)" commands/smbiosDump.txt

# 机型/SN
grep -m5 -E "Product Name|Vendor Name|Serial Number" commands/esxcfg-info_-a.txt.FRAG-00000

# 网络链路事件
grep -n "linkstate is" var/run/log/vobd.log

# 存储异常
grep -n -iE "ScsiDeviceIO|TimedOut|All Paths Down" var/run/log/vmkernel.log
grep -n "performance has deteriorated" var/run/log/vmkwarning.log

# VM 关机/重启原因(对每个 VM 执行)
grep -n -E "CPU reset: (soft|hard)|PM Soft Off|MKS local poweroff|OS_Halt" \
"vmfs/volumes/<uuid>/<vm name>/vmware.log"
```

我目前使用 Camelot 从 PDF 文件中提取表格。很多情况下,一个表格会跨越多页,或者包含空白行。这两种情况都会导致 Camelot 生成两个或多个表格(具体取决于空白行的数量或表格跨越的页数)。我想知道在 Camelot 生成多个表格后,是否有办法将这些单独的表格合并成一个表格?或者,是否有办法让 Camelot 忽略分页符和/或空白行?

以下脚本将从给定的 PDF 文件中提取所有表格,并将跨越多页的表格合并。结果将以 CSV 文件格式保存,文件名格式为 page-x-table-y.csv 其中 page-x-table-y 分别代表表格所在的页码和表格在该页码上的索引(因为一页上可能包含多个表格)。对于合并的表格,文件名中的 x 代表表格起始页码。

请注意,此脚本仅提取带有边框的表格,不适用于其他类型的表格——这意味着它使用的是 Camelot 的 lattice 模式而非 stream 模式 。因此,如有必要,您可能需要根据自身需求进行调整。

如果 PDF 文件中没有表格,则不会生成任何文件!

将以下代码保存到名为 pdf-extract-tables.py 的文件中,然后像这样调用它

python /path/to/pdf-extract-tables.py -p /path/to/file.pdf -t 15

实现神奇效果的代码:

import argparse
import warnings
import camelot
import csv
import os

def get_continued_tables(tables, threshold):

continued_tables = {}
previous_table = False
group_counter = 0

# typical height of a pdf is 842 points and bottom margins are anywhere between 56 and 85 points
# therefore, accounting for margins, 792
page_height = 792

# iterate over the tables
for i, table in enumerate(tables):

# if a previous table exists (remember, we start with this as false)
# and the previous table was on the previous page
# and the number of columns of both tables is the same
if previous_table and table.page == previous_table.page + 1 and len(table.cols) == len(previous_table.cols):

# get the bottom coordinate of the previous table
# note that for pdfs the origin (0, 0) typically starts from the bottom-left corner of the page,
# with the y-coordinate increasing as you move upwards
# this is why for {x0, y0, x1, y1} we need the y0 as the bottom
previous_table_bottom = previous_table._bbox[1]

# get the top coordinate of the current table
# for {x0, y0, x1, y1} we need the y1 as the top
current_table_top = table._bbox[3]

# if the previous table ends in the last 15% of the page and the current table starts in the first 15% of the page
if previous_table_bottom < (threshold / 100) * page_height and current_table_top > (1 - threshold / 100) * page_height:

# if we don't have started this group of tables
if (continued_tables.get(group_counter) is None):

# start by adding the first table
continued_tables[group_counter] = [previous_table]

# add any of the sunsequent tables to the group
continued_tables[group_counter].append(table)

# if this is not a continuation of the previous table
else:

# increment the group number
group_counter += 1;

# if this is not a continuation of the previous table
else:

# increment the group number
group_counter += 1;

# the current table becomes the previous table for the next iteration
previous_table = table

# transform the dictionary into an array of arrays
continued_tables = [value for value in continued_tables.values()]

# return the combined tables
return continued_tables

def main():

class NewlineFormatter(argparse.RawDescriptionHelpFormatter):
def _split_lines(self, text, width):
return text.splitlines()

# create argument parser
parser = argparse.ArgumentParser(description = 'Returns an array of tables that should be grouped, also as an array.\nThe tables that should be grouped represent tables that span over multiple pages.', formatter_class = NewlineFormatter)
parser.add_argument('--path', '-p', type = str, metavar = '', required = True, help = 'path to the PDF file containing tables')
parser.add_argument('--threshold', '-t', type = int, metavar = '', default = 15, help = 'if the table on previous page ends in the last x%% of the page and\nthe table on the next page starts in the first x%% of the page,\ntables will be considered as spanning over those pages.\nDefault is 15')

# parse command-line arguments
args = parser.parse_args()

# suppress warning about no tables found
warnings.filterwarnings('ignore', message = 'No tables found on page-*')

# extract tables
tables = camelot.read_pdf(args.path, flavor = 'lattice', pages = 'all')

# get continued tables
continued_tables = get_continued_tables(tables, args.threshold);

# get the name of the PDF file we are processing (without the extension)
pdf_file_name = os.path.basename(args.path)

# the path where we're writing the file to
# (same place where we processed the file from)
file_path = os.path.dirname(args.path)

written = []

# the number of rows to be written at once to the CSV
# (writing rows one by one is less efficient)
batch_size = 1000

# iterate over found tables
for i, table in enumerate(tables):

# if table was already written as part of a group
if table in written: continue

# check if the current table is a continued table
is_continued = any(table in sublist for sublist in continued_tables)

# define the filename for the CSV file (the PDF file's name suffixed with the page where the table was found and the table's index on that page)
# (the naming is the same as what camelot does when called from the CLI)
file_name = f"{pdf_file_name}-page-{table.parsing_report['page']}-table-{table.parsing_report['order']}.csv"

# open the CSV file for writing
with open(file_path + '/' + file_name, 'w', newline = '') as csv_file:

# create a CSV writer
csv_writer = csv.writer(csv_file)

batch = []

# iterate over the rows in the table
for row in table.data:

# add row to batch
batch.append(row)

# if we gathered enough rows, or if we're at the last row
if len(batch) >= batch_size or row == table.data[-1]:

# write batch to the CSV file
csv_writer.writerows(batch)

# clear the batch
batch = []

# if there are any records left to be written
# write batch to the CSV file
if batch: csv_writer.writerows(batch)

# if the current table is a continued table, write all subsequent continued tables to the same CSV file
if is_continued:

# get the index of the group in "continued_tables" associated with the current table
group_index = next(index for index, sublist in enumerate(continued_tables) if table in sublist)

# iterate over the tables in said group and append their data to the same CSV file
for continued_table in continued_tables[group_index]:

# skip writing the current table as it's already written
if continued_table == table or continued_table in written: continue

# write the data of the continued table to the same CSV file
with open(file_path + '/' + file_name, 'a', newline = '') as csv_file:

# create a CSV writer
csv_writer = csv.writer(csv_file)

batch = []

# iterate over the rows in the table
for row in continued_table.data:

# add row to batch
batch.append(row)

# if we gathered enough rows, or if we're at the last row
if len(batch) >= batch_size or row == table.data[-1]:

# write batch to the CSV file
csv_writer.writerows(batch)

# clear the batch
batch = []

# if there are any records left to be written
# write batch to the CSV file
if batch: csv_writer.writerows(batch)

# keep track of written tables so that are not written again in the main iteration
written.append(continued_table)

if __name__ == "__main__":
main()

1. 问题现象

在 Windows 系统中启动应用(如 Tomcat、Node.js、Spring Boot 等)时,系统抛出 Address already in use (端口已被占用) 错误。然而,使用常规命令 netstat -ano | findstr ":8080" 查询时,却**没有任何进程(PID)**在监听该端口。

2. 根本原因(Why)

该现象由 Windows Hyper-V / WSL2 / Docker Desktop 与 WinNAT(网络地址转换服务) 的底层机制冲突导致:

  1. 系统保留机制:WinNAT 服务在系统启动时,会随机、成批地向系统内核申请保留多段 TCP 端口(通常每组 100 个),用于虚拟机或容器的内部通信。
  2. 默认范围过低:Windows 默认的动态端口起始位置偏低,导致系统在随机霸占端口时,经常误将开发者常用的核心端口(如 8080, 3000, 9000)锁死。
  3. 特征:这些被系统保留的端口在 netstat 中对普通应用显示为“空闲”,但普通应用无权绑定。且每次重启电脑后,被保留的端口区间会随机发生变化。

3. 排查方法

打开具有 管理员权限 的命令提示符(CMD)或 PowerShell,运行以下命令查看系统当前排他的端口范围:

netsh int ipv4 show excludedportrange protocol=tcp

结果分析:
若输出的表格中,目标端口(例如 8080)落在了某一组 开始端口 和 结束端口 的区间内,即可证实该端口已被系统强制保留。

4. 彻底解决方案(One-Time Fix)

为了彻底解决并防止系统未来继续随机侵占低位常用开发端口,最有效的办法是将 Windows 的动态端口起始位置移至国际标准高位段(49152)。

请在 管理员权限 的终端中,严格按照以下步骤执行:

步骤一:暂停网络转换服务

因为部分端口当前正被系统占用,直接修改可能会触发“拒绝访问”或“文件正被使用”的错误。需要先暂停 WinNAT 服务:

net stop winnat

步骤二:重置动态端口范围

将 TCP 和 UDP 的动态端口起点强制修改为 49152(共分配 16383 个端口)。修改后,系统服务将只在 49152~65535 之间随机保留端口,永远不会再触碰 8080 等低位端口。

netsh int ipv4 set dynamicport tcp start=49152 num=16383
netsh int ipv4 set dynamicport udp start=49152 num=16383

步骤三:重新启动网络服务

net start winnat

步骤四:重启计算机

执行完上述操作后,必须重启电脑使配置在系统内核中彻底生效。

5. 验证结果

电脑重启后,再次以管理员身份运行排查命令:

netsh int ipv4 show excludedportrange protocol=tcp

此时会发现诸如 8023-8122 这一类处于低位段(10000以下)的保留区间已全部移至 49152 之后,8080 端口成功释放,应用可正常启动。

一、 项目背景与核心痛点

服务器经常遭受来自全球恶意 IP 的 SSH 暴力破解及 Web 扫描(如本案中记录的越南 IP 203.113.174.95 与 中国香港 IP 101.36.122.139)。

  • 核心业务诉求:精细化控制服务器端口(SSH、HTTP、HTTPS 等),只允许中国大陆境内的 IP 建立连接,非中国大陆 IP 一律在系统最外层直接丢弃(DROP)。
  • 技术底座环境:阿里云 ECS(CentOS 8+ / Rocky Linux 等现代发行版),物理层由 nftables(作为 Firewalld 的现代编译后端)承载,上层配合 Fail2ban 联动监控。
阅读全文 »