一行 YAML 的旅行:从 db: postgresql 到 DATABASE_URL
1. 一行 YAML 为什么没有变成 DATABASE_URL
我一开始把 db: postgresql 理解成一个动作:写进 runway.yaml,再跑一次 runway deploy,平台就会帮我建库,并把连接串注入到容器里。
结果不是这样。
部署成功了,容器也起来了,但应用里读 os.Getenv("DATABASE_URL"),拿到的是空字符串。这个问题最烦的地方是:它不是报错,而是“看起来都正常,只是少了一个变量”。
顺着代码查下去才发现,那行 YAML 只是被解析了,并没有参与部署请求。换句话说,它像一句写在配置里的备注:人看得懂,程序不一定理它。
这篇先不写成“Runway PaaS 机制大全”。我只记录一件事:一个应用最终拿到 DATABASE_URL,中间到底经过了哪些地方;又有哪些地方我一开始想错了。
1.1 先把结论放前面
这次我主要记住了五件事:
db: postgresql不是命令。它不会自动建库,也不会自动注入环境变量。真要建库,要跑runway db create -e dev。deploy不接收 database 配置。CLI 读到了cfg.Database,但构造请求时没有把它发给 server。- DB 凭证不是
db create主动推给应用的。部署时 server 会反过来读 DB stack 的 tfstate,找到 secret ARN,再写进 ECS task definition。 DATABASE_URL不是应用代码用 AWS SDK 取出来的,而是 ECS agent 在容器启动前注入进去的。这里用的是 task execution role,不是 task role。- 这套方案的风格很朴素:命名约定、tfstate 反查、
text/template拼 HCL、固定路径解压 Terraform module。没有复杂框架,但每个选择都有代价。
2. 误会是从 deploy 开始的
我原来的理解是:
1 | runway.yaml 里写 db: postgresql |
后来发现这条链路从第二步开始就是错的。
先看业务方写的 runway.yaml:
1 | name: my-app |
CLI 入口在 internal/cmd/deploy.go 的 runDeploy。它确实会读配置:
1 | cfg, err := config.Load("runway.yaml") |
问题出在下一步。CLI 构造 deploy 请求时,只把这些字段发给 server:
1 | resp, err := c.TriggerDeploy(client.DeployRequest{ |
cfg.Database 到这里就断了。它被解析出来,存在内存里,然后没有进入 DeployRequest。DeployRequest 这个 struct 里没有 Database 字段,server 端的 handleDeploy 也不会根据客户端传来的 database 配置做决定。
所以那行 YAML 最多只能算一份声明。它告诉读配置的人“这个应用需要 PostgreSQL”,但不会驱动自动化。真正建库走的是另一条命令:
1 | runway db create -e dev |
这是另一条链路,也是另一个 Terraform stack。deploy 和 db create 不是父子关系,更像两个互相不知道对方存在的动作。它们能接上,是因为后面部署时会按固定命名去找 DB stack 留下的状态。
正确流程应该是这样:
1 | runway db create -e dev |
这个顺序不能反。先 deploy,再 create db,那一次部署不会有 DATABASE_URL。建完库以后,还要再 deploy 一次,新的 task definition 里才会带上 secret 引用。
3. runway db create 具体做了什么
查清楚 deploy 不管数据库以后,下一步就是看 runway db create 到底做了什么。它没有什么魔法,链路很短:CLI 发请求,server 生成 Terraform 配置,Terraform 建 RDS 和 Secret。
3.1 CLI 端只是包装 HTTP 请求
入口在 internal/cmd/db.go 的 runDBCreate。CLI 做的事很少:
- 读
runway.yaml,校验cfg.Database != nil - 拿
~/.runway/token.json里的 Gitea token 当凭证 - POST 一个 JSON 到
https://runway-server/api/v1/apps/{app}/db - 流式接收 server 的日志输出,原样打印到终端
也就是说,CLI 本地没有碰 AWS,没有跑 Terraform,也没有读 state。它只是把“我要给这个 app 建数据库”这件事告诉 server。
这点很重要。Runway 的 CLI 不是一个带 AWS 权限的本地工具,而是一个远程控制器。真正有权限的是 server。
3.2 Server 端生成 Terraform 根配置
server handler 在 internal/server/db.go。核心动作是 writeServerDBTerraform:用 Go 的 text/template 写出一份 Terraform 根配置。
简化后大概长这样:
1 | const dbMainTpl = ` |
写好以后,server 在 /tmp/runway-server/{app}/{env}/db/ 里执行 terraform init 和 terraform apply。
这里有几个点我读了几遍才理解。
第一,HCL 是字符串拼出来的。 没有 CDK,没有 Pulumi,也没有 go-tf 之类的库。变量插值靠 deployconfig.HCLStringMap 包一层,用 %q 做引号保护,避免把用户输入直接塞进 HCL。
这看起来不高级,但有个好处:生成出来的 .tf 就是普通文本。哪天 Runway 出问题,运维可以把那份 .tf 拿出来手动跑 Terraform,不会被某个上层框架锁死。
第二,module source 是一个固定路径。 source = "/tmp/runway-modules/modules/rds" 不是随机临时目录。这个目录是 server 启动时从二进制里解压出来的。固定路径能复用 Terraform provider 缓存,否则每次 terraform init 都要重新下载 provider,部署会慢几十秒。
第三,shared VPC 通过 terraform_remote_state 引用。 DB stack 不是去 AWS 里按 tag 查 VPC,而是直接读 shared stack 的 state outputs。这样 shared VPC 不存在时,Terraform plan 阶段就会失败,问题暴露得更早。
3.3 RDS 模块真正留下的接口是 Secret
RDS 模块在 internal/tfmodules/modules/rds/main.tf,主要建 5 个资源:
| 资源 | 名称模式 | 作用 |
|---|---|---|
random_password.db | — | 32 字符密码,无特殊符号(避免 URL encode 头疼) |
aws_db_subnet_group.main | {prefix}-subnet-group | RDS 落到哪些子网 |
aws_db_instance.main | {prefix}-postgres | 数据库本体 |
aws_secretsmanager_secret.db_credentials | coding-infra-{env}-{app}/db-credentials | 凭证容器 |
aws_secretsmanager_secret_version.db_credentials | — | 凭证内容(JSON) |
这里真正重要的不是 RDS 本身,而是第 4 个资源:Secrets Manager secret。
它的名字是固定 pattern:
1 | coding-infra-{env}-{app}/db-credentials |
这就是 DB stack 留给 deploy 流程的接口。后面 deploy 不会问 db create “你刚才创建了什么”,而是按这个命名规则去 tfstate 里找 secret_arn。
secret 里存的 JSON:
1 | { |
url 字段就是后面注入到容器里的 DATABASE_URL。
3.4 一个我会补注释的地方:recovery_window_in_days = 0
1 | resource "aws_secretsmanager_secret" "db_credentials" { |
AWS 默认删除 secret 后会保留 7-30 天的恢复窗口。这期间同名 secret 不能重建。Runway 设成 0,意思是删除后立刻释放名字,方便开发环境反复 destroy + recreate。
这个选择不是没有代价:误删以后没法恢复。dev 环境这么做可以理解,prod 环境也这么做就应该写清楚原因。
我会补一行注释,不是因为代码错了,而是这个取舍不应该藏起来。
3.5 另一个让我警惕的地方:publicly_accessible = true
代码里 server 生成 DB terraform 时硬写:
1 | publicly_accessible = true |
这意味着 RDS endpoint 是公网可达的。真正挡住访问的是 security group:只允许来自 ecs-tasks-sg 的 5432。
我第一反应是:是不是为了本地 tunnel?后来想了一下,tunnel 走的是 server-side TCP proxy,server 本来就在 VPC 里,不需要 RDS 公网可达。
所以这里我更倾向于把 RDS 放 private subnet。现在这种做法不是马上会出事,但它把安全性压在 SG 配置上:SG 配错了,公网入口就暴露出来了。
4. deploy 是怎么把 DB 接回来的
数据库建好了,只是第一步。应用真正启动时,还要解决另一个问题:deploy 怎么知道这个 app 已经有 DB 了?
我一开始以为 db create 会把结果登记到某个地方,比如告诉应用 stack:“这个 app 的 DB secret ARN 是 X,下次部署记得注入。”后来发现不是这样。
4.1 不是主动通知,而是部署时反查
Runway 的做法更懒,也更直接:db create 做完就结束,不主动通知任何人。等下次 deploy 时,server 自己去翻 DB stack 的 tfstate。
代码在 internal/server/deploy.go 的 loadServerDeploySecretRefs,简化后就是:
1 | // 1. 读 SSM /runway/{env}/{app}/ 下的覆盖参数 |
readDBSecretFromTFState 做的事也很朴素:
- 读 S3 对象
apps/{env}/{app}/db/terraform.tfstate - 在 outputs 里找
secret_arn - 拿到 ARN 字符串
拿到 ARN 后,再用 SecretJSONKeyRef 包成 ECS 能识别的格式:
1 | func SecretJSONKeyRef(secretARN, jsonKey string) string { |
这个 :url:: 后缀很怪,但它不是 Runway 自己发明的,是 ECS 的语法:这个 secret 是 JSON,不要把整个 JSON 注入进来,只取里面的 url 字段。
4.2 这个选择好在哪,又坑在哪
这个设计的好处是少了很多协调。
db create不需要知道 ECS service 存在。deploy不需要等某个注册表同步。- DB 被删了,下次 deploy 找不到 secret,就自然不注入
DATABASE_URL。
代价也很直接。
- 第一次必须先
db create,再deploy。顺序反了,那次容器里就是没有DATABASE_URL。 - secret 如果被重建,ARN 变了,旧 task definition 还指向旧 ARN,必须再 deploy 一次。
- Terraform 自己看不见这个依赖,因为依赖发生在 Go 代码里,不在 HCL 里。
所以这不是“更高级”的设计,只是一个很明确的取舍:少做中心化登记,代价是使用者要理解顺序。
4.3 一个小优化:边 build 镜像,边查 state
deploy 里还有个我觉得挺顺手的细节:CodeBuild 构建镜像要等几分钟,查 AWS state 只要几秒,所以它们被并行跑起来。
1 | prereqCh := make(chan tfPrereqs, 1) |
这里没有上复杂并发框架,只是一个 buffered channel。两个分支、一个结果,用这个就够了。复杂度没有被“工程化”放大。
5. DATABASE_URL 不是应用自己取出来的
到这里,server 只知道一件事:DATABASE_URL 应该来自哪个 secret 的哪个字段。真正把值取出来、塞进容器里的,不是应用代码,而是 ECS agent。
这也是我一开始容易混的地方:ECS task definition 上有两个 role,看名字都像“容器权限”,但它们负责的时机完全不同。
5.1 Task Execution Role 和 Task Role 不是一回事
| Role | 谁用 | 什么时候用 | 用来干嘛 |
|---|---|---|---|
| Task Execution Role | ECS agent | 容器启动前 | 拉镜像(ECR)、写日志(CloudWatch)、解析 secrets |
| Task Role | 容器内的应用代码 | 容器启动后 | 调 AWS API(用 SDK 或 curl IMDS) |
DATABASE_URL 走的是第一种,也就是 task execution role。
也就是说,容器里的 Go 程序没有调用 AWS SDK,没有读 Secrets Manager,也不需要知道 IAM 是什么。它启动起来以后,只做一件普通到不能再普通的事:
1 | os.Getenv("DATABASE_URL") |
这点很关键。平台复杂,不等于应用代码也要复杂。Runway 把复杂度挡在容器启动前。
ecs-service 模块(internal/tfmodules/modules/ecs-service/main.tf:12-68)给 task execution role 装了哪些权限:
1 | resource "aws_iam_role" "task_execution" { |
这里的 Resource 不是 *,而是 ${var.name_prefix}/*。这个 prefix 等于 coding-infra-{env}-{app},所以这个 app 的 execution role 只能读自己的 secret,读不到别的 app。
5.2 那 Task Role 留给谁用?
1 | resource "aws_iam_role" "task" { |
Task role 是给容器内代码用的。Runway 里这个 role 故意做得很空,目前只在业务方声明 storage: true 时挂 S3 权限。
这里没有 DB 相关权限,因为应用连数据库走的是密码连接串,不是 IAM database authentication。换句话说,数据库这件事在应用眼里只是一个 URL。
这个边界挺干净:启动容器需要的权限,给 execution role;业务代码运行后真的要访问 AWS,再给 task role。不要把所有权限都塞给应用。
5.3 真正危险的是 Runway Server 的 role
还有一个容易被略过的角色:Runway Server 自己也跑在 ECS 上,它自己的 task role 才是真正有权力的。
它能调 CodeBuild,能创建 secret,能跑 Terraform,甚至能创建上面那两个 role。业务方没有 AWS 凭证,CLI 也没有 AWS 凭证,权限都集中在 server 这里。
这套安全模型的重点不是“每个开发者都少给一点权限”,而是:把特权集中到一个平台进程里,然后重点保护这个进程。
5.4 这三个 role 放在一起看
1 | ┌─────────────────────┐ |
我理解这个图以后,才真正明白为什么 task role 可以这么空:如果应用代码被打穿,攻击者拿到的是 task role,而不是能读 secret、创建资源、apply Terraform 的权限。
6. 容器启动那一刻发生了什么
把前面的东西串起来,容器启动时大概是这个顺序:
- ECS scheduler 收到 task 启动指令
- ECS agent 在 Fargate 节点上拉取镜像(用 task execution role)
- ECS agent 解析 task definition 里的
secrets数组:
1 | { |
- ECS agent 调 Secrets Manager
GetSecretValue(用 task execution role),拿到 secret 完整 JSON - 按
:url::后缀提取url字段 - 把
DATABASE_URL=postgresql://...设成容器的环境变量 - 启动容器进程
- 应用代码
os.Getenv("DATABASE_URL")拿到那个完整 URL - 应用尝试连接:
pgx.Connect(ctx, url) - 网络包从 ECS task → ecs-tasks security group → rds security group → RDS endpoint
最后一步是网络。internal/tfmodules/modules/networking/main.tf 定义了三层 security group:
| SG | 位置 | 入站规则 |
|---|---|---|
alb-sg | ALB(公网子网) | 任何人:80/443 |
ecs-tasks-sg | ECS Tasks(私有子网) | 来自 alb-sg 的全部 TCP |
rds-sg | RDS | 来自 ecs-tasks-sg 的 5432 |
1 | resource "aws_security_group" "rds" { |
这里不是写死 IP,而是 SG 引用 SG。这个选择很实际:Fargate task 每次启动 IP 都可能变,写 CIDR 很快就会失控。引用 SG,表达的是“只允许这一类任务访问 RDS”,而不是“只允许这些 IP 访问 RDS”。
7. 我会单独记下来的几个小设计
7.1 ECR tag 用 commit SHA,重复部署可以跳过 build
1 | exists, err := ECRImageExists(repoName, opts.Commit) |
internal/codebuild/codebuild.go 里会先查 ECR 有没有这个 commit 对应的镜像。同一 commit 第二次部署,流程就从“build + push + apply”缩短到只剩 “apply”。
这个优化没什么花活,前提只有一个:镜像 tag 必须稳定。commit SHA 正好满足这个条件。
7.2 Terraform 模块解压到固定路径
1 | func ExtractToTemp() (modulesDir string, cleanup func(), err error) { |
第一次看到 cleanup = noop,我以为是漏写了。后来才明白,它就是故意不删。
Terraform 的 provider 下载很慢,固定路径能复用 .terraform/ 缓存。这里“没有清理”不是偷懒,而是为了下次 terraform init 少做重复工作。
7.3 HCL 拼接至少做了引号保护
1 | type HCLStringMap map[string]string |
它没有引入 HCL AST,也没有专门的生成库,只是把外部字符串统一过 %q。这不是最完整的方案,但至少避免了把用户输入裸塞进 HCL。
7.4 ECS 部署不是看到 COMPLETED 就算结束
1 | // 简化版 |
COMPLETED 不等于真的稳了。容器可能启动后几十秒才 OOM 或 panic,然后触发 ECS 回滚。
所以它又观察了 5 分钟。这个等待很土,但有用。很多平台代码最后拼的不是“更聪明”,而是“别太早报成功”。
8. 这套设计里我不太满意的地方
1. db: postgresql 这个字段容易误导人
它被解析、被存进 struct,然后在 deploy 链路里断掉。新业务方很容易以为“我写了 YAML 就会自动建库”。这里最好做一个明确选择:要么让它真的生效,要么删掉,要么 deploy 时给提示:你写了 db: postgresql,但还没跑 runway db create。
2. recovery_window_in_days = 0 没注释
prod 环境也是 0,意味着误删后没法恢复。这个选择可以存在,但不应该没有注释。我会加成这样:
1 | # 设为 0 是为了让 destroy + recreate 能立即同名复用 secret。 |
3. publicly_accessible = true 依赖 SG 兜底
RDS endpoint 公网可达,实际入站靠 SG 限制。我的倾向是把 RDS 放 private subnet。server 的 tunnel 本来就在 VPC 内,不需要 RDS 暴露公网 endpoint。
4. Secret 轮转不自动重启 task
ECS 只在 task 启动时读一次 secret。secret 轮转以后,已经在跑的 task 仍然拿旧值,直到下次部署。规模小的时候可以手动协调,规模大了就需要 rotation 事件触发 ECS UpdateService。
这些问题都不是马上炸的 bug,但应该写在设计里。否则下一个人会重新踩一遍。
9. 合上代码以后,我会这样讲
如果让我不用代码讲给别人听,我会这样说:
Runway 是个内部部署平台。业务方在自己的代码 repo 里写一份 yaml 描述应用——叫什么、什么语言、需不需要数据库——然后敲一个命令
runway deploy,应用就跑起来了。但数据库这件事得分开来:先跑
runway db create建好库,再跑runway deploy让应用拿到数据库连接串。这俩是独立的,因为数据库的生命周期通常比应用长得多——你天天 deploy 应用,但数据库一年才动一次。建库的时候,平台会调 Terraform 在 AWS 上开一个 RDS 实例,密码随机生成,连接串存进 AWS Secrets Manager(一个加密的”凭证保险柜”)。
Deploy 的时候,平台会去 Secrets Manager 找有没有这个应用的凭证,找到了就告诉 ECS(AWS 的容器调度器):”启动容器时帮我把凭证读出来,塞成环境变量 DATABASE_URL”。ECS 在容器启动前用一个特殊的”启动权限”去读凭证、解密、注入。
容器里的应用代码什么都不知道,启动起来就
os.Getenv("DATABASE_URL")拿到一个能连数据库的字符串。完全感知不到 AWS 的存在——这正是设计的目的:让应用代码可以无痛迁出 AWS。
我自己容易卡住的地方有三个:
- 为什么不让 YAML 直接触发建库?因为应用和数据库生命周期不同。应用可以每天部署,数据库通常按年维护。
- 为什么 task role 那么空?因为应用连 DB 靠连接串,不靠 IAM 调 AWS API。
- 为什么
:url::这个后缀这么怪?因为 ECS 用它表示“取 JSON secret 里的某个字段”。
能把这三个点讲顺,这条链路基本就通了。
10. 这次真正学到的
- 看到 YAML 字段,不要先假设它会驱动行为。先搜代码,看它有没有真的参与请求、写 state、生成配置。
- 跨 stack 协作不一定要主动通知。按命名约定反查也能跑,只是要接受顺序和可见性上的代价。
- ECS 的 task execution role 和 task role 必须分清。前者是容器启动前 ECS agent 用的,后者才是应用代码运行后用的。
- 平台代码不一定要很“框架化”。
text/template、固定路径、commit SHA tag 这些方案都很朴素,但容易接管。 - 取舍要写出来。
recovery_window=0、publicly_accessible=true这种决定不是不能做,而是不能装作它们没有代价。
这篇对我最大的提醒是:配置文件里的一行字,离容器里的一行环境变量,中间可能隔着 CLI、server、Terraform state、Secrets Manager、ECS task definition、IAM role 和 security group。别凭感觉补链路,沿着代码走一遍。