生产环境替换 DNS 解析器不靠英雄救火:18 万行 C++ 到 1.2 万行 Rust
2023 年,一家中型云服务商的团队把老化的、18 万行、每秒解析数百万查询的 DNS 解析器,换成了 1.2 万行的精简 Rust 实现。迁移花了六个月,没有重大事故、没有事后复盘、没有凌晨三点的恐慌。这不是聪明技巧或英雄救火的故事,而是纪律、渐进主义和选择正确抽象边界的案例。
为什么换解析器
旧解析器是 C++ 写的,设计于 2010 年代初。随时间累积的功能、workaround 和补丁让它变成维护负担:内存安全 bug 频发、性能瓶颈难定位,最糟的是没人敢碰它。团队需要:内存安全(消灭缓冲区溢出和 use-after-free)、更好的可观测性(原生指标、追踪、结构化日志)、更简单的代码库(易审计、易测试、易扩展)、现代异步 I/O(tokio 和 async/await 并发)。
计划:替换而非重构
团队很早就决定替换而不是重构。重构有风险:在既有债务上再累积更多 cruft。小心做的干净重写能消除整类问题。关键原则:增量上线(没有大爆炸切换)、双跑模式(新旧解析器并排运行)、流量影子(镜像流量到新解析器但不按结果动作)、渐进切换(流量按百分比一点点切)、每一步都可回滚。
分步迁移
1. 构建新解析器。选 Rust 为内存安全和性能。新解析器基于 trust-dns,实现 RFC 兼容递归解析、上游 DNS-over-TLS 和 DNS-over-HTTPS、Prometheus 指标、tracing 结构化日志、优雅停机。
2. 影子流量。路由任何生产流量前,先把 1% 查询镜像到新解析器并对比响应。这一步暴露了 TTL 处理和缓存行为的微妙差异。
3. 金丝雀上线。流量 1% → 5% → 25% → 75%,每阶段至少一小时,监控延迟(p50/p95/p99)、错误率、缓存命中率、内存/CPU 使用。
4. 完全切换。两周影子测试加一个月渐进上线后,翻开关,旧解析器退役。
踩过的坑
- DNS 行为微妙差异:解析器本应确定性,但缓存、重试、超时的边界情况各不相同。影子测试抓到了大部分差异。
- 上游解析器差异:Cloudflare、Google、AWS 偶尔返回略有不同的结果,统一到 Cloudflare 保持一致。
- TTL 处理:旧解析器有怪异的 TTL 截断逻辑,新解析器严格遵循 RFC,导致早期上线时短暂缓存抖动。
删掉了什么
删 18 万行不只是换代码,是删除:自定义 DNS 解析器(换成 trust-dns)、手写事件循环(换成 tokio)、遗留 TLS 栈(换成 rustls)、临时指标(换成 metrics-exporter-prometheus)。
五条经验
- 从可观测性开始:指标和 tracing 第一天就建进新系统,没有可见性就是蒙眼飞行。
- 永远双跑:别假设等价,两套并排跑并对比输出。
- 慢慢来:抵制赶进度,每阶段要长到能抓到回归。
- 让回滚微不足道:不能瞬间回退,就不配部署。
- 无畏删除:删 18 万行死代码很吓人,直到发现系统因此安全了多少。
FAQ:为什么不用托管 DNS 服务——内部服务需要低延迟、进程内解析,托管服务尾部延迟不可接受;重写花了约三个月核心团队时间,大部分花在测试、影子验证和渐进上线;核心教训是基础设施替换的价值不在代码本身,而在迁移纪律——影子验证、渐进切换、随时可回滚,任何关键组件替换都适用。
来源:Replacing the DNS Resolver in Production Without a Postmortem - DEV Community