国内用 Terraform 管 Cloudflare 踩过的 5 个坑(附可直接复用的代码)

国内用 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。思路是:

  1. 从 GitHub Release 直接下 Cloudflare provider 二进制(GitHub 在国内通畅得多,命中率 > 90%)

  2. 按 Terraform 要求的目录结构摆好:registry.terraform.io/<namespace>/<name>/<version>/<os>_<arch>/

  3. 写一个 .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 的时候:

  1. 第一次:cloudflare_worker_domain 调用成功,CF 隐式创建 DNS 记录 A

  2. 你手动改了 Terraform 配置,想用 cloudflare_record 资源来管 DNS

  3. 第二次 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 丢了可以重建,只要你能:

  1. 检查 state 是不是空的

  2. 如果空,调 CF API 查当前有哪些 cfstarter-* 资源

  3. 按名字匹配,terraform import 回 state

  4. 再正常 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. 个人项目不想每个月花 $1 在 S3 上,这个兜底足够了

  2. 就算你用了 TF Cloud,当 TF Cloud 自己挂了(发生过),这段兜底能让你继续工作

  3. 它让你意识到: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_passwordrandom_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

}

这样整个流程是:

  1. 正常情况:第一次 apply 时 random_password 生成随机值,存 state;后续 apply 保持稳定

  2. state 丢失:脚本从 CF 回读旧 KEY,写到 tfvars;Terraform 看到 var.api_key 非空,不会 invoke random_password 资源;apply 后 KEY 保持不变

核心洞察random_password 这类资源的值应该 允许外部注入,不然它一丢永远丢。给它加一个 var.api_key 旁路,是工程上的标准做法。


写在最后

上面这 5 个坑,从严重到不严重排序:

  1. 坑 5(API_KEY 幂等):最隐蔽,发生时最致命------你以为没问题,客户端报警了才知道

  2. 坑 4(state 恢复):运维层面最重要,没这套兜底,一次手滑就够你加班到凌晨

  3. 坑 2(Worker 上传):开发体验最差------不解决根本没法工作

  4. 坑 3(DNS 冲突):偶发,但每次都要手动清,累计起来很烦

  5. 坑 1(provider 拉不下来):最简单,但入坑门槛

一个有意思的规律:这 5 个坑里,有 4 个的解法都是"让 Terraform 管小一点" 。Terraform 不是万能的,它擅长声明式的状态收敛,不擅长可靠的 IO 和隐式副作用。真正健壮的 IaC 是混合式的:声明式管结构,命令式管边界条件。

代码全部在 terraform-cf-workers-starter 仓库里,端到端可跑。欢迎 issue 和 PR。


下一篇打算写《Terraform 模块化的三个段位》,如果你想看,点个在看或者关注一下。

相关推荐
平凡但不平庸的码农1 小时前
Go context 包详解
开发语言·后端·golang
Gopher_HBo2 小时前
分布式详解
后端
AI人工智能+电脑小能手2 小时前
【大白话说Java面试题】【Java基础篇】第38题:两个对象的hashCode()相同,则 equals()是否也一定为 true?
java·开发语言·后端·面试·hash-index
SamDeepThinking2 小时前
所有的框架源码,最怕的就是被debug
java·后端·程序员
kree2 小时前
通义千问 SSE 流式:累计文本 vs 增量 Delta
后端
fox_lht2 小时前
第十一章 错误处理
开发语言·后端·rust
焗猪扒饭2 小时前
极简案列入门golang依赖注入工具wire
后端·go
M ? A3 小时前
Vue 转 React | VuReact 实时监听开发指南
前端·vue.js·后端·react.js·面试·开源·vureact