Record / Entry
端口不冲突了,我还是不知道 3001 是谁
端口冲突是个已经被解决的问题。没被解决的是:端口号不携带身份,而目录名天生携带。
同一个仓库,五份副本
一个仓库同时开着五个 worktree,是我现在的常态:
$ git worktree list
my-blog [master]
.claude/worktrees/feat-search-index
.claude/worktrees/fix-rss-encoding
.claude/worktrees/fix-code-block-copy
.claude/worktrees/post-editor-shortcuts
.claude/worktrees/chore-bump-astro
每一个都是完整的工作副本,每一个都能独立跑起 dev server。而它们跑的是同一条命令、同一份配置、同一个默认端口。
谁先起来谁拿到默认端口,假设是 3000。第二个发现被占用,自己挪到 3001。第三个 3002。
这套自动避让机制工作得很好,从来没出过错。问题恰恰在于它工作得太好了。
半小时后我在浏览器里切回去看某个改动,地址栏的历史记录里躺着 3000、3001、3002。我想看的是 fix-rss-encoding 那份。
它是哪个?
你解决的是冲突,剩下的是身份
端口冲突是个已经被解决的问题。Vite 会自动递增,Astro 会自动递增,你也可以在配置里写死 --port 3007,或者维护一张「哪个项目用哪个端口」的分配表。这些办法都能让服务成功起来,互不打架。
然后你会发现自己在做另一件事:在脑子里维护一张从数字到项目的映射表。
这张表有几个恶劣的性质。它是运行时才确定的——3001 是谁,取决于今天早上你先起了哪一个。它不持久——关掉终端重开,映射就变了。它没有任何一处被写下来,因为它压根不是配置,是一次竞争的结果。
而 worktree 恰恰是最容易触发这件事的场景。它天生用来并行:一个 worktree 修 bug、一个写文章、一个跑长任务,同时开着才有意义。开得越多,那张表越长,你记得越差。
手写端口表能解决吗?能,代价是你得先有一张表。worktree 是随手创建、用完就删的东西,多数活不过几天。给这样一个目录去分配、登记、回收一个端口号,这件事的成本已经超过它省下的麻烦了。
真正的问题不是 3001 被谁占了,而是端口号本身不携带任何身份信息。它是一个先到先得的匿名数字,跟项目之间没有稳定关系。你没法从 3001 推出任何东西,只能记住。
身份其实早就有了
跳出来看,这些副本从一开始就有一个稳定、唯一、不需要分配的标识:
feat-search-index
fix-rss-encoding
chore-bump-astro
目录名。git 强制它们互不相同——同一个仓库不允许两个 worktree 落在同一个路径上,这是 git 的约束,不是我的自觉。它在目录被创建的那一刻就确定了,不随启动顺序变化,删掉目录它就消失。
更巧的是,Docker Compose 早就在用它了:不显式设置 COMPOSE_PROJECT_NAME 时,Compose 默认取当前目录名作为项目名。
于是整件事就剩一步:让一个反向代理按这个名字转发。
这是 dev-env-router 做的全部事情。机器上常驻一份 Traefik,每个项目的 compose 文件里不再发布任何端口到宿主机,只打几行 label。这是这个博客仓库里真实的那份:
services:
app:
build: .
networks:
- web
labels:
- traefik.enable=true
- traefik.http.routers.${COMPOSE_PROJECT_NAME}.rule=Host(`${COMPOSE_PROJECT_NAME}.localhost`)
- traefik.http.services.${COMPOSE_PROJECT_NAME}.loadbalancer.server.port=4321
# Rationale: 不发布 ports 到宿主机——所有访问都经由共享 Traefik 转发。
networks:
web:
external: true
4321 是这个项目的 dev server 在容器内部监听的端口。它出现在这里是因为 Traefik 需要知道往哪转,但它不再是我需要知道的东西——五个 worktree 里的这个数字完全一样,不会冲突,因为它们各自在自己的容器里。
访问地址是 http://fix-rss-encoding.localhost。不需要分配,不需要登记,也不需要问。目录叫什么,地址就叫什么。
这里有个细节值得说清楚:端口并没有消失,只是被固定成了浏览器默认会用的那一个。Traefik 占住 80,浏览器访问不带端口的地址时走的就是 80。如果把它换成别的端口,每次访问都得在 URL 里带上,等于把「记端口」这件事原样加了回来。
但这真的值得引入一个反向代理吗
到这里最强的反对意见是:为了不记几个数字,我要在机器上常驻一个 Traefik、绑定 80 端口、把 Docker socket 挂进容器?
这个反对是成立的,代价确实存在,而且不小。
Traefik 需要读 /var/run/docker.sock 才能自动发现容器。这本质上等价于拥有宿主机 Docker daemon 的完全控制权——能起停任意容器、挂载任意宿主机目录。只读挂载防不住这一点,这是它的工作原理决定的。所以这套东西只适合个人单用户的本机开发环境,不要搭在多人共用或存有敏感数据的机器上。
还有两条:整套配置是纯 HTTP,没有 TLS,任何需要测 HTTPS 特有行为的场景(Service Worker、Secure cookie)它覆盖不到;80 是系统级端口,以后本机装了别的想用 80 的软件,得手动协调。
值不值得,取决于并行度。只开一两个项目,记两个端口毫无负担,这套方案纯属杀鸡用牛刀。但当 worktree 成为常态——每个任务一个目录,同时开着五六个——那张脑内映射表的维护成本是随数量增长的,而这套方案的成本是一次性的:搭一次,之后每接入一个新项目就是复制一份 compose 文件。
我的分界线是:当你开始需要「查一下」才知道某个服务跑在哪时,就该换掉端口了。
agent 会绕过它
上面这些如果只是给我自己用,写进 README 就够了。做成一个带 skill 和 hook 的插件,是因为写代码的不只有我。
现在多数改动是我描述意图、agent 执行。而 agent 对「起个 dev server」这件事有极强的默认习惯:npm run dev。
这条命令本身没错,它甚至能跑起来。但它起的是宿主机上的裸进程,不经过任何容器,Traefik 完全看不见它。于是这个服务不会出现在 dashboard 里,http://fix-rss-encoding.localhost 打不开,而它自己占了一个端口——那个我刚刚才摆脱掉的、不知道是谁的数字,又回来了。
更麻烦的是它接下来会做的事:跑起来之后,它会告诉我「服务已启动,请访问 http://localhost:3001」。
一次 npm run dev 就足以把整套方案打回原形,而且是静默的——没有报错,服务确实在跑,只有路由这一层悄悄失效了。
这就是那三个 skill 存在的理由。operate 规定了启停必须走 docker compose up -d,规定了报告地址时只报域名、不报端口;onboard 规定了接入一个新项目时该改什么、什么情况下必须先问我;cleanup 处理删掉 worktree 之后留下的孤儿容器。它们不是文档,是写给 agent 的操作约束。
而这里有个我一开始没想到的问题。
skill 会触发,但不保证触发
skill 的激活方式是语义匹配:Claude Code 读 skill 的 description,判断当前任务是否匹配。
这是 best-effort 的判断,不是保证。「启动 dev server」大概率能匹配上 operate,但「跑起来看看效果」「验证一下这个改动」「帮我看下页面渲染对不对」——这些请求的落点都是启动服务,措辞却离 description 越来越远。匹配不上的那次,agent 就会退回它的默认习惯。
所以插件里还有一个 SessionStart hook,它做的事情非常笨:
function isOnboarded(cwd: string): boolean {
for (const filename of COMPOSE_FILENAMES) {
const path = join(cwd, filename);
if (!existsSync(path)) continue;
const content = readFileSync(path, "utf8");
if (/traefik\.enable=true/.test(content)) return true;
}
return false;
}
在 compose 文件里 grep 一个字符串。命中了,就往会话上下文里注入一段话:这个项目已接入路由,启停用 docker compose,报告地址用 http://<目录名>.localhost,不要报端口号。没命中就静默退出,什么也不做。
它跟 skill 的区别是确定性:hook 由事件触发,不经过任何判断,只要会话在这个目录里开始,那段约束就一定在上下文里。skill 承载完整规则,hook 保证最低限度的那条不会漏。
一个依赖语义匹配的机制,需要一个不依赖语义匹配的兜底。这不是冗余设计,是两种触发方式各自的性质决定的——你没法通过把 description 写得更好来让匹配变成 100%。
同样的道理反过来也成立:hook 只能注入一段话,它没法承载「接入新项目时如果已有 compose 文件,禁止直接覆盖,必须先问用户合并方式」这种带分支的规则。那些必须在 skill 里。
会走歪的地方
删掉 worktree 不会自动清掉容器。git worktree remove 只删目录,对应的 Compose 项目还在跑,长期不管就积累成孤儿。
而清理这件事我明确要求它永远不自动执行。cleanup skill 里写死了:列清单和执行删除不允许在同一轮回复里完成,「都删了吧」「你觉得可以就删」这类回复不算确认,必须对着具体项目名逐条确认。
之所以卡这么死,是因为那个比对本身是启发式的:靠 docker compose ls 和 git worktree list 做差集。如果某个项目在 .env 里显式设过 COMPOSE_PROJECT_NAME,它的项目名就脱离了目录名的默认派生规则,比对立刻失效——可能把还在用的项目认成孤儿,也可能让真孤儿因为撞名被漏掉。
一个会误判的清单,配上 down -v 这种连卷一起删的操作,不加人工闸门是不行的。这套方案里唯一不追求「不用管」的环节,就是删除。
还有个更隐蔽的:目录名会被 Compose 归一化。大写字母转小写,点号之类的字符被替换。所以 Feat.SearchIndex 这样的目录,实际域名跟你想的不一样。规则是「以 dashboard 里显示的实际路由为准,不要猜」——猜出来的域名打不开时,你会以为是服务没起来。
收尾
端口从来不是一个需要被使用者知道的概念。它之所以进入日常,只是因为在很长一段时间里,没有比它更省事的定位方式。
而当你开始并行开五个 worktree、并且大部分命令是 agent 替你执行时,这个默认值的代价就浮出来了:你要记一张运行时才确定的映射表,agent 则完全不知道这张表的存在,它只会报给你一个 localhost:3001。
如果只做一件事,我会从这条开始:下次 agent 告诉你「服务已启动,请访问 http://localhost:3001」时,问它一句这是哪个 worktree。
它答不上来。你多半也答不上来。