Runway CLI 工程化踩坑笔记
1. 这篇其实不是讲 CLI 命令
前两篇讲了 Runway 怎么把 YAML 变成 RDS,又怎么把 Terraform 模块塞进 Go server。这一篇看起来是在讲 CLI,其实更像是在讲:一个部署工具怎么把很多“不想让业务方操心”的事情藏起来。
比如本地连数据库,不让开发者配 AWS 凭证;比如 ECS 说部署完成以后,不马上报成功;比如危险操作不能靠前端弹窗确认;比如 admin console 打开 SQL 浏览器时,不要每次都重新启动一个 pgweb。
这些都不是大架构,但很影响工具好不好用。
1.1 先把我记住的点写下来
这篇我主要记住了五件事:
- DB tunnel 的链路是:本地 TCP 连接 -> CLI -> WebSocket -> server -> VPC 内 RDS。开发者本机不需要 AWS 凭证。
- WebSocket 不是普通
io.ReadWriter,双向转发要按消息帧读写,不能直接io.Copy。 - PostgreSQL / Redis 这种小包协议,两端都要
SetNoDelay(true)。只关一端的 Nagle 不够。 - ECS 的
rolloutState=COMPLETED只能说明第一阶段过了,还要再观察一段时间,看有没有 rollback。 - 冷静期应该放在服务端状态里。前端倒计时只是提示,不能当安全边界。
2. DB tunnel:本地像连 localhost,实际进了 VPC
业务方本地调试时,经常只想连 dev 数据库看几行数据。普通做法要么配 AWS CLI,要么走 SSM,要么搞 bastion。
Runway 想把这个动作压成一条命令:
1 | runway db tunnel -e dev |
开发者看到的是 localhost:5432。真正的链路长这样:
1 | psql |
CLI 在本机 net.Listen。每来一个本地连接,它就新建一个 WebSocket 连 server。server 验 token,然后在 VPC 内 net.DialTimeout 到 RDS。
中间那段不是“转发 HTTP 请求”,而是转发数据库协议的字节流。
2.1 为什么不能直接 io.Copy
server 侧的转发代码大概是这样:
1 | done := make(chan struct{}, 2) |
这里不能直接 io.Copy(rdsConn, wsConn),因为 WebSocket 是消息帧协议。你要用 ReadMessage / WriteMessage,不是读写一条普通 TCP stream。
done := make(chan struct{}, 2) 这个写法也值得记。
任意一侧断开,那个方向的 goroutine 先退出,往 done 里写一次。主 goroutine 收到以后,把 RDS 和 WebSocket 都关掉,另一侧正在阻塞的读写就会被打醒,然后也退出。最后第二个 <-done 是为了等两个 goroutine 都干净退出。
这比“开两个 goroutine 不管了”靠谱很多。
2.2 TCP_NODELAY 要两边都关
PostgreSQL 协议里很多包都很小。认证握手、查询命令、返回行,很多时候就是几十到几百字节。
如果 Nagle 算法开着,小包可能会被攒一会儿再发。这个“一会儿”放在普通 HTTP 里不明显,放在数据库交互里就会变成很烦的延迟。
CLI 端会关本地连接的 Nagle:
1 | if tc, ok := localConn.(*net.TCPConn); ok { |
server 端连 RDS 以后也要关:
1 | if tc, ok := rdsConn.(*net.TCPConn); ok { |
我以前容易把这个理解成“客户端设置一下就行”。不行。中间有两段 TCP:psql -> CLI 和 server -> RDS。每段都可能被 Nagle 影响。
Redis tunnel 也是同一个思路,只不过 server 到 ElastiCache 那段是 TLS:
1 | redisConn, err := tls.DialWithDialer(dialer, "tcp", addr, tlsCfg) |
server 把 TLS 终止掉,开发者本机的 redis-cli 就不用再配 --tls。
2.3 配置文件顺手做了授权
Redis tunnel 建立前,server 会检查这个 app 最近一次部署时有没有声明 redis: true。
检查点不是一张 RBAC 表,而是一个部署后写到 S3 的 sidecar 文件:
1 | runway-app-config/{env}/{app}.json |
server 读这个文件,里面有 redis: true 才允许开 tunnel。没有文件,或者文件里没开 redis,就返回 403。
这个设计挺轻。它的含义是:你不是因为“有 token”就能连 tier 级 Redis,而是因为这个 app 的配置声明过它需要 Redis。
权限没有另起一套系统,而是复用了部署产物。
3. ECS 说完成以后,还要再看一会儿
ECS 的 rolloutState=COMPLETED 很容易让人误会。
它说明 service 当前这一刻看起来达到了 desired count。但容器可能再过几十秒才 panic、OOM,或者健康检查失败,然后 ECS Circuit Breaker 把它回滚。
Runway 的 WaitForStableDeployment 分两段:
1 | var ( |
第一段等 PRIMARY deployment 进入 COMPLETED,并且 running >= desired。
第二段再观察 ecsStabilityWindow。当前代码里是 2 分钟,不是原稿里写的 5 分钟。
3.1 观察的不是“健康”两个字,而是 revision 有没有倒退
代码里会记住第一阶段稳定时的 deployment id 和 task definition:
1 | stableID = p.Id |
进入观察期以后,它继续查 PRIMARY。如果 PRIMARY 变了,就看 task definition revision:
1 | if p.Id != stableID || p.TaskDefinition != stableTaskDef { |
这里有个细节:如果 revision 变小,说明 ECS 回滚到了旧版本,返回 rollback;如果 revision 相同或更大,说明可能是后面又有人发起了新部署,那当前这次在被替代前已经稳定,可以算 success。
这个判断比单纯看 COMPLETED 稳很多。
3.2 AWS API 抖动时,宁愿不报 success
观察期里如果 describe-services 经常失败,代码不会强行报成功:
1 | if successfulObservations*2 < checkNum { |
也就是:成功观察次数不到一半,就认为“我没能力确认稳定”。
这点我觉得比阈值本身更重要。它没有把“查不到”解释成“没问题”。部署工具最怕的不是失败,最怕的是在自己看不清的时候还说成功。
server 里还有一个乐观通知:第一阶段刚稳定时先发“deployed”,不用让用户干等完整观察窗口。后面如果发现 rollback 或 failed,再补一条纠正通知。
1 | // Optimistic notify: fire as soon as phase 1 reports stable, without |
这个取舍也实际。大多数部署是成功的,happy path 不多发一条消息;异常时再纠正。
4. 冷静期不是确认弹窗
危险操作如果只做前端确认框,其实没什么安全性。用户可以绕过前端,也可以直接调 API。
Runway 的做法是两步:先 preview,再 confirm。
preview 阶段会判断风险,要求高风险操作必须写 reason,然后把一条 action run 存到数据库:
1 | cooldownSeconds := cooldownFor(req.Kind, risk) |
confirm 阶段不会相信前端说“我等过了”。它重新从 store 里读 action,再用服务端时间判断:
1 | if action.CooldownUntil != nil && time.Now().UTC().Before(*action.CooldownUntil) { |
这才是冷静期。不是 UI 上一个倒计时,而是服务端状态机里的一道门。
4.1 哪些动作算高风险
现在的分类很直白:
1 | func classifyActionRisk(kind string, request json.RawMessage) string { |
高风险统一冷静 20 秒:
1 | func cooldownFor(kind, risk string) int { |
20 秒当然不是什么完美数字,但这个数字不是重点。重点是 action id、actor、reason、before snapshot、cooldown_until 都落库了。
以后要把不同动作改成不同冷静时间,也有地方改。
5. pgweb:不要每次打开都起一个进程
admin console 里有在线 SQL 浏览器,用的是开源工具 pgweb。pgweb 是独立进程,连一个 PostgreSQL,然后暴露一个 HTTP UI。
最直观的做法是每次打开页面就启动一个 pgweb,用完关掉。问题也很明显:
- 启动进程有开销。
- pgweb 启动时还要连 DB。
- 同一个 app 打开多个 tab,会重复启动。
- 不同用户看同一个 DB,也会重复启动。
Runway 做了一个很小的 manager:
1 | type pgwebManager struct { |
key 是 "env/app"。同一个 app 第二次打开,直接复用已有进程:
1 | if inst, ok := m.instances[key]; ok { |
这就是够用的进程池。没有复杂调度,也没有多级缓存。
server 启动时还有一个回收循环:
1 | go s.pgweb.reapLoop(60*time.Second, 10*time.Minute) |
每分钟扫一次,超过 10 分钟没用就 kill。每次反代请求都会 touchPort,活跃中的 session 不会被误杀。
5.1 pgweb 日志要先脱敏
pgweb 启动时可能把完整数据库 URL 打到 stdout / stderr。这个 URL 里有用户名和密码,不能原样进 server 日志。
Runway 用一个正则先抹掉凭证:
1 | var pgwebURLRe = regexp.MustCompile(`://[^@]+@`) |
再用一个按行缓冲的 writer:
1 | func (w *pgwebLogWriter) Write(p []byte) (int, error) { |
这里不用上什么日志 SDK。进程输出可能半行半行写,所以先攒到换行,再统一脱敏和打印。
5.2 反代只能打到自己管的端口
admin console 访问 pgweb 走的是 server 反代:
1 | /pgweb/{port}/... |
如果这里不做限制,就可能变成 SSRF 入口。用户随便填一个本机端口,server 就帮他代理到 127.0.0.1:xxxx。
所以反代前先查这个端口是不是 pgweb manager 登记过的:
1 | if s.pgweb == nil || !s.pgweb.ownsPort(port) { |
这个防护很朴素,但方向对:只允许已登记端口,不去猜哪些端口危险。
5.3 readiness 为什么用 TCP,不用 HTTP
pgweb 启动后要等它 ready。第一反应可能是打 HTTP 请求,看到 200 就算 ready。
但这里不能这么做。pgweb 启动参数里有 --lock-session,它会先同步连接 DB,然后 HTTP server 才起来。DB 连接慢时,HTTP readiness 就会被 DB 延迟拖住。
源码注释写得很直:
1 | // Note: we intentionally use TCP (not HTTP) here. With --lock-session, |
所以代码只检查 TCP 端口有没有 bind:
1 | conn, err := net.DialTimeout("tcp", tcpAddr, 200*time.Millisecond) |
这不是说 HTTP 检查不好,而是这个工具的启动行为决定了 TCP 更合适。
端口分配还有一个小竞态:
1 | l, err := net.Listen("tcp", "127.0.0.1:0") |
拿到空闲端口以后,马上关掉 listener,再把端口交给 pgweb。中间有极短窗口,理论上可能被别的进程抢走。
但在一个 server 进程里,这个概率很低。这里接受它,比为了消灭这个窗口引入一大套端口租约逻辑更划算。
6. 我会怎么概括这些设计
这几个点放在一起看,其实是同一种工程取向:把真实边界放在服务端,CLI 和前端只做入口。
DB tunnel 的边界在 server,因为只有 server 在 VPC 里。冷静期的边界在数据库里的 cooldown_until,不是前端按钮。pgweb 反代的边界在 manager 登记过的端口,不是用户传进来的 URL。ECS success 的边界在观察窗口,不是第一眼看到 COMPLETED。
这类工具的难点不在命令行参数,而在这些“不要太早相信用户、不要太早相信云厂商、不要太早相信自己”的地方。
7. 合上代码以后,我会这样讲
如果给别人讲这篇,我会这样说:
Runway CLI 表面上是一组命令,背后其实是一堆服务端保护。
连数据库时,CLI 只在本地开端口,然后把 TCP 流量包进 WebSocket,交给 VPC 里的 server 去连 RDS。WebSocket 不是普通 stream,所以转发要手写两边的消息循环,还要把两边 TCP 的 Nagle 都关掉。
部署时,Terraform apply 成功以后不能马上说上线了。ECS 的
COMPLETED只是第一阶段,后面还要观察 task definition 有没有回退。观察期里如果 AWS API 多数失败,就宁愿报 failed,也不要假报 success。危险操作走 preview / confirm 两段。preview 写一条 action run,里面有 reason、before snapshot、cooldown_until。confirm 时服务端重新读这条记录,冷静期没过就拒绝。
pgweb 则是一个小型进程池:同一个 app 复用同一个 pgweb,闲置 10 分钟回收;日志先脱敏;反代只允许打到 manager 管着的端口。
能把这几段讲顺,我就基本理解这篇了。
8. 这次真正学到的
我这次最有感触的是:CLI 工具不是把命令做出来就结束了。
一个好用的内部 CLI,很多价值都在“替用户少做危险动作”。不让他配 AWS 凭证,不让他记 VPC 网络,不让他误以为 ECS 稳了,不让他一秒钟点掉高风险操作,不让一个反代接口变成内网探针。
这些点单独看都不大。放在一起,就决定了这个工具是“能跑”,还是“别人愿意天天用”。