国内用 Terraform 管 Cloudflare 踩过的 5 个坑(附可直接复用的代码)
样章 · 《Terraform 实战 Cloudflare Workers》
如果你只是在 CF Dashboard 点点鼠标建个 Worker,这篇文章你可以不看。但只要你踩进 Terraform + Cloudflare 的世界,哪怕是个人项目,下面这 5 个坑你大概率会全部经历一遍。
本文把每个坑拆成三段写:你看到的症状 → 真实原因 → 能直接复制走的解法 。代码全部从一个我开源的项目 terraform-cf-workers-starter 里抽出来,端到端可跑。
读完之后,你应该能少踩 80% 的坑,把精力放在业务上。
场景铺垫
先说清楚我在做什么。
一个常见需求:用 Cloudflare Workers + KV 做一批 HTTP 微服务,每个服务占一个子域名、一个 KV namespace、一个 Worker 脚本、一个自定义域绑定。业务代码比如 URL 短链、Webhook 中转、边缘 A/B 测试,都长这样。
一个服务对应的 CF 资源:
yaml
┌─────────────────────────┐
│ Cloudflare Workers │
│ cfstarter-s1 │ ← Worker 脚本
│ ├─ KV: cfstarter-s1 │ ← 运行时存储
│ ├─ binding: API_KEY │ ← 鉴权密钥
│ └─ custom domain: │
│ s1.example.com │ ← 自定义域(带 DNS 记录)
└─────────────────────────┘
五六个服务起,手点面板就痛苦了。自然想到 Terraform。
我最初的 main.tf 大概长这样(为了讲故事,这是个错误版本):
hcl
resource "cloudflare_workers_kv_namespace" "kv" {
account_id = var.cf_account_id
title = "cfstarter-${var.name}"
}
resource "cloudflare_worker_script" "worker" {
account_id = var.cf_account_id
name = "cfstarter-${var.name}"
content = file("${path.module}/../worker/url-shortener.js")
kv_namespace_binding {
name = "KV"
namespace_id = cloudflare_workers_kv_namespace.kv.id
}
plain_text_binding {
name = "API_KEY"
text = random_password.api_key.result
}
}
resource "cloudflare_worker_domain" "domain" {
account_id = var.cf_account_id
zone_id = var.zone_id
hostname = "${var.name}.${var.domain}"
service = cloudflare_worker_script.worker.name
}
这段代码看起来没问题。跑一下就知道了。
坑 1:terraform init 在国内卡到你想砸电脑
症状
bash
$ terraform init
Initializing provider plugins...
- Finding cloudflare/cloudflare versions matching "~> 4.40"...
- Installing cloudflare/cloudflare v4.52.7...
然后就停在这里。1 分钟、5 分钟、10 分钟。你会以为是网络抖动,重跑一次,还是卡。
真实原因
terraform init 要从 registry.terraform.io 下载 provider 二进制。这个域名在国内的可达性极其不稳定------不是慢,是 TLS 握手成功率本身就不高。挂代理有时候行有时候不行,看代理出口线路。
更坑的地方:Terraform 默认没有合理的超时,失败了会一直等,不会自动换线路。你盯着终端等半小时是常态。
解法:本地 filesystem_mirror
Terraform 支持从本地文件系统读 provider。机制叫 filesystem_mirror。思路是:
-
从 GitHub Release 直接下 Cloudflare provider 二进制(GitHub 在国内通畅得多,命中率 > 90%)
-
按 Terraform 要求的目录结构摆好:
registry.terraform.io/<namespace>/<name>/<version>/<os>_<arch>/ -
写一个
.terraformrc文件告诉 Terraform 去本地找
完整的 PowerShell 函数:
powershell
function Ensure-CloudflareProviderMirror {
param([string] $Root, [string] $Version = '4.52.7')
$mirror = Join-Path $Root 'provider-mirror'
$dest = Join-Path $mirror "registry.terraform.io\cloudflare\cloudflare\$Version\windows_amd64"
$marker = Join-Path $dest "terraform-provider-cloudflare_v$Version.exe"
# 幂等:已经下过就跳过
if (Test-Path $marker) { return $mirror }
if (-not (Test-Path $dest)) { New-Item -ItemType Directory -Force -Path $dest | Out-Null }
# 直接从 GitHub Release 下
$url = "https://github.com/cloudflare/terraform-provider-cloudflare/releases/download/v$Version/terraform-provider-cloudflare_${Version}_windows_amd64.zip"
$zip = Join-Path $env:TEMP "tf-cloudflare-$Version.zip"
Invoke-WebRequest $url -OutFile $zip -UseBasicParsing -TimeoutSec 300
Expand-Archive -Path $zip -DestinationPath $dest -Force
Remove-Item $zip -Force
return $mirror
}
生成 .terraformrc:
powershell
$rcBody = @"
provider_installation {
filesystem_mirror {
path = "$($mirrorPath -replace '\\','/')"
include = ["registry.terraform.io/cloudflare/*"]
}
direct {
exclude = ["registry.terraform.io/cloudflare/*"]
}
}
"@
[System.IO.File]::WriteAllText($rcPath, $rcBody, [System.Text.UTF8Encoding]::new($false))
$env:TF_CLI_CONFIG_FILE = $rcPath
关键细节(我自己踩过的):
-
.terraformrc不能带 BOM 。PowerShell 的Set-Content -Encoding utf8默认会写 BOM,Terraform 的 HCL 解析器直接报illegal char。必须显式用UTF8Encoding.new($false)。 -
path 里反斜杠要换成正斜杠。这是 HCL 字符串的坑,反斜杠会被当转义。
-
direct { exclude = [...] }不能省 。如果只写filesystem_mirror,其他 provider(比如random)就拉不到了。
做完这一步,terraform init 从 10 分钟 timeout 变成 3 秒完成。
坑 2:Worker 脚本上传到 200KB 就挂
症状
terraform apply,大多数资源都创建成功了,只有 cloudflare_worker_script 这一个卡住:
makefile
cloudflare_worker_script.worker: Still creating... [10s elapsed]
cloudflare_worker_script.worker: Still creating... [1m0s elapsed]
cloudflare_worker_script.worker: Still creating... [5m0s elapsed]
...
Error: context deadline exceeded
最神奇的是脚本越大失败率越高。你发现只要 Worker 文件超过 200KB(比如带了一些第三方库),失败率就接近 100%。
真实原因
翻 Cloudflare provider 的源码(v4.x)能看到 cloudflare_worker_script 资源对应的 API 调用:它把脚本作为单次 multipart/form-data 请求上传,没有分片、没有重试、没有细粒度超时。
这种实现在稳定网络下没问题。但只要你的出口链路存在丢包,大 multipart 请求就是个定时炸弹------TLS 长连接中途被 reset,Terraform 就卡死在 Transport.RoundTrip。
Terraform 外层倒是有 -parallelism 控制并发,但这不是并发的问题,是单个请求的底层没重试。
解法:把 Worker 上传从 Terraform 剥离出去
这是一个重要的设计决策:不要什么都交给 Terraform。
Terraform 的强项是状态收敛。你声明一个资源期望的状态,它算 diff 然后 apply。但是"可靠地把一个 200KB 的文件上传到云 API"不是状态收敛问题,是 IO 可靠性问题。Terraform 的 provider 生态在这类场景下普遍薄弱。
所以我的分工是:
| 资源类型 | 管理者 | 原因 |
|---------|--------|------|
| KV namespace | Terraform | 声明式、幂等,输出 id 给下游 |
| random_password | Terraform | state 里持久化随机值 |
| Worker script 上传 | PowerShell 脚本直调 CF API | 需要精细的超时/重试 |
| Worker custom domain | PowerShell 脚本直调 CF API | 避开 DNS 记录冲突(见坑 3) |
上传 Worker 用 System.Net.HttpWebRequest,它能控制超时到毫秒级:
powershell
function Upload-Worker {
param(
[string] $AccountId, [string] $WorkerName,
[string] $ApiKey, [string] $KvId,
[string] $ScriptBody, [hashtable] $Headers
)
# 手动构造 multipart
$boundary = [Guid]::NewGuid().ToString('N')
$metadata = @{
main_module = 'worker.js'
compatibility_date = '2024-09-01'
compatibility_flags = @('nodejs_compat')
bindings = @(
@{ type = 'kv_namespace'; name = 'KV'; namespace_id = $KvId },
@{ type = 'plain_text'; name = 'API_KEY'; text = $ApiKey }
)
} | ConvertTo-Json -Depth 10 -Compress
$LF = "`r`n"
$bodyParts = @()
$bodyParts += "--$boundary$LF"
$bodyParts += "Content-Disposition: form-data; name=`"metadata`"; filename=`"metadata.json`"$LF"
$bodyParts += "Content-Type: application/json$LF$LF"
$bodyParts += "$metadata$LF"
$bodyParts += "--$boundary$LF"
$bodyParts += "Content-Disposition: form-data; name=`"worker.js`"; filename=`"worker.js`"$LF"
$bodyParts += "Content-Type: application/javascript+module$LF$LF"
$bodyParts += "$ScriptBody$LF"
$bodyParts += "--$boundary--$LF"
$body = [System.Text.Encoding]::UTF8.GetBytes($bodyParts -join '')
$url = "https://api.cloudflare.com/client/v4/accounts/$AccountId/workers/scripts/$WorkerName"
# 3 次重试 + 固定 120 秒超时
$attempt = 0
while ($true) {
$attempt++
try {
$req = [System.Net.HttpWebRequest]::Create($url)
$req.Method = 'PUT'
$req.ContentType = "multipart/form-data; boundary=$boundary"
$req.Timeout = 120000 # 总超时
$req.ReadWriteTimeout = 120000 # 单次读写超时
foreach ($k in $Headers.Keys) { $req.Headers.Add($k, $Headers[$k]) }
$req.ContentLength = $body.Length
$stream = $req.GetRequestStream()
$stream.Write($body, 0, $body.Length)
$stream.Close()
$resp = $req.GetResponse()
$reader = New-Object System.IO.StreamReader($resp.GetResponseStream())
$respText = $reader.ReadToEnd()
$reader.Close(); $resp.Close()
$json = $respText | ConvertFrom-Json
if (-not $json.success) { throw "CF reported failure: $respText" }
return $true
} catch {
if ($attempt -ge 3) {
throw "Worker upload failed after $attempt attempts: $($_.Exception.Message)"
}
Start-Sleep -Seconds 5
}
}
}
为什么不是 Invoke-WebRequest:它的 -TimeoutSec 是粗粒度的总超时,且 multipart 构造需要手工拼 body,不如直接用 HttpWebRequest 清楚。
为什么重试能解决问题:CF 的 API 走 anycast,每次 TCP 连接可能路由到不同的边缘节点。第一次丢包了,5 秒后重连大概率走另一条线路。我实测从 v2rayN 节点出去上传,50% 的情况第一次失败,但三次内必成。
坑 3:绑定自定义域时,DNS record already exists
症状
用 cloudflare_worker_domain 绑定 s1.example.com:
vbnet
Error: failed to create worker domain:
error 1003: DNS record conflicts with an existing record
你打开 Dashboard 一看,确实有一条 CNAME s1.example.com → 100-7-...workers.dev。但你的 Terraform 里根本没有声明过这条 DNS 记录。
真实原因
这是 Cloudflare 平台侧的隐式行为:调用 Worker Custom Domains API 时,CF 会自动创建一条对应的 DNS 记录。这条记录不在你的 Terraform state 里,是 CF 后台自己建的。
当你反复 apply 的时候:
-
第一次:
cloudflare_worker_domain调用成功,CF 隐式创建 DNS 记录 A -
你手动改了 Terraform 配置,想用
cloudflare_record资源来管 DNS -
第二次 apply:Terraform 尝试创建 DNS 记录 → 和 CF 隐式创建的那条冲突 → 报 1003
这个坑本质上是 Terraform 的 resource graph 和 CF 的隐式副作用不对齐。
解法:放弃用 Terraform 管 Worker 自定义域
Worker Domain API 本身就是幂等的(PUT),而且它隐式管 DNS 这件事对 CF 来说是"设计"不是"bug",CF 不会改。那就不如不要让 Terraform 去管这一块:
powershell
$body = @{
zone_id = $zone.id
hostname = $n.hostname
service = $n.worker_name
environment = 'production'
override_existing_dns_record = $true # ← 关键参数
} | ConvertTo-Json -Compress
$url = "https://api.cloudflare.com/client/v4/accounts/$($account.id)/workers/domains"
Invoke-RestMethod -Method Put -Uri $url -Headers $headers -Body $body -ContentType 'application/json'
override_existing_dns_record=true 告诉 CF:如果有冲突的 DNS 记录(比如上次绑过遗留的),直接覆盖。
工程上的教训:当你发现某个 Terraform 资源和 cloud provider 的隐式行为"拗"起来了,别硬拗。声明式的边界就在那,越过边界就该用命令式工具。不要因为已经用了 Terraform 就所有资源都要塞进 Terraform。
坑 4:tfstate 不小心删了,生产全乱
症状
你手抖 rm terraform.tfstate,或者换电脑忘了把 state 文件带过来。重跑 terraform apply:
vbnet
Plan: 10 to add, 0 to change, 0 to destroy.
Terraform 想重新创建所有资源。但是 CF 侧其实已经有了:
vbnet
Error: error creating KV namespace: namespace with title cfstarter-s1 already exists
你盯着终端心跳加速。重跑也没用,继续报同样的错。
真实原因
Terraform 把资源的身份(CF 那边的真实 id)完全存在 tfstate 里。没有 state,它只知道你声明了什么资源,不知道这些资源在 cloud provider 那边叫什么。
官方推荐的做法是用远程 backend(S3、TF Cloud),state 存云上。但如果你就是一个个人项目,不想接 S3,或者 backend 配置本身也挂了?
解法:从 CF 反向 import
Cloudflare 是你的事实来源(source of truth)。state 丢了可以重建,只要你能:
-
检查 state 是不是空的
-
如果空,调 CF API 查当前有哪些
cfstarter-*资源 -
按名字匹配,
terraform import回 state -
再正常 apply
代码:
powershell
# 先判断 state 是否为空(或者没有我们关心的 module)
$stateEmpty = $false
$stateList = & terraform state list 2>&1
if ($LASTEXITCODE -ne 0 -or -not ($stateList | Where-Object { $_ -match 'module\.service' })) {
$stateEmpty = $true
}
if ($stateEmpty) {
Info 'state is empty, attempting to import pre-existing CF resources...'
# 查 CF 侧所有 KV namespace
$allKv = (Invoke-RestMethod "https://api.cloudflare.com/client/v4/accounts/$($account.id)/storage/kv/namespaces" -Headers $headers).result
foreach ($svc in $Services) {
$kvMatch = $allKv | Where-Object { $_.title -eq "cfstarter-$svc" } | Select-Object -First 1
if ($kvMatch) {
# 注意 PS 5.1 的转义坑:传给外部 .exe 时 " 会被吃掉,必须用 \"
$addr = 'module.service[\"' + $svc + '\"].cloudflare_workers_kv_namespace.kv'
$id = "$($account.id)/$($kvMatch.id)"
& terraform import -input=false $addr $id
}
}
}
# 现在 state 已经恢复,apply 就是 no-op 或增量更新
& terraform apply -auto-approve -input=false
PowerShell 5.1 的转义陷阱 :terraform import 需要的资源地址是 module.service["s1"].cloudflare_workers_kv_namespace.kv,带字面双引号。直接写 'module.service["s1"]...' 时,PS 5.1 在调用 .exe 前会把 " 吃掉(这是 Windows 命令行的老问题,不是 PS 的问题)。解法:预先转义成 \",让 CommandLineToArgvW 看到转义后的引号再传给 terraform。这个坑我调了整整一个下午。
进阶:为什么我不用 S3 backend
其实我推荐 用 TF Cloud 或 S3 backend。以上这段"从 CF 反导入"的逻辑是兜底而不是首选。
但是它的价值是:
-
个人项目不想每个月花 $1 在 S3 上,这个兜底足够了
-
就算你用了 TF Cloud,当 TF Cloud 自己挂了(发生过),这段兜底能让你继续工作
-
它让你意识到:cloud 本身才是真的 source of truth,state 只是快照
坑 5:重建 state 之后,所有客户端的 API_KEY 都失效了
症状
接着坑 4 的情景。你用上面的 import 脚本救回了 state,terraform apply 也成功了。你长舒一口气。
几小时后,客户端打电话来:API 全部 401 了。
你看 CF 后台,Worker 上的 API_KEY binding 已经变成了一个新值。
真实原因
Terraform 声明是这样写的:
hcl
resource "random_password" "api_key" {
length = 32
special = false
}
random_password 资源的值存在 state 里。state 丢了,Terraform 重建 state 的时候只 import 了 KV namespace,没 import random_password (random_password 没有对应的 CF 资源可以 import)。
所以 Terraform 看到 random_password.api_key 在 state 里是空的,重新生成了一个。然后通过 output 传给脚本,脚本上传到 Worker binding,把老的 API_KEY 覆盖了。
解法:主动从 CF 回读 API_KEY 到 tfvars
这是个比较微妙的设计点:让 CF 侧的现状反向"污染"你的 Terraform 输入。
在生成 terraform.tfvars 之前:
powershell
# 1. 查 CF 现有的 worker 脚本
$existingScripts = (Invoke-RestMethod "https://api.cloudflare.com/client/v4/accounts/$($account.id)/workers/scripts" -Headers $headers).result
# 2. 对每个我们关心的 worker,读它的 bindings,找到 API_KEY
$existingKey = @{}
foreach ($s in $existingScripts) {
if ($s.id -match '^cfstarter-(.+)$') {
$svc = $matches[1]
$settings = Invoke-RestMethod "https://api.cloudflare.com/client/v4/accounts/$($account.id)/workers/scripts/$($s.id)/settings" -Headers $headers
$keyBinding = $settings.result.bindings | Where-Object { $_.name -eq 'API_KEY' -and $_.type -eq 'plain_text' } | Select-Object -First 1
if ($keyBinding -and $keyBinding.text) { $existingKey[$svc] = $keyBinding.text }
}
}
# 3. 生成 tfvars 时,如果 CF 有旧 KEY 就写进去,没有就留空让 Terraform 随机生成
$servicesHcl = ($Services | ForEach-Object {
$k = $existingKey[$_]
if ($k) { " $_ = { api_key = `"$k`" }" } else { " $_ = {}" }
}) -join "`n"
对应 Terraform 侧的逻辑:
hcl
resource "random_password" "api_key" {
length = 32
special = false
}
locals {
# 如果 var.api_key 非空就用它,否则用随机生成的
api_key = var.api_key != "" ? var.api_key : random_password.api_key.result
}
这样整个流程是:
-
正常情况:第一次 apply 时
random_password生成随机值,存 state;后续 apply 保持稳定 -
state 丢失:脚本从 CF 回读旧 KEY,写到 tfvars;Terraform 看到
var.api_key非空,不会 invokerandom_password资源;apply 后 KEY 保持不变
核心洞察 :random_password 这类资源的值应该 允许外部注入,不然它一丢永远丢。给它加一个 var.api_key 旁路,是工程上的标准做法。
写在最后
上面这 5 个坑,从严重到不严重排序:
-
坑 5(API_KEY 幂等):最隐蔽,发生时最致命------你以为没问题,客户端报警了才知道
-
坑 4(state 恢复):运维层面最重要,没这套兜底,一次手滑就够你加班到凌晨
-
坑 2(Worker 上传):开发体验最差------不解决根本没法工作
-
坑 3(DNS 冲突):偶发,但每次都要手动清,累计起来很烦
-
坑 1(provider 拉不下来):最简单,但入坑门槛
一个有意思的规律:这 5 个坑里,有 4 个的解法都是"让 Terraform 管小一点" 。Terraform 不是万能的,它擅长声明式的状态收敛,不擅长可靠的 IO 和隐式副作用。真正健壮的 IaC 是混合式的:声明式管结构,命令式管边界条件。
代码全部在 terraform-cf-workers-starter 仓库里,端到端可跑。欢迎 issue 和 PR。
下一篇打算写《Terraform 模块化的三个段位》,如果你想看,点个在看或者关注一下。