程序员开发笔记
服务器、网络、自托管与前端工程的踩坑记录。原创内容,无账号、无评论、无广告。
共 15 篇笔记 · 按 / 聚焦搜索
分类
全部笔记 共 15 篇
-
给 nginx 全局打开 gzip:15.8 MB 到 5.35 MB 的实测与三处配置盲区
弱网下 7.4 MB 的前端文件加载中断,排查后才发现压缩只覆盖了网页本身。把配置收进全局块、补齐类型与响应头、对齐测量方式后,资源体积降到了原来的三分之一。
服务器出口实测约 440 KB/s(≈3.5 Mbps),而站上有个 7.4 MB 的 wasm 前端 SDK。弱网下浏览器直接报
WebAssembly compilation aborted: Network error: Response body loading was aborted,一个原本普通的静态资源问题,最后逼着我把 nginx 的压缩配置从“某个 location 里的局部写法”改成全局统一。现象:同一个文件,有时传完,有时半路断
先去访问日志里对比同一个 URL 的两次请求:一次
body_bytes_sent是 7,378,704 字节,和文件大小一致;另一次只有 3,881,354 字节就结束了。客户端报的是 WebAssembly 编译中断,但根因是响应体根本没传完。出口带宽只有约 3.5 Mbps,意味着这个文件不压缩要下 17 秒左右。弱网 + 长传输,任何一个环节的默认超时都可能把连接掐掉。此时有两个方向:把发送超时放宽,以及把文件压小。后者收益更大,也更容易被忽略——因为“gzip 默认是开的”这个印象并不完整。
坑一:gzip on 不等于反代响应会被压缩
nginx 的
gzip on默认只对text/html且非代理的响应生效,反向代理的响应默认根本不压缩:gzip_proxied的默认值是off。我最初只在站点 location 里写了一句gzip on,静态页有效果,走反向代理的那个前端却怎么配都不压缩;后端的Content-Type、文件大小都正常,唯独响应头里没有Content-Encoding。修正就是显式写
gzip_proxied any。这一条不写,后面所有关于类型的配置都白搭。坑二:text/javascript 不在默认类型里
默认只压缩
text/html(并且是隐式包含的),其余类型要靠gzip_types显式声明。问题在于这个前端发的 JSContent-Type是text/javascript,不是更常见的application/javascript。只写后者,JS 全部不压缩,而且从体积上很难第一眼发现。wasm 同样要单独加:
application/wasm。svg、manifest 这些也别漏,收益不大但没有理由不压。坑三:缺 Vary,压缩反而会喂错客户端
压缩响应必须带
Vary: Accept-Encoding,否则中间缓存可能把压缩版发给不支持 gzip 的客户端。这一条之前长期缺失:文档里曾写着“已经正确”,实测却是所有响应都没有Vary,直到本次一并打开gzip_vary on才真正成立。文档写错的代价比代码写错更隐蔽,因为它会让人跳过检查。做法:配置收到全局 http 块,删掉局部声明
另一个陷阱是配置的位置。原来 gzip 写在某个子路径的 location 里,只覆盖部分类型,还会挡住全局配置的后续修改——典型症状是“改了全局,那个应用却纹丝不动”。统一后只保留一份配置,放在
nginx.conf的http块:gzip on; gzip_vary on; gzip_proxied any; gzip_comp_level 5; gzip_min_length 1024; gzip_types text/css text/plain text/xml application/javascript text/javascript application/wasm image/svg+xml application/manifest+json application/json;压缩级别选 5:再高压缩率提升有限,CPU 与延迟不划算;再低则收益下降明显。这台机器只有 2 vCPU,级别 5 是个可接受的平衡点。
测量:四个把我带偏的假阴性
这次最耗时间的不是改配置,而是确认“到底生效没有”。我踩到的四个坑按杀伤力排序:
curl默认不发Accept-Encoding,服务器当然不压缩,拿到未压缩体积后很容易得出“没生效”的错误结论。systemctl reload nginx是平滑重启,紧接着的测量可能仍被旧 worker 处理,必须等几秒再测。- 检查这类由上游生成响应的反代应用时,别只看
curl -I:它的 HEAD 响应可能是空壳,Content-Length: 0且不带压缩头,看起来像没生效,GET 其实是正常的。 - 只看
Content-Encoding不够,要同时确认Vary存在、Content-Type没被破坏。
# 错:不带 Accept-Encoding,拿到的是未压缩体积 curl -sk -o /dev/null -w '%{size_download}\n' https://203.0.113.10/style.css # 对:显式带压缩头,并同时核对响应头 curl -skI -H 'Accept-Encoding: gzip' https://203.0.113.10/style.css \ | grep -iE 'content-encoding|vary|content-type' # 反向代理的应用用 GET 看头,不要用 -I curl -sk -H 'Accept-Encoding: gzip' -D- -o /dev/null https://203.0.113.10/app/ \ | grep -iE '^HTTP/|content-type|content-encoding|vary'结果:15.8 MB 降到 5.35 MB
资源 压缩前(字节) 压缩后(字节) SDK wasm 7,378,704 2,879,718 main.js 5,333,631 1,473,702 vendor.js 2,827,146 960,085 styles.css 255,532 39,427 合计 15.8 MB 5.35 MB(-66%) 校验方式比数字重要:把压缩响应解压后算 sha256,与未压缩响应完全一致;
Content-Type: application/wasm没有被破坏;Content-Encoding与Vary都在。三个条件同时满足,才算真的无损。还有一个观察点是访问日志里的
body_bytes_sent:压缩生效后它本身就会比原始文件小,而响应被截断时它会明显小于Content-Length。两个现象要分开看,否则很容易把“正常压缩”误判成“又断了”。除上面四个大文件,站点首页的静态资源也一起受益:同一套配置下首页合计从 22.3 KB 降到 11.0 KB。小文件上压缩率看起来没那么惊人,但它是被访问次数最多的那一批,省下的是重复往返的时间。
顺手补上的第二个洞:发送超时
压缩把体积降下去,但慢客户端的问题还在。默认
send_timeout是 60 秒,大文件传到一半被掐断的日志正是这个特征。于是把反向代理那个 location 的send_timeout与proxy_send_timeout提到 300 秒。压缩和超时是两件事,别指望一个能替另一个。复盘清单
- 压缩配置只留一处,放在全局
http块,vhost 里不再重复声明。 gzip_proxied any必写,否则反向代理的响应不压缩。gzip_types按实际Content-Type核对,特别是text/javascript与application/wasm。gzip_vary on,并确认响应头里真的有Vary。- 测量命令永远显式带
Accept-Encoding;反向代理的应用用 GET,不用-I。 - reload 后等几秒再测,避免读到旧 worker 的结果。
- 体积之外还要验无损与
Content-Type。 - 大文件场景同时检查发送超时,别只盯着压缩。
经验:gzip 的坑几乎都不在指令本身,而在“配置在哪一层生效”——类型、位置、代理标记、响应头,四者缺一不可。只改其中一处时,现象往往只是“没有任何变化”,而不会报错。这类“配置写了但没生效”的问题,最可靠的判别方式是从响应头反推,而不是从配置反推。相关记录还有 弱网下的大文件加载 和 自建密码保险库,后者正是这次体积问题的现场。
-
给只有 IP 的服务器签证书:160 小时有效期与一条不能断的续期链路
服务器没有域名,只能用 IP 字面量对外。签完证书才发现有效期只有 160 小时,验证方式、80 端口、HSTS 取舍全都要围着续期链路重新设计。
站点的公网入口只有服务器 IP。自签证书会让每个访客先看一屏警告,所以决定用 Let's Encrypt 的 IP 证书。签下来之后才意识到,真正的成本不在签发,而在这张证书只有 160 小时有效期——后面所有配置都要围着“怎么保证它永远不过期”来设计。
先接受四个硬约束
项目 值 影响 SAN IP:203.0.113.10,仅 IPv4机器没有公网 IPv6,只能签单栈 算法 ECDSA,Let's Encrypt 签发 现代客户端都支持,握手开销更小 有效期 160 小时(约 6.7 天) 连续关机或断网超过 6 天,HTTPS 必然失效 证书路径 /etc/letsencrypt/live/<IP>/nginx 里手工维护,certbot 不会自动写 160 小时不是可调项,是 IP 证书必须走短期 profile 的要求。它直接改变了运维方式:续期不再是“一年管一次”的事,而是必须在两次续期之间任何时刻都保持链路可用。
工具:apt 的 certbot 不行,要 snap 版
系统 apt 源里是 certbot 4.0.0,不支持 IP 证书的签发参数(没有
--ip-address),装上去只会浪费时间。换成 snap 版之后是 5.8.0,才具备这个能力。另一个反直觉的点是插件:certbot 的nginx与apache插件不支持 IP 证书,只能用webroot验证,也就是说必须有一个真实可访问的 HTTP 目录。# apt 版本太老,换成 snap(5.8.0 才支持 IP 证书) sudo snap install certbot --classic # 用 webroot 验证;具体参数以本机 certbot --help 为准 sudo /snap/bin/certbot certonly --webroot -w /var/www/html \ --ip-address 203.0.113.10还有一点要记住:certbot 不会替你把证书路径写进 nginx。自动化只负责“换文件”,vhost 里的
ssl_certificate与ssl_certificate_key始终是手工维护的。443 必须是 default_server:IP 访问不带 SNI
浏览器访问 IP 字面量时不发送 SNI。nginx 拿不到主机名,就无法按
server_name选虚拟主机,只能落到该端口的default_server。漏写这个词,结果就是拿到错误证书或者握手失败——而错误信息通常不会直说“你没写 default_server”。server { listen 443 ssl default_server; listen [::]:443 ssl default_server; ssl_certificate /etc/letsencrypt/live/203.0.113.10/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/203.0.113.10/privkey.pem; # ... }80 端口:只服务 ACME,但绝不能关
80 端口只放行
/.well-known/acme-challenge/,其余请求 301 到 https。这个设计的关键是前提:ACME 校验走明文的 80,必须真的可达。如果把 80 全量跳 443、或者在安全组与防火墙里关掉 80,HTTP-01 校验会直接失败,证书就续不上。用webroot插件验证时,走的就是这条明文路径。所以我把它当成续期链路的一部分,而不是“顺手加的跳转”。改 nginx 时如果动了 80 的 location,必须跑一次 dry-run 验证。
有意不开 HSTS:别把自己锁死
HSTS 的语义是“以后只准用 HTTPS 访问我”。听起来很对,但配上 6 天的证书有效期就变成风险:一旦续期失败,访客连“忽略警告继续用 HTTP”的回退都做不到,等于把站点锁在坏证书上。要开 HSTS,得先能证明续期链路足够可靠;在 IP 证书这个场景下我选择不开,等换成域名证书(90 天轮转)再评估。
这是一个明确的取舍,不是遗漏。写进文档是为了避免下一个人看到“没有 HSTS”就顺手加上。
续期:timer 每天两次,hook 自动 reload
snap 版自带
snap.certbot.renew.timer,每天跑两次;续期配置里加renew_hook = systemctl reload nginx,续完自动让 nginx 加载新证书,不需要人工介入。续期配置里的server必须指向生产 ACME,不能是 staging——签测试证书不会有任何报错,但浏览器不认,排查起来非常费劲。# 只做真实校验,不签发(验证 80 端口与 ACME 链路) sudo /snap/bin/certbot renew --dry-run # 看实际出示的证书;不带 -servername,模拟浏览器访问 IP 的情形 echo | openssl s_client -connect 203.0.113.10:443 2>/dev/null \ | openssl x509 -noout -dates -ext subjectAltName # 续期失败的第一现场 journalctl -u snap.certbot.renew.service ls -la /var/log/letsencrypt/关于
renew_hook的执行顺序:它是在证书文件替换完成之后才触发的,所以里面只需要 reload,不需要重启整个 nginx——重启会中断现有连接,reload 不会。这个区别在低流量站上不明显,在有长连接的服务上就是可用性问题。监控与真正会挂的场景
这套方案里唯一必然导致 HTTPS 失效的场景是:服务器连续关机或断网超过 6 天。这不是“概率问题”,而是时间一到就会发生,所以必须有监控。最省事的做法是把证书剩余有效期做成一条可观测指标:低于阈值就告警,而不是等访客来告诉你证书坏了。
监控之外还有一条:这张证书不只服务站点,还挂在子路径反代的几个服务上(见 单 IP 多服务的子路径反代)。证书一挂是全部一起挂,所以它的权重比一般静态站高得多。也正因为这样,我把“续期 dry-run”做成了控制台里可以手动触发的检查项,改完 nginx 立刻能验证链路,而不用等到定时器下一次运行。
取剩余有效期的数据源有两个:
certbot certificates能直接列出每张证书的到期时间;openssl s_client连上去、再把输出交给openssl x509 -enddate,则是从“服务实际出示的证书”这一侧确认。前者反映文件状态,后者反映 nginx 是否真的加载了新证书,两个都对上才放心。落地清单
- 用 snap 版 certbot,确认版本支持 IP 证书;apt 版直接放弃。
- 验证方式只能用
webroot,必须保留可访问的 80 明文路径。 - 443 的 server 块写
default_server,IPv4 与 IPv6 各一行。 - 续期配置的
server指向生产 ACME,加renew_hook自动 reload。 certbot renew --dry-run作为改动后的固定验证。- 证书剩余有效期纳入监控,低于阈值告警。
- HSTS 是否开启要有明确结论并写入文档,不要默认跟随教程。
- 证书路径手工维护在 nginx 里,升级或迁移时记得同步。
经验:IP 证书把“证书有效期”从年度事项变成了周常事项。凡是把有效期写进系统设计的场景,都要把续期链路当成主路径来测试——dry-run 通过、证书真的换上了、服务真的 reload 了,三者缺一不可。相关记录:nginx 全局压缩 是同一台机器上的另一项配置改造,自建密码保险库 则是最依赖这张证书的服务之一。
-
一个 IP、一条 443:子路径反代的尾斜杠、WebSocket 与 cookie Path
服务器只有 IP 入口,几个服务挂在同一个 443 的子路径下。反向代理末尾的斜杠决定后端收到什么路径,长连接与登录态的路径也各有各的坑。
服务器只有一个公网入口,没有域名可用。给每个服务单独开端口意味着安全组、证书和暴露面都要跟着变复杂,最后选了一个更克制的方式:一个 443 vhost,按子路径把请求分给不同后端。看起来只是几条 location,实际踩的坑全在路径语义和协议细节上。
结构:所有后端只监听回环
路径 后端 关键约束 /app/静态 PWA 文件直接由 nginx 提供,不经过后端 /_login、/_devices127.0.0.1:13444登录换 token、设备列表 /remote/、/_bridge127.0.0.1:13444镜像页与 WebSocket 升级 /Vaultwarden/127.0.0.1:8000proxy_pass不带 URI后端一律只监听
127.0.0.1,外网只能从 443 的这几个 location 进来。相比防火墙规则,这条约束更不容易被后续改动绕过:应用自己根本没有公网监听,改错 nginx 最多是 502,而不是意外把服务暴露出去。公共的反代头也统一了:
Host透传、X-Real-IP与X-Forwarded-For追加、X-Forwarded-Proto告诉后端原始协议是 https。少了最后一条,后端可能一直以为自己在 HTTP 下,于是生成的跳转地址或 cookie 的 Secure 属性就可能是错的——而且通常要等到用户登录才暴露。proxy_pass 末尾斜杠:两种完全不同的语义
这是子路径反代最容易错的一行。
proxy_pass后面带 URI(哪怕只是一个/)会替换 location 匹配到的前缀;不带 URI 则原样透传原始 URI。# 语义 A:原样透传 —— 后端收到 /Vaultwarden/foo location /Vaultwarden/ { proxy_pass http://127.0.0.1:8000; } # 语义 B:剥掉前缀 —— 后端收到 /foo location /Vaultwarden/ { proxy_pass http://127.0.0.1:8000/; }直觉上“剥前缀”更常见:
location /app/配proxy_pass http://127.0.0.1:9000/,后端收到/foo而不是/app/foo。这个规则对大多数按根路径设计的应用是对的。但对这个保险库是错的:它自己的
DOMAIN配置里就带 base dir,路由本身就是/Vaultwarden/...,再剥一次前缀就全部 404。这种反直觉的点必须写进注释,否则下一个人(包括三个月后的自己)一定会“修正”回去。判断标准很简单:问后端“你的路由里有没有这个前缀”。有就原样透传,没有才剥前缀。如果出现一部分路由带前缀、一部分不带,说明应用本身没设计子路径部署,应该先改应用配置,而不是在 nginx 里打补丁。
WebSocket:升级头用 map,别写死
子路径里的实时通道(通知、远程镜像)需要 WebSocket 升级,反向代理必须显式转发
Upgrade与Connection。常见写法是把Connection直接写死成upgrade,但那只适用于“这个 location 只服务 WebSocket”的情况;普通请求也带上 upgrade 头,行为会变得奇怪。正确做法是用 map 按请求动态决定:
# /etc/nginx/conf.d/ws-map.conf map $http_upgrade $connection_upgrade { default upgrade; '' close; }location /_bridge { proxy_pass http://127.0.0.1:13444; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_read_timeout 3600s; proxy_buffering off; }map 放在独立文件里,由
http块的 include 引入。主配置动得越少越好:删这个服务时直接删文件,不会留下改了一半的全局配置。超时、缓冲与请求体
三个默认值会咬人:
send_timeout与proxy_send_timeout默认 60 秒:慢客户端传大文件会被截断;大文件场景提到 300 秒。client_max_body_size默认 1 MB:附件上传大于 1 MB 会被 nginx 先拦下(413),和后端无关。- buffering 默认开启:SSE 或长连接看起来像“没有流式输出”,要对该 location 关掉 buffering。
这三项都放在具体服务的 location 或公共 snippet 里,不动全局默认值——局部问题局部解决,避免影响其它服务。
cookie 的 Path 与前端绝对路径
子路径部署最隐蔽的问题不在 nginx,在应用生成的 URL。后端如果按根路径生成静态资源地址、跳转地址,或者把 cookie 的 Path 设成
/,浏览器请求就会跑到子路径之外(通常是根路径),表现就是静态资源 404 或跳转循环。- cookie
Path=/:同域下所有子路径都能收到这个 cookie,方便也污染;登录态跨应用共享不是好事,验收时要在开发者工具里逐个确认。 - cookie
Path=/应用前缀/:只在该前缀下带上,职责清晰;换了前缀要同步改应用配置。 - 前端路由是绝对路径的 SPA:刷新时服务端根本没有这个文件,需要在该应用 location 内做
try_files回退到index.html,不要写在站点根。
自带 base dir 配置的应用(比如这个保险库)会好处理很多,因为它生成的 URL 本身就带前缀。选应用时优先选支持子路径部署的,能省掉一堆改写工作。
排查顺序:从“请求落到哪”开始
子路径反代出问题时,我按下面顺序查,能避免在一堆可能原因里乱猜:
- 先确认请求落到哪个 location、后端有没有收到:看 access.log 的 URI 与 error.log 的 upstream 信息。
- 用 GET 加
-D-看响应头,重点看Content-Type、Location、Set-Cookie的 Path。不要用-I,反向代理应用的 HEAD 经常是空壳。 - 502 或 504 是上游问题:先用
ss确认后端在听回环端口,再看 error.log 是 refused 还是 timeout。 - 404 但路径看着“少了一段”或“多了一段”:先怀疑
proxy_pass的尾斜杠,而不是后端路由。 - 页面能开、接口 401 或 403:先怀疑 cookie 没带上,或 Host 与 Origin 的信任栅栏,反向代理本身通常没问题。
# 后端收到的请求与上游错误 tail -50 /var/log/nginx/error.log # 逐跳确认头部(不要用 -I) curl -sk -D- -o /dev/null https://203.0.113.10/Vaultwarden/ \ | grep -iE '^HTTP/|content-type|location|set-cookie' # 确认后端确实只在回环上监听 ss -lntp | grep -E '127.0.0.1:(8000|13444)'改动前先备份 vhost 与主配置,
nginx -t通过后再 reload;reload 后回归验证老路径(站点、保险库)还是 200。这套流程见 改配置前先留档。复盘清单
- 后端只监听
127.0.0.1,对外只走 443 子路径。 - 每个 location 的
proxy_pass尾斜杠写注释说明语义。 - WebSocket 用 map 动态决定
Connection,不写死。 - 超时、请求体上限、缓冲按服务局部设置。
- cookie Path 与前端绝对路径在浏览器里实测,不靠推断。
- 改配置前留档,
nginx -t通过再 reload,reload 后回归老服务。
相关记录:自建远程访问中继 用了同样的子路径加 WebSocket 模式,全局压缩 则是同一台机器上另一个“配置位置比内容更容易出错”的例子。
-
给 dsh web 套反向代理:token 换 cookie、Host 栅栏与 401/403/502
dsh web 只监听回环,登录靠启动时打印的一次性链接。直接反代出去页面能开、接口全 401;最后用启动脚本换出绑定域名的登录态才解决。
我在本机跑 dsh web,想在浏览器里用一个固定域名打开它,而不是每次去日志里翻带 token 的链接。第一版反向代理很朴素:把
127.0.0.1:3080挂到https://dsh.example.com/。页面能打开,但所有 API 都返回 401,提示dsh web authentication required。现象:页面能开,接口全 401
首页、静态资源都是 200,只有
/api一律 401,重启 dsh 也不变。这说明 nginx 的转发本身没问题,问题在认证层;也说明“把上游 URL 直接代理出来”对一个有状态认证的应用并不成立。先理解 dsh web 的认证模型
启动时它会打印一行类似
dsh web: http://127.0.0.1:3080/?token=...的地址,token 每次进程启动都是随机生成的。浏览器带着这个 token 请求/,校验通过后返回 303 并下发一张 HttpOnly 签名 cookie,默认 30 天有效。签名密钥持久化在~/.dsh/.credentials.yaml,所以 dsh 重启之后旧 cookie 仍然有效。关键在最后一点:cookie 的名字和签名 audience 都绑定请求的 authority(scheme + host + port)。token 换来的 cookie 绑的是
127.0.0.1:3080;浏览器访问dsh.example.com时,dsh 用 Host 去找对应的 cookie,找不到就返回 401。另外还有两条规则:除首页(
/、/index.html)外静态资源不校验,但/api一律要求有效 cookie,而且先过 Host 与 Origin 的信任栅栏。这几条叠加起来,决定了“把 token URL 直接交给反向代理”这条路走不通。做法:启动时换 cookie,由 nginx 注入
思路是把换 cookie 的动作放进 dsh 的启动脚本:截获 stdout 里的 token 行,用带目标 Host 的请求去换一张绑定该域名的 cookie,写进 nginx snippet,由 nginx 给每个请求注入
Cookie头。浏览器什么都不用做。# 1) 启动脚本边转发日志,边抓出启动 token 行 # dsh web: http://127.0.0.1:3080/?token=xxxx # 2) 用目标域名作为 Host 去换 cookie curl -s -D- -o /dev/null \ -H 'Host: dsh.example.com' \ 'http://127.0.0.1:3080/?token=xxxx' \ | grep -i '^set-cookie:' # 3) 把 Set-Cookie 写成一行 proxy_set_header,落盘 snippet 后 nginx -t 再 reload几个细节:
- snippet 文件权限
0600,内容只有一行proxy_set_header Cookie "...";。cookie 是登录凭据,不能写进 vhost,更不能进代码仓库。 - 保留上一份可用副本,
nginx -t失败时自动回滚。 - 后台每 7 天用同一个 token 重新换一张(30 天有效期留足余量),进程长期不重启也不会掉登录。
- 启动 dsh 时加
--trusted-host dsh.example.com,让/api的 Host 与 Origin 栅栏接受这个域名;旧版本没有这个参数时脚本要能降级启动。
nginx 侧保持真实 Host,跨站防护才还在:
server { listen 443 ssl; server_name dsh.example.com; location / { proxy_pass http://127.0.0.1:3080; proxy_http_version 1.1; proxy_set_header Host $host; include /etc/nginx/snippets/dsh-auth.conf; proxy_buffering off; # SSE / 流式输出 proxy_read_timeout 3600s; client_max_body_size 320m; } }注意 snippet 是被硬 include 的:文件被删,nginx 直接起不来。所以它必须由启动脚本幂等生成,并保留可回滚副本。
启动脚本里的顺序也重要:先抓 token,再换 cookie,接着写 snippet 并做生成自检,然后
nginx -t,最后才 reload。如果顺序反过来,reload 的瞬间 snippet 还是旧的,/api会短暂 401;窗口虽然很短,但排障时容易误判成配置根本没生效。三个坑
不要改 Host 去绕栅栏
一个很自然的想法是把 Host 改成
127.0.0.1:3080,让 cookie 命中。但浏览器 POST 的 Origin 是https://dsh.example.com,与 Host 不匹配,/api还是 403。正确做法是保留真实 Host,用--trusted-host明确告诉 dsh 该域名可信。snippet 行尾漏分号
nginx -t会报unexpected end of file, expecting ";" or "}",指向 snippet 第 4 行附近。更阴险的是如果 nginx 正在运行、只是 reload 失败,它只打一条 WARNING,旧配置还在内存里,外界看起来一切正常;坏文件留到下次重启才让 nginx 冷启动失败。所以生成代码必须有自检,写入失败或校验失败要回滚。只跑 nginx -t 会假通过
因为
nginx -t读的是盘上的旧文件。改完生成逻辑后,必须确认新内容真的落盘、行尾有分号、reload 之后从域名请求一次是 200。这条是踩过事故之后补的:现在启动脚本里有生成自检和失败回滚,另有一份不需要 root、不碰真 nginx 的自测脚本,专测 snippet 生成与回滚逻辑。此外每次 dsh 启动(包括自动更新之后)都会跑一次入口复验:检查首页是否 200、
/api的认证与栅栏是否正常、设置是否可写、cookie 是否新鲜,结果写进 journald。它失败只告警、不阻断启动——最坏情况是重新登录一次,而不是让服务起不来。后来做的一次减法
早期版本为了兼容上游一个设置页的 loopback 判定,额外注入过前端脚本、维护过第二张 loopback cookie,还要处理“上游压缩导致注入不生效”的问题。后来上游把那个判定去掉了,我一次性删掉了
sub_filter、第二张 cookie 和对应的 server 块,只保留一张域名 cookie。教训是:反向代理层要尽量薄,上游一变就要能整块删掉。排查表
症状 最可能的原因 先查哪里 401 authentication requiredcookie 没命中或已过期 snippet 内容、authority 是否匹配、dsh 日志里的换 cookie 记录 /api403Host 或 Origin 栅栏 是否带 --trusted-host、Origin 与 Host 是否同源502 上游没起或端口不对 systemctl status、ss -lntp看 3080页面白屏、静态资源 404 SPA 回退或路径前缀 该 location 的 try_files与前端 basenginx 起不来 snippet 被删或语法错误 nginx -t的报错行,检查被 include 的 snippet 与主配置# 域名入口应 200(本机解析到回环时可用 --resolve 模拟) curl -sk --resolve dsh.example.com:443:127.0.0.1 -o /dev/null -w '%{http_code}\n' \ https://dsh.example.com/ # /api:未知路由返回 404 说明已过认证与栅栏;403 才是被栅栏拦住 curl -sk --resolve dsh.example.com:443:127.0.0.1 -o /dev/null -w '%{http_code}\n' \ -X POST -H 'Content-Type: application/json' --data '{}' https://dsh.example.com/api/安全边界:这个入口等价于把 operator 身份交给任何能访问它的人——dsh 带 shell 与文件工具,等于 RCE 能力。所以它只在本机 hosts 解析加私有 CA 的环境下使用,不要把这个 vhost 暴露到不可信网络。另外 journal 里有启动 token,能读 journal 的人可以藉此换一张新 cookie,别把日志内容贴到不可信的地方。清单
- 不要直接代理 token URL;用启动脚本换绑定域名的 cookie。
- nginx 保持真实 Host,认证靠
--trusted-host与注入的 cookie。 - snippet 权限 0600、内容只有一行,保留回滚副本。
- snippet 生成后必须端到端验证,
nginx -t单独跑不算数。 - 定期换 cookie,别等 30 天过期才发现登录态掉了。
- 这个入口等于本机 root 能力,只在本机可信网络里用。
相关记录:手机远程访问的自建中继 就是在这套入口之上做的;全局压缩 与 改配置前留档 是同一台机器上的配套实践。
- snippet 文件权限
-
给没有公网 IP 的电脑做远程访问:自建中继的配对、掉线与密钥轮换
电脑没有公网 IP,手机却要打开上面的服务。于是自建了 router 与 bridge 中继:服务器只监听回环,入口上开几条子路径,靠长连接维持通道。
需求很具体:在手机上打开电脑上的 dsh web,功能和电脑端一致;不想把电脑暴露到公网,也不想依赖第三方托管。电脑没有公网 IP,所以中间必须有一台有公网入口的服务器做中继。最后落地的方案是三段式:手机连服务器上的反向代理,服务器上的 router 与电脑上的 bridge 用 WebSocket 保持一条长连接。
架构:公网入口只留在服务器上
整条链路是:手机 → nginx 443 → relay-router(服务器)→ WebSocket 长连接 → bridge(电脑)→
127.0.0.1:3080的 dsh web。分工很清楚:router 对外提供 PWA 静态页、登录换 token、设备列表和镜像页反代;bridge 在电脑侧主动连出去,所以电脑不需要任何入站端口,也不用动路由器。服务器上的 router 只监听
127.0.0.1:13444,公网唯一入口是 443 的几条子路径;安全组里仍然只放行 80 与 443,没有为这个服务新开端口。路径 作用 /app/、/login/PWA 静态页(手机入口),由 nginx 直接给文件 /_login用访问密钥换本地 JWT /_devices在线设备列表 /remote/、/_bridge镜像页反代与 WebSocket 升级 /(仅带设备 cookie 时)镜像页里 dsh web 的绝对路径兜底 配对与凭据:一把访问密钥
这套自建模式没有账号体系,访问密钥就是实例根凭据。手机在 PWA 里输入密钥,请求
/_login换到一张本地 JWT(30 天滑动续期),之后的/_devices与/remote/请求都带着它。bridge 也用同一把密钥换自己的 token,于是“能不能证明自己知道密钥”就是唯一的身份判断。密钥保存在服务器上一个权限
0600的环境变量文件里,安装时只交付一次,不进代码仓库,也不抄进文档。设备列表不落库:router 在内存里维护实时注册表,bridge 一掉线,设备就从列表里消失。这个设计简单直接,但也意味着列表反映的是“此刻在线什么”,而不是“历史上有什么设备”。手机侧不需要装 App:浏览器打开
/app/,输入访问密钥、选设备,就进入 dsh web 的镜像页,功能与电脑端一致;也可以“添加到主屏幕”当 PWA 用。升级 router 或前端静态页时,手机上不需要做任何安装动作。WebSocket 长连接是整条链路的核心
手机与 bridge 都靠 WebSocket 维持通道,所以反向代理这一层必须做对:升级头用 map 按请求决定、关掉 buffering、给足读超时。漏一个的典型症状是“页面能开但一直转圈”。要点如下:
location /_bridge { proxy_pass http://127.0.0.1:13444; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; # map 动态决定,不写死 upgrade proxy_read_timeout 3600s; proxy_buffering off; }还有一个容易漏的细节是根路径。手机镜像页里的 dsh web 会请求一些绝对路径(例如
/api/...),这些不能落到站点静态目录。做法是让根 location 只在请求带设备 cookie 时才转给 router,其余请求照旧走站点:location / { error_page 418 = @relay; if ($cookie_dsh_device) { return 418; } try_files $uri $uri/ =404; } location @relay { proxy_pass http://127.0.0.1:13444; # 公共反代头与升级配置复用 snippet }这样手机镜像页可用,站点行为完全不变。另外 router 与 bridge 的 WebSocket 地址必须与入口的证书、域名或 IP 一致(
wss://),否则握手会直接失败;证书复用的就是服务器上那张 IP 证书,见 IP 证书与续期链路。部署:独立用户、systemd、最小权限
程序放在
/opt下,跑独立系统用户;systemd unit 给ProtectSystem=strict、MemoryMax=256M、Restart=on-failure。Node 版本有下限要求(router 要求 22.13 以上,系统源自带的 22.22 够用)。日志单独轮转,和 journal 互补,排障时两边都要看。nginx 侧把公共反代头抽成 snippet,路由插入段放在独立文件里;改动前先备份 vhost 与主配置,
nginx -t通过再 reload;reload 后回归验证站点、保险库、80 到 443 的跳转都还是原状态。这套“改前留档 + 改后回归”的流程见 改配置前先留档。三个已知问题
- 面板状态误报:电脑侧面板用
pgrep -fl检测 bridge 进程,但 procps-ng 的-l只打印进程名(Node 进程名是MainThread),取不到命令行,于是面板一直显示“正在启动”,实际早已在线。判断在线与否要以手机能否看到设备为准;修法是把-fl换成-af,但插件升级会覆盖这个补丁。 - 开机自恢复不完整:电脑侧没有可用的 user systemd 时,bridge 只能由 detached watcher 兜底;重启电脑后,要在电脑上打开一次远程访问面板(或调用一次状态接口),bridge 才会重新连上,期间手机端是离线的。要做到开机自动恢复,得再加一个系统级 unit,属于额外变更,暂时没做。
- 证书寿命:整条链路复用服务器那张 IP 证书(约 6.7 天),续期失败会同时影响站点、保险库和远程访问。它是单点,必须有监控。
这三个问题的共同点是“看起来像坏了,其实是设计边界”,所以都写进了文档,避免下次重新排查一遍。
升级、回滚与密钥轮换
升级:取上游新版本的 router 与 PWA 静态页,按部署目录的 README 重装,保留环境变量文件,重启 unit。插件版本与中继版本不必严格一致,帧协议不变即可;升级前先看上游的变更记录。回滚就停 unit、删掉新增的 map 与 snippet、用安装留档恢复 vhost,
nginx -t之后再 reload。换密钥:改环境变量文件里的访问密钥(可以逗号分隔多把,便于平滑轮换)→ 重启 router → 手机与插件面板重填新密钥。旧 JWT 仍然有效到过期;要立刻让全部失效,就同时换 JWT 签名密钥。
# 换密钥后重启中继,确认监听面没有变化 sudo systemctl restart dsh-remote-router ss -lntp | grep 13444 # 无 token 的设备列表接口应为 401 curl -sk -o /dev/null -w '%{http_code}\n' https://203.0.113.10/_devices # 失败时先看中继日志 journalctl -u dsh-remote-router -n 50复盘
- 电脑侧永远只做“主动连出”,公网入口只留服务器的 443。
- 凭据只有一把,必须能轮换;密钥只交付一次,不落文档、不入库。
- 实时性要靠长连接,反代层就要为 WebSocket 专门配置,而不是假定默认值够用。
- 状态判断以用户视角为准:手机能不能打开,比面板上的文字可信。
- 证书是这条链路的单点,监控要覆盖它。
相关记录:子路径反代的路径与 WebSocket 坑、dsh web 的域名反代与 cookie 认证、把新服务登记进服务目录控制台。
- 面板状态误报:电脑侧面板用
-
把密码库搬回自己的服务器:Vaultwarden 子路径部署与备份
在一台没有域名、只有 IP 的服务器上用容器部署密码库,走 nginx 子路径反代,补上每日一致性备份、两步验证与恢复注意事项,记下几个反直觉的坑。
密码库是我最不想托管给别人、又必须随时能打开的东西。最后我把 Vaultwarden 装在了自己的服务器上:容器只监听回环地址,nginx 用一条子路径把它暴露出去,旁边挂一个每日备份的定时器。整个过程里真正花时间的不是部署本身,而是几个“想当然”的坑。
部署:一个 compose 文件就够
选它的原因很实际:官方客户端齐全、同步协议兼容、单容器能跑完,数据就是一个 SQLite 库加几份附件,备份和迁移都看得见摸得着。服务器上没有域名,只有一个公网 IP,所以我的目标是先能用起来——通过
https://<服务器 IP>/Vaultwarden/这个子路径访问,域名以后再说。编排文件放在
/opt/vaultwarden/docker-compose.yml,固定版本 1.37.4(这个版本包含一批安全修复,不打算往更早的版本降)。容器端口只映射到回环,写pull_policy: missing是为了避免每次启动都去访问一个在这台机器上连不通的镜像仓库;镜像从可用的镜像源拉下来后在本地重新打 tag。services: vaultwarden: image: vaultwarden/server:1.37.4 container_name: vaultwarden pull_policy: missing restart: unless-stopped ports: - "127.0.0.1:8000:80" volumes: - /var/lib/vaultwarden:/data environment: DOMAIN: "https://203.0.113.10/Vaultwarden" TZ: "Asia/Shanghai" SIGNUPS_ALLOWED: "false" INVITATIONS_ALLOWED: "false" WEB_VAULT_ENABLED: "true"上面写的是最终状态:注册关闭、邀请关闭、管理面板不启用(不设管理口令,少一个攻击面)。第一次部署时要先把
SIGNUPS_ALLOWED临时设为"true",注册完唯一账号后改回"false"并docker compose up -d重启;之后登录页面就只剩下“登录”一件事可做。常用命令也简单,全部在编排目录里执行:
cd /opt/vaultwarden docker compose ps docker compose logs -f --tail=100 docker compose up -d子路径反代:唯一不能凭直觉写的 proxy_pass
Vaultwarden 的路由本身包含 base dir:只要
DOMAIN里写了/Vaultwarden,它生成的所有链接、Cookie 路径、静态资源前缀都会带上这个前缀。所以 nginx 不能剥前缀——这和我配其他子路径反代时的直觉刚好相反。location /Vaultwarden/ { proxy_pass http://127.0.0.1:8000; # 原样透传 /Vaultwarden/... proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; client_max_body_size 128m; # 附件上传 send_timeout 300s; proxy_send_timeout 300s; }如果写成
proxy_pass http://127.0.0.1:8000/;(末尾多一个斜杠),nginx 会把/Vaultwarden剥掉再转发,结果连首页都是 404。判断方法很直接:看访问日志里浏览器请求的路径,和后端容器日志里实际收到的路径是否一致。更多子路径反代的通用问题记在 子路径反代那篇里。另外两处必须单独处理:
/Vaultwarden/notifications/hub是 WebSocket 通道,要单独一个 location 带上Upgrade与Connection头,否则页面能开、通知永远是灰的;gzip 也必须打开,否则加密 SDK 那个 7.4 MB 的 wasm 在弱网下传到一半就断,客户端报WebAssembly compilation aborted。gzip 的配置细节我在 另一篇笔记里写过,这里不重复。备份:WAL 模式下别直接复制文件
数据目录是
/var/lib/vaultwarden:db.sqlite3、rsa_key.pem、attachments/、sends/,外加可以重建的图标缓存。备份脚本每天 03:30 由 systemd 定时器触发,Persistent=true保证关机错过的那次会补跑。最开始我想用
cp db.sqlite3了事,后来才意识到库跑在 WAL 模式下:直接复制主库文件会丢掉还在 WAL 里的事务,运气不好甚至会复制出一个损坏的副本。正确做法是用 SQLite 的在线备份 API 生成一致性快照:import sqlite3 src = sqlite3.connect("file:/var/lib/vaultwarden/db.sqlite3?mode=ro", uri=True) dst = sqlite3.connect("/tmp/vaultwarden-snapshot.sqlite3") src.backup(dst) dst.close() src.close()快照再和
rsa_key.pem、config.json、attachments/、sends/一起打进 tar 包,单份大约 104 KB。保留策略是“最多 14 份、目录总量不超过 1 GiB”,超出从最旧的删起,最新一份永远保留。手工触发和看日志:systemctl start vaultwarden-backup.service journalctl -u vaultwarden-backup.service -n 50 --no-pager ls -lh /var/backups/vaultwarden/ | tail -5恢复时最容易犯的错:只把
db.sqlite3放回去、却换了一份新的rsa_key.pem。库里所有已登录会话都和这对密钥相关,换钥匙的结果是全部客户端被登出,个别数据也可能读不出来。这两个文件必须成对恢复。关于 SQLite 一致性备份的更多细节(临时文件、原子替换、WAL 检查点),我单独写在 WAL 备份那篇里;改备份脚本本身之前,我会先跑一次 变更前留档脚本,把旧脚本和定时器单元一起打包。
两步验证:恢复代码不是保险箱
开启 TOTP 之后,登录体验是“主密码 + 6 位验证码”,勾了“记住我”的浏览器大约 30 天内免验证码。已经登录的设备不受影响——两步验证管的是建立新会话,不是给已有会话上锁。
值得记下来的是官方恢复代码的坑:社区里有恢复代码失效的报告(issue #6203),所以我没有把它当唯一退路。我的做法是恢复代码离线抄一份,同一个 TOTP 密钥加进两个验证器 App。如果真被锁在门外,还有一个不依赖恢复代码的紧急手段:直接删掉数据库里的两步验证记录(表
twofactor,按用户 UUID 删除),再重新登录配置。方式 前提 我的选择 验证器应用(TOTP) 开箱可用 启用,主用 邮箱验证码 必须配置 SMTP 未配,不启用 Duo 需要 Duo 账号的密钥 不启用 YubiKey OTP 需要 YubiCloud 凭据 不启用 WebAuthn / Passkey 依赖 DOMAIN 与 HTTPS,和域名强绑定 等域名就位再说 所以 WebAuthn / Passkey 我一直没在 IP 下注册——它和域名强绑定,现在注册的密钥换域名后不生效,等于白注册一次。
迁到域名之前先想清楚
域名迁移的清单我提前写好了,因为这里面有几件事必须在动手前确认:
- 域名 A 记录指向服务器 IP;大陆服务器用域名对外提供 Web 服务要先完成 ICP 备案,未备案时 80/443 会被阻断,备案期间继续用 IP 访问。
- 建议直接落到子域名根路径
https://vault.example.com/,那是 Vaultwarden 的原生布局,比分路径少一大半反代问题。 - 证书用 certbot 签域名证书,90 天轮转,比 IP 证书的 6 天有效期稳得多;nginx 增加对应
server_name。 - 改
DOMAIN并重建容器即可,数据库、附件、rsa_key.pem都不需要迁移。 - 所有客户端(浏览器扩展、手机 App、桌面端)要逐个改服务器地址,旧地址上的会话会失效,需要重新登录。
- WebAuthn / Passkey 换域名后不生效,应等域名就位后再注册。
收尾:真正要维护的是三件边界
现在回头看,这套部署的成本主要不在容器,而在三条边界上:路由边界(DOMAIN 的 base dir 与 proxy_pass 的语义必须对齐)、数据边界(一致性快照而不是复制文件,库和密钥成对恢复)、身份边界(两步验证的退路要提前演练)。这三样东西一旦想清楚,剩下的就是例行巡检。
如果你也在考虑自托管密码库,我的建议是先把备份恢复完整地走一遍,再去配置两步验证和域名。恢复演练失败一次,比线上出问题十次都值。
-
给本地知识库接上图谱与 MCP:WeKnora 部署记录
在本机(本地开发环境)用容器跑起一套 WeKnora,为关系密集的资料开启图谱抽取,再把 MCP 端点接进编码助手,记录两级开关、APOC 依赖与排障过程。
我最初只想在本地跑一个能检索自家资料的知识库,结果发现 WeKnora 真正有意思的是两件事:它能把关系密集的文档抽成知识图谱,还能开一个 MCP 端点让编码助手直接调用。前者坑多,后者好用。这篇按我实际踩过的顺序记一遍。
一套不算轻的本地栈
编排目录在
/opt/weknora,compose 项目名weknora,版本固定在v0.8.3。整个栈一共六个容器:应用(8080)、前端(8081)、PostgreSQL(ParadeDB 发行版)、Redis、文档解析器,以及默认不随主 profile 启动的 Neo4j(7474 / 7687)——默认起的是前五个。检索驱动我选的是
RETRIEVE_DRIVER=postgres:向量检索由 pgvector 的 halfvec 与 HNSW 索引提供,关键词检索由 ParadeDB 的 BM25 提供,所谓“混合检索”其实是应用层用 RRF 把两路结果融合,不是引擎自带的混检。这个细节在调召回效果时很重要——参数调错方向,改半天图谱也没用。本地改动我写在
docker-compose.override.yml里,不去改上游的docker-compose.yml:这样拉新版本时不会和本地修改冲突,升级时也好回滚。另一个和本地环境有关的事实是:我给这套服务配了一个本地私有域名,证书由本机私有 CA 签发,Node 的默认信任库不认它,所以脚本调用一律直连http://127.0.0.1:8080,省掉证书校验的麻烦。部署与口令管理
部署本身不复杂,麻烦的是敏感配置。我的原则是:所有口令用
openssl rand现生成,只写进服务器的.env,既不写进 compose 文件,也不进任何笔记和聊天记录。改.env之前先备份一份,因为后面排障时经常需要对照“改之前是什么样”。cd /opt/weknora cp -p .env .env.bak-$(date +%Y%m%d-%H%M%S) openssl rand -hex 20 # 生成 Neo4j 口令,手工写进 .env 的 NEO4J_PASSWORD docker compose up -d docker compose ps数据库连接、JWT 一类敏感值全在
.env里,容器只从环境变量读。这样重启、重建、换镜像都不需要重新贴口令。图谱的两级开关,缺一不可
知识图谱是我花时间最多的一块。它必须在全局和知识库两个层级同时打开,少一个都只会得到“未启用”:
层级 开关 位置 全局 NEO4J_ENABLE=true.env,改完必须重建应用容器全局 neo4j容器在运行compose profile neo4j知识库 indexing_strategy.graph_enabled知识库 → 索引策略 → 知识图谱 知识库 extract_config.enabled与实体关系配置知识库 → 知识图谱 → 实体关系提取 后端判定逻辑大致是「索引策略开启图谱」并且「抽取配置存在且启用」,两个都满足才会在文档解析后逐个文本块入队抽取。开启顺序也有讲究:先起 Neo4j,再重建应用容器。
cd /opt/weknora # .env 中放开并填好 # NEO4J_ENABLE=true # NEO4J_URI=bolt://neo4j:7687 # NEO4J_USERNAME=neo4j # NEO4J_PASSWORD=<强口令> docker pull neo4j:2025.10.1 docker compose --profile neo4j up -d neo4j # 先起图谱库 docker compose --profile neo4j up -d app # 再重建应用,让它读到新的环境变量顺序之所以重要,是因为应用启动时会用
must()装配图数据库客户端:如果启动那一刻连不上 Neo4j,进程直接启动失败,而不是“降级运行”。另外我在 override 里给 Neo4j 显式限了内存(堆 512m–2G、page cache 512m)并加了健康检查——这台机器总共 15 GiB 内存,还要跑 PostgreSQL 和文档解析器,不能放任它自己规划堆大小。还有一点容易忽略:改
.env后restart不会带上新的环境变量,必须重建(recreate)容器。我第一次就是 restart 完回到界面,发现开关依然不出现。验证:先看 APOC,再看连接
图谱写入依赖 APOC(用到
apoc.merge.node与apoc.periodic.iterate),所以第一件要确认的事是插件可用;第二件是应用自己的驱动确实连上了:cd /opt/weknora PW=$(grep -E '^NEO4J_PASSWORD=' .env | cut -d= -f2-) # APOC 必须可用 docker exec WeKnora-neo4j cypher-shell -u neo4j -p "$PW" \ "RETURN apoc.version() AS apoc" # 看当前连接,期望出现 Go Driver 的 bolt 连接 docker exec WeKnora-neo4j cypher-shell -u neo4j -p "$PW" \ "CALL dbms.listConnections() YIELD connector, username, userAgent \ RETURN connector, username, userAgent" docker inspect WeKnora-neo4j --format '{{.State.Health.Status}}'如果连接列表里能看到
Go Driver/6.0.0这样的 agent,基本就说明应用侧的配置通了。界面上还可以再确认一次:系统信息接口里的graph_database_engine不再是"Not Enabled",而是上报成 Neo4j。排障:界面只显示“图谱未启用”
界面上那组图谱设置,是前端根据
graph_database_engine !== 'Not Enabled'决定要不要渲染的。看不到开关,说明后端认为没有可用图谱库。我按下面的顺序检查,基本四次之内能定位:- 应用容器里是否真的有
NEO4J_ENABLE=true:docker exec WeKnora-app env | grep NEO4J_ENABLE。改过.env没重建容器,这里是空的。 - Neo4j 容器是否在跑:profile 没启用时它根本不会启动,
docker compose --profile neo4j ps一眼就能看出来。 NEO4J_URI是否写成了容器内服务名bolt://neo4j:7687,而不是localhost——容器里的 localhost 是它自己。- 开关都开了却仍不抽取:回去看知识库级的两个开关、实体关系配置是否填全,以及文档是否需要重新解析。抽取发生在解析之后,改开关不会追溯已经解析过的文档。
成本提醒:图谱抽取是整条管线里最贵的一步——每个文本块一次模型调用,走独立的异步队列,失败重试 3 次、单次超时 30 分钟。我只给关系密集的资料(组织、产品、条款类)开图谱,纯 FAQ 和参数表一律关掉,否则账单和队列长度都会难看。
MCP Server:把知识库交给编码助手
0.8.3 内置了 MCP Server(Streamable HTTP),不需要再单独部署早期
mcp-server/目录下的 Python 进程——那个目录已经弃用。创建入口在“设置 → 发布与集成 → MCP Server”:每个端点有独立令牌、知识库范围和工具白名单,令牌只在创建时展示一次,服务端只存它的 SHA-256。协议地址是
/mcp/<endpoint_id>,请求头带Authorization: Bearer mcp_...。传输是无状态的,任意副本都能处理任意请求,客户端不必维持会话 id。工具按用途分成四组:组 工具 说明 检索与阅读 列库、搜索、块级 grep、列文档、读文档 只读;搜索支持 hybrid / 语义 / 关键词,默认返回 10 条,上限 30 问答 ask服务端建会话并返回带引用的完整回答,单次上限 4 分钟 Wiki 搜索、读页、建索引 只对开启 Wiki 的知识库生效 写入 新增、更新、删除文档 默认关闭,确需 ingest 能力才授权 客户端一侧的配置很简单,关键是超时要给够:
ask服务端上限 4 分钟,客户端超时若还是默认的几十秒,长回答必然被掐断。- id: mcp-weknora name: mcp-client config: serverName: weknora transport: streamable-http url: http://127.0.0.1:8080/mcp/<endpoint_id> headers: Authorization: "Bearer <令牌>" toolCallTimeoutMs: 300000配置文件不会被运行中的客户端热加载,改完要重启客户端进程。联调时直接发一条 JSON-RPC 就够了:
curl -s -X POST "http://127.0.0.1:8080/mcp/<endpoint_id>" \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ -H "Authorization: Bearer <令牌>" \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'如果要经 nginx 暴露这个端点,记得把
/mcp/原样转发、保留Authorization与 MCP 协议头、关闭响应缓冲(问答是流式的,缓冲会让回答一次性蹦出来或者干脆卡住)。仓库自带的 nginx 配置里已经包含这段 location。知识库怎么分,比调参更重要
我的经验是:按语料性质分库,不要堆一个巨库。MCP 端点按库授权,搜索也按库检索,分库才能精确限定范围。分块参数上,结构化文档用自动策略、块长 512、重叠 80;长手册打开父子分块(父块 4096、子块 384),子块负责召回、父块负责作答,命中质量会稳很多。FAQ 逐条成块、不重叠。
两个硬约束:向量维度建库后不可改(当前用的 embedding 是 1024 维,绑定要在建库前定好);文档卫生比调参重要得多——优先 Markdown、保留标题层级、一文档一主题,比反复试分块参数有效。
维护:改之前先留档
这套栈的维护成本主要在“改之前”而不是“改之后”。我碰
.env、override 或版本号之前,会先跑一次工作区的 变更前留档脚本,把 compose、override 与.env一起打包;之后再动docker compose --profile neo4j up -d。大版本升级我还没有实际做过,原则是先读上游变更、备份再动,出问题就按备份回滚,而不是现场救火。另外这套服务也登记在自托管控制台里,卡片能看容器状态、内存占用和日志,出问题时不用先 SSH 上去。目录怎么写的,见 服务目录那篇。
- 应用容器里是否真的有
-
用一份服务目录,管住所有自托管服务
把本机与远程服务器上的系统服务与容器项目登记进一份 JSON 目录,用同一份探针采集状态并生成控制台,记录字段设计、动作语义、热加载踩坑与安全边界。
服务装到第十个之后,我发现自己每次排查都要先回忆一遍:这个服务是 systemd 还是 compose、端口多少、日志怎么看、开机自启开没开。与其每次查文档,不如把这些事实写成一份机器可读的目录,让控制台和探针都从目录生成。这篇记录那份目录的结构,以及几个只有踩过才知道的坑。
从导航页到控制台
最初的入口只是一个静态导航页,列着各个服务的链接。问题很快出现:导航页能告诉你“去哪儿”,但回答不了“它现在活着吗”“上次重启是什么时候”“日志里报了什么”。服务分布到两台机器、两种运行方式之后,每加一个服务都要改页面,维护成本很快超过收益。
于是我把它改成“目录驱动”:一份
services.json定义所有服务,后端在启动时读目录,生成采集清单和可执行动作;前端也按目录渲染卡片。目录本身放在运行时位置(/etc下),仓库里只保留一份模板——源码与运行时分离,模板只在首次安装时复制,之后运行时那份由网页维护,重跑安装脚本也不会覆盖它。数据流大概是这样:浏览器加载静态页,
/api/反向代理到只监听回环的后端;后端用一份探针脚本采集本机指标,远程主机则通过阿里云 Workbench CLI 执行同一份探针。共用探针意味着指标口径与采集逻辑一致,不会出现两边数字对不上的情况。一个条目包含什么
目录是一个 JSON 对象,每个服务是
services[]里的一条。字段不多,但每个都对应一段生成逻辑:字段 作用 id唯一标识,用于 API 路径;只允许字母、数字与 _.-name/note卡片标题与一句话说明 hostlocal或remote,对应主机清单里的键kindsystemd(含.timer)或composeunitsystemd 的单元名,如 nginx.servicecompose{ dir, project, profiles? },profiles 会带进启动命令category分组显示用 url/linkLabel对外入口与链接文字 probe可用性探测: { url, expect: [200] }或{ cmd }timer定时任务:隐藏“重启”,显示上次与下次执行时间 backups/certificate备份目录与证书路径,卡片显示数量、占用与剩余有效期 onDemand按需服务:未运行不算异常,不进就绪分母 selfImpact/noStop停掉会影响别的服务时二次确认;后端自己隐藏“停止” 新增一个服务的最小条目大概长这样:
{ "id": "local-foo", "name": "Foo 服务", "host": "local", "kind": "systemd", "unit": "foo.service", "category": "应用", "url": "https://foo.example.com/", "note": "一句话说明它是干什么的", "probe": { "url": "http://127.0.0.1:9099/health", "expect": [200] } }只要目录里有这条,探针的单元清单、指标采集、卡片渲染、启停按钮就全都自动可用,不需要改任何代码。
kind 决定动作的语义
目录里最关键的抽象是
kind:它决定了“启动 / 停止 / 重启 / 自启”到底翻译成什么命令。kind 启动 停止 / 重启 自启开关 systemd systemctl startsystemctl stop / restartsystemctl enable / disablecompose docker compose start,容器不存在时回退up -ddocker compose stop / restartdocker update --restart=unless-stoppedcompose 的自启有个反直觉之处:“自启”实际写进的是容器配置(
docker update),而 compose 文件里的restart:声明只在容器被重新 create 时生效。也就是说,网页上打开自启后,一旦某次变更导致容器重建,它会回到 compose 文件里写的值。要长期保持一致,应该改 compose 文件,而不是只点开关。所有变更类操作都进同一个串行队列。如果已有动作在执行,新动作直接返回 409,避免两个
docker compose并发去动同一个项目。探测:服务没运行就不探测
可用性探测有两种形式:有 HTTP 入口的写
{ url, expect },expect里列出算“健康”的状态码(有些服务没带凭据时返回 401 才正常,这种就得把 401 写进去);没有 HTTP 入口的写{ cmd },命令在目标主机上随探针执行,退出码 0 就算通过,适合 docker 引擎、定时器这类对象。一个重要的设计是服务未运行时不探测:systemd 非 active、compose 没有运行中容器时,HTTP 探测直接标记为跳过、不发请求;命令探测在探针里先判断运行状态,不满足就回一个表示“跳过”的标记。卡片显示中性文案,既不算探测失败,也不进问题列表。否则一个故意不自启的按需服务会常年挂在“异常”里,告警就失去意义。
采集频率分三档:本机指标 5 秒、服务器指标 15 秒、可用性探测 20 秒。历史保留 180 个采样点,差不多够看半小时的曲线。CPU 使用率是拿相邻两次采样的累计 jiffies 算差值,所以页面刚打开时第一次显示的是破折号,不是采集失败。
热加载,以及手工编辑的坑
网页上“保存”目录条目会走一套固定流程:先校验(单元名、绝对路径、期望状态码范围、容器数量、探测不能同时给 url 和 cmd),再备份当前目录,然后原子替换(写临时文件再 rename),最后热加载。热加载只重建采集与动作清单,主机历史与指标保留,不用重启后端。
但这里有一个我踩过的坑:后端没有文件监听器。直接手工编辑运行时的
services.json,内容变了但进程不知道,表现为“明明改了,详情页还是旧内容”。要么systemctl restart控制台后端,要么调用一次配置接口(哪怕传一个空的 patch)触发“写回 + 热加载”。改运行时配置前先留档这件事,我单独写在 变更前留档那篇里。另外,设置页可以改名称、分组、探测、单元名、编排目录、备份目录等字段,但不能改
id、host、kind。要换主机或换运行方式,正确做法是新增一条,再删掉旧的——否则历史数据、依赖关系、探测缓存全都会错位。安全边界:它等价于本机 root
控制台后端只监听
127.0.0.1,对外只有一个本地 vhost 暴露/api/;变更类请求要求同源 Origin,跨站请求直接 403。更重要的是能力边界:网页上能执行的命令只有目录里声明过的那些(按 key 索引),没有任何“任意命令”入口;标记为诊断的操作还要二次确认,并且进同一个串行队列。但必须说清楚:能打开这个页面的人,等于拿到了本机 root 能力——可以启停服务、读日志,还能经 Workbench 操作远程服务器。目前它只靠 hosts 与私有 CA 限定在本机可信环境里。不要把它发布到不可信网络;真要暴露,第一步必须是加认证,而不是加反向代理。后端本身不保存任何服务器凭据,远程操作完全复用 Workbench 的登录态。
名字、图标与“安装成应用”
控制台也做成了一个可安装的 Web 应用:地址栏出现安装图标,装完是独立窗口。三个必要条件里,最容易被忽略的是第一条——
- 安全上下文必须是 HTTPS。用私有 CA 签的本地证书时,必须先把根证书导入系统或浏览器信任库,否则 Chrome 不认为这是安全来源,安装图标不会出现。
- 需要有 web app manifest:
name、short_name、start_url、display: standalone,以及至少一张 192×192 以上的图标。 - service worker 不再是硬性要求,本站没注册也能装。
还有一个服务端的小坑:nginx 的
mime.types里没有webmanifest,清单文件会被发成application/octet-stream,浏览器不认。加一行显式指定即可:location = /manifest.webmanifest { default_type application/manifest+json; }iOS 的“添加到主屏幕”只认 PNG 的
apple-touch-icon,不支持 SVG,所以还要准备 180×180 的 PNG。一份目录带来的东西
目录驱动最大的收益不是省了几行前端代码,而是把“服务的定义”变成了一个可以 review、可以备份、可以迁移的文件。新装一个服务,流程固定成三步:加目录条目、重启或热加载、刷新页面确认状态与探测。写目录时我尽量把“已知取舍”也塞进
note与详情声明里。 -
让 opencode serve 按需待命:一个不自启的编码服务
把 opencode serve 装成系统服务却故意不设开机自启,只监听回环并用 nginx 反代成网页,记录 502 被误判成“版本不兼容”的排查过程与升级注意事项。
opencode 平时是我的终端编码工具,但它也能起一个 Web UI。我想要的形态是:需要时点一下就开,不用时彻底不占内存、不占端口,所以安装脚本里故意既不
enable也不start。这套“按需服务”跑了一段时间,最值得写下来的是一次被错误信息带偏的排查。为什么装成服务,却不开机自启
默认情况下,装成一个 systemd 服务就意味着开机自启。但这台机器内存不大,opencode 又是个 186 MB 的 Node 二进制,平时并不是每天都要用 Web UI。所以我把它定义成“按需服务”:单元文件装好、
daemon-reload做掉,但不 enable 也不 start。这样就得到几个可验证的状态:
/etc/systemd/system/multi-user.target.wants/opencode.service不存在——它不会跟着系统启动;- 装完
is-active是inactive,不占内存、不监听端口; - 想用时在控制台卡片上点“启动”,或命令行
systemctl start opencode.service; - 真需要开机自启时,把控制台上的“自启”开关打开,等价于
systemctl enable opencode.service。
这个取舍的代价是:它不是常驻服务,任何“打开页面发现 502”的情况,第一个要怀疑的就是“服务压根没启动”,而不是代码坏了。
单元与端口:一定要显式指定端口
单元里跑的是
opencode serve,只监听127.0.0.1:4096。这里有个容易忽略的默认值:opencode serve的--port默认是 0,也就是让系统随机分配一个空闲端口。对交互式使用没问题,但作为 systemd 服务就必须写死,否则反代和探针都不知道该连哪里。# 看单元实际在跑什么(ExecStart 里应能看到显式的 --port 4096) systemctl cat opencode.service # 启动后确认监听地址 ss -ltnp | grep 4096服务端没有任何鉴权,所以边界只有一条:只监听回环,经本机私有域名反代出去,绝不
--hostname 0.0.0.0,也绝不挂到公网入口上。能访问这个端口的人等于能操作这台机器上的任意项目。端点 用途 /opencode Web UI /doc、/openapi.jsonOpenAPI 文档,排查接口时用 /global/health健康检查,控制台的可用性探测就是打这个,期望 200 Web UI 反代:三处不能改错
浏览器入口是一个本地私有域名(hosts 指向 127.0.0.1,证书由本机私有 CA 签发),80 端口统一 301 到 https,再反向代理到
127.0.0.1:4096。有三处配置一旦写错,界面表现出来的症状都很奇怪:proxy_buffering off加Upgrade/Connection头:Web UI 的消息流与工具输出走 SSE 与 WebSocket,被缓冲就会卡死或者迟迟不刷新;client_max_body_size 128M:会话里要粘贴图片、上传附件,nginx 默认的 1 MB 会把它们拒掉;- 读写超时给到 3600s:长会话中途不能被 nginx 掐断。
location / { proxy_pass http://127.0.0.1:4096; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_set_header Host $host; proxy_buffering off; proxy_read_timeout 3600s; proxy_send_timeout 3600s; client_max_body_size 128m; }服务没启动时,这个域名返回 502 是预期行为——nginx 找不到上游。控制台卡片上的“打开”按钮此时会置灰,点不动,就是为了避免这种困惑。
踩坑:502 被误报成“版本不兼容”
这是整篇最想记的一段。opencode Web UI 里有一个响应拦截器,逻辑大致是:只要收到的
content-type是text/html,就抛出Request is not supported by this version of OpenCode Server。而 nginx 默认的 502 / 503 错误页,恰恰好是text/html。于是出现了一个极具误导性的现象:服务端没在跑,界面却弹“客户端与服务端版本不匹配”。我当时的真实时间线是这样的:
- 16:15 我把服务停了;
- 16:20 重新启动,但浏览器里还开着旧页面;
- 16:20:35 页面的一批懒加载 chunk 与
/pty/shells请求打到已经重启中的服务,命中 502; - 界面弹出“版本不兼容”,看起来像是升级把前后端搞出了兼容问题。
确认方法其实很简单,而且不需要看代码:
grep 本地域名 /var/log/nginx/access.log看那批请求的状态码是不是 502/503,再对照journalctl -u opencode.service看服务端在同一时刻是否在运行。如果状态码和“服务正在重启”对得上,就不是版本问题。为了以后不再被带偏,我在 vhost 里把网关错误改成了 JSON:
error_page 502 503 504 = @oc_unavailable; location @oc_unavailable { default_type application/json; return 502 '{"error":"opencode server unreachable"}'; }这样前端收到的就不再是
text/html,而是明确的网关错误,一眼能区分“服务没开”和“版本不对劲”。排查这类问题,先怀疑“服务在不在”,再怀疑“版本配不配”。交给控制台:状态、启停与日志
服务目录里给它登记了一条
local-opencode条目,类型是 systemd,探测打/global/health期望 200,并标了onDemand: true——按需服务未运行不算异常,状态点用中性色,也不进“N/M 服务就绪”的分母;但只要它正在跑而探测失败,依然会进问题列表。控制台还处理了一个很实际的细节:点“启动”后端口绑定有大约 1 秒的延迟,如果立刻探测必然失败,所以动作后会带一次 1.5 秒的重试,下一个 20 秒的探测周期也会自动纠正。日志直接走
journalctl,单元带了--print-logs,服务端输出都在里面。如果你也想把类似的按需服务接进来,服务目录的字段和动作实现见 服务目录那篇。
升级:npm 12 不跑 postinstall
升级走 npm 全局安装,但有一个当下版本 npm 的坑:npm 12 默认不执行依赖的 postinstall 脚本(allowScripts 策略收紧)。而 opencode 的平台二进制正是靠 postinstall 从平台包里硬链接到
bin/的,不跑就会得到一堆占位脚本,opencode --version直接报错。npm --cache /tmp/npmcache install -g --no-save opencode-ai@<新版本> node "$(npm root -g)/opencode-ai/postinstall.mjs" opencode --version也可以让 npm 自己跑:
npm install -g --allow-scripts=opencode-ai opencode-ai。另外两件事要记:- nvm 换 Node 版本后,单元里 ExecStart 的绝对路径会失效,重跑一次安装脚本重新生成单元即可;
- 官方推荐里还有一个
opencode upgrade,它会把二进制装到~/.opencode/bin,和当前 npm 布局不同。用之前先想清楚 PATH 与 ExecStart 指向哪个,否则会出现“命令行是新版、服务还是旧版”的错位。
日常启停与排查清单
systemctl start opencode.service systemctl stop opencode.service systemctl restart opencode.service systemctl is-enabled opencode.service systemctl is-active opencode.service journalctl -u opencode.service -n 50 --no-pager ss -ltnp | grep 4096 # 端口被占 / 起不来时看这里症状 先查什么 卡片点“启动”后立刻显示不可达 端口绑定有约 1 秒延迟,等一个探测周期或看服务日志 Web UI 打开是 502 服务没在跑; systemctl is-active,或卡片上点启动界面报“版本不兼容” 先确认服务在跑,再看 access.log 里那批请求是不是 502;不是版本问题 域名打不开 hosts 是否把该域名指向 127.0.0.1;nginx 侧靠 server_name匹配,不需要系统解析opencode --version报 postinstall 未运行手工执行 postinstall.mjs这套“装好但不自启、需要时再开”的模式适用于所有低频服务。它省下的是常驻内存,付出的是“每次用之前要记得启动”的管理成本;把这个成本交给控制台卡片之后,基本可以忽略。
-
改配置之前先留档:一个变更前备份脚本的设计
改 nginx、证书或服务单元之前先跑一遍留档脚本,把运行时文件按相对根路径打成 tar 包,附清单与校验和,记录必带与可选清单以及 set -e 的静默坑。
这个仓库里只有源码,真正跑起来的东西全在
/etc、/opt下面——它们不进版本控制。于是“改坏了怎么回去”就成了一个必须提前回答的问题。我的办法是写一个变更前留档脚本(脚本本身在私有仓库里,不公开;下面只讲设计):动手之前先跑一遍,把运行时文件打成带清单和校验和的包,出事时按路径还原。为什么是“变更前”,而不是“每天”
日常备份通常按时间跑,防的是数据丢失;变更前备份防的是一次具体的误操作——改 vhost 改到 nginx 起不来、换证书时把私钥覆盖成不可用的版本、编辑服务目录把控制台自己的条目写坏。这类事故的特点是:范围很小(就几个文件)、必须很快回滚(几分钟内)、但如果没留档就非常难恢复,因为你根本记不清改动前那一行写的是什么。
我的仓库里有一份源码快照,但运行时配置不在其中:服务目录在
/etc下由网页维护,证书私钥与私有 CA 只在服务器上,容器项目的.env更不可能入库。所以“git 能回滚”在这里不成立——必须单独为运行时文件留一份档,而且必须把路径关系保存对。清单分三层:必带、可选、临时追加
脚本把要打包的路径分成三类,这个分层是整个设计的核心:
- 必带:缺了就报错退出、拒绝生成不完整的包。目前是三项——控制台的服务目录、nginx 的证书目录(含私有 CA 与站点私钥)、控制台后端自己的 systemd 单元。这三样任何一样丢了,控制台或 HTTPS 都直接不可用。
- 可选:有就带上,没有只在清单里记一行“跳过”。包括服务目录的历史备份、三个 vhost、dsh 启动脚本、WeKnora 的 compose 与 override 与
.env、gh 的登录凭据文件等,大约十项。 - 临时追加:
--之后写任意路径,比如这次还要一起改/etc/hosts,就追加它。
为什么必带项要“缺了就失败”?因为备份最怕的不是没跑,而是跑了一半还告诉你成功。等真出事了才发现关键文件根本不在包里,那时候已经没有第二次机会。可选清单里的通配符(比如服务目录的历史备份文件)要先展开,展开结果为空就跳过,不算失败。
# 改 nginx 证书之前 sudo bash deploy/backup-before-change.sh --tag nginx-tls --note "更换站点证书前的现状" # 只看会打包什么 / 演练一遍不写文件 sudo bash deploy/backup-before-change.sh --list sudo bash deploy/backup-before-change.sh --dry-run # 额外追加路径 sudo bash deploy/backup-before-change.sh --tag hosts -- /etc/hosts /etc/fstab产物是三件套,不是一个包
每次运行产出三个文件,都在工作区的
backups/下,文件名是<标签>-<时间戳>:产物 内容 用途 .tar.gz归档本身,内部路径相对 /直接解压还原 .sha256归档的校验和 还原前确认包没坏 .manifest.txt时间、主机、标签、说明、源码版本与未提交改动数、归档大小、sha256、逐文件字节数与修改时间、跳过项、恢复步骤 半年后还能看懂这个包是什么 清单里我特意放了“源码版本 + 未提交改动数”:这样能判断这个包对应的代码状态,避免出现“包是好包,但不知道当时跑的是哪个版本”的尴尬。产物权限统一收成 600、目录用 700,因为包里一定包含私钥和口令。
另外两个小开关很实用:
--keep N只保留最新 N 个包(默认 20),超出从最旧的开始删;--list和--dry-run用来在真正动手前确认范围。变更频繁的时期,20 个包足够覆盖最近几周的每一次改动。用相对根路径打包,恢复才只需要一条命令
归档是用
tar -C /打包的,所以包内路径全部相对根目录,比如etc/nginx/certs/...,而不是绝对路径。这个选择让恢复变得极其简单:# 全量还原(会覆盖当前文件;建议先再备份一次现状) tar -xzf backups/nginx-tls-20261022-101500.tar.gz -C / # 只还原某一项,把路径缀在后面 tar -xzf backups/nginx-tls-20261022-101500.tar.gz -C / etc/nginx/certs # 还原了单元 / 服务目录之后 systemctl daemon-reload && systemctl restart <控制台后端单元> # 还原了证书 / vhost 之后 nginx -t && systemctl reload nginx # 还原前校验包本身 cd backups && sha256sum -c nginx-tls-20261022-101500.sha256为什么不用绝对路径?因为绝对路径打包后,解压时 tar 会剥离前导
/并给出警告(不同实现的行为略有差异),恢复动作容易变成一堆手工参数。相对根路径则天然支持“只还原某几项”,这在实际排障里比全量还原更常用。建议每季度做一次恢复演练:找一个无关紧要的临时路径,把包解开看一眼文件是否齐全、权限是否正确,再用
sha256sum -c校验。备份只有在被成功还原过一次之后,才能算备份。set -e 下的两个静默失败
这个脚本最值得写的部分,是它踩过的两个 Bash 坑——都发生在
set -euo pipefail之下,表现都是脚本一声不响地退出,没有任何报错。第一个坑:通配符展开函数不能有失败返回。脚本里有一个
expand()负责展开通配符,调用方式是把它的输出接到另一层命令替换里。如果这个函数在“没有匹配”时返回非零,set -e会直接把整个脚本打死。所以函数内部必须显式兜底,最后return 0:expand() { local pat=$1 if [[ $pat == *\** || $pat == *\?* || $pat == *\[* ]]; then compgen -G "$pat" 2>/dev/null || true # 没匹配也算成功 elif [ -e "$pat" ]; then printf '%s\n' "$pat" fi return 0 } hits=$(expand "$pat") || true第二个坑:
set -e下,命令替换里的失败会被静默放大成脚本退出。我最初的兜底函数在“通配符没匹配到任何文件”时返回了非 0,调用处写的是hits=$(expand "$pat")——赋值失败,整个脚本直接退出,而且没有任何报错。修法是让这个函数永远返回 0(compgen ... || true),需要区分“没有结果”的地方再显式判断;取值类命令同理,兜底要写在赋值这一层:GIT_HEAD=$(git -C "$WS" rev-parse --short HEAD 2>/dev/null) || GIT_HEAD='(非 git 仓库)' GIT_DIRTY=$(git -C "$WS" status --porcelain 2>/dev/null | wc -l | tr -d ' ') || GIT_DIRTY=0这两处现在都在脚本里留了注释,避免以后“整理一下”又改回去。写带
set -e的运维脚本时,我的经验是:任何可能“没有结果”的步骤,都要显式定义什么叫成功,而不是指望默认值。包里有凭据,所以它永远留在本地
这个包必然包含证书私钥、容器项目的
.env、登录凭据一类的东西,所以从设计上它就不该进仓库。backups/在.gitignore里,脚本在生成前还会主动检查一次git check-ignore:如果这个目录没有被忽略,就打印警告,提醒包里的凭据有被误提交的风险。- 永远不要
git add -f backups/; - 不要执行会连忽略文件一起删的清理命令(
git clean -fdx会把整个backups/删掉); - 包只放在本机,需要异地副本就单独加密传输、单独保管,别混进源码同步。
还要诚实地说清楚它的局限:同盘备份只能防误操作,防不了磁盘故障。它的定位是“改配置前的安全带”,不是灾难恢复方案。真正的冷备、异地副本是另一件事,不能因为有了这个脚本就认为数据安全了。
把它变成流程的一部分
现在我的操作顺序固定成:先
--list或--dry-run确认范围,再带--tag与--note生成包,然后才动配置;改完立刻验证(nginx -t、服务状态、页面回归),验证通过后按惯例提交源码改动。这个流程和具体改什么无关——换证书、改服务目录、升级 WeKnora 的 compose、调整 Vaultwarden 的反代,都先留档。顺带一提,产生的包与凭据边界和 git 那篇里写的规则是同一套:源码与文档进仓库,运行时配置、私钥、备份产物永远留在机器上。把两者分开之后,自动化提交才敢放得开。
-
从一把打不开的 SSH 私钥到私有仓库:git/GitHub 接入笔记
把 Windows 上拷来的 SSH 私钥放到 Linux 机器上,踩了 CRLF、末尾缺换行与权限三道坑,接通 GitHub 私有仓库,并定下自动提交、提交信息与凭据边界。
换机器之后,我从旧机器上把 SSH 私钥拷了过来,以为改个权限就能用,结果连着报了三个“看起来像密钥坏了”的错。修好之后我又顺手把私有仓库、自动提交与凭据边界一起理了一遍。这篇按报错顺序记,给同样要从别的系统搬密钥的人避坑。
现象:私钥“坏了”,但公钥没坏
第一反应是
ssh -T git@github.com测试认证。报错信息很吓人:Load key "...": error in libcrypto,而用ssh-keygen -lf去读指纹则说is not a key file。但同一把钥匙的公钥在旧机器上一直能用,说明密钥本身没丢,问题出在文件的字节上。现象 原因 修法 is not a key file;Load key ... error in libcrypto文件是 Windows 换行(CRLF),base64 段里混进了 \r去掉 CR 后重新写回 去掉 CR 后仍报同样的错 文件末尾少了换行,OpenSSH 按行解析到文件尾会判为格式错误,随后落到 PEM 分支报 libcrypto 给文件补一个结尾换行 Permissions ... are too open私钥或目录权限不是 600 chmod 700 ~/.ssh、chmod 600私钥这三条会串在一起出现:CRLF 是最先暴露的,删掉 CR 之后末尾缺换行才露出来,最后一次是权限。所以我建议一次把三件事都检查掉,而不是修一个试一次。
修复:只动字节,不碰内容
修复过程中绝不要打印私钥内容,所有操作都基于字节判断。去 CR、补末尾换行、收紧权限:
cd ~/.ssh tr -d '\r' < id_ed25519 > id_ed25519.tmp printf '\n' >> id_ed25519.tmp # 若本来已有结尾换行,这行会补成两个,通常也无碍; mv id_ed25519.tmp id_ed25519 # 更稳妥的做法是先判断最后字节再决定是否补 chmod 700 ~/.ssh chmod 600 ~/.ssh/id_ed25519判断最后字节的方法不需要泄露内容:
tail -c 1 ~/.ssh/id_ed25519 | od -c # 期望看到 \n ssh-keygen -lf ~/.ssh/id_ed25519 # 能读出 SHA256 指纹 = 文件结构有效 ssh-keygen -y -f ~/.ssh/id_ed25519 # 输出对应公钥(公钥可以贴到 GitHub)顺带记一个尺寸参照:我这边一把正常的 ED25519 私钥是 387 字节。如果你手里的文件明显多出几十字节,多半是 CRLF 没清干净。
known_hosts 与首次连接
Private key 修好之后还有一层:首次连接 GitHub 要确认主机指纹。手动用
ssh -T验证时,交互式地确认一次即可;如果是无人值守的机器,我会在~/.ssh/config里显式写清楚:cat ~/.ssh/config # Host github.com # HostName github.com # User git # IdentityFile ~/.ssh/id_ed25519 # IdentitiesOnly yes # StrictHostKeyChecking accept-newIdentitiesOnly yes是为了避免 SSH 把手里所有钥匙依次递一遍——钥匙多了之后,某些服务会因为你尝试次数过多直接拒绝。accept-new只对“新主机”自动接受,已知主机指纹变了依然会报错,兼顾了方便与安全。ssh -T git@github.com # 期望:You've successfully authenticated... git ls-remote git@github.com:<账号>/<仓库>.git # 能列出远端 refs 就通了私有仓库与 gh CLI
仓库我选了 private,远端走 SSH。这样私有仓库不需要把凭据做成访问令牌塞进远端 URL,也不会有令牌被写进
.git/config的风险。如果已有仓库的远端是 https,可以全局转成 SSH:git config --global url."git@github.com:".insteadOf "https://github.com/"创建仓库、看 PR、查当前登录账号用 GitHub CLI 更顺手,但这里有个环境相关的坑:agent 的 shell 里
HOME可能被设成了某个工作目录,而 gh 默认去$HOME/.config/gh找登录凭据,于是明明登录过却提示未认证。注意这时HOME=~不解决问题——~仍按被改过的 HOME 展开;要用真实家目录的绝对路径去覆盖:gh auth status # 报未登录时先怀疑 HOME 被改了 REAL_HOME=$(getent passwd "$(id -un)" | cut -d: -f6) # 真实家目录 HOME="$REAL_HOME" gh auth status GH_CONFIG_DIR="$REAL_HOME/.config/gh" gh auth status登录凭据只留在那台机器的配置目录里,不复制、不进仓库、不贴进任何文档。控制台也不保存 git 凭据——它只是碰巧和 git 跑在同一个用户下。
日常:一次改动一个提交
现在这个仓库的默认规则是:每完成一处改动、验证通过后,自动
add→commit→push,不再逐次征求确认。这条规则听着省事,实际上依赖两个前提。第一是提交信息要能当 changelog 读。我的格式是
type(scope): 摘要,例如feat(portal): 新增按需服务卡片、docs(nginx): 补 gzip 全局化说明、fix(web): 修复反代片段缺分号。跨多个不相关主题时拆成多个提交,不要一个“杂项更新”打包几十个文件。第二是提交前必须跑验证。服务类改动确认服务正常、文档类改动至少过一遍链接与格式检查、配置类改动先
nginx -t。验证失败不提交——否则“自动提交”就变成了“自动制造坏历史”。还有一条硬边界:只提交源码、文档与模板。
/etc下的运行时配置、证书私钥、.env、备份包,全都不入库。它们由.gitignore挡住,但每次提交前我仍会看一眼git status,确认没有意外的.key、.env或backups/里的东西。工具链在暂存 diff 时也会扫描私钥、云厂商密钥、GitHub 令牌一类的特征串,命中直接拒绝提交,这是最后一道闸。安全规则:哪些操作必须先问
“自动提交并推送”是长期授权,但它不包含下列操作,这些在任何情况下都要先确认:
git push --force:会覆盖远端历史,别人拉过的提交可能直接消失;git reset --hard、git clean:本地未提交的改动或未跟踪文件会被直接丢弃,clean还可能删掉被忽略但很重要的目录;- 改写历史、动 tag、改分支保护规则。
推送失败(网络、凭据、远端拒绝)时,正确做法是保留本地提交并报告原因,不强推、不改写历史、不换远端地址重来。权限问题就修权限,冲突就正常合并,绕过问题只会把问题推到下一次。
顺手升级 git 与 gh
Ubuntu 仓库里的 git 版本偏旧,我通过 Git Maintainers 的 PPA 升到了 2.55.0;gh 则用 GitHub CLI 官方 apt 源升到 2.102.0。两个工具都随
apt upgrade一起更新,日常维护命令很简单:apt-get update apt-get install --only-upgrade git gh REAL_HOME=$(getent passwd "$(id -un)" | cut -d: -f6); HOME="$REAL_HOME" gh auth status # 升级后复核登录态回滚思路是移除对应的软件源、重新安装发行版里的版本,但要先确认目标版本仍在官方源里。升级后我验证了三件事:
git ls-remote正常、gh 仍是登录状态、机器上其它服务未受影响。结论
这次真正的教训不是“私钥要用 LF”,而是:凭据文件的字节细节和权限细节,决定了整套认证链路是否可用。密钥修好之后,剩下的事情反而简单——用 SSH remote 代替 URL 里的令牌、把“自动提交”限定在源码与文档、给 force push 与 reset 留一道人工确认。这样日常提交可以放手自动化,而破坏性操作永远需要人点头。
相关记录:改配置前先留档、用服务目录管住自托管服务。
-
大体积 wasm 的弱网交付:压缩、超时与校验
7.4 MB 的 wasm 在窄带宽上的问题不是慢,而是传不完;压缩与 Vary、超时调大、内容哈希缓存、有限重试和部署期 sha256 校验,缺一环就回到报错。
一个自托管服务的 Web 客户端在手机上报错:
WebAssembly compilation aborted: Network error: Response body loading was aborted。报错指向网络,浏览器也确实认为下载断了,但根因不是网络坏了,而是体积、带宽和超时三个数字叠在一起。这篇只写交付侧——怎么把一个 7.4 MB 的资源变成能稳定下完、能校验、失败能重试的东西。现象:错误说是网络,其实是 17 秒
这个客户端的加密 SDK 是一个 7,378,704 字节的
.wasm。服务器出口实测只有约 440 KB/s(约 3.5 Mbps),除一下就是十七秒左右——前提是中间没有任何环节把它掐断。现实里就是会被掐断。浏览器用流式编译接口
WebAssembly.instantiateStreaming():边下载边把字节喂给编译器,响应体中途中只要断开,抛出的就是网络相关错误,而不是「文件太大」。错误因此非常误导人,看起来像服务器挂了。定位靠的不是浏览器控制台,而是 nginx 的
access.log:同一个 URL 有两条记录,一次body_bytes_sent是完整的 7,378,704,另一次只有 3,881,354。后者说明响应确实被中途掐断,而不是浏览器缓存、校验或跨域的问题。被谁掐断?
send_timeout与proxy_send_timeout默认都在 60 秒量级,管的是两次写之间允许的间隔,不是总时长:弱网下常有「连接还活着但几十秒几乎不前进」的阶段,超时一到连接就被关掉。所以问题不是带宽够不够,而是传输时间超过了中间环节的耐心。交付:先把 7.4 MB 变成 2.9 MB
第一个动作是压缩。wasm 是字节码,重复模式很多,gzip 效果很好:7,378,704 压到 2,879,718,约少六成。整个客户端的静态资源合计从 15.8 MB 降到 5.35 MB,同一份代码、同样的功能,只是传输量变了。
配置上有三个点必须同时成立,缺一个就白做:
- 把
application/wasm写进gzip_types; - 如果资源经过反向代理,
gzip_proxied要显式放行(默认是off); gzip_vary on,让响应带上Vary: Accept-Encoding。
最后一条不只是缓存礼节:没有 Vary,中间缓存可能把压缩过的版本发给一个声明不支持 gzip 的客户端。配置写法本身在 gzip 与反代响应那篇里,这里只强调客户端侧能看到什么:带上
Accept-Encoding: gzip的请求,响应里应该有Content-Encoding: gzip,而Content-Type仍然是application/wasm——这一条如果被压缩层改写,流式编译会直接失败。缓存:文件名带哈希,然后当它是 immutable
7 MB 不该每次访问都重新下载。做法是构建时把内容哈希写进文件名,然后对这类路径发一组长缓存头:
Cache-Control: public, max-age=31536000, immutable。名字随内容变,浏览器缓存就是安全的;内容不变时连条件请求都不用发。反过来,入口 HTML 与清单要短缓存甚至
no-cache,否则用户会拿旧页面引用已经不存在的旧哈希文件,表现是白屏加一串 404。这是哈希文件名方案唯一的坑:入口必须能及时刷新。另外两件顺手事:别让 wasm 请求带上无关 cookie,静态资源走独立 location 与缓存规则;确认缓存层不会把压缩版和未压缩版混在同一个 key 下,这是
Vary的意义,也是缓存层最容易被忽略的头。流式编译:一次下载,边下边编译
浏览器侧我保留了
instantiateStreaming:一次下载、内存峰值最低,弱网下也能更早开始编译。它有两个硬要求:响应必须成功,Content-Type必须是application/wasm。MIME 不对时抛TypeError,和字节损坏、传输中断是不同错误,最好分开处理。流式接口的代价是没有第二次机会:流断了,这次编译就废了。有人用
res.clone()留副本兜底,这能兜住 MIME 与编译错误,但兜不住传输中断——clone()的 tee 两条分支共享同一个源,源出错时两边一起报错。想从截断恢复,只能重新请求。async function fetchWasm(url) { const res = await fetch(url); if (!res.ok) throw new Error(`wasm: HTTP ${res.status}`); const type = res.headers.get("content-type") || ""; if (!/^application\/wasm/.test(type)) throw new Error("wasm: bad content-type"); return res; } async function loadWasm(url, imports) { try { // 流式:一次下载,内存峰值最低,但不允许失败重来 return await WebAssembly.instantiateStreaming(fetchWasm(url), imports); } catch (streamError) { // 最可能是响应体被截断,这批字节已经废了,重新请求一次并整块收下 const res = await fetchWasm(url); const bytes = await res.arrayBuffer(); return WebAssembly.instantiate(bytes, imports); } }fetchWasm()返回 Promise,instantiateStreaming接受 Promise 响应,MIME 检查失败会直接 reject。整块兜底只做一次:连它都失败,说明网络或服务端确实有问题,再重试只是拖长白屏。校验:先在服务端证明无损
开了压缩之后,风险从「太大」变成了「可能压坏」。所以部署侧加一步:把压缩后的响应取回来、解压、和源文件比 sha256。
# 取压缩响应,解压后与源文件比对 curl -sS -H 'Accept-Encoding: gzip' -o /tmp/sdk.wasm.gz \ https://example.com/assets/sdk-abc123.wasm gunzip -c /tmp/sdk.wasm.gz | sha256sum sha256sum /path/to/dist/sdk-abc123.wasm # 顺手确认头部 curl -sSI -H 'Accept-Encoding: gzip' https://example.com/assets/sdk-abc123.wasm \ | grep -iE 'HTTP/|content-type|content-encoding|vary'三个测量坑:
curl默认不发Accept-Encoding,不带-H量到的是未压缩体积,会误判「压缩没生效」;systemctl reload nginx是平滑重启,旧 worker 可能还在处理请求,reload 完立刻测会读到旧配置,等几秒再测;查反代后面的应用别用curl -I,有些应用的 HEAD 响应是空壳,必须用 GET。客户端要不要也做 sha256 校验?看能否承受一次完整缓冲:流式接口不允许边下边算摘要,浏览器里也没有原生流式 SHA-256,想校验只能先把 7 MB 收进内存再编译,内存峰值和首屏都变差。我的取舍是传输完整性交给 HTTPS 与缓存层,内容完整性在部署时证明一次;插件、更新包这类「装错就出事」的资源,再考虑整块校验。
超时:把耐心调到比传输时间长
压缩之后 2.9 MB 在 440 KB/s 下约 6.5 秒,但弱网手机的实际速率可能只有几百 KB/s,还会出现长时间几乎不前进的阶段。修法是把 nginx 的
send_timeout与proxy_send_timeout从默认的 60 秒提到 300 秒,同时确认链路上没有别的 60 秒级空闲超时(负载均衡的健康检查、CDN 回源、边界网关都可能各有一份)。改完不要只看「现在能打开了」。带上
Accept-Encoding完整取一次,量出压缩后的体积(这里是 2,879,718),再回头核对access.log:同一个 wasm 的body_bytes_sent应当稳定等于这个数,出现明显更小的记录就说明还有一环在掐断。顺便记两个数字做参照:不压缩时 7,378,704 字节在 440 KB/s 下约 17 秒;压缩后 2,879,718 字节约 6.5 秒。这不是「快了三倍」的营销数字,它的意义是把「大概率在 60 秒内完成」变成了「大概率在 7 秒内完成」,从而把超时窗口甩开一个数量级。
客户端重试:有限次、可观察、别重试 404
弱网下最不该做的是无界重试,最该做的是把失败变成可恢复的状态。我的加载器只对网络类错误自动补一次整块下载;HTTP 4xx 直接失败、不重试(配置错误重试多少次都没用);重试仍失败时,界面明确给出「资源加载失败,检查网络后重试」的按钮,而不是把异常吞掉变成白屏。
缓存也参与重试:
fetch默认走 HTTP 缓存,之前成功的响应会命中缓存,第二次打开几乎不产生流量;但第一次因截断失败时,缓存里通常没有完整条目,重试仍要走网络。所以别把「刷新一下就好了」当稳定解法。还有一处容易忘:如果页面里有多个资源(wasm、主 JS、样式),不要让它们各自独立重试并各自弹提示。加载器统一收口,失败一次只提示一次,否则弱网下用户会看到三四个重试按钮同时出现。
小结:弱网交付清单
把这次的经验收成一张清单:
- 压缩:
application/wasm进 gzip 类型,反代时放行gzip_proxied,gzip_vary on。 - 缓存:文件名带内容哈希 + 长缓存 immutable,入口 HTML 短缓存。
- MIME:
application/wasm不能被压缩层或反代改写,否则流式编译失败。 - 超时:
send_timeout、proxy_send_timeout提到 300 秒,并清掉链路上其它 60 秒级超时。 - 校验:部署时取压缩响应、解压、比 sha256;确认
Content-Encoding与Vary都在。 - 客户端:流式优先、失败补一次整块下载;不重试 4xx;失败给出可操作的提示。
- 观测:
access.log的body_bytes_sent要能稳定等于压缩后的完整体积(与响应头里的Content-Length一致)。
根因不是「wasm 太大」一件事,而是体积、带宽、超时、缓存和错误处理挤在同一条链路上。压缩降体积、超时提耐心、校验保证压缩和传输没引入新问题、重试兜住剩下的小概率——四步都做了,弱网才算真的覆盖。配置侧细节见 gzip 与反代响应,客户端所属服务的部署取舍见 自建密码库。
- 把
-
WAL 模式下,别用 cp 备份 SQLite
WAL 模式下直接复制 .db 会丢掉尚未 checkpoint 的事务,.db 与 -wal 一起复制也可能撕裂;要用 backup API 或 VACUUM INTO 这类保证页级一致的机制,配合临时文件与校验才可用。
我的密码库是自建的,数据落在一个 SQLite 文件里,备份脚本从第一天就在跑。最初只有一行 cp:每天复制一次、按日期留几份,看起来够用——直到我读完 WAL 的写入路径,才发现这份备份在最坏时几乎等于不存在。下面是把「复制文件」换成「在线备份接口」的过程。
起点:备份文件比源库小
有次例行检查,我比了一下大小:备份比线上那个小一点,解释很自然——数据库刚整理过。真正让我警觉的是另一个现象:从备份恢复的数据少了最近一段记录,但库本身没有任何报错,打开、查询、读表结构,一切正常。
这是最坏的一类备份问题:不是坏得打不开,而是好得看不出问题。等你真需要它时,才发现缺的正好是最后那一段。
源库开的是 WAL 模式:多个连接在读,写入不希望阻塞读。问题恰恰出在这里。
WAL 的写入路径:提交先落在 -wal
WAL 模式下,事务提交不是把页写回主数据库文件,而是把修改过的页追加到
-wal里,形成一串帧。主库要等 checkpoint 才更新:checkpoint 把有效帧按页号回写进.db,再截断或重置 WAL。于是两个结论:主库文件是滞后的;已提交、已返回成功的事务,完全可能只存在于-wal里。容易误会的是
-shm:它只是 WAL 的共享内存索引,给读连接定位帧用,不是数据本体,恢复时不需要;真正不能丢的是-wal。checkpoint 什么时候发生?SQLite 有自动阈值,默认 WAL 超过 1000 页就触发,但它按写入量判断,不是定时任务。服务进程长期持连接、写入又零散时,主库可能长时间停在旧状态。我的密码库恰好如此:白天偶尔同步,夜里几乎不动,cp 出来的 .db 可能丢掉一整天。
一把 cp 两个文件也不安全
知道原因后,第二版脚本变成先 cp .db、再 cp -wal、最后 cp -shm。看起来是把同一时刻的三个文件一起拿走,但前提不成立:三次复制之间数据库仍在运行。
如果两次复制之间正好发生 checkpoint,主库已包含 WAL 中的部分帧,而复制到的 WAL 还是旧内容。恢复时 SQLite 按 WAL 头部的 salt 与校验和判断帧是否有效,对不上的帧被忽略,于是你得到一份数据回退的副本;更差时读到半截帧,直接报
database disk image is malformed或file is not a database。更隐蔽的是时间点撕裂:复制到的 .db 是 checkpoint 之后的状态、WAL 是之前的状态,组合出的快照从未在历史上同时存在。它可能打得开、大部分数据也对,但个别页半新半旧,比打不开更危险。
判断规则:只要复制时数据库还可能被写,你拿到的就不是备份,而是一次撞运气。cp、rsync、tar 这些工具不理解 WAL 的语义,只能保证字节被搬过去,不能保证它们构成一致的数据库。
改用 SQLite 自己的在线备份接口
SQLite 提供了在线备份 API:同一进程里开两个连接,一个指向源库、一个指向目标文件,把源库按页拷到目标库。Python 标准库包装了它,就是
sqlite3.Connection.backup()。import sqlite3 src = sqlite3.connect("/srv/demo/data/app.db", timeout=30) dst = sqlite3.connect("/var/backups/demo/app-tmp.db") def report(status, remaining, total): # remaining 变小后又回到 total,说明源库在过程中被写过,备份重启了 if remaining == total: print("restarted: source was modified during backup") src.backup(dst, pages=256, progress=report) check = dst.execute("PRAGMA integrity_check").fetchone()[0] if check != "ok": raise SystemExit("integrity_check: " + check) dst.close() src.close()它和 cp 的本质区别:backup 在 SQLite 的 pager 层做,每步都落在事务边界上,目标库处在受控的写事务里,拿到的必然是某一时刻的一致快照。过程中源库的其它连接可以继续读,写入也可以继续,只是写入会导致备份重新开始。全程不用停服务。
前提是它走连接而不是文件,备份进程必须能打开源库;被容器独占时,把脚本放进同一命名空间,或共享目录、对齐权限。
较新的 SQLite 还有
VACUUM INTO 'file',在读快照上把库导出成独立文件,也是合法备份手段;但它是单条语句,不方便统计进度和重启,所以我留在 backup API 上。源库写得凶时,备份会一直追不上
backup API 有一条关键约定:备份期间源库被其它连接修改,下一次调用会重新开始。它不假装自己拿到了一致快照,代价是高写入时可能永远走不到 remaining 归零。
我的处理是把参数调小、节奏放慢:
pages取 256 左右,每步之后留时间给其它连接,在进度回调里数「重新开始」的次数;连续重启超阈值就放弃这轮、记日志、几分钟后重试。源库连接要设busy_timeout,否则偶尔的锁冲突会直接打挂脚本。密码库这种「一个人用、写入突发」的负载,夜里三点半基本是空的;但哪天变成多人共享,就得挑维护窗口、短暂停写,或把备份放到业务低谷。脚本不该假设负载会一直配合它。
定时器、临时文件与原子重命名
接口对了,接下来是让脚本「失败也不留下假备份」:中途被杀不能留下看起来正常的残文件,校验不通过的文件不能进备份目录,保留策略只删旧文件、不删最新一份。手法老套但有效:先写临时文件,校验通过再原子重命名。
#!/usr/bin/env bash set -euo pipefail SRC=/srv/demo/data/app.db DIR=/var/backups/demo KEEP=14 STAMP=$(date +%Y%m%d-%H%M%S) # 临时文件必须与目标在同一文件系统,mv 才是原子 rename TMP=$(mktemp "$DIR/.tmp-$STAMP-XXXXXX") trap 'rm -f "$TMP"' EXIT python3 - "$SRC" "$TMP" <<'PY' import sqlite3, sys src = sqlite3.connect(sys.argv[1], timeout=30) dst = sqlite3.connect(sys.argv[2]) src.backup(dst, pages=256) check = dst.execute("PRAGMA integrity_check").fetchone()[0] dst.close() src.close() if check != "ok": raise SystemExit("integrity_check failed: " + check) PY mv "$TMP" "$DIR/app-$STAMP.db" trap - EXIT # 只保留最新的 KEEP 份 ls -1t "$DIR"/app-*.db | tail -n "+$((KEEP + 1))" | xargs -r rm -f几个细节:临时文件放同一目录、以点开头,同目录保证 rename 原子,点开头让文件名不匹配保留策略里的
app-*.db,孤儿文件不会被当成有效备份;保留策略放在重命名之后,避免中途失败两头空;integrity_check是硬门槛,把库完整读一遍才能证明文件完整;rename 只保证目录项原子,不保证数据落盘,要更强持久性还得 fsync 文件和目录,我的负载小,省了这一层。定时器用 systemd 而不是 crontab:它自带日志、失败状态和错过的任务补跑。
# demo-backup.timer [Unit] Description=Nightly SQLite backup [Timer] OnCalendar=*-*-* 03:30:00 Persistent=true RandomizedDelaySec=5m [Install] WantedBy=timers.targetPersistent=true保证 03:30 没开机时,下次启动后补跑一次;RandomizedDelaySec避免以后机器变多时同一秒一起压上来。服务单元用Type=oneshot,失败时systemctl status与journalctl -u直接能看到退出码。上线后先手动跑一次,确认产物、日志、保留策略都对,再等它自动跑。恢复演练:备份存在不等于能恢复
这套东西我用了一个多月才真的恢复一次。结论:脚本成功、目录里躺着十几份文件,都不能证明备份可用;唯一能证明的是「从备份启动一次服务,并读到应有的数据」。
演练贴近真实:找临时目录,从归档解出数据库(若是 tar 包,先
tar -tzf看一眼),跑integrity_check,再用另一份配置起一次性实例,登录、打开一条记录。密码库尤其要注意:数据库和加密密钥必须成对恢复。只恢复库而换新密钥文件,库能打开,但所有客户端令牌失效,表现为「服务正常、所有人都要重新登录」。附件目录也要和库同一时间点,否则会出现有记录、点开却没有文件。演练写进固定清单:每月一次,恢复到临时端口,检查能登录、目标记录在、附件能下载,在 README 记一行日期。不到二十分钟,却把「备份」从信仰变成可验证的事实。
小结:一个方案够不够的判断
我现在用一句话判断:如果复制过程和写入过程可以重叠,那它就不是备份。数据库文件有日志、有 checkpoint、有页级一致性;只有理解这套格式的接口(SQLite 的 backup API、
VACUUM INTO,以及pg_dump、mysqldump)才能给出一个「曾经真实存在过」的快照。落地清单:在线接口生成快照、临时文件加原子重命名、
integrity_check作为硬门槛、保留策略在成功后执行、定时器带补跑、每月一次真实恢复。少一条都可能让流程在关键时刻变成安慰剂。配置与密钥的留档见 变更前留档;密码库本身的备份范围见 自建密码库。 -
把布局脚本交回 CSS:has() 与容器查询
父级能响应子元素状态、组件能按自身宽度排版,两组原生 CSS 能力删掉了不少布局脚本,也带来了特异性与包含块的新边界。
这个站和本机那个 服务控制台 都不打包、不引框架:样式是手写的
style.css,交互是几百行原生 JS。以前遇到「表单有错误时整组变红」「同一张卡片放进侧栏就换一种排布」,都要写 JS 去读 DOM、加 class。后来:has()与容器查询进入稳定版,我把这类脚本删掉了大半。这篇记录它们适合做什么,以及我踩过的边界。has():父级终于能看子级的状态
CSS 选择器长期是单向的:元素能根据祖先、兄弟、自身属性匹配,但父元素没法根据子元素状态改变自己。
:has()补上了这个方向——参数是相对选择器,只有当它能在当前元素的后代或兄弟里找到匹配,当前元素才命中。最常见的用法是表单校验:
/* 输入非法时,整组字段变红,并把提示文字放出来 */ .field:has(:is(input, select, textarea):invalid) { border-color: #b3261e; } .field:has(:invalid) .field-hint { display: block; } .field:has(:invalid) .field-label::after { content: " *"; color: #b3261e; }以前这段要靠监听
input与blur,逐个子元素判断checkValidity(),再给父元素加is-invalid类。现在状态直接从原生校验伪类来,没有第二份要维护的状态。进阶还能用:user-invalid,只在用户交互过之后才命中,观感更好,但老浏览器可能不认,要写:invalid回退。相对选择器还能表达「下一个兄弟」这种以前很难写的东西:
/* 只在两个相邻条目之间画分隔线,最后一条不画 */ .item:has(+ .item) { border-bottom: 1px solid var(--line); }注意三条。第一,
:has()的特异性取参数里最高的那个,.field:has(input:invalid)比想象中重,和其它规则打架要靠@layer或书写顺序解决,别用!important硬压。第二,动态匹配有成本:浏览器要为可能影响:has()的 DOM 变化做失效计算,选择器越宽越贵。第三,它是选择器级能力,不做布局计算。容器查询:组件按自己的宽度排版
媒体查询问的是视口,可组件需要知道的是自己占了多宽。同一张卡片放在主栏宽度约 700px,放进侧栏只有 280px,媒体查询在两种情况下得到同一个答案。
@container解决的就是这个:先给父级声明一个查询容器,再让内部元素按容器尺寸切换布局。.card-slot { container-type: inline-size; container-name: card; } .card { display: grid; gap: 1rem; } /* 容器够宽才变成左图右文 */ @container card (min-width: 34rem) { .card { grid-template-columns: 12rem 1fr; align-items: start; } }这样卡片不需要知道自己在页面哪里,只需要知道「我现在有多宽」。响应式从页面级断点变成组件级断点,删掉的是那些为每个放置位置各写一套的
@media,以及为测宽度写的ResizeObserver。它的坑更偏布局,都在迁移时暴露:
container-type: inline-size给元素加上布局与内联尺寸的包含,意味着它成为内部绝对定位元素的包含块,内联尺寸也不能再依赖内容撑开。原来的width: autoflex 子项加上 container 后可能突然变窄甚至塌成 0——不是查询写错了,是包含生效了。我的做法是只给本来就有明确宽度来源的盒子加(块级、grid 列、显式宽度容器),而不是随手加在span上。另外,查询单位(
cqw、cqi)在容器内部可用,写流式字号顺手,但它们相对最近的查询容器而不是视口,嵌套时容易算错。渐进增强:基础先能用,增强再叠加
站点要求「JS 不可用也能读」,样式层是同一个原则:基础样式必须在旧浏览器里也能排出一个能看的页面,新特性只负责更好。写法上我坚持「基础在前、增强在后」,而不是写一堆
@supports not (…)的反向补丁。/* 基础:单列布局,任何浏览器都能看 */ .card-list { display: grid; gap: 1rem; } /* 增强:支持容器查询时按容器宽度重排 */ @supports (container-type: inline-size) { .card-slot { container-type: inline-size; } @container card (min-width: 34rem) { .card { grid-template-columns: 12rem 1fr; } } }探测用
@supports而不是读 UA:@supports (container-type: inline-size)问的是「这个声明能不能被解析并生效」,@supports selector(:has(*))问的是「选择器引擎认不认」。浏览器支持现状不用太保守::has()从 Chrome 105、Safari 15.4、Firefox 121 起支持;容器查询从 Chrome 105、Safari 16、Firefox 110 起支持。真正要留退路的通常是嵌入式 WebView 和老旧移动端内核,让它们落在基础样式上即可。一个细节:
@supports里的规则仍然遵守层叠顺序,位置写错会被后面的基础规则覆盖。我的习惯是把增强块放在文件末尾,或者用@layer明确分层,让基础层在前、增强层在后,而不是靠「文件里谁在后面」这种脆弱约定。可访问性:样式表达状态,语义仍然要写
两件事容易被「纯 CSS 方案」带偏。第一,状态不能只用颜色表达。
:has(:invalid)只是把边框上色,色觉障碍用户或屏幕阅读器用户得不到信息,输入框上仍然需要aria-invalid,错误文字要通过aria-describedby与输入框关联,且文案本身说清楚哪里错了。CSS 负责「看得见」,ARIA 负责「听得见」。第二,容器查询不该变成「窄容器就把内容藏掉」。用
display: none隐藏的文本,屏幕阅读器也读不到;如果它是有用信息,宁可缩小字号、换行、或折叠成可展开的 details,也不要直接删掉。窄容器最该变的是排布,不是信息量。另外两个顺手能做的:键盘焦点用
:focus-visible而不是:focus,避免鼠标点击也出现一圈轮廓;过渡动画放进prefers-reduced-motion: no-preference的查询里,给对动效敏感的用户留一个安静版本。这些都不依赖新特性,但和:has()一起用时很容易被忘掉。性能:省掉的是脚本,不是成本
把 JS 布局逻辑换成 CSS,收益不只是少几行代码:没有 JS 就没有「先渲染、再测量、再加 class」的抖动,也没有
ResizeObserver回调里读布局再写样式的风险。但成本没消失,只是换了地方。:has()的失效计算发生在 DOM 变化时;列表一次插入几十张卡片,如果有针对每张卡片的复杂:has()规则,代价会叠加。选择器尽量短,限定在已知子树里。- 容器查询在容器尺寸变化时重算样式;不要在动画里连续改变容器宽度(比如做宽度过渡),否则每帧都要重新求值。要动就动内部元素的
transform。 - 用 DevTools 的样式面板确认规则真的命中,而不是靠猜:容器查询在 Chrome 里会标注查询容器,
:has()没命中时先检查参数选择器能不能匹配到后代。 - 单文件、无构建的项目里,规则顺序就是全局状态。我用
@layer base, layout, components, utilities固定优先级,避免新规则要往上翻几百行确认。
什么时候还是得交给 JS
有两类状态 CSS 拿不到。一类是「不由 DOM 表达的状态」:网络是否在线、请求是否在飞、权限是否授予、当前用户是谁。这类我现在的写法是让 JS 只做一件事——把状态写到祖先元素的
data-*属性上,视觉分支全部留给 CSS:// JS 只同步状态 app.dataset.state = online ? "ready" : "offline";/* 样式分支全在 CSS 里 */ .app[data-state="offline"] .status-dot { background: #b3261e; } .app[data-state="ready"] .retry { display: none; }另一类是必须读 DOM 才能判断的几何与滚动信息,比如「元素是否滚出视口」「滚动到全文的百分之几」。这些现在也能用
IntersectionObserver或滚动驱动动画少写一点,但浏览器支持参差,兜底路径仍然需要 JS。判断标准其实很简单:如果状态已经存在于 DOM(合法性、是否有子元素、容器宽度、是否有兄弟节点),就交给 CSS;如果状态来自应用逻辑,就让 JS 把它投影成属性,样式还是用 CSS 写。这条边界清楚之后,所谓「布局脚本」基本就消失了。
小结
两组能力我会这样分工:
:has()管「状态」——表单错误、列表分隔、有无内容时的占位;容器查询管「空间」——组件在不同宽度下的排布。两者都不需要构建、不需要框架,写进一个普通的 CSS 文件就生效。代价是要接受浏览器之间的差异,用@supports给旧引擎留一条基础路径。这个站没有打包步骤,样式文件手写,改完刷新就能看效果——这也是我先考虑原生能力的现实原因:在无构建的项目里,多一行 CSS 的维护成本,远低于多一个构建插件或一个布局脚本。
-
require(esm) 默认可用了,库还要不要发双格式
require(esm) 默认开启后,纯 ESM 包能被 CJS 直接加载,双格式不再是硬性要求;但顶层 await 仍是硬边界,双包危害与条件导出的细节仍需逐条判断。
给一个内部小库补 CJS 支持这件事,我拖了很久:为了兼容
require(),得配两套构建产物、两套类型声明路径,还要在文档里解释「为什么同一个包被加载两次会出问题」。Node 22.12 之后答案变了——纯 ESM 的包可以被require()直接加载,双格式不再是硬性要求。这篇记录我对现状的理解,以及迁移时的取舍。变化:require() 能读 ESM 了
过去的边界很硬:ESM 文件只能用
import或动态import()加载,CJS 里require()一个 ESM 包会直接抛ERR_REQUIRE_ESM。于是库作者的标准回答是「发双格式」:同一份源码构建出 CJS 与 ESM 两份产物,用exports条件导出分发。Node 22.12 起,
require(esm)默认可用,不再需要实验开关;Node 24 作为 LTS 已经把默认行为稳定下来。CJS 里require()一个纯 ESM 模块时,拿到的是模块的命名空间对象:默认导出在.default上,具名导出直接用属性访问。互操作细节值得留心:CJS 侧常见的 Babel/TypeScript 助手会先看
__esModule标记决定怎么取默认导出,而命名空间对象在这块的行为和转译产物不完全一样。稳妥的做法是别假设「两种导入方式完全等价」,在项目里各写一个最小冒烟文件确认一遍——两个文件、十几行代码,比事后排查「为什么默认导出是 undefined」便宜得多。唯一的硬边界:顶层 await
require()是同步 API,它必须在返回前拿到模块的执行结果。所以 require(esm) 只对「能同步求值完」的模块成立。模块图里只要有顶层 await,就会抛ERR_REQUIRE_ASYNC_MODULE,错误信息会提示改用动态import()。这不是版本问题,而是异步语义与同步接口的根本冲突,未来也不会消失。它带来一条设计约束:把顶层 await 留在入口脚本里,共享库不要用。比如我有个一次性维护脚本,用顶层 await 动态 import 上游模块、查线上接口、再写回本地文件——这类代码天然是 ESM 入口,本来也不该被 require;但同一批工具里有共用函数,如果哪个函数偷偷加了顶层 await,就会把整个包变成不可 require 的。CI 里加一条检查值得。
双格式真正的代价:同一个包两份状态
为什么要摆脱双格式?最实际的理由不是构建麻烦,而是双包危害(dual package hazard):如果 CJS 与 ESM 分别指向不同文件,那么同一个进程里通过
require()和import加载同一个包,会得到两份独立的模块实例。各自的模块级状态(缓存、计数器、注册表、单例)互不相通,instanceof可能失败,事件监听注册到其中一份而另一份永远收不到通知。这个问题在日志、ORM、依赖注入容器、任何「用模块级变量当全局注册表」的库上都会出现,而且往往只在用户把 CJS 和 ESM 混着用时才爆发,库作者自己很难复现。
只发 ESM 恰好绕开了它:CJS 通过 require(esm) 拿到的是同一个 ESM 命名空间,两边共享的模块实例是同一份。这是我决定新库只发 ESM 的主要理由——不是为了省构建时间,而是为了消除一类只在用户侧出现的 bug。反过来,如果为了兼容 22.12 以前的 Node 而保留双格式,那份双包危害就还在,至少要在文档里写清楚「不要混用两种加载方式」。
条件导出怎么写
只发 ESM 的包,
package.json可以简化成一份:{ "name": "demo-lib", "version": "1.0.0", "type": "module", "engines": { "node": ">=22.12" }, "exports": { ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" } } }default是兜底条件,import与require都会落到它上面;type: module让.js被当作 ESM 解析。engines要写清楚,它是这条路线的前置条件:npm 安装时只会给出EBADENGINE警告(除非开启engine-strict);真正在低于 22.12 的 Node 上运行会抛ERR_REQUIRE_ESM,错误发生在加载阶段,比运行到一半才发现好。如果要兼容老版本、仍然发双格式,条件导出的顺序很关键:
{ "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js", "require": "./dist/index.cjs", "default": "./dist/index.js" } } }规则是「更具体的条件放前面,
default永远最后」。较新的 Node 还提供module-sync条件,用来表达「这份 ESM 可以同步加载,require 和 import 都能用它」——如果确实需要双格式、又想让 require 侧也拿到 ESM 实例,可以把它放在import与require之前,把 require 导到一份能同步加载的 ESM 上。这样老 Node 回落到.cjs,新 Node 统一实例,两边都不牺牲。dirname、import.meta 与两个方向的加载
迁到 ESM 的第一件小事通常是
__dirname没了。等价写法:import path from "node:path"; import { fileURLToPath } from "node:url"; import { createRequire } from "node:module"; // 优先用 import.meta.dirname(Node 20.11+),否则从 url 推 const here = import.meta.dirname ?? path.dirname(fileURLToPath(import.meta.url)); // ESM 里需要用 require 时,用当前模块的 URL 造一个 const require = createRequire(import.meta.url); const legacy = require("./legacy-tool.cjs");两个方向的加载现在都通了,但语义不同:
方向 结果 CJS require()CJS模块的 module.exports,和以前一样CJS require()ESMESM 命名空间对象, .default是默认导出;要求模块图里没有顶层 awaitESM importCJSdefault是module.exports;具名导出取决于静态分析能识别多少ESM importESM标准行为,命名空间对象 表里第三行是长期被抱怨的地方:CJS 包写
module.exports = { ... }时,import { a } from ...能不能成功,取决于工具的静态分析能力,而不是运行时的真实形状。常见的exports.a = ...与对象字面量赋值大多能识别,动态拼出来的属性名则不能。所以我写 CJS 时尽量保持简单直白的导出形式,减少让分析器猜的情况。还有一个跨平台的小坑:ESM 里动态 import 一个绝对路径时,要传 URL 而不是路径。Linux 上
import("/abs/path/x.js")看起来能跑,Windows 上会被当作c:协议解析直接失败。稳妥写法是import(pathToFileURL(resolve(...)).href)。我在维护脚本里用动态 import 加载过一个运行时才算出来的模块路径,这种场景尤其要注意——静态 import 做不到,就更容易把路径当 URL 用错。工具链:谁已经跟上了
打包器这一侧基本不是问题:Vite、esbuild、Rollup、webpack 原生就吃 ESM,纯 ESM 依赖对它们是更好的输入。真正滞后的是测试与执行工具:Jest 的 ESM 支持多年来一直需要额外配置,ts-node 也有自己的解析分支;TypeScript 的
module: "nodenext"按 Node 的真实规则解析,是最接近运行时行为的设置,而moduleResolution: "bundler"适合交给打包器的项目。这些工具的版本迭代很快,结论以你在项目里实际跑通为准。Node 的 require(esm) 让一批「内部用 require 写、又依赖 ESM-only 包」的 CLI 与旧脚本少了一处改造。它不会自动修好测试工具内部的加载逻辑,但至少让「库只发 ESM」不再等于「所有 CJS 用户都被劝退」。顺带说一句:服务端脚本本来就适合直接 ESM,比如本机 服务控制台 的后端就是一个由 node 直接执行的
.mjs,没有构建步骤,这类项目里 ESM 从来不是负担。我的迁移顺序
先在一个不发布、只内部使用的小库上把
type: module、exports、engines改好;然后在 CI 里加两个冒烟文件,一个.cjs用require()加载,一个.mjs用import加载,各调用一个导出函数;最后检查所有入口(bin、构建脚本、测试)——入口脚本允许顶层 await,共享模块禁止。跑通之后再动用户更多的包。还有一个容易忽略的检查项:
bin字段指向的脚本如果被别处require()当模块用,就会踩顶层 await 的边界。命令行入口就让它只做入口的事,把可复用的逻辑拆到独立模块里,这条规则在 CJS 时代也成立。判断清单
- 新库、只跑在现代 Node(
engines不低于 22.12):只发 ESM,用default条件,不配双构建。 - 还要支持老 Node:继续双格式,但要知道双包危害,文档里写清不要混用;新 Node 可以加
module-sync让 require 落到同一份 ESM。 - 模块图里有顶层 await:只能
import,把它留在入口,不要放进共享库。 - 迁 ESM 时先处理
__dirname、动态 import 的路径与 URL 差异,再跑一遍双加载冒烟。
回到标题的问题:双格式要不要发,现在取决于
engines与用户分布,而不是「CJS 用户能不能用」这个技术禁忌。对我这种小规模、自己控制使用方的项目,答案是只发 ESM;对下载量大的公共库,判断要多一层:老 Node 用户还有多少,值不值得为它承担双包危害。 - 新库、只跑在现代 Node(