手记 004:用 Python 写第一个运维脚本
日期: 2026-09-16 用时: 约 90 分钟 难度: ⭐⭐⭐⭐☆
1. 任务背景
前三个任务是配置和排障,本次开始编写实际的运维脚本。
目标是分析 nginx 访问日志,输出:
- 状态码分布统计
- 请求最多的 IP(TOP 5)
选择 Python 而非 Bash:日志解析涉及正则匹配、字典聚合、 类型转换,用 Bash 需要大量 awk/sed 管道拼接,可读性和可维护性都差。
2. 遇到的困难
主要集中在编辑环节。服务器上通过 vi 编写 Python 代码, 存在两个持续性的困扰:
缩进难以控制
Python 用缩进表达代码块,而 vi 默认不展开 Tab。 一次编辑后出现 TabError: inconsistent use of tabs and spaces in indentation, 另一次出现 IndentationError: unexpected indent。 定位这类问题时,靠肉眼看代码不可靠——缩进差异在终端里很难分辨。
缺少语法提示
在 IDE 里写 Python 会有实时提示,服务器上写没有, 拼写错误和变量名错误只能等运行时才暴露。
这两点共同指向一个结论:服务器不是写代码的最佳场所。 后续需要建立"本地编辑 + 传输到服务器"的工作流。
3. 学到的方法
本次练习的主要收获是三个方面。
3.1 交叉验证:用独立手段核对脚本结果
这是本次最重要的收获。
脚本第一次运行时报出状态码分布,但无法确认它是否准确。 用另一条独立的路径去核对:
awk '{print $9}' /var/log/nginx/personal-site.access.log \
| grep -E '^[0-9]{3}$' | sort | uniq -c | sort -rn
结果出现了差异:
| 状态码 | 脚本 | awk |
|---|---|---|
| 200 | 71 | 71 |
| 404 | 42 | 42 |
| 400 | 47 | 18 |
awk 还多出了 150(11 次)和 "-"(18 次)这两个"状态码"。
进一步排查发现,awk 按空格切分字段的做法有缺陷—— 日志的请求字段含空格:
45.33.x.x - - [...] "" 400 0 "-" "-" ← 空请求
66.132.x.x - - [...] "\x16\x03\x01\x00\xEE..." 400 ← 二进制数据
85.217.x.x - - [...] "SSH-2.0-Go" 400 150 "-" "-" ← SSH 协议
这些行的请求字段被多次切分,导致 $9 取到的不是状态码。 18 + 11 + 18 = 47,正好等于脚本报告的 400 数量。
结论:脚本用正则按引号边界解析,比 awk 更准确。
这件事的意义不在"谁对谁错",而在于:
- 如果只看脚本的输出,无法判断它是否正确
- 只有引入第二条独立的验证路径,差异才会暴露
- 而差异暴露之后,才能判断哪一方有问题
这条方法可以推广到所有脚本:任何脚本的输出,都应该用另一种 手段核对一次。特别是当脚本的结论会影响决策时(比如告警、清理、发布)。
3.2 静态检查:在运行前发现问题
本次安装了 pyflakes:
pip3 install pyflakes
pyflakes /root/ops-lab/quests/daily-check-framework.py
它能在运行前发现的问题类型:
| 类型 | 例子 |
|---|---|
| 未定义的名称 | 拼写错误的函数名或模块名 |
| 未使用的导入 | import stat as stat_mod 但从未使用 |
| 未使用的变量 | 赋值后从未引用 |
| 重复定义 | 同名函数写了两遍 |
与 py_compile 的区别:
| 工具 | 检查范围 | 能否发现拼写错误 |
|---|---|---|
python3 -m py_compile | 语法 | ❌ 不能(名称错误是运行时才解析) |
pyflakes | 静态分析 | ✅ 能(未定义的名称会被标记) |
本次实际用过一次:pyflakes 报出 'stat as stat_mod' imported but unused, 确认是一个多余的导入,删掉即可。
3.3 验证层次:区分"脚本执行成功"和"结果正确"
这是前三个任务反复出现的主题,本次在脚本编写上再次遇到。
脚本可能的状态:
| 层次 | 检查方式 | 本次实例 |
|---|---|---|
| 语法正确 | py_compile | 通过 |
| 无未定义名称 | pyflakes | 通过 |
| 能运行不报错 | 直接执行 | 通过 |
| 输出符合预期 | 人工核对 | 需要交叉验证 |
| 输出正确 | 与独立手段比对 | awk 对照发现差异 |
关键:脚本"跑通了"不等于"结果对了"。
本次有一处逻辑错误,前四个层次全部通过:
for ip, n in ip_counter.most_common(5):
print(" %-18s %4d 次" % (ip, n))
else:
print(" (无有效日志数据)")
输出里总是多出一行 (无有效日志数据),即使有数据。
根因:Python 的 for...else 语义不是"循环是否为空", 而是"循环有没有被 break 打断"。
| 情况 | 执行 else? |
|---|---|
| 循环正常跑完(无 break) | ✅ 执行 |
| 循环被 break 中断 | ❌ 不执行 |
代码里没有 break,所以 else 每次都会执行。
正确写法:
if ip_counter:
for ip, n in ip_counter.most_common(5):
print(" %-18s %4d 次" % (ip, n))
else:
print(" (无有效日志数据)")
这一类错误的特点:语法合法、运行不报错、程序不崩溃, 只是结果不对。只能靠人工核对输出发现。
4. 脚本的最终形态
4.1 正则解析
LINE_RE = re.compile(
r'^(?P<ip>\S+)\s+\S+\s+\S+\s+'
r'\[(?P<time>[^\]]+)\]\s+'
r'"(?P<req>[^"]*)"\s+'
r'(?P<status>\d{3})\s+'
r'(?P<bytes>\d+|-)\s+'
r'"(?P<ref>[^"]*)"\s+'
r'"(?P<ua>[^"]*)"'
)
几个设计考虑:
| 写法 | 原因 |
|---|---|
用 re.match 而不是 split() | 请求字段含空格,不能按空格切分 |
[^"]* 而不是 .*? | 按引号边界匹配,避免跨字段 |
| 匹配失败只计入 skipped | 单行异常不应中断整个处理 |
具名分组 (?P<status>...) | 比数字索引可读,改动字段顺序时不易出错 |
4.2 实际输出
=== nginx 访问日志分析 ===
日志文件: /var/log/nginx/personal-site.access.log
总行数: 189
已解析: 189
已跳过: 0
状态码分布:
200 80 次 42.3%
400 56 次 29.6%
404 53 次 28.0%
请求最多的 IP:
127.0.0.1 22 次
80.94.x.x 16 次
45.135.x.x 8 次
216.180.x.x 7 次
66.132.x.x 6 次
4.3 分析结果的意义
状态码分布本身反映了服务器的暴露情况:
| 状态码 | 次数 | 含义 |
|---|---|---|
| 200 | 80 | 正常访问 |
| 400 | 56 | nginx 拒绝的畸形请求 |
| 404 | 53 | 探测不存在路径 |
400 和 404 合计 109 次,超过正常访问的 80 次。
查看这些请求的内容可以看到典型的扫描特征:
45.33.x.x - - [...] "" 400 0 "-" "-" 裸连接
66.132.x.x - - [...] "\x16\x03\x01..." 400 TLS 握手发到 HTTP 端口
85.217.x.x - - [...] "SSH-2.0-Go" 400 150 "-" "-" SSH 协议发到 HTTP 端口
20.40.253.42 - - [...] "MGLNDD_<你的IP>_8080" 400 端口探测
这些请求全部被 nginx 以 400 挡下,站点未受影响。 但这个脚本为后续做流量分析、识别扫描来源提供了基础。
5. 结论
脚本的正确性需要外部验证,不能自证
脚本输出"状态码 400 有 47 次"这句话本身无法判断真假。 只有引入 awk 这条独立路径,差异才暴露出来, 进而才能判断"awk 因字段错位而失准,脚本的正则解析更可靠"。
后来我在巡检脚本里也加了这一层: 检查结果会拿来发告警,所以每一项都有独立的验证方式。
验证要分层,且必须覆盖"结果正确"这一层
本次经历的验证层次:
语法检查(py_compile) ✅ 通过
静态检查(pyflakes) ✅ 通过
能运行不报错 ✅ 通过
输出格式正确 ✅ 通过
逻辑正确 ❌ 这一层出了问题(for...else)
最后一层无法自动化检测,只能靠人核对输出。 因此每次脚本产出结果后,都应人工扫一眼"这个结果符合常识吗"。
Python 运维脚本的开发环境需要重新考虑
在服务器上用 vi 编写 Python,缩进和语法提示都成问题。 本次多次因此产生错误。后续应建立本地编辑的工作流:
本地编辑器写代码
↓
静态检查(pyflakes)+ 语法检查
↓
传输到服务器(scp / rsync / git)
↓
在服务器上运行验证
这既是为了效率,也是为了把"编辑"和"运行"这两个环节分开—— 在本地发现的问题,不必等到服务器上才暴露。
6. 遗留问题
log-analysis.py 目前只完成基础功能(状态码 + TOP IP)。 原计划还包含:
- 404 最多的路径统计
- 被限流(429)的 IP 统计
- 扫描器特征识别
- 处理
.gz压缩日志
这些留待后续完善。
另:服务器未安装 git,尚未建立版本控制。已确认 .ssh/ 等 敏感目录需通过 .gitignore 排除,避免私钥进入仓库。
7. 后续计划
- 建立本地编辑 + 传输的开发工作流(安装 git)
- 完善日志分析脚本的剩余功能
- 编写日常巡检脚本(
daily-check.py已填完框架,待正式落地)
可用键盘 ← → 切换上下篇