Runway CLI 工程化踩坑笔记

1. 这篇其实不是讲 CLI 命令

前两篇讲了 Runway 怎么把 YAML 变成 RDS,又怎么把 Terraform 模块塞进 Go server。这一篇看起来是在讲 CLI,其实更像是在讲:一个部署工具怎么把很多“不想让业务方操心”的事情藏起来。

比如本地连数据库,不让开发者配 AWS 凭证;比如 ECS 说部署完成以后,不马上报成功;比如危险操作不能靠前端弹窗确认;比如 admin console 打开 SQL 浏览器时,不要每次都重新启动一个 pgweb。

这些都不是大架构,但很影响工具好不好用。


1.1 先把我记住的点写下来

这篇我主要记住了五件事:

  1. DB tunnel 的链路是:本地 TCP 连接 -> CLI -> WebSocket -> server -> VPC 内 RDS。开发者本机不需要 AWS 凭证。
  2. WebSocket 不是普通 io.ReadWriter,双向转发要按消息帧读写,不能直接 io.Copy
  3. PostgreSQL / Redis 这种小包协议,两端都要 SetNoDelay(true)。只关一端的 Nagle 不够。
  4. ECS 的 rolloutState=COMPLETED 只能说明第一阶段过了,还要再观察一段时间,看有没有 rollback。
  5. 冷静期应该放在服务端状态里。前端倒计时只是提示,不能当安全边界。

2. DB tunnel:本地像连 localhost,实际进了 VPC

业务方本地调试时,经常只想连 dev 数据库看几行数据。普通做法要么配 AWS CLI,要么走 SSM,要么搞 bastion。

Runway 想把这个动作压成一条命令:

1
2
3
runway db tunnel -e dev
# tunnel listening on localhost:5432
psql -h localhost -p 5432 -U app_user -d my_app

开发者看到的是 localhost:5432。真正的链路长这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
psql
|
v
localhost:5432
|
v
runway CLI
|
| HTTPS + WebSocket + Bearer token
v
runway server
|
| TCP inside VPC
v
RDS:5432

CLI 在本机 net.Listen。每来一个本地连接,它就新建一个 WebSocket 连 server。server 验 token,然后在 VPC 内 net.DialTimeout 到 RDS。

中间那段不是“转发 HTTP 请求”,而是转发数据库协议的字节流。


2.1 为什么不能直接 io.Copy

server 侧的转发代码大概是这样:

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
done := make(chan struct{}, 2)

go func() {
defer func() { done <- struct{}{} }()
buf := make([]byte, 32*1024)
for {
n, err := rdsConn.Read(buf)
if n > 0 {
if werr := wsConn.WriteMessage(websocket.BinaryMessage, buf[:n]); werr != nil {
return
}
}
if err != nil {
return
}
}
}()

go func() {
defer func() { done <- struct{}{} }()
for {
_, data, err := wsConn.ReadMessage()
if err != nil {
return
}
if _, err := rdsConn.Write(data); err != nil {
return
}
}
}()

<-done
rdsConn.Close()
wsConn.Close()
<-done

这里不能直接 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
2
3
if tc, ok := localConn.(*net.TCPConn); ok {
tc.SetNoDelay(true)
}

server 端连 RDS 以后也要关:

1
2
3
if tc, ok := rdsConn.(*net.TCPConn); ok {
tc.SetNoDelay(true)
}

我以前容易把这个理解成“客户端设置一下就行”。不行。中间有两段 TCP:psql -> CLIserver -> RDS。每段都可能被 Nagle 影响。

Redis tunnel 也是同一个思路,只不过 server 到 ElastiCache 那段是 TLS:

1
2
3
4
redisConn, err := tls.DialWithDialer(dialer, "tcp", addr, tlsCfg)
if tc, ok := redisConn.NetConn().(*net.TCPConn); ok {
tc.SetNoDelay(true)
}

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
2
3
4
5
6
var (
ecsPollIntervalInitial = 5 * time.Second
ecsPollIntervalSteady = 15 * time.Second
ecsStabilityWindow = 2 * time.Minute
ecsStabilityPoll = 15 * time.Second
)

第一段等 PRIMARY deployment 进入 COMPLETED,并且 running >= desired

第二段再观察 ecsStabilityWindow。当前代码里是 2 分钟,不是原稿里写的 5 分钟。


3.1 观察的不是“健康”两个字,而是 revision 有没有倒退

代码里会记住第一阶段稳定时的 deployment id 和 task definition:

1
2
stableID = p.Id
stableTaskDef = p.TaskDefinition

进入观察期以后,它继续查 PRIMARY。如果 PRIMARY 变了,就看 task definition revision:

1
2
3
4
5
6
7
if p.Id != stableID || p.TaskDefinition != stableTaskDef {
newRev := taskDefRevision(p.TaskDefinition)
if isRollbackRevision(stableRevision, newRev) {
return "rollback", rollbackError(p, snap.events)
}
return "success", nil
}

这里有个细节:如果 revision 变小,说明 ECS 回滚到了旧版本,返回 rollback;如果 revision 相同或更大,说明可能是后面又有人发起了新部署,那当前这次在被替代前已经稳定,可以算 success。

这个判断比单纯看 COMPLETED 稳很多。


3.2 AWS API 抖动时,宁愿不报 success

观察期里如果 describe-services 经常失败,代码不会强行报成功:

1
2
3
4
5
6
7
if successfulObservations*2 < checkNum {
return "failed", fmt.Errorf(
"ECS deployment observation incomplete: only %d/%d successful checks",
successfulObservations,
checkNum,
)
}

也就是:成功观察次数不到一半,就认为“我没能力确认稳定”。

这点我觉得比阈值本身更重要。它没有把“查不到”解释成“没问题”。部署工具最怕的不是失败,最怕的是在自己看不清的时候还说成功。

server 里还有一个乐观通知:第一阶段刚稳定时先发“deployed”,不用让用户干等完整观察窗口。后面如果发现 rollback 或 failed,再补一条纠正通知。

1
2
// Optimistic notify: fire as soon as phase 1 reports stable, without
// waiting the 2-min observation window.

这个取舍也实际。大多数部署是成功的,happy path 不多发一条消息;异常时再纠正。


4. 冷静期不是确认弹窗

危险操作如果只做前端确认框,其实没什么安全性。用户可以绕过前端,也可以直接调 API。

Runway 的做法是两步:先 preview,再 confirm。

preview 阶段会判断风险,要求高风险操作必须写 reason,然后把一条 action run 存到数据库:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
cooldownSeconds := cooldownFor(req.Kind, risk)
if cooldownSeconds > 0 {
t := now.Add(time.Duration(cooldownSeconds) * time.Second)
cooldownUntil = &t
}

action := &store.ActionRun{
ID: uuid.NewString(),
Kind: req.Kind,
RiskLevel: risk,
Reason: req.Reason,
CooldownUntil: cooldownUntil,
Status: "pending_confirmation",
CreatedAt: now,
}

confirm 阶段不会相信前端说“我等过了”。它重新从 store 里读 action,再用服务端时间判断:

1
2
3
4
if action.CooldownUntil != nil && time.Now().UTC().Before(*action.CooldownUntil) {
writeJSON(w, http.StatusConflict, apiError("cooldown not finished"))
return
}

这才是冷静期。不是 UI 上一个倒计时,而是服务端状态机里的一道门。


4.1 哪些动作算高风险

现在的分类很直白:

1
2
3
4
5
6
7
8
9
10
11
12
func classifyActionRisk(kind string, request json.RawMessage) string {
switch kind {
case "destroy.app", "deploy.run", "deploy.rollback":
return "high"
case "env.set", "env.delete":
return "low"
case "sql.query":
return classifySQLRisk(req.SQL)
default:
return "low"
}
}

高风险统一冷静 20 秒:

1
2
3
4
5
6
func cooldownFor(kind, risk string) int {
if risk == "high" {
return 20
}
return 0
}

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
2
3
4
5
6
type pgwebManager struct {
mu sync.Mutex
instances map[string]*pgwebProcess
startFunc func(env, app, dbURL string, port int, prefix string) (*pgwebProcess, error)
portFunc func() (int, error)
}

key 是 "env/app"。同一个 app 第二次打开,直接复用已有进程:

1
2
3
4
if inst, ok := m.instances[key]; ok {
inst.lastUsed = time.Now()
return inst.port, nil
}

这就是够用的进程池。没有复杂调度,也没有多级缓存。

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
2
3
4
5
var pgwebURLRe = regexp.MustCompile(`://[^@]+@`)

func sanitizePgwebLog(s string) string {
return pgwebURLRe.ReplaceAllString(s, "://*@")
}

再用一个按行缓冲的 writer:

1
2
3
4
5
6
7
8
9
10
11
12
13
func (w *pgwebLogWriter) Write(p []byte) (int, error) {
w.buf = append(w.buf, p...)
for {
i := bytes.IndexByte(w.buf, '\n')
if i < 0 {
break
}
line := sanitizePgwebLog(strings.TrimRight(string(w.buf[:i]), "\r"))
log.Printf("pgweb[%s/%s:%d] %s: %s", w.env, w.app, w.port, w.stream, line)
w.buf = w.buf[i+1:]
}
return len(p), nil
}

这里不用上什么日志 SDK。进程输出可能半行半行写,所以先攒到换行,再统一脱敏和打印。


5.2 反代只能打到自己管的端口

admin console 访问 pgweb 走的是 server 反代:

1
/pgweb/{port}/...

如果这里不做限制,就可能变成 SSRF 入口。用户随便填一个本机端口,server 就帮他代理到 127.0.0.1:xxxx

所以反代前先查这个端口是不是 pgweb manager 登记过的:

1
2
3
4
if s.pgweb == nil || !s.pgweb.ownsPort(port) {
http.Error(w, "forbidden: not a managed pgweb instance", http.StatusForbidden)
return
}

这个防护很朴素,但方向对:只允许已登记端口,不去猜哪些端口危险。


5.3 readiness 为什么用 TCP,不用 HTTP

pgweb 启动后要等它 ready。第一反应可能是打 HTTP 请求,看到 200 就算 ready。

但这里不能这么做。pgweb 启动参数里有 --lock-session,它会先同步连接 DB,然后 HTTP server 才起来。DB 连接慢时,HTTP readiness 就会被 DB 延迟拖住。

源码注释写得很直:

1
2
3
// Note: we intentionally use TCP (not HTTP) here. With --lock-session,
// pgweb connects to the DB synchronously before starting its HTTP server,
// so an HTTP readiness check would hang until the DB connection succeeds.

所以代码只检查 TCP 端口有没有 bind:

1
2
3
4
5
6
conn, err := net.DialTimeout("tcp", tcpAddr, 200*time.Millisecond)
if err == nil {
conn.Close()
ready = true
break
}

这不是说 HTTP 检查不好,而是这个工具的启动行为决定了 TCP 更合适。

端口分配还有一个小竞态:

1
2
3
4
l, err := net.Listen("tcp", "127.0.0.1:0")
port := l.Addr().(*net.TCPAddr).Port
l.Close()
return port, nil

拿到空闲端口以后,马上关掉 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 稳了,不让他一秒钟点掉高风险操作,不让一个反代接口变成内网探针。

这些点单独看都不大。放在一起,就决定了这个工具是“能跑”,还是“别人愿意天天用”。