把 Terraform 当库用:Embedded IaC 的工程内幕

1. 为什么我说 Runway 是把 Terraform 当库用

我以前对 Terraform 的默认理解很简单:业务仓库里放 .tf 文件,CI 跑 terraform plan,合并后再跑 terraform apply。Atlantis、Terraform Cloud、Spacelift 大多都是这个路子。

Runway 不是这么做的。

业务方只写 runway.yaml。Terraform 模块跟 Go server 一起被编进二进制里。部署时,server 解压模块、拼出一份临时的根 .tf,再启动一个 Terraform 子进程去 apply

所以这里说“把 Terraform 当库用”,不是说 Go 代码里真的 import terraform。它的意思是:Terraform 仍然是执行引擎,但模块版本、调用时机、输入生成、日志输出,都被平台代码接管了。

这篇记录我读这套设计时真正卡住的几个点:模块怎么塞进二进制,为什么解压目录不能随机,为什么 HCL 只是字符串模板,跨 stack 引用为什么一会儿走 Terraform、一会儿走 Go 代码。


1.1 先把结论放前面

这套 embedded Terraform 方案,我主要记住了五件事:

  1. go:embed all:modules 可以把 Terraform module 树直接打进 Go 二进制。
  2. 模块解压目录必须固定。随机临时目录会让 Terraform provider 缓存失效,每次 deploy 都重新下载 provider。
  3. terraform/modules 是 symlink,指向 internal/tfmodules/modules。一份模块源码同时服务 server embed 和运维手动 apply。
  4. HCL 是 text/template 拼出来的,不是 CDK / Pulumi / go-tf。好处是生成物还是普通 .tf,出问题时可以直接拿来接管。
  5. embedded 模式最大的取舍是:模块版本跟 server 二进制绑定。避免业务仓库各用各的模块版本,但平台升级一次可能影响所有应用。

2. 模块是怎么进二进制的

入口代码很短,在 internal/tfmodules/tfmodules.go

1
2
//go:embed all:modules
var modulesFS embed.FS

embed.FS 会在编译时把 modules/ 目录打进二进制。all: 这个前缀不能省,因为 Terraform 目录里可能有 .terraform.lock.hcl 这类点文件;不用 all:,这类文件会被默认忽略。

模块目录大概是这样:

1
2
3
4
5
6
7
modules/
├── ecs-service/ # 应用的 ECS Service + Target Group + IAM
├── rds/ # PostgreSQL 实例 + Secret
├── redis-cluster/ # ElastiCache 集群(tier 级)
├── networking/ # VPC + 三层 SG
├── storage/ # S3 共享 bucket + CloudFront
└── s3-access/ # S3 prefix IAM policy

这些 HCL 加起来并不大,塞进一个 30MB 左右的 Go 二进制里,体积不是问题。


3. 为什么解压目录不能随机

Terraform CLI 看不到 embed.FS 里的文件。server 启动后必须先把模块解压到磁盘,再在生成的 .tf 里写:

1
2
3
module "db" {
source = "/tmp/runway-modules/modules/rds"
}

我第一反应是用随机临时目录:os.MkdirTemp,解压进去,用完删掉。Runway 没这么做。

它用了固定路径:

1
2
3
4
5
6
7
8
9
10
11
12
13
func ExtractToTemp() (modulesDir string, cleanup func(), err error) {
dir := filepath.Join(os.TempDir(), "runway-modules") // ← 固定路径
if err := os.MkdirAll(dir, 0755); err != nil {
return "", nil, err
}

err = fs.WalkDir(modulesFS, "modules", func(path string, d fs.DirEntry, err error) error {
// ...
return os.WriteFile(dest, data, 0644) // 覆盖写
})

return filepath.Join(dir, "modules"), func() {}, nil // ← cleanup 是 noop
}

/tmp/runway-modules/ 是固定的,cleanup 也是空函数。每次启动都是覆盖写,不主动删除。

这个选择看着怪,但原因很实际:Terraform provider 下载慢。

第一次 terraform init 会下载 AWS provider,几十 MB 很常见。只要工作目录稳定,.terraform/ 缓存能复用。下次 init 看到 provider 和 lock 文件匹配,就不需要重新下载。

如果每次都用随机目录,工作目录就变了,缓存也没了。用户每次部署都多等几十秒。

所以“不清理”不是偷懒,是为了缓存。

代码里还有一个计时日志:

1
2
3
t0Init := time.Now()
if err := tf.Init(); err != nil { return err }
fmt.Fprintf(w, "[TIMING] terraform init: %s\n", time.Since(t0Init).Round(time.Second))

冷启动和热启动差异会直接打到部署日志里。这里我会更希望代码旁边也有注释,但至少日志能让人看到这个设计的效果。


我读到这里时有个疑问:为什么看起来有两份模块?

1
2
internal/tfmodules/modules/ecs-service/main.tf
terraform/modules/ecs-service/main.tf

如果真是两份,那一定会漂移。后来 ls -la terraform/modules/ 才看到:

1
lrwxr-xr-x  modules -> ../internal/tfmodules/modules

terraform/modules 只是一个 symlink,真正维护点只有 internal/tfmodules/modules

为什么要这么绕一下?因为同一份模块要服务两类人:

  • Runway server:编译时从 internal/tfmodules/modules embed 进去,运行时解压使用。
  • 运维 break-glass:需要绕开 server,直接在仓库里 terraform apply

运维在 terraform/environments/shared 下面跑 Terraform 时,HCL 里可以写 source = "../../modules/ecs-service"。这个相对路径经过 symlink,最后还是指到 internal/tfmodules/modules/ecs-service

这比 Makefile 同步、构建时复制都简单。只要记住:模块只改 internal/tfmodules/modules


5. HCL 不是生成 AST,而是模板字符串

业务方写的是 YAML,Terraform 要的是 HCL。Runway 中间没有上 CDK、Pulumi,也没有专门的 HCL 生成库。

它就是用 Go 的 text/template 拼字符串。internal/server/deploy.go 里的 writeServerTerraform 大概这样:

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
36
37
38
39
40
41
42
const mainTpl = `
terraform {
backend "s3" {
bucket = "{{.StateBucket}}"
key = "apps/{{.Env}}/{{.App}}/terraform.tfstate"
dynamodb_table = "coding-infra-tfstate-lock"
encrypt = true
}
}

data "terraform_remote_state" "env" {
backend = "s3"
config = {
bucket = "{{.StateBucket}}"
key = "environments/shared/terraform.tfstate"
region = "{{.Region}}"
}
}

module "app" {
source = "{{.ECSModulePath}}"

app_name = "{{.App}}"
env = "{{.Env}}"
image_uri = "{{.ImageURI}}"
environment_variables = {{.EnvironmentVariables}}
secrets = {{.Secrets}}

vpc_id = data.terraform_remote_state.env.outputs.vpc_id
private_subnet_ids = data.terraform_remote_state.env.outputs.private_subnet_ids
ecs_cluster_arn = data.terraform_remote_state.env.outputs.cluster_arn
alb_listener_arn = data.terraform_remote_state.env.outputs.alb_listener_arn
}
`

func writeServerTerraform(workDir string, vars tmplVars) error {
tmpl := template.Must(template.New("main").Parse(mainTpl))
f, err := os.Create(filepath.Join(workDir, "main.tf"))
if err != nil { return err }
defer f.Close()
return tmpl.Execute(f, vars)
}

变量通过 tmplVars 传入,模板执行后写成 main.tf。这方案不酷,但好处很直接:

  1. 生成物是普通 .tf,出问题时可以打开看,也可以手动 terraform apply
  2. 读代码的人只要懂 Go template 和 HCL,不需要再学一层 IaC SDK。
  3. 调试时 dump 出生成的 HCL,直接看文本就行。

代价也明显:没有编译期类型检查。比如 ECSModulePath 是空字符串,Go 编译不会报错,Terraform init 时才会炸。

外部字符串进 HCL 时,它至少做了一层统一转义:

internal/deployconfig/deployconfig.go 里有个 HCLStringMap

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
type HCLStringMap map[string]string

func (m HCLStringMap) String() string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys) // ← 排序!

var b strings.Builder
b.WriteString("{\n")
for _, k := range keys {
fmt.Fprintf(&b, " %q = %q\n", k, m[k]) // ← %q 自动加引号 + escape
}
b.WriteString("}")
return b.String()
}

这里有两个小点值得记:

  • %q 会加引号并转义双引号、反斜杠,避免把用户输入裸塞进 HCL。
  • sort.Strings(keys) 保证输出稳定。Go map 遍历顺序随机,不排序的话,每次生成的 HCL 都可能变,Terraform diff 会很吵。

第二点很容易被忽略,但线上很烦:明明配置没变,plan 却一直说有 diff。


6. 跨 stack 引用为什么有两种写法

Runway 的 state 不是一个大文件,而是按用途拆开:

1
2
3
4
5
6
S3 bucket: coding-infra-tfstate-{accountID}
├── environments/shared/terraform.tfstate # VPC + ALB + ECS
├── apps/dev/my-app/terraform.tfstate # app 的 ECS Service
├── apps/dev/my-app/db/terraform.tfstate # app 的 RDS
├── apps/dev/my-app/storage/terraform.tfstate # app 的 S3 IAM
└── services/redis-nonprod/terraform.tfstate # tier 级 Redis

部署一个 app 时,不可能只看自己的 state。ECS service 要知道 VPC、subnet、cluster、ALB listener。应用如果声明了数据库,还要拿到 RDS 那边输出的 secret ARN。

我一开始以为跨 stack 引用应该统一写法。后来发现它故意分了两条路:必须存在的引用交给 Terraform,可有可无的引用先让 Go 代码看一眼。

6.1 必须存在的引用,交给 Terraform

shared 环境里的东西就是必须存在的。没有 VPC、没有 ECS cluster,app 部署本来就不应该继续。

这种情况直接写在 HCL 里:

1
2
3
4
5
6
7
8
9
10
11
12
data "terraform_remote_state" "env" {
backend = "s3"
config = {
bucket = "coding-infra-tfstate-..."
key = "environments/shared/terraform.tfstate"
}
}

module "app" {
vpc_id = data.terraform_remote_state.env.outputs.vpc_id
ecs_cluster_arn = data.terraform_remote_state.env.outputs.cluster_arn
}

terraform_remote_state 会在 plan / apply 时读 S3 里的 state,再把 outputs 注入当前 stack。

这里我觉得写在 HCL 里更合适。shared state 不存在,Terraform 当场报错。这不是坏事,它反而把“app 必须部署在 shared VPC 里”这条规则钉住了。

6.2 可选引用,先让 Go 看一眼 state

DB secret 就不一样。不是每个 app 都有数据库。没有数据库时,runway deploy 不应该因为找不到 apps/dev/my-app/db/terraform.tfstate 就失败。

Runway 在 Go 层直接读 tfstate JSON:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// internal/tfstate/tfstate.go
func OutputValue(data []byte, name string) (string, error) {
var state struct {
Outputs map[string]struct {
Value json.RawMessage `json:"value"`
} `json:"outputs"`
}
if err := json.Unmarshal(data, &state); err != nil {
return "", err
}
output, ok := state.Outputs[name]
if !ok {
return "", fmt.Errorf("output %q not found", name)
}
var value string
if err := json.Unmarshal(output.Value, &value); err != nil {
return "", err
}
return value, nil
}

调用时也很直白:

1
2
3
4
5
6
7
// internal/server/deploy.go
dbStateKey := fmt.Sprintf("apps/%s/%s/db/terraform.tfstate", env, app)
data, exists, err := readOptionalTerraformState(awsCli, stateBucket, dbStateKey)
if exists {
secretARN, _ := tfstate.OutputValue(data, "secret_arn")
secretRefs["DATABASE_URL"] = deployconfig.SecretJSONKeyRef(secretARN, "url")
}

这段代码做的事情是:先去 S3 找 db stack 的 state。找不到就跳过。找到了,就取 secret_arn,拼成 ECS secret 支持的格式,最后塞进生成的 HCL。

如果这里也用 terraform_remote_state,Terraform 会把 db stack 当成硬依赖。那就变成:即使这个应用没声明数据库,plan 阶段也可能因为 db state 不存在而失败。这个行为不符合产品语义。

所以这不是“Terraform 写法”和“Go 写法”谁更好看的问题。区别只有一个:这个值缺失时,deploy 应不应该失败。

6.3 我会怎么选

我后来把规则压成这个表:

场景写法
不存在就应该失败terraform_remote_state
不存在就应该跳过Go 读 tfstate
只在模块里用terraform_remote_state
需要先参与 Go 层判断Go 读 tfstate

这个点挺有意思。embedded 模式多了一步“跑 Terraform 之前,平台代码可以先做判断”。如果是普通业务仓库里写死 .tf,这个空间就小很多。


7. backend 本身怎么创建

Terraform 的 S3 backend 也要有人创建。S3 bucket 和 DynamoDB lock table 没有出来之前,其他 stack 没地方放 state。

Runway 用 terraform/bootstrap/main.tf 做这件事。这个 stack 不配置 backend,直接用 Terraform 默认的本地 terraform.tfstate

这听起来有点不优雅,但其实很正常。backend 是后面所有 stack 的地基,它自己不能再依赖这个地基。

bootstrap 跑完以后,其他 stack 才开始指向这个 S3 bucket:

1
2
3
4
apps/{env}/{app}/terraform.tfstate
apps/{env}/{app}/db/terraform.tfstate
environments/shared/terraform.tfstate
services/redis-{tier}/terraform.tfstate

DynamoDB lock table 也在这一步建出来。每次 apply 时,Terraform 会按 {bucket}/{key} 去占锁。代码里统一用了 -lock-timeout=90s,所以同一个 state 最多等 90 秒。

这个数字我不会过度解读。对小团队够用。等到同一批 app 经常并发 deploy,再回来看它就行。


8. Terraform 还是子进程,不是真正的 Go 库

internal/runner/terraform.go 封装得很薄:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func (t *Terraform) Init() error {
return t.run("init", "-lock-timeout=90s", "-input=false", "-no-color")
}

func (t *Terraform) Apply(autoApprove bool) error {
args := []string{"apply", "-lock-timeout=90s", "-input=false", "-no-color"}
if autoApprove {
args = append(args, "-auto-approve")
}
return t.run(args...)
}

func (t *Terraform) run(args ...string) error {
cmd := exec.Command("terraform", append([]string{"-chdir=" + t.workDir}, args...)...)
cmd.Stdout = t.writer
cmd.Stderr = t.writer
return cmd.Run()
}

这里没有魔法。Go 代码只是启动 terraform 命令。

几个参数值得记一下:

  • -chdir=<workDir>:不需要在 Go 进程里 cd。server 里并发跑多个部署时,改全局 cwd 很危险。
  • -lock-timeout=90s:init、apply、destroy 都用同一个等待时间。
  • -input=false:server 进程不能等 Terraform 在 stdin 里问问题。
  • -no-color:日志会进 CloudWatch 和 SSE,ANSI 颜色码只会添乱。

stdout 和 stderr 都接到同一个 writer。前端和 CLI 看到的实时日志,就是这个 writer 一行行推出来的。

这段代码也有一个明显的省略:没有 context.WithTimeout,没有 kill。

1
return cmd.Run()

Terraform 如果卡在 AWS API 上,这个 goroutine 就会一直等。按更严格的平台标准看,这是要补的。但放在当时的规模里,我能理解为什么先不做:应用数不多,部署频率不高,真卡住了可以人工进容器杀进程。

我会把它记成一个边界:小规模下可以接受,规模上去以后就不能再假装没事。


9. 几个我会记下来的细节

9.1 ALB priority 不是存表,而是算出来

ALB listener rule 要求 priority 唯一。多个 app 共享一个 ALB,如果专门建一张表来分配 priority,会多出并发分配、回滚、恢复这些问题。

Runway 用 stableHash(app + "-" + env) 算出一个固定数字,范围落在 [1000, 49000)。生成 HCL 时再用 seenPriorities 检测一次,真撞了就报错。

这个选择我挺喜欢。它没有引入一个“分配中心”,只是把冲突概率压低,再把极小概率的冲突变成显式错误。

9.2 自定义域名证书要分两次 deploy

业务方写:

1
custom_domain: api.myapp.com

Runway 可以去 ACM 申请证书。但 ACM 证书要 DNS 验证,DNS 没配好之前状态就是 PENDING_VALIDATION

如果 Terraform 第一次 apply 就把这个证书绑到 HTTPS listener 上,大概率失败。所以这里拆成两次:

  1. 第一次 deploy:创建证书,发现还没验证,不绑定 listener,只提示用户去配 CNAME。
  2. 用户配 DNS,等 ACM 变成 ISSUED
  3. 第二次 deploy:证书已经可用,再把它挂到 listener 上。

这个状态机放在 Go 层。Terraform 模块只拿到一个 certReady 参数。这样比在 Terraform 里硬等 DNS 验证更贴近真实操作流程。

9.3 prevent_destroy 必须写死

Redis 模块里有这个:

1
2
3
4
5
6
resource "aws_elasticache_replication_group" "main" {
# ...
lifecycle {
prevent_destroy = true
}
}

prevent_destroy 不能写成 var.prevent_destroy。Terraform 要求它是字面量。

这个限制一开始看起来烦,后来想想也合理。它就是故意让“删除 Redis”这件事不能被变量轻轻一改就放行。要删,必须改代码。这个摩擦是有价值的。

代价也有:没法按 tier 动态决定哪些 Redis 可以删、哪些不能删。简单粗暴,但安全。

9.4 CodeBuild 的等待时间被拿来读 prereqs

部署前要构建镜像,CodeBuild 通常要等几分钟。Runway 同时起一个 goroutine 去读 Terraform 前置状态:

1
2
3
4
prereqCh := make(chan tfPrereqs, 1)
go func() { prereqCh <- loadTFPrereqs(...) }()
imageTag, err := codebuild.BuildImage(...)
pre := <-prereqCh

这不是复杂并发,只是把本来会浪费掉的等待时间用起来。等镜像构建完,AWS 那边的前置信息也读好了。

9.5 Secret 立即删除这件事要小心

RDS secret 上用了:

1
recovery_window_in_days = 0

dev 环境里这很方便。destroy 以后马上能用同名 secret 重建,不用等 7 天。

但 prod 里这就是另一回事了。误删以后没有恢复窗口。代码里如果没有注释,后来的人很容易只看到“方便”,看不到风险。


10. embedded 模式的几个硬伤

这套设计也有明显代价。它解决了业务方不想碰 Terraform 的问题,也把一些东西藏到了平台里面。

10.1 没有 drift detection

terraform apply 是推一次就结束。有人手工改 AWS Console,下次 apply 才知道。Terraform Cloud、Spacelift 这类工具可以定时 plan,Runway 这里没有。

靠 IAM 权限和团队约束能挡一部分,但机制上确实缺一块。

10.2 没有 PR 里的 plan review

业务方改 runway.yaml 时,reviewer 在 PR 里看不到“这次会改哪些 AWS 资源”。runway deploy --dry-run 可以出 plan,但它不在 PR 流程里。

如果团队要求四眼 review Terraform plan,这里就不够。

10.3 server 是部署入口的单点

所有 deploy 都要过 server。server 挂了,大家都不能部署。多副本能缓解运行时可用性,但还有一个绕不开的问题:server 自己怎么升级?

你不能完全依赖 server 来部署 server 自己。

10.4 state lock 会让同一个 app 串行

DynamoDB lock 是必要的。不加锁会更糟。

但结果就是同一个 app 的两次 deploy 不能并发。不同 app 可以同时跑,同一个 app 必须排队。这个限制在小团队里没什么,部署量大了就会被感知到。

10.5 一些 Terraform 工具不好接

infracost、tfsec、checkov、terragrunt 这类工具都默认你有一份稳定的 .tf 文件。Runway 的根 .tf 是部署时临时生成的。

不是不能接,但要自己补集成。embedded 模式把入口收回平台以后,也顺手把生态工具的默认入口挡掉了一部分。


11. 合上代码以后,我会这样讲

如果给一个用过 Terraform、但没看过 Runway 的人讲,我会这么说:

我们没有让每个业务仓库自己维护 .tf。Terraform 模块放在平台代码里,和 Go server 一起编译进二进制。

业务方写 runway.yaml。部署时,server 把内置模块解压到固定目录,用 Go template 生成一份根 main.tf,再启动 terraform apply

固定目录不是随便选的,是为了复用 provider 缓存。HCL 用字符串模板生成,也不是因为酷,而是因为生成物还是普通 .tf,出事时能打开看,必要时也能手工接管。

跨 stack 引用分两种:shared VPC 这种必须存在的,写 terraform_remote_state;DB secret 这种可选的,Go 代码先读 tfstate,存在才注入环境变量。

这套方案最大的选择是:模块版本跟 server 二进制绑定。业务方不用管模块版本,平台团队也拿回了控制权。代价是平台每次升级模块,都可能影响所有应用。

这段能讲顺,说明我真的理解了,不只是把代码抄了一遍。


12. 这次真正学到的

我这次最大的收获不是某个 Terraform 参数,而是几个判断习惯。

第一,看到“不清理临时目录”这种代码,先别急着判死刑。它可能是在换缓存命中率。

第二,跨边界的引用不要追求统一写法。该让 Terraform 报错的,就放在 Terraform 里;该让平台先判断的,就留在 Go 里。

第三,平台代码不一定要显得高级。text/template、固定路径、子进程、hash priority,这些方案都不花哨,但读得懂,出问题也有接管办法。

第四,所有简化都有规模边界。没有 timeout 的 Terraform 子进程,在 8 个应用时可以先放过;到了 100 个应用,早晚会变成真问题。

第五,可观察性比解释更有用。[TIMING] terraform init: 47s 这种日志,能直接告诉你设计有没有效果。