一行 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 先把结论放前面

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

  1. db: postgresql 不是命令。它不会自动建库,也不会自动注入环境变量。真要建库,要跑 runway db create -e dev
  2. deploy 不接收 database 配置。CLI 读到了 cfg.Database,但构造请求时没有把它发给 server。
  3. DB 凭证不是 db create 主动推给应用的。部署时 server 会反过来读 DB stack 的 tfstate,找到 secret ARN,再写进 ECS task definition。
  4. DATABASE_URL 不是应用代码用 AWS SDK 取出来的,而是 ECS agent 在容器启动前注入进去的。这里用的是 task execution role,不是 task role。
  5. 这套方案的风格很朴素:命名约定、tfstate 反查、text/template 拼 HCL、固定路径解压 Terraform module。没有复杂框架,但每个选择都有代价。

2. 误会是从 deploy 开始的

我原来的理解是:

1
2
3
4
5
6
7
runway.yaml 里写 db: postgresql

runway deploy

平台发现我要数据库

容器里出现 DATABASE_URL

后来发现这条链路从第二步开始就是错的。

先看业务方写的 runway.yaml

1
2
3
4
5
name: my-app
runtime: go
environments:
dev: { instance: small }
db: postgresql # ← 这行

CLI 入口在 internal/cmd/deploy.gorunDeploy。它确实会读配置:

1
2
cfg, err := config.Load("runway.yaml")
// cfg.Database 现在 = DatabaseConfig{Engine: "postgresql"}

问题出在下一步。CLI 构造 deploy 请求时,只把这些字段发给 server:

1
2
3
4
5
6
7
8
resp, err := c.TriggerDeploy(client.DeployRequest{
App: cfg.Name,
Env: env,
RepoURL: repoURL,
Commit: commit,
Redis: cfg.Redis,
// ← Database 字段在 DeployRequest 里根本不存在
})

cfg.Database 到这里就断了。它被解析出来,存在内存里,然后没有进入 DeployRequestDeployRequest 这个 struct 里没有 Database 字段,server 端的 handleDeploy 也不会根据客户端传来的 database 配置做决定。

所以那行 YAML 最多只能算一份声明。它告诉读配置的人“这个应用需要 PostgreSQL”,但不会驱动自动化。真正建库走的是另一条命令:

1
runway db create -e dev

这是另一条链路,也是另一个 Terraform stack。deploydb create 不是父子关系,更像两个互相不知道对方存在的动作。它们能接上,是因为后面部署时会按固定命名去找 DB stack 留下的状态。

正确流程应该是这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
runway db create -e dev


┌────────────────┐
│ RDS instance │
│ + Secret │ ← 第一步:建好库和凭证
└────────────────┘

│ (sleep, 或者下次部署)

runway deploy -e dev


┌────────────────────┐
│ ECS task │
│ DATABASE_URL=... │ ← 第二步:拿凭证、起容器
└────────────────────┘

这个顺序不能反。先 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.gorunDBCreate。CLI 做的事很少:

  1. runway.yaml,校验 cfg.Database != nil
  2. ~/.runway/token.json 里的 Gitea token 当凭证
  3. POST 一个 JSON 到 https://runway-server/api/v1/apps/{app}/db
  4. 流式接收 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
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
const dbMainTpl = `
terraform {
backend "s3" {
bucket = "coding-infra-tfstate-{{.AccountID}}"
key = "apps/{{.Env}}/{{.App}}/db/terraform.tfstate"
dynamodb_table = "coding-infra-tfstate-lock"
}
}

data "terraform_remote_state" "env" {
backend = "s3"
config = {
bucket = "coding-infra-tfstate-{{.AccountID}}"
key = "environments/shared/terraform.tfstate"
}
}

module "db" {
source = "/tmp/runway-modules/modules/rds"
name_prefix = "coding-infra-{{.Env}}-{{.App}}"
vpc_id = data.terraform_remote_state.env.outputs.vpc_id
private_subnet_ids = data.terraform_remote_state.env.outputs.private_subnet_ids
public_subnet_ids = data.terraform_remote_state.env.outputs.public_subnet_ids
publicly_accessible = true
environment = "{{.Env}}"
...
}
`

写好以后,server 在 /tmp/runway-server/{app}/{env}/db/ 里执行 terraform initterraform 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.db32 字符密码,无特殊符号(避免 URL encode 头疼)
aws_db_subnet_group.main{prefix}-subnet-groupRDS 落到哪些子网
aws_db_instance.main{prefix}-postgres数据库本体
aws_secretsmanager_secret.db_credentialscoding-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
2
3
4
5
6
7
8
{
"username": "app_user",
"password": "<random 32 chars>",
"host": "my-app-postgres.xxx.us-west-2.rds.amazonaws.com",
"port": 5432,
"dbname": "my_app",
"url": "postgresql://app_user:xxx@host:5432/my_app?sslmode=require"
}

url 字段就是后面注入到容器里的 DATABASE_URL

3.4 一个我会补注释的地方:recovery_window_in_days = 0

1
2
3
4
resource "aws_secretsmanager_secret" "db_credentials" {
name = "${var.name_prefix}/db-credentials"
recovery_window_in_days = 0 # 立即删除,不要 7 天恢复期
}

AWS 默认删除 secret 后会保留 7-30 天的恢复窗口。这期间同名 secret 不能重建。Runway 设成 0,意思是删除后立刻释放名字,方便开发环境反复 destroy + recreate。

这个选择不是没有代价:误删以后没法恢复。dev 环境这么做可以理解,prod 环境也这么做就应该写清楚原因。

我会补一行注释,不是因为代码错了,而是这个取舍不应该藏起来。

3.5 另一个让我警惕的地方:publicly_accessible = true

代码里 server 生成 DB terraform 时硬写:

1
2
publicly_accessible = true
public_subnet_ids = data.terraform_remote_state.env.outputs.public_subnet_ids

这意味着 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.goloadServerDeploySecretRefs,简化后就是:

1
2
3
4
5
6
// 1. 读 SSM /runway/{env}/{app}/ 下的覆盖参数
// 2. 如果没人覆盖 DATABASE_URL,就去 db tfstate 找
secretARN, err := readDBSecretFromTFState(env, app)
if err == nil {
refs["DATABASE_URL"] = deployconfig.SecretJSONKeyRef(secretARN, "url")
}

readDBSecretFromTFState 做的事也很朴素:

  1. 读 S3 对象 apps/{env}/{app}/db/terraform.tfstate
  2. 在 outputs 里找 secret_arn
  3. 拿到 ARN 字符串

拿到 ARN 后,再用 SecretJSONKeyRef 包成 ECS 能识别的格式:

1
2
3
4
func SecretJSONKeyRef(secretARN, jsonKey string) string {
return fmt.Sprintf("%s:%s::", secretARN, jsonKey)
}
// 输出:arn:aws:secretsmanager:us-west-2:123:secret:coding-infra-dev-my-app/db-credentials:url::

这个 :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
2
3
4
5
6
7
prereqCh := make(chan tfPrereqs, 1)
go func() {
prereqCh <- loadTFPrereqs(...) // 包括读 db tfstate
}()

imageTag, err := codebuild.BuildImage(...) // 这里阻塞 2-5 min
pre := <-prereqCh // 此时 channel 早就有值了

这里没有上复杂并发框架,只是一个 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 RoleECS 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
resource "aws_iam_role" "task_execution" {
name = "${var.name_prefix}-exec-role"
assume_role_policy = data.aws_iam_policy_document.ecs_tasks_assume.json
}

# AWS 标准 policy:拉 ECR、写 logs
resource "aws_iam_role_policy_attachment" "task_execution_managed" {
role = aws_iam_role.task_execution.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}

# 自定义 policy:读 SSM 参数 + 读 Secrets Manager 凭证
resource "aws_iam_role_policy" "task_execution_secrets" {
policy = jsonencode({
Statement = [{
Action = ["secretsmanager:GetSecretValue"]
Resource = "arn:aws:secretsmanager:*:*:secret:${var.name_prefix}/*"
}, {
Action = ["ssm:GetParameters"]
Resource = "arn:aws:ssm:*:*:parameter/runway/${var.env}/${var.app}/*"
}, ...]
})
}

这里的 Resource 不是 *,而是 ${var.name_prefix}/*。这个 prefix 等于 coding-infra-{env}-{app},所以这个 app 的 execution role 只能读自己的 secret,读不到别的 app。

5.2 那 Task Role 留给谁用?

1
2
3
4
5
6
7
8
9
10
11
resource "aws_iam_role" "task" {
name = "${var.name_prefix}-task-role"
assume_role_policy = data.aws_iam_policy_document.ecs_tasks_assume.json
}

# 仅当 yaml 里有 storage: true 时才挂 S3 policy
resource "aws_iam_role_policy_attachment" "task_s3" {
count = var.s3_policy_arn != "" ? 1 : 0
role = aws_iam_role.task.name
policy_arn = var.s3_policy_arn
}

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
                ┌─────────────────────┐
│ Runway Server │
│ task role: │
│ - terraform apply │
│ - create secrets │
│ - put role policy │ ← 顶点:特权
└──────────┬──────────┘
│ creates
┌──────────────┴──────────────┐
▼ ▼
┌─────────────────────┐ ┌──────────────────────┐
│ Task Execution Role │ │ Task Role │
│ (ECS agent 用) │ │ (容器内代码用) │
│ - pull image │ │ - S3 (if 声明) │
│ - read secret │ │ - (基本没东西) │
│ - write logs │ │ │
└─────────────────────┘ └──────────────────────┘
│ ▲
│ injects DATABASE_URL │ 容器内
▼ │
┌─────────────────────────────────────────┐
│ Container │
│ os.Getenv("DATABASE_URL") ← 一行代码 │
└─────────────────────────────────────────┘

我理解这个图以后,才真正明白为什么 task role 可以这么空:如果应用代码被打穿,攻击者拿到的是 task role,而不是能读 secret、创建资源、apply Terraform 的权限。


6. 容器启动那一刻发生了什么

把前面的东西串起来,容器启动时大概是这个顺序:

  1. ECS scheduler 收到 task 启动指令
  2. ECS agent 在 Fargate 节点上拉取镜像(用 task execution role)
  3. ECS agent 解析 task definition 里的 secrets 数组:
1
2
3
4
5
6
{
"secrets": [{
"name": "DATABASE_URL",
"valueFrom": "arn:aws:secretsmanager:us-west-2:123:secret:coding-infra-dev-my-app/db-credentials:url::"
}]
}
  1. ECS agent 调 Secrets Manager GetSecretValue(用 task execution role),拿到 secret 完整 JSON
  2. :url:: 后缀提取 url 字段
  3. DATABASE_URL=postgresql://... 设成容器的环境变量
  4. 启动容器进程
  5. 应用代码 os.Getenv("DATABASE_URL") 拿到那个完整 URL
  6. 应用尝试连接:pgx.Connect(ctx, url)
  7. 网络包从 ECS task → ecs-tasks security group → rds security group → RDS endpoint

最后一步是网络。internal/tfmodules/modules/networking/main.tf 定义了三层 security group:

SG位置入站规则
alb-sgALB(公网子网)任何人:80/443
ecs-tasks-sgECS Tasks(私有子网)来自 alb-sg 的全部 TCP
rds-sgRDS来自 ecs-tasks-sg 的 5432
1
2
3
4
5
6
7
8
resource "aws_security_group" "rds" {
ingress {
from_port = 5432
to_port = 5432
protocol = "tcp"
security_groups = [aws_security_group.ecs_tasks.id]
}
}

这里不是写死 IP,而是 SG 引用 SG。这个选择很实际:Fargate task 每次启动 IP 都可能变,写 CIDR 很快就会失控。引用 SG,表达的是“只允许这一类任务访问 RDS”,而不是“只允许这些 IP 访问 RDS”。


7. 我会单独记下来的几个小设计

7.1 ECR tag 用 commit SHA,重复部署可以跳过 build

1
2
3
4
exists, err := ECRImageExists(repoName, opts.Commit)
if err == nil && exists {
return imageURI(repoName, opts.Commit), nil // 跳过 CodeBuild
}

internal/codebuild/codebuild.go 里会先查 ECR 有没有这个 commit 对应的镜像。同一 commit 第二次部署,流程就从“build + push + apply”缩短到只剩 “apply”。

这个优化没什么花活,前提只有一个:镜像 tag 必须稳定。commit SHA 正好满足这个条件。

7.2 Terraform 模块解压到固定路径

1
2
3
4
5
6
7
func ExtractToTemp() (modulesDir string, cleanup func(), err error) {
dir := filepath.Join(os.TempDir(), "runway-modules") // 固定路径
fs.WalkDir(modulesFS, "modules", func(path string, ...) {
os.WriteFile(filepath.Join(dir, path), data, 0644) // 覆盖写
})
return filepath.Join(dir, "modules"), func() {}, nil // cleanup = noop
}

第一次看到 cleanup = noop,我以为是漏写了。后来才明白,它就是故意不删。

Terraform 的 provider 下载很慢,固定路径能复用 .terraform/ 缓存。这里“没有清理”不是偷懒,而是为了下次 terraform init 少做重复工作。

7.3 HCL 拼接至少做了引号保护

1
2
3
4
5
6
7
8
9
type HCLStringMap map[string]string

func (m HCLStringMap) String() string {
var b strings.Builder
for k, v := range m {
fmt.Fprintf(&b, "%q = %q\n", k, v) // %q 自动加引号 + escape
}
return b.String()
}

它没有引入 HCL AST,也没有专门的生成库,只是把外部字符串统一过 %q。这不是最完整的方案,但至少避免了把用户输入裸塞进 HCL。

7.4 ECS 部署不是看到 COMPLETED 就算结束

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 简化版
func WaitForStableDeployment(ctx, cluster, service) error {
// Phase 1: 等 rolloutState == COMPLETED
waitForCompleted(ctx)

// Phase 2: 再观察 5 分钟,看会不会被回滚
until := time.Now().Add(5 * time.Minute)
for time.Now().Before(until) {
if currentTaskDef != originalTaskDef {
return errors.New("rollback detected")
}
time.Sleep(15 * time.Second)
}
return nil
}

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
2
3
# 设为 0 是为了让 destroy + recreate 能立即同名复用 secret。
# 注意 prod 环境误删无法恢复——通过 IAM 限制 destroy 操作来兜底。
recovery_window_in_days = 0

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. 这次真正学到的

  1. 看到 YAML 字段,不要先假设它会驱动行为。先搜代码,看它有没有真的参与请求、写 state、生成配置。
  2. 跨 stack 协作不一定要主动通知。按命名约定反查也能跑,只是要接受顺序和可见性上的代价。
  3. ECS 的 task execution role 和 task role 必须分清。前者是容器启动前 ECS agent 用的,后者才是应用代码运行后用的。
  4. 平台代码不一定要很“框架化”。text/template、固定路径、commit SHA tag 这些方案都很朴素,但容易接管。
  5. 取舍要写出来。recovery_window=0publicly_accessible=true 这种决定不是不能做,而是不能装作它们没有代价。

这篇对我最大的提醒是:配置文件里的一行字,离容器里的一行环境变量,中间可能隔着 CLI、server、Terraform state、Secrets Manager、ECS task definition、IAM role 和 security group。别凭感觉补链路,沿着代码走一遍。