-今日
-本周
-本月
-累计
项目工作台运维 个人项目与实践记录

手记 004:用 Python 写第一个运维脚本

20071
40442
**400****47**

日期: 2026-09-16 用时: 约 90 分钟 难度: ⭐⭐⭐⭐☆


1. 任务背景

前三个任务是配置和排障,本次开始编写实际的运维脚本。

目标是分析 nginx 访问日志,输出:

  1. 状态码分布统计
  2. 请求最多的 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
2007171
4044242
4004718

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 分析结果的意义

状态码分布本身反映了服务器的暴露情况:

状态码次数含义
20080正常访问
40056nginx 拒绝的畸形请求
40453探测不存在路径

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)。 原计划还包含:

这些留待后续完善。

:服务器未安装 git,尚未建立版本控制。已确认 .ssh/ 等 敏感目录需通过 .gitignore 排除,避免私钥进入仓库。


7. 后续计划

  1. 建立本地编辑 + 传输的开发工作流(安装 git)
  2. 完善日志分析脚本的剩余功能
  3. 编写日常巡检脚本(daily-check.py 已填完框架,待正式落地)

可用键盘 ← → 切换上下篇