两周前我还觉得这事儿挺离谱的:一个从 2019 一直加功能加到 2026 的大型 Vue2 前端项目,页面数是“数千”这个量级。几年前我们认真评估过一次升级成本——500~1000 人天,几乎等同重写。最后结论很现实:算了,成本扛不住。

但这两年 AI 编程的发展太快了,快到你会忍不住再回头看一眼当初“不可能”的东西。于是我决定趁热打铁:直接把项目从 Vue2 升到 Vue3

开始前的几个前提(也是我这次的选择):

  • 没有新开工程、逐步迁移(那种“搭新壳子慢慢搬”的模式我没走)
  • 在原工程里新开分支,就地升级,随时能回滚
  • 全程主力模型用 GPT 5.2 xhigh,负责把 Vue3 / Element Plus 等等基础组件升级、替换、并把构建跑通(yarn dev / yarn build 作为硬门槛)
  • 使用AI Skills,把 vue3 最佳实践 放了进来。AI每次修改都能准照Skills要求进行

为什么我敢这次真动手

我不太会前端(对,就是那种“能看懂、但不爱写”的程度),所以一开始对困难是有敬畏的:
页面多、路由多、历史包袱重,随便一个小改动可能就炸一片。

但也正因为我不算“前端熟练工”,我反而更愿意把过程变成一套“可控的流水线”——AI 不负责拍脑袋,我负责定规则、定验收、定节奏。
同时我也想再测试下 AI 的编程能力边界。


路线选择:原地升级,而不是新工程迁移

很多团队升级 Vue3 会选“新工程 + 双轨迁移”。优点是稳,缺点也很明显:周期长、维护成本高,两边要跑很久。

我这次选的是更激进但更直接的方式:

  1. 原仓库开新分支(比如 feat/vue3-upgrade
  2. 以“能跑起来”为第一目标,把工程骨架升级到 Vue3
  3. 然后再做“页面级的修修补补”,一点点把功能验证过去

这条路线有风险,但好处也很明显:
升级结果能尽快落地,而不是永远卡在“迁移中”。


我用到的 AI 工具(以及它们各自干什么)

我这次主要用两类工具(按我的实际体验分工):

  • codex:适合做“批量、工程级”的活(大范围改造、统一替换、成片修复)。使用的是 vs code 中的 codex 插件
  • antigravity:我用它里面的 Gemini / Claude 做“页面级、细碎兼容”的问题(一个页面一个页面地抠)

另外我自己主力用的是 GPT 5.2 xhigh
它在“升级基础组件 + 拉通构建链路”这块特别省心,能把项目从“跑不起来”推到“能启动、能构建”,缺点就是慢,要等。 不过相对整个人工升级耗时来说,codex 速度也算 神速


实操流程:我怎么把一个大项目“喂给 AI”,再把它拉起来

下面基本按我当时的操作顺序写(中间有些步骤我会解释一下为什么这么做)。

1)先让 AI 充分了解项目(别急着改)

我一开始用 codex 的高强度模式,把项目结构、依赖、构建脚本、路由组织方式先过一遍。
目的不是让它“直接开干”,而是让它建立上下文:这是个什么项目、哪里是关键路径、哪些东西不能乱动
同时也是让我自己对项目有个清晰的认知。

2)跟 AI 把升级方案聊清楚(不清楚就别动代码)

我会强制让它回答这些问题:

  • 升级路线是什么?(依赖怎么换、入口怎么改、哪些插件要替代)
  • 哪些点是“必炸点”?(Vue2 语法、指令、全局 API、第三方库)
  • 每一步完成的验收是什么?(比如必须 yarn dev 能启动、路由能进首页)

3)从多个方案里选一个“我能接受的”

AI 往往会给很多选项:激进的、保守的、折中的。
我最后选的是:先把工程骨架升级到 Vue3 并跑通构建,再去处理页面兼容。原因很简单——工程能跑起来,你才有“可迭代”的基础。

4)把方案写进一个升级文档(我叫它 upgradevue3.md

这一步非常值。
我让 AI 把方案和实施步骤写成文档,后面每一次跑偏、每一次改错,都可以回到这份文档里“对齐目标”。

说白了:让 AI 自己写“施工图”,后面才不会边改边忘。

5)开始动手升级(让 AI 按方案执行)

这一步我交给 codex 先跑一轮大改造:依赖升级、入口改造、路由适配、构建配置等。

6)让 AI 连续干 4~5 小时,把“骨架”先搭出来

codex 那次连续干了四五个小时,停下来的时候,项目已经完成了“最关键的那一跃”:
yarn dev 能启动、yarn build 能打包

对我来说,这一刻基本就能判断:这事儿成了大半。因为只要构建链路通了,剩下就都是“兼容性”和“细节”。

7)启动本地开发,看页面是不是能正常打开

我不会一上来就点所有菜单,那会把自己搞崩溃。
我只看三件事:

  • 首页能不能进
  • 路由跳转是不是正常
  • 控制台是不是成片报错(少量告警我先忍)

8)哪里异常就改哪里(不要同时动太多)

页面级问题我会拆小:一次只处理一类报错,或者一个功能模块。
这样回滚也方便,定位也快。

9)让 AI 判断“现在处在方案的哪一步”

这也是我觉得很实用的一招:
我会让 AI 基于当前代码状态,告诉我“我们现在完成了哪些步骤、基于升级方案下一步该做什么”。
这样不会出现“改着改着忘了目标”。

10)主流程跑通后,剩下就是菜单级验证

到这一步,其实就是体力活:一个个功能点进去看,哪里炸了修哪里。
遇到很多类似问题,直接 叫起 Codex 批量修改。


后半程怎么推进:Gemini / Claude 负责“碎活”,codex 负责“批量修复”

当骨架升级完成后,剩下最多的就是两类问题:

  1. Vue2 / Vue3 的语法兼容小问题(多且碎),记住 AI 不能一次性处理所有的 语法问题,需要我们自己进行测试验证来排查
  2. 某些页面控制台有告警/报错,但不一定影响使用

所以我后面改页面时的分工是这样的:

  • 页面级问题:用 antigravity 里的 Gemini / Claude 来抠细节(快、准、上下文短)
  • 需要批量处理的兼容问题:再把任务丢回 codex,让它做“成片替换、成片修复”

结果:差不多 10 个工作日,项目基本正常

就这样,差不多 10 个工作日,整个项目已经“基本能用”:

  • 页面能打开,路由基本正常,功能正常
  • 还有少量页面会有控制台告警(不影响使用)
  • 偶尔也会有页面报错(就继续修)

我自己的体感是:
**升级工作大概做了 70%**,后面更多是“语法兼容 + 边角料”。

最爽的是第一周:当我看到程序已经能跑、路由能跳、页面能显示的时候,心里那种感觉真的是——
“稳了,这次肯定能成。”


一些很现实的体会(写给也想这么干的人)

  1. 别让 AI 直接开干
    先让它读懂项目、再跟你对齐方案,否则越改越乱。

  2. yarn dev / yarn build 当闸门
    每个阶段都必须过这两关,否则别往下走。

  3. “主力模型 + 备用模型”非常有用
    有的模型擅长工程级改造,有的擅长修细节。别迷信单一工具。

  4. 原地升级一定要开分支
    这不是建议,是底线。没有分支保护,心态会崩。

  5. 对于需要替换的组件,让AI给建议
    由于大型项目,涉及到的依赖组件会特别多,大概率会碰到一些组件依赖没有适配Vue3,需要更换组件的情况。
    这时可以跟AI讨论,项目里遇到使用这些功能组件的替换选择,选一个功能最接近、升级代价最小的组件。


最后聊一句:效率真的会刷新记忆

AI 的效率这件事,我以为自己已经见识过很多次了,但它还是会不断刷新你的印象:
以前要 500 人天的东西,现在两周就能看到一个“能跑起来”的结果——这不是优化,是量级变化。

另外,介绍下文中我用到的订阅:
GPT Plus,以及 Google One(我用的是一个月免费试用)。成本肯定有,但和“重写项目”的成本比起来……你自己算算就明白了。


如果你也在纠结“Vue2 项目到底要不要升 Vue3”,我的建议很简单:
别在脑子里演,找个时间开个分支,让 AI 先把骨架推起来——跑得起来之后,你自然就知道该怎么做了。

从买房踩坑到手搓一个日照网站:这玩意儿真能帮你避开“阴森森”的二手房

大家好啊!今天想跟你们唠点实在的——关于买房时那个让人抓狂的“日照问题”。多年前夏天,我差点在xx看中一套二手房,户型、价格都挺香,结果中介轻飘飘一句“采光嘛,还行吧”就糊弄过去了。等我挑冬天进去才发现,下午三点后客厅就黑得像电影院,晾个衣服都得靠烘干机。气得我直拍大腿:为啥没人告诉我真相?

后来一打听,市面上那些专业日照分析软件,动辄几千块年费,操作复杂得像开飞机——导入CAD图纸、调参数、等渲染……普通购房者谁受得了这个?尤其看二手房,房东和中介哪会给你掏钱买报告?我翻遍了小红书和知乎,一堆人吐槽:“想看看阳光能不能照进我家飘窗,咋比考驾照还难?”

一拍脑袋,我决定自己搞个工具。
我不是房产中介,就是个业余爱捣鼓的普通人。前几天,趁着周末和深夜,我吭哧吭哧开始写代码。说真的,过程挺狼狈:3D建模部分卡壳时,差点把键盘砸了;太阳轨迹算法调试到凌晨三点,咖啡杯堆成塔。不过呢,这次我偷偷用了点“外挂”——现在不是流行AI辅助编程嘛,像Copilot/ChatGPT这类工具帮我快速生成些基础代码片段,省了不少重复劳动。但核心逻辑全是自己抠的,比如怎么把经纬度、节气、楼高这些变量揉在一起算出真实阴影。重点不是AI多神,而是它让我这个半吊子能把精力花在刀刃上:让工具真正“傻瓜化”。

于是,这个小破站诞生了:SunSpot(名字土了点,但好记!)

打开它,你不会看到一堆吓人的参数表。首页就是一个干净的3D网格地图,像玩《我的世界》一样简单:

  • 拖拖拽拽画房子:点“+”按钮加一栋楼,鼠标一拉就能调高度、改位置;不满意?直接删掉重来。二手房小区布局乱?自己还原真实场景就行。
  • 太阳会“动”给你看:选个日期,比如冬至,界面上立刻画出太阳一整天的轨迹线,连阴影怎么爬过你家阳台都清清楚楚。再也不用脑补“下午四点会不会晒被子”了。
  • 最狠的是这功能:点中任意一栋楼,再选具体楼层和户号(比如“5楼东户”),它直接甩给你一张表——24节气里,每天阳光照进你家窗台的精确时间。春分这天能晒2.5小时?大寒可能只有40分钟?数据全摊开,中介忽悠不了你。

上周我拿它试了试老家亲戚看中的一套老小区房子。亲戚原本觉得“楼层高肯定亮堂”,但SunSpot一算:对面新盖的写字楼在冬至那天,把阳光全挡了,下午根本见不着太阳。亲戚果断换房,后来发微信跟我吐槽:“早有这玩意儿,我何必白跑三趟售楼处!”

为什么坚持做成免费+极简?

因为我太懂普通人的痛了——买房是大事,不该被信息差割韭菜。那些收费软件把简单问题搞复杂,本质是吃定了你急着交定金。SunSpot没有登录墙、没有付费弹窗(靠我收入养着服务器呢)。技术上当然不完美:小bug还在修……但核心就一条:让你5分钟内,看清阳光会不会“住”进你家。

最后说点掏心窝子的

写代码时,我妈总笑我:“你一个买房的,咋还搞起气象站了?”但每次收到用户留言说“靠它避开了阴暗房”,就觉得那些熬夜的夜没白熬。技术不该是门槛,而是帮普通人把生活理得更明白的小梯子。

如果你最近在看房,尤其二手房,真心建议去试试 SunSpot。操作有疑问?评论区吼我,我手把手教。也欢迎吐槽——这站活着的唯一意义,就是替你挡住那些“看不见的阴影”。

需求

部署了私有化的 Harbor,需要 Harbor 同时支持 内网IP(HTTP)、外网IP(HTTP)、域名(HTTPS)进行访问。

配置

  • 1、在 harbor.yaml 中配置 external_url
    1
    external_url: http://62.132.105.159:5080

5080 端口映射到 外网,即可使用 http://62.132.105.159:5080 访问 Harbor。

  • 2、使用域名访问

配置 nginx 代理,配置如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
user  root;
worker_processes auto;

events {
worker_connections 1024;
}

http {
include mime.types;
default_type application/octet-stream;

log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';

sendfile on;
keepalive_timeout 65;

gzip on;
gzip_static on;

proxy_buffer_size 128k;
proxy_buffers 32 128k;
proxy_busy_buffers_size 128k;

fastcgi_buffers 8 128k;
send_timeout 60;

server_tokens off;

# =========================
# Harbor HTTPS Reverse Proxy
# =========================
server {
listen 443 ssl http2;
server_name xx.xxx.com;

ssl_certificate /home/cert/xx.xxx.pem;
ssl_certificate_key /home/cert/xx.xxx.key;

client_max_body_size 0;

# 建议给 registry 单独一段,避免某些客户端拉大层超时/缓冲问题
location /v2/ {
proxy_pass http://172.16.36.52:8894;

# ✅关键:一定要用 $http_host,才能把 :5088 带进去
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Host $http_host;

# ✅关键:告诉 Harbor 外部访问端口是 5088(不是 443)
proxy_set_header X-Forwarded-Port 5088;
proxy_set_header X-Forwarded-Proto https;

proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

proxy_http_version 1.1;
proxy_read_timeout 900;
proxy_send_timeout 900;

# 拉镜像层时建议关掉请求缓冲
proxy_request_buffering off;
}

location /service/ {
proxy_pass http://172.16.36.52:8894;

proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-Port 5088;
proxy_set_header X-Forwarded-Proto https;

proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

proxy_http_version 1.1;
proxy_read_timeout 900;
proxy_send_timeout 900;
}

location / {
proxy_pass http://172.16.36.52:8894;

proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-Port 5088;
proxy_set_header X-Forwarded-Proto https;

proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

proxy_http_version 1.1;
proxy_read_timeout 900;
proxy_send_timeout 900;
}
}

}

部署 Nginx 服务后,即可使用 域名访问、拉取 Harbor 数据。

1)背景:前端入口链路

我们的站点前端是典型的两层入口:

  • HAProxy:公网入口(80/443),负责分流与转发
  • Nginx:承载前端静态资源与后端反代

部署架构图

部署架构


2)问题现象:同样是 SlowHTTP,HTTPS “十几秒断光”,HTTP 却能一直挂着

我们用 slowhttptest 测试 Slow Headers / Slowloris(慢慢发送请求头,让服务器长时间保持连接,最终耗尽连接资源)时:(HAProxy Technologies)

  • HTTPS(443):连接在 10 多秒左右大量断开,攻击很难长期占用资源
  • HTTP(80):连接能长期保持;攻击持续后才出现 429,但 429 会影响所有正常用户访问

原因 : 一开始我们把防护都写在 Nginx(例如 header 超时等),结果出现非常反直觉的情况:HTTPS 有效,HTTP 无效


3)根因分析:HTTP/HTTPS 的“第一层解析 HTTP 的位置”不同

这次的关键点不是 Nginx 配得对不对,而是:

Slowloris 卡住的是“第一层负责接收/解析 HTTP 请求头”的组件。

✅ HTTPS(443)为什么能防住

我的 443 链路是:

  • HAProxy:mode tcp + SNI 分流(不解析 HTTP
  • 后端:send-proxy-v2 透传到 Nginx
  • Nginx:负责 TLS 终止与 HTTP 解析(第一层真正“读请求头”的地方)

因此 Slowloris 的“慢请求头”最终会到 Nginx,Nginx 的相关超时策略会把它掐掉,所以你看到 HTTPS 连接会在十几秒内“断光”。

另外,Nginx 要接收 PROXY 协议,必须在 listen 上启用 proxy_protocol。(loadbalancer.org)

❌ HTTP(80)为什么最初防不住

我的 80 链路是:

  • HAProxy:mode http会解析 HTTP

这意味着 HAProxy 才是第一层接收并解析 HTTP 请求头的地方。而 HAProxy 在收到完整请求之前不会把请求转发到后端(官方说明:只有收到完整请求才会发给后端,因此后端往往看不到 slowloris 流量)。(HAProxy Technologies)

所以你在 Nginx 上做的“读请求头超时”对 HTTP 链路会出现一个现实问题:

慢头连接根本没到 Nginx,就卡在 HAProxy 这一层了。

这就是“HTTPS 能防、HTTP 防不住”的根本原因:两条链路的防护点不在同一层


4)最终解决方案:把 SlowHTTP 防护前移到 HAProxy 的 80(HTTP)入口

要在 HAProxy mode http 下解决 slowloris,核心就一条:

  • timeout http-request:限制“收完整请求头”的最大时间(slowloris 依赖慢慢发头来续命,这条能直接掐断)(HAProxy Technologies)

在此基础上我们叠加 stick-table,用“按源 IP 的并发连接数 / 请求速率”做更精细的保护与兜底。(HAProxy Technologies)


5)最终版配置

下面给出最终版(精简但完整可用)的 HAProxy + Nginx 关键配置结构,你可以把业务路由部分按自己的域名/SNI/后端继续扩展。


A. HAProxy(最终版)

版本:haproxy:2.3(Docker)
说明:443 维持 TCP/SNI 分流,80 负责 HTTP 分流 + SlowHTTP 防护

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
global
log 127.0.0.1 local0
daemon
maxconn 2000

defaults
log global
option redispatch
retries 3
timeout connect 5s
timeout client 50s
timeout server 50s

# 管理页面(可选)
frontend admin_stats
bind :8070
mode http
stats enable
stats uri /admin
stats refresh 30s
stats auth admin:xxxxxxxx
stats hide-version

#####################################################################
# HTTP :80 入口(关键:在这里防 slowloris / slowhttp)
#####################################################################
frontend header_front
bind *:80
mode http
option httplog
option forwardfor

# --- 核心 1:专杀 Slow Headers / Slowloris ---
# 限制接收完整请求头时间:慢头无法“续命”
timeout http-request 10s

# 可选:缩短 keep-alive 空等时间,减少连接长期占用
timeout http-keep-alive 10s

# 可选:收紧 client(视业务而定)
timeout client 30s

# --- 核心 2:stick-table(按IP并发/速率兜底)---
stick-table type ip size 200k expire 30s store conn_cur,conn_rate(10s),http_req_rate(10s)

# 连接一建立就跟踪:对“慢头阶段”的连接非常关键
tcp-request connection track-sc0 src
acl abuse_conn sc0_conn_cur gt 80
tcp-request connection reject if abuse_conn

# 请求层限速(按业务调整阈值)
http-request track-sc0 src
acl abuse_rps sc0_http_req_rate gt 100
http-request deny deny_status 429 if abuse_rps

# --- 你的业务分流规则(示例保留你的结构)---
acl finereport_h5 hdr_dom(host) -i finereport.xxxxx.com.cn
http-request redirect scheme https if finereport_h5

acl iphone_browser hdr_reg(User-Agent) -i iPhone
acl ipad_browser hdr_reg(User-Agent) -i iPad
acl android_browser hdr_reg(User-Agent) -i Android
use_backend app_backend if iphone_browser or ipad_browser or android_browser
default_backend pc_backend

backend app_backend
mode http
option httpchk
server mom 192.168.139.118:30001 check
# 注意:不要再手动 set-header X-Forwarded-For,否则会和 option forwardfor 叠加导致 "ip, ip"
http-request set-header X-Real-IP %[src]

backend pc_backend
mode http
server mom-app 192.168.139.118:30002
http-request set-header X-Real-IP %[src]

#####################################################################
# HTTPS :443 入口(维持 TCP + SNI 分流,交给 Nginx 终止 TLS)
#####################################################################
frontend https_frontend
bind *:443
mode tcp

tcp-request inspect-delay 5s
tcp-request content accept if { req.ssl_hello_type 1 }

acl app_host req.ssl_sni -i app.xxxxx.com.cn
acl finereport_host req.ssl_sni -i finereport.xxxx.com.cn

use_backend app_https_backend if app_host
use_backend finereport_https_backend if finereport_host
default_backend pc_https_backend

backend pc_https_backend
mode tcp
server mom-https 192.168.139.118:18443 send-proxy-v2

backend app_https_backend
mode tcp
server app-https 192.168.139.118:18443 send-proxy-v2

backend finereport_https_backend
mode tcp
server fr-https 192.168.139.118:18443 send-proxy-v2

关键点回顾:

  • timeout http-request 是解决 HTTP slowloris 的“必杀开关” (HAProxy Technologies)
  • stick-table 用于按 IP 做并发/速率兜底;http-request deny deny_status 429 是官方/手册常用写法 (HAProxy Technologies)

B. Nginx(最终版:只保留与链路相关的两句)

你不需要在这篇文章里展开 Nginx 的各类参数(因为这次问题本质在 80 的 HAProxy)。但为了说明链路,保留两句就够:

1
2
3
4
5
# HTTP 入口
listen 80;

# HTTPS 入口:接收 HAProxy send-proxy-v2
listen 443 ssl proxy_protocol;

proxy_protocol 的意义:让 Nginx 能从 HAProxy 透传的 PROXY 协议里获取真实源地址。(loadbalancer.org)


6)验证方式:怎么确认 HTTP 真的“像 HTTPS 一样”防住了

  1. 再跑一次 slowhttptest(SLOW HEADERS 模式)
  • 观察 80 端口:连接会在 timeout http-request 10s 附近开始大量断开,不会长期“挂住”
  • 443 端口:保持原有效果(十几秒内大量断开)
  1. HAProxy 日志/统计
  • 你会看到更多连接在请求未完成阶段被拒绝/超时
  • 若开了 stats(:8070),也能看到前端并发回落更快

7)总结:这次踩坑的真正经验

  • 同样是 slowloris,HTTP/HTTPS 的防护点可能完全不同。
  • 80 端口 HAProxy 是 mode http 时,慢头卡在 HAProxy,Nginx 的“读头超时”自然不生效。
  • 解决它的正确方式是:在 HAProxy 80 入口加 timeout http-request + stick-table,把 slowhttp “掐死在入口层”。

几个月前,我做了一件“疯狂”的事——把 Headscale 这个本为单机设计的开源项目,硬生生改造成了支持 集群模式 的分布式系统。核心改动很简单:引入 集群 ID 机制。通过这个 ID,多个 Headscale 实例能协同工作,像乐高积木一样横向扩展。用户再也不用担心单点故障,节点规模也能轻松突破千级,甚至用户可无感一键切换节点。那会儿深夜改代码的茶杯还堆在桌角,但看到集群流量平稳跑起来时,值了。

而最近一个月,我又给自己挖了个更大的“坑”:多租户化改造。这次的目标很明确——让 HeadscaleTailscale 官方服务一样,支持多团队、多客户隔离运行。想象一下:市场部、研发部、外包团队各自拥有独立的网络空间,设备互不可见,策略分权管理,账单按租户拆分。技术上,我在认证层和数据层等关键模块加了租户隔离,配合之前的集群 ID 能力,终于把单体架构蜕变成真正的 “集群+多租户”双引擎。现在它不仅能扛住海量节点,还能让不同租户在同一个系统节点里“老死不相往来”。我们正从面向个人的 SaaS 服务,跨越到同时服务个人与企业的统一平台。更令人兴奋的是,结合近几个月将 Tailscale/Headscale 应用于工业场景的实践,我们的服务边界已从个人网络、企业 IT 延伸至智能制造等产业级场景

坦白说,这过程像在飞行中给飞机换引擎。ACL 策略冲突、租户配额溢出、跨集群同步延迟……Bug 追着我跑。但当测试环境里两个租户的设备同时上线却互不干扰时,那种 “成了” 的心跳感,比在寒冬里用冰水冲脸还提神。

目前整个系统仍在紧锣密鼓开发/调试中。性能压测、审计日志、租户自助面板……这些拼图正一块块归位。我们离一个真正企业级的 Headscale 从未如此之近。 期待很快能和你分享完整方案——这次,开源世界或许真能长出媲美商业产品的羽翼。

web

A few months ago, I did something “crazy”: I transformed Headscale—a project originally designed for single-node deployments—into a distributed system supporting cluster mode. The core change was elegantly simple: introducing a Cluster ID mechanism. With this ID, multiple Headscale instances now collaborate like Lego bricks, scaling horizontally without limits. Users no longer fear single-point failures, node counts can effortlessly surpass thousands, and—best of all—users can seamlessly switch nodes with a single click. The teacups from those late-night coding sessions still clutter my desk, but watching cluster traffic glide smoothly made every sip worth it.

Over the past month, I’ve dived into an even deeper challenge: multi-tenancy transformation. The mission was clear—to make Headscale operate like Tailscale’s official service, enabling isolated networks for multiple teams and clients. Imagine this: Marketing, Engineering, and an external contractor team each own their private network realm. Devices stay invisible across boundaries, policies are delegated per team, and billing splits cleanly by tenant. Technically, I implemented tenant isolation across authentication layers, data layers, and other critical components—all fused with our existing Cluster ID capability. This finally evolved the monolithic architecture into a true “cluster + multi-tenancy” dual-engine system. Today, it doesn’t just handle massive node loads; it ensures tenants coexist on the same physical node yet remain “blissfully unaware of each other’s existence.” We’ve crossed from a personal-SaaS tool into a unified platform serving both individuals and enterprises. Beyond this, months of applying Tailscale/Headscale in industrial settings have expanded our vision: our solution now scales from personal networks and enterprise IT to smart manufacturing and beyond.

Let’s be honest—this felt like rebuilding an airplane mid-flight. ACL policy collisions, tenant quota overflows, cross-cluster sync delays… bugs hunted me down relentlessly. But the moment two tenants’ devices came online simultaneously in the test environment—without a single byte leaking between them—sent a jolt through me sharper than dousing my face with ice water in winter.

Right now, the entire system is in intense development and debugging phases. Performance stress tests, audit trails, tenant self-service dashboards… these puzzle pieces are snapping into place. We’ve never stood closer to an enterprise-grade Headscale. I can’t wait to share the full blueprint with you soon. This time, the open-source world might truly sprout wings strong enough to rival commercial giants.

web

尾巴甩一甩,双城生活像开了挂:我的跨城联网“耍赖”记

朋友们,你们有没有过这种抓狂时刻?上周六晚上,我正躺在B市老家的沙发上啃鸭脖,刷到A市家里电脑上存着的《猫和老鼠》4K修复版——馋得我直咽口水。要是搁五年前,我只能哀嚎着爬起来买高铁票,或者对着云盘转圈圈等下载。但现在?我掏出iPad,点开那个小狐狸头图标,十秒后,A市电脑的桌面就活蹦乱跳地出现在我眼前。延迟?不到20毫秒!汤姆追杰瑞的动画丝滑得像在客厅投影仪上播,连鸭脖的油都顾不上擦了。

说来话长,我家是“双城玩家”:工作日扎根A市(联通宽带老铁),周末窜回B市(移动宽带盟友)。以前远程办公?简直是渡劫——TeamViewer转圈圈,QQ远程卡成PPT,有次开视频会议,同事问我:“你背景音是拖拉机在耕地吗?” 我默默翻了个白眼,决定自救。

于是,我捣鼓起了自建Headscale(Tailscale的平替版,懂的都懂)。架设过程?咳,踩坑无数:路由器设置像解九连环,防火墙规则看得我眼冒金星。但一通“sudo”乱炖后,奇迹发生了——我家两台宽带,一个联通一个移动,原本像两个互掐的广场舞队(“你信号强?”“你网速快?”),居然被IPv6强行撮合成了好基友!两家都测过IPv6,通得像村口小卖部的WiFi,P2P直连?稳了!

ping

ipv6test

最绝的是出门带iPad的体验。接上Tailscale的“魔法尾巴”(对,我就爱叫它尾巴,甩一甩就能回家),它自动嗅到A市电脑,像狗狗认主似的直连。用微软远程桌面操作?流畅到离谱!上周在B市咖啡馆改方案,同事偷瞄我屏幕:“你这不像远程啊,像本地开机?” 我得意一笑:“秘密是——我给网络装了火箭推进器。” 延迟18ms,打《原神》都能躲开钟离的岩脊,更别说办公时拖拽Excel像玩贪吃蛇了。

异地生活从此开挂:

  • 上班摸鱼? 不,是高效办公!B市早高峰地铁上,我用iPad给A市电脑渲染视频,到公司直接交片,老板以为我通宵了(其实我在被窝刷剧)。
  • 休闲娱乐? 周末回B市陪爸妈,饭后窝在阳台用iPad连A市电脑打《双人成行》,老爹凑过来看:“这游戏机挺新潮啊?” ——嘿嘿,您闺女在跨城云蹦迪呢。
  • 应急救星? 有次忘带U盘,B市亲戚急要照片。我远程连A市电脑导出,5分钟搞定。亲戚惊了:“你这网,比我家猫跑得还快!”

这方案让我觉得:所谓“家”,早就不限于四面墙了。它是一串IPv6地址,一条P2P隧道,外加一只甩来甩去的“狐狸尾巴”。

ipv6test

如果你也双城奔波,别再被运营商PUA了!折腾下Tailscale+IPv6,成本低到只花你一杯奶茶钱(电费另算,哈哈)。记住啊,网络世界里,距离不是问题——只要尾巴够长,心(和电脑)永远在手边。

最后灵魂一问:下次回B市,我要不要在A市电脑挂个自动煮咖啡的脚本?尾巴一甩,咖啡香飘三百公里…算了,先让我把鸭脖吃完。

(P.S. 联通移动的哥们别找我打广告,真香警告!)

—— 一个靠“尾巴”续命的数字游民,于B市阳台啃鸭脖时手打 🦊✨

Tailscale 在加入域的 Windows 电脑上运行异常:一次典型的“进程用户不一致”问题复盘

在日常使用 Tailscale / Headscale 进行企业级组网时,很多问题并不出现在协议、NAT、DERP 或控制面,而是发生在操作系统层面的细节

本文记录一次真实遇到的问题:
同一套 Tailscale CLI 用法,在普通 Windows 电脑上一切正常,但在“已加入域(Domain Joined)”的 Windows 电脑上,却出现无法连接本地守护进程、401 Unauthorized 等异常。

问题本身并不复杂,但非常具有迷惑性,也很容易在企业环境中反复踩坑。


一、问题现象

在一台 已加入 AD 域的 Windows 电脑 上,使用 Tailscale CLI 方式运行(而非 Windows 服务模式)时,出现以下典型现象:

  • 执行 tailscale up 时提示:

    • 无法连接到本地的 tailscaled 进程
  • 执行 tailscale logout / status 时返回:

    • 401 Unauthorized
    • 提示当前 Tailscale 正在以 server / unattended mode 运行
    • 并明确指出:
      后台运行的用户 ≠ 当前 CLI 所属的用户

更让人困惑的是:

  • 两个命令窗口都是“以管理员身份运行”
  • 同样的操作,在未加入域的电脑上完全正常

这很容易让人误判为:

  • 域策略限制
  • 防病毒 / EDR 拦截
  • 防火墙或端口问题
  • Headscale 权限配置错误

但实际上,都不是。


二、核心原因:Windows 上的“进程用户” ≠ “管理员权限”

1️⃣ Windows 的一个关键事实(很多人会忽略)

在 Windows 上:

“以管理员身份运行” ≠ “使用同一个用户运行”

尤其在 域环境 中,常见的用户至少包括:

  • 本地管理员(HOSTNAME\Admin
  • 域用户(DOMAIN\Username
  • SYSTEM(系统服务)

它们之间的区别不仅是权限,还包括:

  • 不同的 用户 SID
  • 不同的 用户 Profile
  • 不同的 LocalAppData / Registry / Named Pipe / IPC 权限

2️⃣ Tailscale CLI 的工作方式(关键点)

在 Windows 上,Tailscale 的架构是:

  • 一个 后台守护进程(tailscaled)
  • 一个或多个 前台 CLI(tailscale)

两者通过 本地 IPC 通信。

重要约束:

Tailscale 的 CLI 只能与“同一用户上下文”下运行的 tailscaled 进行通信

一旦出现以下情况:

  • tailscaled 是由 用户 A 启动的
  • tailscale CLI 是由 用户 B 运行的(即便都是管理员)

那么结果就是:

  • CLI 连接不上后台
  • 或直接返回 401 Unauthorized

三、为什么“加入域”后更容易触发?

在未加入域的普通 Windows 电脑上,常见情况是:

  • 所有人都用同一个本地管理员
  • 用户切换、权限边界不明显
  • 很难无意中启动“不同用户的后台进程”

而在 域环境 中,情况会复杂很多:

  • 电脑加入域后,日常登录的是 域用户

  • 某些运维操作、脚本、历史命令,可能是用 本地管理员 执行的

  • 甚至可能:

    • 第一次启动 tailscaled 用的是本地 Admin
    • 之后日常操作用的是域账号

于是就出现了:

后台 tailscaled 被“某个用户”占用,而你已经无法用当前账号再控制它

这正是 401 报错中提示 “connection from XXX not allowed” 的真实含义。


四、为什么有时会同时看到两种错误?

不少人会遇到一种更“诡异”的组合现象:

  • tailscale up → 提示连不上后台
  • tailscale logout → 却返回 401 Unauthorized

这通常意味着:

  • 后台进程 存在,但不在当前用户上下文
  • IPC 有时能探测到,有时不能
  • CLI 对不同命令路径的校验逻辑不同

本质仍然是:用户不一致导致的控制权问题


五、正确、稳定的解决方案

✅ 方案一(推荐):显式指定 state 目录,统一为机器级状态

在域环境中,最稳妥的方式是:

让 tailscaled 的状态不依赖任何“用户 Profile”,而是固定在机器级目录

例如使用:

1
.\tailscaled.exe --statedir=C:\ProgramData\Tailscale

这能解决多个关键问题:

  • 不同用户登录不会产生多份 state
  • 不会因为切换账号而“变成新设备”
  • 减少用户 SID 差异带来的 IPC/权限问题
  • 更符合企业服务器 / 运维主机的使用习惯

这是本文场景中最终生效、且最干净的解法。


✅ 方案二:严格保证“后台与 CLI 使用同一账号”

如果你坚持用 CLI 方式运行(而非 Windows 服务):

  • 必须确保:

    • 启动 tailscaled 的账号
    • 后续执行 tailscale CLI 的账号
      完全一致

仅仅“都是管理员”是不够的。


⚠️ 不推荐但可用:频繁 kill 后台重启

在调试阶段,有人会选择:

  • 手动结束 tailscaled
  • 重新用当前用户启动

这在测试时可行,但在企业环境、自动化、长期运行场景下并不可靠。


六、经验总结

这次问题的本质不是 Tailscale 或 Headscale 的 bug,而是:

Tailscale 在 Windows 上严格遵循“进程用户边界”,而域环境放大了这一差异

记住这几条,就能避免 90% 的类似问题:

  1. Windows 域环境 ≠ 单用户桌面环境
  2. 管理员权限 ≠ 相同用户身份
  3. CLI ↔ 后台通信,强依赖用户上下文
  4. 企业/域环境下,优先使用机器级 state 目录

七、写在最后

如果你正在用 Tailscale / Headscale 构建企业级、工业级、跨地域的组网方案,
那么这类 “操作系统层面的细节” 往往比协议本身更容易成为隐性故障源。

理解它、规避它,系统就会稳定得多。

如果你后续还遇到:

  • 域环境 + unattended mode
  • 多用户登录导致设备重复
  • Windows Server 上的 Tailscale 行为差异

这些其实都属于同一类问题的不同表现

问题

自建的headscale服务,在3台设备上登入同一个账号。这三台设备有2台是 windows电脑,记作A、B,1台是 Linux边缘网关记作C。三台都登入后,在ACL设置规则让3台设备互通,但是发现一个问题,即A、B可以相互 ping 通,A、B任何一台都无法 ping 通 C。只有当ssh到C设备,在C设备发起 ping A或B 后(可以 ping 通A、B),同时 A、B就能 ping 通 C了。
在 C 上使用 tailscale netcheck 命令发现如下信息:

1
2
3
4
5
6
* UDP: true
* IPv4: yes, 115.199.109.106:49676
* IPv6: no, unavailable in OS
* MappingVariesByDestIP: true
* PortMapping: UPnP
* Nearest DERP: hw-shanghai

先说结论:
这个现象本质上是 C 在一个“比较难搞的 NAT + UPnP 环境”里,在它主动往外发一次包之前,Tailscale 没有为它准备好一个“可从公网直接打进来的端口映射”,所以 A/B 先打它时没路可走;等 C 主动 ping 一下 A 之后,通过 UPnP 成功在路由器上开了一个外部 UDP 端口,从那一刻起其它节点就能主动连到 C 了,所以你看到“C 先发一次之后就都通了”的效果。


1. 先解读一下 netcheck 的两组输出

C(嵌入式网关)上的 netcheck

1
2
3
4
5
6
* UDP: true
* IPv4: yes, 115.199.109.106:49676
* IPv6: no, unavailable in OS
* MappingVariesByDestIP: true
* PortMapping: UPnP
* Nearest DERP: hw-shanghai

关键点:

  • MappingVariesByDestIP: true
    说明 C 后面的 NAT 是 hard NAT / symmetric NAT,也就是“对不同目的地址会用不同的 (IP, port) 映射”,属于比较难穿的那种 NAT。(tailscale.com)
  • PortMapping: UPnP
    说明 tailscaled 在 C 上 成功通过 UPnP 在你家路由器上申请了一个外部端口映射(本质上就是给 41641/UDP 开了个洞),借助这个端口映射,哪怕后面依然是 hard NAT,也有机会做到“外面主动打进来”。(tailscale.com)

官方文档的解释是:

  • MappingVariesByDestIP = true → “这个设备在不同 DERP 上看到的外网 (IP,port) 不一样,基本就是 hard NAT,只靠 UDP 打洞很难搞,通常只能走 DERP 中继”。(tailscale.com)
  • PortMapping = UPnP → 设备通过 UPnP / NAT-PMP / PCP 之类协议可以在路由器上开端口,这会把它从“完全 hard NAT”拉回到“相对容易打洞的 NAT”,提高直连成功率。(tailscale.com)

A(Windows)上的 netcheck

1
2
3
4
5
* UDP: true
* IPv4: yes, 123.152.221.80:55074
* IPv6: no, but OS has support
* MappingVariesByDestIP: false
* PortMapping:
  • MappingVariesByDestIP: false → A 所在网络是 easy NAT 或无 NAT,端口映射对不同目的地址是稳定的,属于很好穿的类型。(tailscale.com)
  • PortMapping: 为空 → 说明 A 侧路由器没有用 UPnP/PCP/NAT-PMP 做额外端口映射,但因为 NAT 本身是“easy”的,所以也不需要。(tailscale.com)

所以整体形势是:

  • A / B:在一个 easy NAT / 普通家用 NAT 后面,比较好打洞。
  • C:在一个 hard NAT + 支持 UPnP 的路由器 后面,理论上可以通过 UPnP 把它变成“可被外界直接访问”的节点,但前提是 UPnP 端口映射已经真正建立

2. 为啥“一开始 A/B ping 不通 C,C 先 ping 一下 A 之后就都好了”?

结合 Tailscale 的连接流程,可以大致这样理解:(tailscale.com)

  1. 刚开始时的状态

    • C 刚上线,tailscaled 还没完全跑完所有探测 / 打洞 / UPnP 端口映射逻辑,或者 UPnP 端口映射还没建立好。

    • 从 headscale 的角度,它知道 C 的存在,但 对 C 的“可达外网端点(external endpoint)”信息还不稳定

    • 这时候 A/B 看到 C 在线了,就开始往 C 的 100.x IP 发 ICMP ping:

      • OS 把 ICMP 包扔进 tailscale0;

      • tailscaled 尝试用当前掌握的 C 的 endpoint 建立 WireGuard 通道;

      • 由于 C 这边 NAT 比较凶(MappingVariesByDestIP:true),再加上此时可能还没搞定 UPnP 端口映射/打洞,导致:

        • 直连 UDP 打洞暂时失败;
        • DERP 也还在尝试协商 / 或者路径不稳定;
      • 结果就是:从 A/B 看起来就是 “ping 不通 C”。

  2. C 主动 ping A 时发生了什么

    当你在 C 上执行 ping 100.x.x.x(A)tailscale ping A 的那一刻,会触发一堆事情:

    • C → headscale/DERP 方向会有新的 DISCO / STUN / netmap 相关流量:

      • tailscaled 再次探测外网出口,更新自己的 (公网IP, 端口) 组合;
      • 再次执行或刷新 UPnP 端口映射,在路由器上为 C 打开一个稳定端口(你 log 里那几行 UPnP reply 就是这个过程)。
    • headscale 收到 C 更新后的网络信息后,会把 最新的 endpoint/portmapping 信息 发给 A/B。

    • A/B 端的 tailscaled 收到更新后再试着连 C,此时:

      • 要么能通过 C 的 UPnP 映射端口 直接打进来;
      • 要么至少能可靠地通过 DERP 建立中继通路。
    • 于是,你看到:

      • C ping A 正常;
      • 紧接着再 A ping C 也突然开始正常了。
  3. 为什么后面重启 C / 重启 A 之后依然能互通?

    有两种常见情况都能解释你看到的“之后一直通”:

    • UPnP 映射在路由器里是持久的或长时间不失效
      很多家用路由的 UPnP 映射要么:

      • 写进配置(重启客户端不丢),
      • 要么有很长的超时时间(比如几个小时甚至更长);
        只要路由器本身没重启、没有清表,这个 外部端口 → C:41641 的映射就一直在。
        所以哪怕 C 重启、A 重新登录,新的 tailscaled 继续用这个端口,A/B 都能直接打进来。
    • 即便 UPnP 映射后来过期,硬 NAT 的行为对「这个 DERP/这个 peer」变得稳定了
      一旦 C 对某些 DERP / 目标发了足够多的流量,NAT 的分配策略趋于稳定,配合 Tailscale 的路径探测与缓存,后续的打洞就更容易成功。
      再加上 headscale/clients 会把可用路径缓存一段时间,所以“通了以后一直通”是常见现象。


3. 那个 PortMapping: UPnP 到底意味着什么?

结合文档简单翻译下:(tailscale.com)

  • 这行不是说“现在一定在用 UPnP 通信”,而是说:

    “当前设备已经确认可以通过 UPnP/PCP/NAT-PMP 之类协议在网关上申请端口映射,并且已经申请到了。”

  • 这对 C 这种 MappingVariesByDestIP: true 的 hard NAT 场景非常关键:

    • 单纯靠 UDP 打洞很难;
    • 但一旦能在路由器上开一个 固定外部端口,其它设备就可以一直往这个端口打,而不用和 NAT 的随机策略博弈。

所以你看到的流程,其实很像:

  1. C 启动时还没来得及把 UPnP 端口映射搞定,A/B 先打 → 不通。
  2. 你从 C ping 一下 A,顺便触发了端口映射创建/刷新。
  3. 从那一刻起,C 变成“背后有静态端口映射的节点”,其它节点就可以随时主动连它了。

4. 这是不是 Bug?需要怎么调优/验证?

从 Tailscale 官方的角度,这更像是 复杂 NAT 环境下的时序问题 + UPnP 行为,不算标准的“功能 Bug”,但确实有优化空间。

你可以做几组实验来印证上面的推断:

4.1 看看 A ↔ C 之间实际是直连还是走 DERP

在 A 上:

1
2
3
tailscale status
tailscale status 100.xxx.xxx.xxx # C 的 tailscale IP
tailscale ping C-Hostname # 或 tailscale ping 100.x.x.x

观察输出里有没有:

  • direct x.x.x.x:41641 → 直连
  • relay "hw-sh" / via DERP(hw-shanghai) → 走中继

文档里有样例:前几包经 DERP,再切换到 direct 是正常的握手过程。(tailscale.com)

4.2 复现一次“刚上线不通”的过程,再对比 log

下次你可以:

  1. 重启 C(或者断电再上电,让 tailscaled 完全冷启动)。

  2. 先不要从 C 发任何东西,只在 A 上:

    1
    tailscale ping --tsmp --verbose C

    TSMP ping 会绕过 OS IP 栈,直接看 Tailscale 通道本身,--verbose 会打印路径信息。(tailscale.com)

  3. 如果这时候仍然 ping 不通,再在 C 上执行一次:

    1
    tailscale ping --tsmp --verbose A
  4. 对比前后两次 tailscale ping 输出,以及 /var/log/tailscaled.log 中的 NAT 路径变化。

你应该能看到类似:

  • 先只有 via DERP(...),甚至前几次完全没 pong;
  • C ping A 之后,出现 via 123.123.123.123:41641 的 direct 路径。

4.3 试试禁用 UPnP 看行为是否更一致

在你这个 TP-Link(TL-XDR6030 易展版)路由器里,把 UPnP 功能关掉,让 C 完全处在 hard NAT + 无端口映射的环境,然后看看现象变成什么样:

  • 理论上,这时 C 往往只能通过 DERP 收发数据,PortMapping: 会为空,MappingVariesByDestIP: true 依旧。(tailscale.com)
  • 正常情况下,即便只能走 DERP,也不应该出现“完全 ping 不通,直到对方先发一次”的情况——如果还出现,就更像是 headscale 侧或你自己的 tailscale fork 里有逻辑 bug,可以重点排查。

5. 小结一句话版

之所以必须先在 C 上向 A/B 发一次包,才会出现 A/B 能 ping 通 C,是因为 C 所在的网络是 hard NAT,Tailscale 需要在 C 主动发包时,借助 UPnP 在路由器上开一个稳定的外部 UDP 端口;在这个端口映射建立之前,A/B 主动打 C 找不到可用路径,一旦映射建立并被 headscale/客户端缓存之后,后续就可以随时主动连 C 了。

很多人在 k8s 集群里习惯这样做:
前端用一个 Nginx Deployment,当成“自建 Ingress / 前端网关”,然后在 nginx.conf 里直接用 Service 名称 去反向代理后端服务。

一开始一切正常,但当你 更新后端服务 之后,诡异的问题来了:

  • Nginx 容器里 ping 这个 Service 完全没问题
  • 但通过 Nginx 访问接口,就是 502 / 连接不上
  • 重启一下 Nginx Pod,又好了……

这其实就是:Nginx 的 DNS 缓存在 k8s 场景下坑了你

下面就用一个真实案例,完整说明:

  • 问题现象
  • 背后原因(Nginx + k8s Service + DNS 的联动)
  • 两套可落地的解决方案(原生 Nginx + Tengine 动态解析),附完整配置

一、问题:后端升级后,Nginx 无法再访问 Service

场景简化一下:

  • 前端:一个 Nginx Pod,做反向代理
  • 后端:若干微服务,通过 k8s Service 暴露,如 demo-dlp-service.huanfa
  • Nginx 里直接写:
1
proxy_pass http://demo-dlp-service.huanfa:80;

现象:

  • 初次部署时,一切访问正常

  • 当你 更新后端 Deployment / 重建 Service / 变更 Endpoints

    • Nginx 容器里 ping demo-dlp-service.huanfa 仍然能 ping 通
    • 但浏览器访问前端页面时,接口变成 502/超时
    • 重启下 Nginx Pod 又恢复正常(直到下一次后端更新)

这类问题常让人误以为是:

  • k8s 网络有问题?
  • Service 没更新?
  • Pod 探针 / readiness 有坑?

实际上,非也——核心问题在于:Nginx 的 DNS 解析结果被缓存住了


二、原因:Nginx 只在“第一次”解析域名,之后就不再更新

在传统物理机 / 虚拟机环境中,后端服务器 IP 很少变,所以 Nginx 默认的 DNS 行为一直没啥问题:

  • 配置里写了 proxy_pass http://some-domain:80;
  • Nginx 在启动或第一次处理请求时做一次 DNS 解析
  • 把解析出来的 IP 缓存在内存里,之后就一直用这个 IP

但是在 k8s 环境里,Service 对应的后端 Pod IP 是会变的

  • 滚动升级 / 重启 Pod
  • HPA 扩容缩容
  • 节点故障迁移

都会导致 Service 背后的 Endpoints 变化

而如果你 只是简单地在 proxy_pass 里写了 Service 名称,又没有额外配置 DNS 相关指令,那么:

Nginx 仍然只会在“第一次”解析 Service 的域名,后面就一直用旧 IP
这就是为什么你在 Nginx 容器里 ping 没问题(系统 DNS 是新的),
但 Nginx 自己还是连旧的 Pod(甚至已经不存在的 IP)——自然就访问失败了。


三、解决方案一:在原生 Nginx 中启用动态 DNS 解析(resolver + set)

在 nginx 1.18.0 版本中测试通过。

核心思路:

  1. 告诉 Nginx:DNS 服务器是谁(比如 k8s 里的 kube-dns
  2. 将 Service 名称赋值给一个变量
  3. proxy_pass 中使用这个变量
  4. 这样 Nginx 就会在访问时按 valid=… 设定,周期性重新解析域名

完整配置如下(保持原样,不做任何修改):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40

user root;
worker_processes auto;
events {
worker_connections 1024;
}
http {
include mime.types;
default_type application/octet-stream;
access_log /var/log/nginx/nginx-access.log;
error_log /var/log/nginx/nginx-error.log;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';

sendfile on;
keepalive_timeout 65;
gzip on;
gzip_static on;
proxy_buffer_size 128k;
proxy_buffers 32 128k;
proxy_busy_buffers_size 128k;
fastcgi_buffers 8 128k;
send_timeout 60;
server {
listen 80;
server_name demo-dlp-service.huanfa;
resolver kube-dns.kube-system.svc.cluster.local valid=5s;
# resolver_timeout 3s;
set $endpoint_service demo-dlp-service.huanfa.svc.cluster.local;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
location / {
proxy_pass http://$endpoint_service:80;
}
}
}

关键配置说明

  • resolver kube-dns.kube-system.svc.cluster.local valid=5s;

    • 指定 Nginx 使用 k8s 的 DNS 服务(kube-dns)进行解析
    • valid=5s 表示解析结果最多缓存 5 秒,过期后会重新查
  • set $endpoint_service demo-dlp-service.huanfa.svc.cluster.local;

    • 把 Service 的完整域名(一定要写全 FQDN)赋值给变量 $endpoint_service
  • proxy_pass http://$endpoint_service:80;

    • 使用变量做 proxy_pass,配合上面的 resolver,就能让 Nginx 定期重新解析域名

这种方案适用场景

  • 已经在用 原生 Nginx(官方镜像等),不方便切到 Tengine
  • 服务数量不算特别多,Nginx 做简单前端网关
  • 希望配置简单、变更小,只是补上 DNS 动态解析能力

四、解决方案二:使用 Tengine 的 dynamic_resolve 功能

如果你愿意使用淘宝开源的 Tengine 3.1,可以直接用其内置的 dynamic_resolve 动态解析能力

重点注意:Service 地址一定要写完整 FQDN,例如:
imes-system-service.demowms.svc.cluster.local

下面是完整配置(保持原样,不做任何修改):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160


# Generated by nginxconfig.io
# See nginxconfig.txt for the configuration share link
user nginx;
pid /usr/share/nginx/temp/nginx.pid;
worker_processes auto;
worker_rlimit_nofile 65535;
# Load modules
include /usr/share/nginx/conf/modules-enabled/*.conf;
events {
multi_accept on;
worker_connections 65535;
}
http {
charset utf-8;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
server_tokens off;
log_not_found off;
types_hash_max_size 2048;
types_hash_bucket_size 64;
client_max_body_size 16M;
# MIME
include mime.types;
default_type application/octet-stream;
# Logging
access_log /usr/share/nginx/logs/access.log;
error_log /usr/share/nginx/logs/error.log warn;
gzip on;
gzip_disable "msie6";
gzip_comp_level 6;
gzip_min_length 1100;
gzip_buffers 16 8k;
gzip_proxied any;
gzip_types
text/plain
text/css
text/js
text/xml
text/javascript
application/javascript
application/x-javascript
application/json
application/xml
application/rss+xml
image/svg+xml/javascript;
proxy_buffer_size 128k;
proxy_buffers 32 128k;
proxy_busy_buffers_size 128k;
fastcgi_buffers 8 128k;
send_timeout 60;
resolver_timeout 5s;
resolver kube-dns.kube-system.svc.cluster.local ipv6=off;
upstream sys {
dynamic_resolve fallback=stale fail_timeout=15s;
server imes-system-service.demowms.svc.cluster.local:8150;
}
upstream ppc {
dynamic_resolve fallback=stale fail_timeout=15s;
server imes-ppc-service.demowms.svc.cluster.local:1100;
}
upstream dev {
dynamic_resolve fallback=stale fail_timeout=15s;
server imes-dev-service.demowms.svc.cluster.local:2100;
}
upstream wms {
dynamic_resolve fallback=stale fail_timeout=15s;
server imes-wms-service.demowms.svc.cluster.local:8155;
}
upstream fts {
dynamic_resolve fallback=stale fail_timeout=15s;
server imes-fts-service.demowms.svc.cluster.local:8190;
}
upstream flowable {
dynamic_resolve fallback=stale fail_timeout=15s;
server imes-flowable-service.demowms.svc.cluster.local:1587;
}
upstream po {
dynamic_resolve fallback=stale fail_timeout=15s;
server imes-po-service.demowms.svc.cluster.local:1200;
}
server {
listen 443 ssl;
server_name webServer;
ssl_certificate /home/cert/server.crt;
ssl_certificate_key /home/cert/server.key;
ssl_session_cache shared:SSL:1m;
ssl_session_timeout 5m;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
autoindex_localtime on;
client_max_body_size 1200M;
root /usr/share/nginx/html;
index index.html;

location ^~/api/ppc/ {
# resolver 10.122.37.170 valid=5s;
proxy_read_timeout 300s;
proxy_pass http://ppc/api/ppc/;
}
location ^~/api/dev/ {
# resolver 10.122.37.170 valid=5s;
proxy_read_timeout 300s;
proxy_pass http://dev/api/dev/;
}
location ^~/api/wms/ {
# resolver 10.122.37.170 valid=5s;
proxy_read_timeout 300s;
proxy_pass http://wms/api/wms/;
}
location ^~/api/sys/ {
# resolver 10.122.37.170 valid=5s;
proxy_read_timeout 300s;
proxy_pass http://sys/api/sys/;
}
location ^~/api/fts/ {
# resolver 10.122.37.170 valid=5s;
proxy_read_timeout 300s;
proxy_pass http://fts/api/fts/;
}
location ^~/static/ {
proxy_pass http://fts/static/;
}
location ^~/api/fluent/ {
proxy_read_timeout 1s;
proxy_pass http://69.139.185.178:28081/api/fluent/;
}
location ^~/api/flowable/ {
# resolver 10.122.37.170 valid=5s;
proxy_read_timeout 300s;
proxy_pass http://flowable/api/flowable/;
}
location ^~/api/im/ {
proxy_read_timeout 300s;
proxy_pass http://192.168.0.117:30010/api/im/;
}
location ^~/api/po/ {
# resolver 10.122.37.170 valid=5s;
proxy_read_timeout 300s;
proxy_pass http://po/api/po/;
}
location / {
root /usr/share/nginx/html/;
add_header Cache-Control no-store;
rewrite ^.+(?<!js|css|png|ico|jpg|jpeg|woff|ttf|map|otf)$ /index.html break;
index index.html;
}
location ~* \.(?:js|css|png|jpe?g|gif|ico)$ {
# add_header Content-Encoding gzip;
gzip_static on;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html/;
}
}
}

关键点解析

  • resolver kube-dns.kube-system.svc.cluster.local ipv6=off;

    • 同样是指定使用 k8s 的 DNS
  • upstream sys { dynamic_resolve fallback=stale fail_timeout=15s; … }
    以及其它一系列 upstream

    • dynamic_resolve:开启动态解析
    • fallback=stale:当解析失败时,继续使用旧的 IP,避免瞬时解析失败导致大面积 502
    • fail_timeout=15s:失败后多长时间内视为不可用
  • server imes-xxx-service.demowms.svc.cluster.local:端口;

    • 一定要写完整的 FQDN(*.svc.cluster.local,否则可能无法正确解析

这种方案适用场景

  • 你愿意使用 Tengine 作为前端 Web 服务器
  • 有大量后端服务 / 复杂路由,需要更强大的 upstream 能力
  • 希望在解析出问题时有更优雅的降级(例如 fallback=stale 继续使用旧 IP)

五、实践中的几个小建议

无论你选用哪种方案,建议注意以下几点:

  1. 永远使用 Service 的全限定域名(FQDN)
    比如:my-service.my-namespace.svc.cluster.local
    避免依赖 search domain,减少“在某些环境下突然解析失败”的坑。

  2. 适当调小 DNS 缓存时间

    • 原生 Nginx:valid=5s 是比较折中的设置
    • Tengine:搭配 dynamic_resolve 使用
      时间太短会增加 DNS 压力,太长又起不到动态更新的效果,一般 5–30 秒比较合适。
  3. 区分“系统 DNS 能 ping 通”和“Nginx 内部解析是否更新”

    • ping service 成功,只说明 容器的 /etc/resolv.conf + CoreDNS 正常
    • Nginx 使用的是 自己的 DNS 缓存机制,必须通过 resolver / dynamic_resolve 主动接入。
  4. 升级 / 重启策略

    • 即便有了动态解析,生产环境里还是建议对 Nginx 做滚动升级 / 定期 reload
    • 避免因为历史配置 / 旧连接导致的边角问题

六、总结

在 k8s 中使用 Nginx 作为前端服务器时,DNS 缓存问题是一个非常容易被忽略的坑

  • 表面上看是“后端升级后,Nginx 偶尔 502 / 访问失败”
  • 实际上是:Nginx 只在第一次解析域名,后面 Service 或 Pod IP 变了,它却仍然握着旧 IP 不放

本文基于实际案例,给出了两种实测可行的解决方案:

  1. 原生 Nginx:
    使用 resolver + set + 变量 proxy_pass,在 nginx 1.18.0 上验证通过

  2. Tengine:
    使用 dynamic_resolve,支持更丰富的动态解析和降级策略

如果你也在 k8s 里用 Nginx 直连 Service,建议尽快检查自己的 nginx.conf
看看是不是还停留在“写死一个 Service 名称”的阶段——
早点补上 DNS 动态解析配置,可以少踩很多坑。

0%