热重载
配置文件在磁盘上发生变化时,etemenanki-app 会自行重新加载配置。它没有重载信号,也没有控制 API:你保存文件,运行中的进程就会构建新配置、进行检查,新配置有效时再替换正在运行的配置。
本页介绍变化是如何被察觉的、无效文件会怎样处理、一次成功的重载会对监听器和已打开的连接产生什么影响、重载会应用哪些设置,以及如何在不出意外的情况下修改正在使用的配置文件。修改承载真实流量的代理的配置之前,请先阅读本页。katana 重载自身配置的方式不同,详见 katana 指南。
| 问题 | 答案 |
|---|---|
| 什么会触发重载? | 配置文件所在目录中发生任何事件,且此时文件内容的字节与 etemenanki-app 上一次应用或拒绝的版本不同。 |
| 多快生效? | 变化发生后约 200 ms 以内。 |
| 新文件无效? | 记录日志,当前运行的配置继续提供服务。 |
| 新文件有效? | 整个运行中的配置被替换:所有监听器关闭,所有已打开的连接断开,哪怕只改了注释里的一个字符。 |
| 证书或 geodata 文件变了? | 在配置文件本身发生变化之前,什么都不会发生。 |
修改了 [log].level? |
会报告,但不会应用。需要重启才能生效。 |
SIGHUP? |
会终止进程。它不是重载信号。 |
Generation
Section titled “Generation”etemenanki-app 把一份正在运行的配置称为一个 generation(一代实例)。generation 拥有从配置文件构建出来的一切:每个出站、每个负载均衡器及其健康探测器、路由器及其 geodata、DNS 解析器及其缓存、每个监听器,以及这些监听器上接受的每个连接。
重载从不修补现有的 generation,而是构建一个完整的新 generation;构建成功后,停止旧 generation 并启动新 generation。旧 generation 中的任何东西都不会沿用,因此重载相当于在同一进程内重启代理,只有一点不同:新文件有问题时,旧 generation 会继续提供服务。
如何察觉变化
Section titled “如何察觉变化”启动时,etemenanki-app 会对通过 -c 传入的文件的父目录启动文件监视,而不是监视文件本身。许多编辑器保存时会先写一个新文件,再把它重命名覆盖旧文件;监视文件本身会在第一次保存后失去目标,而监视目录则能看到每一次保存。当 -c 只是一个裸文件名(例如 config.toml)时,被监视的目录就是工作目录。
监视器的工作方式如下:
- 该目录中的任何事件都会唤醒它,无论涉及的是哪个文件:文件被创建、打开、写入、重命名、删除,或者权限、时间戳发生变化。
- 等待这批事件平息:丢弃已排队的事件,等待 200 ms,再丢弃这段时间内到达的事件。编辑器保存一次往往会产生多个事件,这样只会触发一次重载尝试。
- 按路径重新读取配置文件。
- 将读取到的字节与上一次应用或拒绝的版本比较。如果完全相同,则什么也不做,也不记录日志。
- 否则解析并构建新文件,具体见后面几节。
处理过程中到达的事件会排队,并在之后触发下一轮处理。第 3 步读取配置文件时会打开它,而这次打开本身就是被监视目录中的一个事件。因此一旦第一个事件到达,每一轮处理都会引发下一轮,只要进程在运行且文件可读,etemenanki-app 就会以大约每秒五次的频率持续读取配置文件。每次读取得到的字节都相同,因而不做任何事。实际效果是:无论变化在什么时刻落盘,都会在约 200 ms 内被读到,这也是应当一步替换文件的又一个理由(见安全地修改运行中的配置)。
正是字节比较让无关的活动几乎没有开销,它也带来以下结果:
| 你的操作 | 结果 |
|---|---|
| 保存配置文件,包含任何改动,包括注释或空白 | 重载。 |
touch 配置文件,或以相同内容保存 |
什么都不发生。字节相同。 |
| 在同一目录中读取、写入、创建或删除其他文件 | 配置文件会被重新读取并比较,除此之外什么都不发生。 |
| 替换配置引用的证书、密钥、CA 或 geodata 文件 | 什么都不发生,无论该文件放在哪里:配置文件的字节没有变化。见更换证书或 geodata 文件。 |
| 再次保存同样有问题的内容 | 什么都不发生。这些有问题的字节已经尝试过了。 |
| 删除配置文件,或将其重命名移走 | 记录 reload: cannot read …,当前 generation 继续提供服务。读取失败不会打开任何文件,因此监视器会等待目录中的下一个事件,例如文件被放回。 |
监视器无法启动时
Section titled “监视器无法启动时”如果无法建立监视,例如达到了每用户的 inotify 上限(fs.inotify.max_user_instances、fs.inotify.max_user_watches),etemenanki-app 会记录一行日志,然后在没有热重载的情况下继续运行:
ERROR etemenanki_app: config hot-reload disabled: <reason>在重启之前,代理会一直使用启动时的配置。对文件的修改不会被读取,也没有其他任何提示,因此如果你依赖重载,请在启动后检查是否出现这一行。
重载的具体步骤
Section titled “重载的具体步骤”flowchart TB
E["配置目录中发生事件"] --> D["等待 200 ms 平息,丢弃已排队事件"]
D --> R{"读取配置文件"}
R -- "读取出错" --> RE["记录 reload: cannot read,保留旧 generation"]
R -- "成功" --> S{"与上次读取的字节相同?"}
S -- "是" --> N["什么都不做"]
S -- "否" --> P{"解析并构建"}
P -- "出错" --> PE["记录错误,记住这些字节,保留旧 generation"]
P -- "成功" --> L["记录 config reload: 摘要"]
L --> C["停止旧 generation:监听器关闭,连接断开"]
C --> B["按文件顺序绑定新入站"]
B --> F["绑定失败的入站记录日志,其他入站照常服务"]
在动旧 generation 之前,新配置会被完整构建。构建涵盖配置文件页面介绍的两个校验阶段:先解析 TOML,再构建 DNS 解析器、每个出站、每个负载均衡器、路由器和每个入站,这一步会读取配置引用的所有文件。只有绑定监听器和创建 TUN 设备发生在切换之后。
在构建过程中,两个 generation 会短暂地同时存在于内存中。如果 geodata 文件很大,进程会短时间需要容纳两份路由数据的内存。
新文件无效时
Section titled “新文件无效时”如果新文件解析失败或构建失败,etemenanki-app 会记录错误并保留旧 generation。不会有任何东西停止,也不会断开任何连接。这两种日志分别是:
ERROR etemenanki_app::instance: reload: parse failed, keeping current config: <error>ERROR etemenanki_app::instance: reload: build failed, keeping current config: <error><error> 与对该文件运行 --test 时输出的文本相同。例如,[[outbound]] 中拼错了一个键:
ERROR etemenanki_app::instance: reload: parse failed, keeping current config: TOML parse error at line 17, column 1 |17 | bogus = 1 | ^^^^^unknown field `bogus`, expected one of `tag`, `protocol`, `server`, `port`, `stream`, `address_family`, `settings`又如,一条路由指向了不存在的出站:
ERROR etemenanki_app::instance: reload: build failed, keeping current config: route references unknown outbound tag: nopeetemenanki-app 会记住被拒绝文件的字节,不会再次尝试。由此有两点需要注意:
- 再次保存同样有问题的内容,或者执行
touch,都不会有任何效果。请修正文件后再保存。 - 如果构建失败是由文件之外的原因造成的,例如缺少证书(
No such file or directory (os error 2),其中不会给出文件名),那么仅把缺失的文件放回去是不够的。还需要修改配置文件,例如改一下注释,让它的字节发生变化。
保存修正后的文件时,重载会正常进行。如果你只是把文件恢复成当前正在运行的版本,它的字节仍然与被拒绝的版本不同,因此 etemenanki-app 会执行一次完整的重载,记录 config reload: no changes 并断开所有连接,尽管配置并没有变化。
完全无法读取的文件则另作处理:记录 reload: cannot read <path>: <error>,其中 <path> 与你传给 -c 的值完全一致。这种情况不会记住任何内容,下一个目录事件会再次尝试。
ERROR etemenanki_app::instance: reload: cannot read /etc/etemenanki/config.toml: No such file or directory (os error 2)ERROR etemenanki_app::instance: reload: cannot read /etc/etemenanki/config.toml: Permission denied (os error 13)第二行通常意味着新文件的属主或权限与旧文件不同,服务用户无法读取它。修正属主或权限本身就是一个目录事件,因此之后无需再次编辑,重载就会继续进行。
新文件有效时
Section titled “新文件有效时”在停止旧 generation 之前,etemenanki-app 会用一行日志概括发生了哪些变化:
INFO etemenanki_app::instance: config reload: inbounds ~[http-in]; outbounds +[out] -[direct]摘要逐段比较新旧配置:
| 部分 | 何时输出 | 含义 |
|---|---|---|
inbounds +[a,b] |
出现新的入站 tag | 新增的入站,按新文件中的顺序排列。 |
inbounds -[a] |
某个入站 tag 消失 | 移除的入站,按旧文件中的顺序排列。 |
inbounds ~[a] |
相同 tag 的入站有任何设置不同,包括用户 | 发生变化的入站。 |
outbounds +[…] -[…] ~[…] |
同上,针对出站 | 新增、移除和发生变化的出站。 |
route changed |
[route] 下有任何不同,包括规则和 geodata 路径 |
|
log changed |
[log] 下有任何不同 |
新的级别不会被应用;见下文。 |
no changes |
以上都没有 | generation 仍然会被替换。 |
各部分之间用 ; 分隔,方括号内的 tag 用 , 分隔。入站和出站按 tag 匹配,因此重命名一个 tag 会显示为一次新增加一次移除。
摘要仅供参考。它不决定重建哪些内容,也不覆盖所有配置段:
- 只涉及
[dns]或[[balancer]]的改动会记录为config reload: no changes,但仍然会生效。 - 调整入站或出站在文件中的位置不会被报告。这一点对出站很重要:没有设置
[route].default时,第一个[[outbound]]就是默认出站,因此调整它们的顺序可能改变未匹配流量的去向,而日志显示的却是no changes。 - 只改注释或空白会记录为
config reload: no changes,但仍然会替换整个 generation。
每次重载都会替换整个 generation
Section titled “每次重载都会替换整个 generation”记录摘要之后,etemenanki-app 会取消旧 generation,并等待其监听器关闭。取消会丢弃该 generation 派生的所有任务:
- 每个监听器停止接受连接并释放端口。Unix socket 入站会删除它的 socket 文件。
- 每个入站上的每个已打开连接都会立即关闭:TCP 连接、SOCKS UDP 关联、Hysteria 2 电路和 TUN 流都一样。etemenanki-app 不会等待它们结束,客户端会看到连接断开,无论其入站的设置是否发生了变化。
- 每个负载均衡器的探测器都会停止,但会先完成已经开始的那次探测。新 generation 的探测器启动时认为所有成员都健康,并立即开始探测。
- DNS 解析器及其缓存被丢弃。新 generation 从空缓存开始。
- 出站会话随使用它们的连接一起结束。新 generation 中的 WireGuard 或 Hysteria 2 出站会在第一条流需要时建立新会话,并重新握手。
有两种入站会在取消之后继续持有资源,以便新 generation 能使用同一个 UDP 端口或 TUN 设备名;etemenanki-app 会先等它们释放,再绑定任何新的监听器:
| 入站 | 旧 generation 等待什么 | 最长 | 超时后的日志 |
|---|---|---|---|
hysteria2 |
关闭每个 QUIC 连接,等待 endpoint 空闲,再等待 UDP 端口可以重新绑定 | 3 s,然后再等 3 s | hysteria2: <address> did not come free within 3s |
tun |
等待 TUN 设备的描述符及其上的每条 TCP 流被释放 | 3 s | tun: device fd or <n> tcp flows still open after 3s |
等待超时后,会记录上述警告,新 generation 仍会尝试绑定;如果端口或设备仍被占用,该入站就不会启动(见下一节)。
在旧 generation 收尾、新 generation 绑定期间,没有任何入站接受连接。对于基于 TCP 的入站,这段空档只是关闭并重新打开监听器所需的时间。如果旧配置中有 Hysteria 2 或 TUN 入站,空档可能长达数秒。
绑定新 generation
Section titled “绑定新 generation”新 generation 按文件顺序逐个绑定入站,并为每次成功绑定记录日志:
INFO etemenanki_app::instance: inbound socks-in listening on 127.0.0.1:1080绑定失败在启动时和重载时的处理方式不同:
| 时机 | 绑定失败 | 结果 |
|---|---|---|
| 启动时 | 第一次失败 | 进程以状态码 1 退出:failed to start: inbound http-in bind 127.0.0.1:1080 failed: Address already in use (os error 98) |
| 重载时 | 任意入站 | 记录为 inbound http-in bind 127.0.0.1:1080 failed: Address already in use (os error 98)。该入站保持停止状态,其他入站照常启动并提供服务。 |
重载时,一个出问题的端口不应让整个代理下线,因此 etemenanki-app 会继续运行。包含失败入站的这份配置照样成为当前运行的配置,它的字节也会被记住。失败的入站不会自动重试:在释放地址或修正端口之后,需要再次保存配置文件,并让它的字节发生变化。
--test 无法发现绑定失败,因为它不绑定任何东西。常见原因包括:端口已被其他进程占用;同一文件中的两个入站使用了相同的地址和端口;没有绑定 1024 以下端口所需的权限;TUN 设备无法创建。
重载会应用哪些设置
Section titled “重载会应用哪些设置”一次成功的重载会应用文件中除日志级别以外的所有内容:
| 设置 | 重载时是否应用 |
|---|---|
[[inbound]]:所有设置,包括地址、端口、协议、用户、传输层和 TLS |
是 |
[[outbound]]:所有设置 |
是 |
[[balancer]]:outbounds、strategy、probe_interval、probe_timeout |
是(如果没有其他改动,记录为 no changes) |
[route]:default、规则、geoip 和 geosite 路径 |
是 |
[dns]:所有设置 |
是(如果没有其他改动,记录为 no changes);缓存从空开始 |
| 配置引用的文件:证书、密钥、CA 文件、geodata | 是,每次成功重载时都会重新读取 |
[log].level |
否。报告为 log changed,但不会应用。 |
环境变量 RUST_LOG |
否。只在启动时读取一次。 |
配置文件路径(-c) |
否。在进程整个生命周期内固定不变。 |
日志级别只在进程启动时设置一次:如果设置了 RUST_LOG 且有效,就使用它;否则使用 [log].level;再否则使用 info。要修改日志级别,请重启进程。文件中的相对路径在每次重载时都相对于进程的工作目录解析,与启动时相同。
更换证书或 geodata 文件
Section titled “更换证书或 geodata 文件”etemenanki-app 只在构建 generation 时读取证书、密钥、CA 文件和 geodata 文件。在磁盘上替换其中某个文件不会触发重载,当前 generation 会继续使用它已加载的副本。要让新文件生效,请在新文件就位后修改配置文件。
一种方便的做法是专门保留一行注释,由证书续期或数据更新任务改写。例如,把下面这一行作为配置文件的第一行:
# reload-stamp: 0并在每次证书续期或 geodata 更新后运行:
sed -i "s/^# reload-stamp:.*/# reload-stamp: $(date +%s)/" /etc/etemenanki/config.tomlGNU sed -i 会在同一目录中写一个新文件,再把它重命名覆盖旧文件,因此代理永远不会读到写了一半的文件。这次重载会替换 generation,并随之断开所有连接。
安全地修改运行中的配置
Section titled “安全地修改运行中的配置”编辑正在被运行中的代理监视的文件时,可能出两种问题:
- 写了一半的文件:监视器会在任何事件发生后约 200 ms 内读取文件,而在第一个事件之后,它大约每秒读取五次。如果你的工具分多步写入文件,或者你通过较慢的链路复制文件,etemenanki-app 可能读到一个被截断的文件。被截断的文件通常会被拒绝,但如果恰好截断在两个表之间,文件仍可能是有效的,此时它会被应用,而截断处之后的表都会丢失。
- 保存之后才发现的错误:无效文件会被安全地拒绝,但一个有效却行为错误的文件,例如某条路由指向了错误的出站,会立即生效。
请在正在使用的文件旁边写好新版本,检查它,然后一步移动到位。以下命令假定使用生产环境运行中的 etemenanki 服务用户和 /etc/etemenanki 目录布局:
-
复制正在使用的配置,保留其属主和权限,并在同一目录中编辑副本:
终端窗口 sudo cp -p /etc/etemenanki/config.toml /etc/etemenanki/config.toml.newsudoedit /etc/etemenanki/config.toml.new写入副本会唤醒监视器,但正在使用的文件的字节没有变化,所以什么都不会发生。
-p很重要:服务用户无法读取的副本之后会被拒绝,并记录reload: cannot read …: Permission denied (os error 13)。 -
用
--test检查副本,并以服务用户身份、在服务所用的工作目录中运行,使文件权限和相对路径与重载时一致:终端窗口 sudo -u etemenanki sh -c 'cd /etc/etemenanki && exec /usr/local/bin/etemenanki-app --test -c config.toml.new'成功时会输出
Configuration OK.。除了绑定监听器和创建 TUN 设备之外,--test会执行重载时的所有检查,因此已被占用的端口仍能通过检查。 -
将副本重命名覆盖正在使用的文件:
终端窗口 sudo mv /etc/etemenanki/config.toml.new /etc/etemenanki/config.toml同一目录内的重命名是原子操作:代理读到的要么是旧文件,要么是新文件,绝不会是两者的混合。不要用
cp覆盖正在使用的文件,那样会原地写入。 -
检查日志,确认出现重载日志行,并且每个入站各有一行
listening on:终端窗口 journalctl -u etemenanki -n 20尤其要留意
bind … failed,它表示有入站未能启动。
| 现象 | 原因 | 解决方法 |
|---|---|---|
| 保存文件后没有任何变化,也没有出现重载日志行 | 字节没有变化;或者相同的有问题的字节已经被拒绝过;或者监视器未运行(config hot-reload disabled) |
做一次真正的修改;查找之前的 reload: … failed 日志;如果监视器未运行,重启进程 |
| 续期后的证书没有生效 | 证书文件不会触发重载 | 在证书就位后修改配置文件 |
reload: build failed, keeping current config: No such file or directory (os error 2) |
配置引用的某个文件缺失,或者相对路径是相对于另一个工作目录解析的 | 修正路径,然后再次修改配置文件以便重试 |
日志显示 config reload: no changes,但所有客户端都重新连接了 |
每次成功重载都会替换 generation,即使只改了注释、[dns] 或 [[balancer]],或者只是恢复了原文件 |
属于预期行为。把修改攒在一起一次保存 |
重载后少了一个入站,日志中有 inbound … bind … failed |
新地址已被占用,可能是被同一文件中的另一个入站占用 | 释放该地址或更换端口,然后再次保存文件 |
日志显示 log changed,但日志级别没变 |
[log].level 只在启动时读取 |
重启进程 |
reload: cannot read …: Permission denied (os error 13) |
新文件的属主或权限不允许服务用户读取 | 修正属主或权限;这一修改本身会触发重新读取 |
进程在收到 SIGHUP 后退出,例如 unit 中有一行 ExecReload=/bin/kill -HUP $MAINPID |
etemenanki-app 没有 SIGHUP 处理程序,因此该信号会终止进程 |
删除 ExecReload= 这一行,改为保存文件;见生产环境运行 |