FediMeteo与时区处理:不破坏现有系统的艺术FediMeteo, timezones, and the art of not breaking what already works
作者分享维护FediMeteo(全球天气服务)时遇到的时区处理挑战,强调在系统升级中保持向后兼容的重要性,并通过HAProxy等技术实现平滑过渡,避免影响现有用户。
Stefano Marinelli
我之前写过关于FediMeteo的诞生,以及HAProxy如何帮助减少到达snac的请求数量。
从外部看,FediMeteo几乎显得静止不动。有一个每小时重新生成的静态主页。有城市页面及其天气预报。有待抓取的RSS源,有待请求的JSON对象,Fediverse实例刷新数据、订阅、取消订阅、检索个人资料和阅读笔记。
这是可见的部分。
然而,背后FediMeteo远不止是一个主页、几个ActivityPub账户和一个表现良好的反向代理。它是一系列小部件,遵循正确的Unix风格,每个都尽力做好一件事。
这条链,尽管从外部几乎看不见,但并非一开始就井井有条。它经历了变化、重写,适应了新国家、时区、模糊的城市名称、外部服务限制,也适应了我自己的错误。
有些错误很小,有些则大得多。
因为FediMeteo是人类的项目,因此不完美。这种不完美体现在人类的不完美上,如今这似乎已不合潮流。我喜欢这一点。
最初版本的机器人脚本简单得令人尴尬,我为此感到自豪。
它以城市名作为输入,通过geopy向Nominatim询问坐标,调用Open-Meteo API获取当前天气及未来几天的预报,并打印一个包含当前状况、今日预报、接下来十二小时和未来几天的markdown块。文本是意大利语。城市是意大利的。时区为Europe/Rome。无需任何计算。
围绕这个脚本,一个小sh包装器读取城市列表,对每个城市运行Python程序,并将输出管道传输到snac note_unlisted。cron任务每六小时运行一次该包装器。输出是松散格式的markdown,snac欣然渲染,集成方式就是标准输出进入标准输入。没有比这更复杂的了。
我喜欢这种设计。这是即使在时尚变迁中依然存活的Unix哲学的一部分。
当我开始添加其他欧洲国家时,我不需要做太多改变。我将操作逻辑与本地化字符串分离,将字符串移动到每个国家的单独JSON文件中,并分布cron条目,使不同国家不在同一分钟发布。每个国家都有自己的snac实例,在各自的FreeBSD jail中,有自己的数据集。机器人内部几乎是之前的那个脚本。
之所以这样工作是因为,本质上,欧洲在我关心的国家中跨越两三个时区。
然后我添加了德国,德国教会了我关于名称的第一课。
在德国有几个叫Neustadt的地方。有Frankfurt am Main和Frankfurt an der Oder,它们不是同一个城市。有Saxony-Anhalt的Halle和North Rhine-Westphalia的Halle。向Nominatim查询“Germany的Frankfurt”一致地返回其中一个,但不总是想要的。一些德国用户礼貌地指出,“他们的”法兰克福预报实际上是另一个城市的。
我开始考虑消歧,但仅够解决眼前的情况。机器人仍然接受单一城市名。对于模糊的名称,我编辑城市文件并希望最好。
事后看来,这就是后来发生的事情的种子。
美国打破了机器人脚本成长过程中所有的假设。
第一个问题是城市数量。我希望在州一级实现合理的覆盖范围,这意味着要识别出五十个主要州的首府或核心城市。最终列表超过1200条条目,仅这一项就比项目中其他所有国家的城市总数还多。
第二个问题是时区问题。美国本土四十八州涵盖四个时区,阿拉斯加和夏威夷又增加了两个,总共六个时区。同一瞬间在纽约和洛杉矶生成的“12:00当前天气”数据在技术上属于同一时刻,但两座城市处于不同的昼夜时段,“今日预报”的窗口期甚至不完全重叠。如果机器人假设所有城市使用同一时钟,每天都会出现错误,有时甚至令人尴尬。
第三个问题再次涉及名称,但规模更大。存在几十个“Springfield”,还有俄勒冈州的波特兰与缅因州的波特兰。针对德国问题的解决方案——手动编辑城市文件并依赖Nominatim选择正确城市——显然无法适用于同名也是州名的国家。
我纠结了几天后才承认自己早已知道的事实。
必须重写这个机器人程序。
困难并非来自重写本身,而是要求在不破坏现有功能的前提下完成重构。
当我决定添加美国时,机器人周边的基础设施已扩展成一套我信赖的系统:监狱隔离、快照备份、定时任务、生产路径上的snac实例、HAProxy负载均衡层、聚合粉丝数的首页cron脚本,以及每六小时按顺序处理的长城市列表。这些组件完全不关心机器人内部结构,却极其关注其外部行为:输入城市名和国码,输出有效Markdown内容,最终推送到时间线。
因此,尽管从未正式文档化,接口约定已然明确:命令行界面、输出格式、退出代码、包装脚本调用方式、JSON国家配置的结构——所有这些必须保持不变。意大利语功能不能中断,德语功能不能中断,每六小时运行的cron任务必须维持原有输出形态,仅新增国家数据。
我几乎修改了所有底层逻辑。
城市参数新增了可选的__state后缀,双下划线作为分隔符:
python3 main.py springfield__illinois us
python3 main.py springfield__massachusetts us
python3 main.py new_york__new_york us未带后缀的城市仍完全兼容旧版,这正是欧洲各国所需。国家配置文件新增了时区字段,支持固定字符串或"auto"字面量;当设为"auto"时,机器人会通过resolved坐标调用timezonefinder确定该城市的具体时区。我将天气服务抽象为接口,使Open-Meteo保持主用,MET Norway和wttr.in作为备用方案,主服务失败时自动切换。单位设置改为按国家配置:温度(华氏/摄氏)、风速(英里/小时/公里/小时)、降水量(英寸/毫米)。美国需要华氏度、英里每小时和英寸,而欧洲多数国家偏好摄氏度、公里每小时和毫米。现在机器人按国家分别支持两种单位体系,无需区分具体对应关系。
这里我略去了很多细节,但核心原则始终如一:每一个新增的自由度都必须能通过配置中的可选字段或命令行标志(CLI flag)来表达。如果某个国家未设置新字段,则沿用旧行为,与之前完全一致。
为了验证这一点,我用新版机器人运行旧国家的配置文件,逐行对比输出结果。若出现差异,问题一定出在新版机器人上,而非测试环节。
重写部署后的首轮运行中,除美国外所有国家的表现都与之前毫无二致——这正是设计目标。
我不愿讲述这段经历,正因如此才更该把它说出来。
开发过程中,调试某条异常不正常的 Open-Meteo 响应时,我在错误路径里加了一条打印语句:只要出错就完整输出请求 URL。Open-Meteo 客户端点的完整 URL 包含 apikey 查询参数。这条本用于调试的日志,我却忘了删除。
我提交了代码。
此后每当 Open-Meteo 发生短暂故障(有时持续几分钟),机器人就会忠实地将失败请求 URL 打印到帖子正文中——每个城市、每次故障周期都如此。包装脚本默默地将输出通过 snac note_unlisted 处理,帖子仍会分发到 Fediverse,而我的 API key 就这样赤裸裸地暴露在文本里,任人读取。
部分用户善意地告知了我,另一些则冷嘲热讽。两组人的批评都没错,这根本不该发生。
我将事件报告给 Open-Meteo 团队,他们非常理解并立即轮换了我的密钥。我删除了调试日志后,还做了更有意义的事:在多层级添加脱敏处理——从机器人输出、守护进程日志到调试工具本身。形似 API key 的 URL 查询参数会被掩码;名为 apikey 或 OPEN_METEO_APIKEY 的环境变量和配置键值,会在任何字符串到达 stdout 或日志前就被脱敏。甚至 JSON 类字段若含 open_meteo_apikey,程序打印时也会被彻底清理。
教训不是“更小心”,而是调试路径早晚都会泄漏秘密,所以从一开始就必须让密钥无法触及调试路径。现在做到了。
那天下午意识到问题后,我关窗静坐了一分钟,然后开始修复。
Nominatim 是公共服务,虽慷慨却非无限。项目里每个城市都需要坐标,最初每个循环都会向 Nominatim 重新请求所有城市的坐标。多数时候能成功,少数时候不行。
在未引入缓存的早期,曾有次 Nominatim 对我的某个查询直接无响应。geopy 调用超时了,机器人抛出异常,包装脚本放弃该城市继续处理。少数用户发现某日某市预报缺失,追问原因。
后来我添加了坐标缓存,至今仍为此庆幸。
缓存的设计是刻意保持简单的。机器人第一次解析城市时,会将纬度和经度写入以该城市(若存在州名则附加州名)命名的 /tmp 目录下的小文件。后续运行均读取此文件。若文件存在,则不调用 Nominatim;若缺失,则调用 Nominatim并写入文件。首次成功查询后,该缓存即成为该城市坐标的权威来源。
这种方式减轻了 Nominatim 的压力,使每个周期更快运行,且对瞬时故障更具容错性。还有一个我未曾预料到的优点。
Nominatim 是一个地理编码服务,和其他所有地理编码工具一样,它带有主观倾向。
我住在费拉拉,因此在添加意大利时确保费拉拉在列表中,并检查首轮运行确认一切正常。预报结果合理:温度适中,图标与窗外天气匹配。合上笔记本后我便不再关注此事。
数月后的某个傍晚,我仔细查看 Nominatim 返回的“费拉拉,意大利”坐标时,发现其并未指向城市中心,而是靠近省几何中心的某处——该省面积广阔,多为乡村。实际预报针对的是城郊某片田地,而非市中心。
我不确定为何之前未察觉。可能因为费拉拉与城郊的天气,对不仔细观察的人来说多数日子并无区别。但这类细节绝不能出错,尤其是关乎我的家乡。
其他地点也存在地理编码偏差问题。有时偏移几公里,有时误入不同街区,甚至完全错误。
由于缓存仅为每个城市一个文件,修复也只需修改对应文件。打开缓存文件,替换正确的经纬度值并保存即可。下一轮直接使用修正坐标,无需代码改动、重新部署或特殊工具。我将手动修正的城市单独保存在文本文件中,重建缓存时不会丢失这些调整。
这种运维简洁性正是我所推崇的。由普通文件构成的缓存几乎零成本,每次小问题出现时都能默默发挥作用。
生成报告的同时,机器人还会将简化的英文快照写入 /tmp/<city>.txt,或含州名时的 /tmp/<city>__<state>.txt。
这是有意为之,并非调试残留。目前暂不便说明具体用途,但这属于项目未来的方向之一。文本作为中间格式非常实用,磁盘上存储每份预报的干净、语言无关表示几乎零成本,未来或许价值巨大。
我喜欢让想法成熟后再公开承诺,因此目前仅作介绍。
美国全境完整处理需数小时。
并非工作量过大,而是我在城市间刻意加入了短暂延迟,以便 snac 能及时发送上一条预报再生成下一条。超过 1200 个城市串联下,即使短暂停滞也会累积。我不急于求成,几分钟间隔的预报无碍,此前 bot 在其他地方已是礼貌型用户。礼貌的周期完全可接受。
循环缓慢的问题不在于持续时间,而在于它会发生什么。
最初的设计中,循环由 cron 触发。每六小时,cron 调用包装脚本,该脚本遍历城市文件并对每个城市运行 bot,并将输出传递给 snac。项目中根本没有调度器,cron 就是调度器。包装脚本只是一个简单的循环。
重启 snac 是无害的。包装脚本会为每个城市调用 snac note_unlisted,如果 snac 恰好短暂不可用,单次调用可能会失败,但循环会继续执行,snac 通常几秒内就能恢复。snac 本身并不是维持循环的关键。
真正维系循环的是包装进程,而该进程运行在 jail 环境中。
如果在包装进程运行时重启 FreeBSD jail,循环会停在当前处理的城市位置。cron 对此并不关心,六小时后下一个 cron 周期会从第一个城市重新开始,重启时本应处理的城市会被跳过。对美国而言,这意味着数百个城市可能因此错过更新。
更糟糕的情况是,主机恰好在 cron 应触发的时刻重启——此时 cron 根本不会触发。没有守护进程去补错过的周期,循环甚至不会启动。六小时的预测数据会在静默中丢失,日志里没有任何异常提示。
我长期容忍这种问题。因为重启很少发生,影响有限,而添加状态管理这类改进总被推迟到“下周”。
最终改变这一切的不是某个重大事件,而是无数小问题的累积:计划中的 VPS 重启、升级后的 jail 重启……单独来看微不足道,合起来却成了持续漏掉周期的“滴水效应”。
于是我写了一个守护进程。
原先的 crontab 配置被移除,现在 jail 内有一个随系统启动长驻的进程,自行完成调度。调度时间从 JSON 配置读取,每分钟检查一次是否需启动新周期,按需执行或等待。
关键部分是状态文件。
守护进程遍历城市文件时,会将当前位置写入小型 JSON 文件(包括正在处理的城市文件和下一城市的索引)。写入发生在城市切换边界,因为只有在此处恢复才有意义。若进程在处理中途中断,恢复时会重试该城市;绝不会存在半途而废的更新。
守护进程启动时读取状态文件。若发现与当前城市文件匹配,则从保存的索引继续;若城市文件已修改,则视为新周期重新处理。此检查是保守的:重命名或修改的城市文件被视为不同周期,否则索引将失去意义。
最终实现了本该从一开始就有的行为:若美国数据处理期间主机重启,守护进程随 jail 恢复后读取状态,无缝继续处理。所有城市仍会收到更新,仅因重启自身产生微小延迟。循环完整结束,状态文件重置,一切恢复正常。
最糟糕的 cron 时代已经过去。守护进程无需任何人手动启动,只要 jail 在运行,守护进程就会持续运行,下一个预定周期会在其预定时刻自动触发,而不管具体某一分钟发生了什么。
在我对项目所做的所有改动中,我最喜欢这一项。它并非激动人心的工作,也鲜少获得掌声——因为正常运行时不会产生任何可见事件。但它消除了日常琐事中的一类小烦恼,并使缓慢的流程对那种乏味的失败具有鲁棒性:这类失败无人预料,却总会最终发生。
当前机器人的功能远比最初的意大利脚本强大得多。它能按城市处理时区、集成三个天气供应商并支持自动回退,提供温度、风速和降水量的单位转换,可选空气质量数据,当供应商提供气压数据时显示气压趋势指示,为未来使用预留简化的英文文本快照,支持手动修补的坐标缓存,多层级敏感信息脱敏,适配主机上安装的任一 HTTP 客户端的心跳机制,以及能在重启后继续运行的调度与恢复守护进程。
但从外部看,几乎一切未变。
欧洲国家的配置方式始终如一。包装脚本未作修改。snac 集成仍是一行管道命令。前置的 HAProxy 层完全不知晓机器人已被重写。统计粉丝数并重新生成静态页面的主页 cron 作业运作方式与之前完全相同。
原始的意大利脚本已不存在于文件中,但作为默认配置保留了下来。若将国家时区设为 Europe/Rome 且无特殊选项,其行为与最初版本的机器人完全一致。其余功能均为可选启用。
我喜爱这种类型的工作。
需要完整排版与评论请前往来源站点阅读。