一次全量替换改了 225 个文件:能回退才敢动手
换网段,一个脚本改了 225 个文件 1275 处,校验 0 残留。第二天打开脚本第一步就超时——文档和可执行资产不是一类东西,历史记录也不该跟着改。
机房要换网段,通知提前一周发下来。我手上这个工作区里有几十份部署脚本、几百份 YAML 和一份 HTML 排障记录,里面的地址全是旧网段。运维的意思是:先把文件改好,等窗口一到直接执行。
我用了不到一个小时把替换跑完,校验通过:旧地址出现 0 次,新地址出现 1275 次。看起来很干净。第二天早上打开其中一份脚本,第一行就卡住了——脚本里的地址是新网段,但机器还是旧网段,这条命令现在执行必然超时。
问题不在于替换错了,而在于我把"文档"和"可执行资产"当成了一类东西,一起改了。前者记录的是"当时发生了什么",后者描述的是"现在该做什么",它们的正确性判据完全不同。下文的命令里,旧网段用 $OLD_SEG、新网段用 $NEW_SEG 表示,实际执行时替换成真实前缀,形如 a.b.c.。
一、先数清有多少处要改
替换之前必须有一个基线数字,否则"改完了"只是感觉。统计要排除掉不该碰的目录,这是第一步的坑:
cd "$WORK"
OLD_SEG='a.b.c.' # 旧网段前缀,形如 10.20.30.
grep -rn --binary-files=without-match --fixed-strings \
--exclude-dir=.git --exclude-dir=node_modules --exclude-dir=_backup \
"$OLD_SEG" . | wc -l
第一次统计出来是 1683 处,比实际多了四百多。多出来的部分全在 _backup 和一些构建产物里——备份目录里的文件是"原文快照",改了它就失去了备份的意义;构建产物会重新生成,改了也没用。
按文件类型拆开看,分布是这样的:
grep -rl --binary-files=without-match --fixed-strings \
--exclude-dir=.git --exclude-dir=node_modules --exclude-dir=_backup \
"$OLD_SEG" . | sed 's/.*\.//' | sort | uniq -c | sort -rn
| 类型 | 文件数 | 处理方式 |
|---|---|---|
.yaml |
96 | 必须改,但要能改回来 |
.py / .sh |
58 | 必须改,改完要过语法检查 |
.md |
27 | 改,属于操作文档 |
.html |
47 | 不改,历史记录 |
| 其他(配置、清单) | 27 | 视情况 |
这张表决定了后面的两步走:先把可执行资产和文档改掉,HTML 单独留一批。
二、第一批只改资产,不动历史报告
第一批改了 171 个文件、799 处。脚本本身很短,重点在"改之前先存原文":
STAMP=$(date +%Y%m%d-%H%M)
BAK="_backup/ip-seg-$STAMP"
mkdir -p "$BAK"
# 逐文件备份原文,保留相对路径;HTML 与备份目录排除在外
grep -rl --binary-files=without-match --fixed-strings \
--exclude-dir=.git --exclude-dir=node_modules --exclude-dir=_backup \
--exclude='*.html' "$OLD_SEG" . \
| while read -r f; do
mkdir -p "$BAK/$(dirname "$f")"
cp -p "$f" "$BAK/$f"
sed -i "s/${OLD_SEG//./\\.}/$NEW_SEG/g" "$f"
echo "$f" >> "$BAK/CHANGELOG.md"
done
三个细节值得说:
一是 grep -rl 的结果要排除二进制文件,否则 cp 和 sed 会把压缩包、图片弄坏。--binary-files=without-match 让 grep 直接跳过它们。
二是 cp -p 保留时间戳。备份目录里如果时间戳全变成"刚刚",将来按时间对比就失去参考价值。
三是 CHANGELOG.md 逐文件记一行,而不是最后统一 diff -r。文件多了之后,靠 diff 找差异要先有一个"改前快照",而快照本身可能被误改;一行一文件的记录最笨也最可靠。
第一批改完,抽查了几处关键文件:健康检查脚本的目标列表、反向代理配置里的上游地址、外部数据库负载均衡的地址。全部落在新网段,说明替换本身没问题。
三、HTML 为什么不能一起改
那 47 份 HTML 是过去几个月的排障记录,里面有命令输出、有截图说明、有"当时 ping 不通"的结论。如果把它们里面的地址也替换成新网段,这些记录就变成了"在新网段下发生过这些事",而事实上新网段那时还不存在。
更麻烦的是时间线。一份记录里写着"迁移前 curl 超时、迁移后 200",两段地址如果被统一替换,这段对比就自相矛盾了。
所以第二批我只改了文件名里的旧前缀,没有动 HTML 正文。代价是工作区里同时存在两种口径:资产写新网段,历史记录写旧网段。这个不一致是刻意保留的,我在 CHANGELOG.md 开头写清楚了一句:以 _backup 里的原文和 HTML 记录为准,资产文件只描述未来状态。
四、第二批改文件名,问题来了
有些脚本的文件名里带地址(比如按目标主机命名的巡检脚本)。第二批改了 27 个文件名,用 git mv 保留历史:
python3 - <<'PY'
import json, os, subprocess
mapping = {}
skip = ('.git', 'node_modules', '_backup')
for root, dirs, files in os.walk('.'):
if any(p in root for p in skip):
continue
for f in files:
if OLD_SEG in f: # OLD_SEG 从环境变量读入
new = f.replace(OLD_SEG, NEW_SEG)
old_path, new_path = os.path.join(root, f), os.path.join(root, new)
subprocess.run(['git', 'mv', old_path, new_path], check=True)
mapping[old_path] = new_path
json.dump(mapping, open('_backup/rename-map.json', 'w'), indent=2)
PY
改完文件名才发现引用没跟着改:有三个脚本按文件名去调另一个脚本,改名之后路径不存在,跑起来报 No such file or directory。这类问题在替换阶段完全看不出来,因为没有语法错误,只有运行到那一行才炸。
补救办法是把所有"引用了被改文件名"的地方找出来,一起改:
python3 -c "
import json
m = json.load(open('_backup/rename-map.json'))
for old, new in m.items():
print(old.split('/')[-1], new.split('/')[-1])
" | while read -r o n; do
grep -rl --binary-files=without-match --exclude-dir=_backup --fixed-strings -- "$o" . \
| xargs -r sed -i "s/$o/$n/g"
done
这一步之后再做全量断言,确认旧地址和旧文件名都是 0:
grep -rn --binary-files=without-match --exclude-dir=_backup --exclude-dir=.git \
--fixed-strings "$OLD_SEG" . | wc -l # 期望 0
grep -rl --fixed-strings "$NEW_SEG" . | wc -l # 期望等于改过的文件数
五、回退设计比替换本身重要
替换脚本写错最多重跑一次,回退没设计好就是不可逆。这次做了两级回退,因为改动分两批:
- 一级:跑
_backup/ip-seg-all-*/rollback.py,把文件名和内容退回到第二批之前(资产已改、HTML 原样)。 - 二级:在上一级基础上
cp -r "_backup/ip-seg-<第一批时间>/." .,退回到完全未改的状态。
rollback.py 的逻辑很直白:读 rename-map.json 反向 git mv,再从备份目录 cp 回原文。之所以写成脚本而不是一段 cp,是因为 171 个文件分布在几十个目录里,手写 cp 一定会漏。
一级回退只回到"第一批已改"的状态,这一点必须写进 CHANGELOG,否则执行回退的人会以为退到了原点,然后基于错误的状态继续操作。
六、事后加的三条规矩
第一,口径与环境分离。资产文件描述"目标状态",现网是"当前状态",两者不一致是正常的,但必须在文件头显式写清楚口径属于哪一版。这次的做法是给每个改过的 YAML 加一行注释:# 网段口径:新网段(落地前需按现网回转)。
第二,替换必须能自证 0 残留。不是"我看了一遍没问题",而是一条可以贴进变更单的命令输出。grep | wc -l 得到 0,才叫改完。
第三,落地前再转一次。文件是新网段、现网是旧网段,任何"顺手执行一下"都会失败。这次的实际做法是在部署脚本入口加一段硬检查:读本机地址,如果与文件里的网段不一致就拒绝执行。
HOST_IP=$(hostname -I | awk '{print $1}')
case "$HOST_IP" in
"${NEW_SEG}"*) : ;;
*) echo "本机地址 $HOST_IP 与文件口径 $NEW_SEG 不一致,拒绝执行" >&2; exit 1 ;;
esac
这段检查加进去的当天就拦住了一次误执行——有人想"先跑一下看看",如果没这道闸,脚本会在第一步就去连一个不存在的地址,报一堆超时,然后被人当成"环境有问题"。
小结
- 替换前先数清基线,统计要排除
.git、node_modules、_backup和二进制文件。 - 历史记录(HTML/报告)不能跟着替换,否则时间线自相矛盾,证据性直接消失。
- 改文件名要同步改引用,否则只在运行时才炸;用 rename-map 记录映射,回退和补引用都靠它。
- 备份要存原文、保留时间戳、逐文件记录;两级回退的每一级都要说清"退到哪个状态"。
- 资产描述目标状态、现网是当前状态,必须在文件里写明口径,并在执行入口加网段一致性检查。
正在加载评论…