Coder 工作区模板:从 Terraform 构建到可控退出
周一早晨,开发者启动昨天停止的工作区,计算实例重新出现,终端也能打开,但未提交代码和依赖缓存全部消失。平台侧看到构建状态是 succeeded,于是最初判断是“用户忘了保存”;继续翻构建计划才发现,模板把容器和工作目录一起绑定到 start_count,停止动作销毁了两者。控制面确实完成了 Terraform apply,完成的却是一份错误的生命周期设计。
这类事故说明,远程开发环境“能创建”远远不够。团队需要同时回答:哪段 Terraform 决定资源,谁执行它,agent 为什么能回连,停止时销毁什么,删除时又销毁什么,模板升级如何抵达旧工作区,以及身份、云凭据、源码、持久数据和费用分别由谁持有。Coder 把这些问题集中到模板与工作区构建中,也因此要求模板像生产代码一样接受评审、试运行和退出演练。
先把四个对象分开
Coder 模板是 Terraform 配置及其辅助文件。一次 coder templates push 不会就地改写所有工作区,而是发布一个新的模板版本。工作区保存自己采用的模板版本、参数和 Terraform 状态;启动、停止、更新、删除都会产生一次 workspace build,build 再交给 provisioner 执行计划与 apply。构建成功只表示声明收敛成功,不表示 agent 已就绪、应用健康或数据保存策略正确。
provisioner 是执行 Terraform 的地方。默认 provisioner daemon 与 Coder server 一起运行;external provisioner可以把云 API、Docker socket、Kubernetes 凭据和执行负载隔离到独立环境。一个 provisioner 同时处理一个 workspace build,因此同时开工人数高于可用 daemon 数时,最先出现的证据通常是排队时间增长,而不是 Terraform 本身变慢。把不同区域、云账号或敏感级别的任务按 tag 路由到不同 provisioner pool,可以减少凭据横向扩散,也能避免预构建任务占满交互式启动队列。
coder_agent 是工作区里的连接与状态端点。Terraform 创建计算资源时,把 agent 的初始化脚本与注册 token 交给实例或容器;进程启动后主动连接 Coder 的访问地址。资源已经创建但 agent 无法解析域名、验证 CA、访问 WebSocket 或取得 token 时,Terraform apply 可能已经完成,工作区仍会进入 unhealthy,而不是 Running。agent 不是 provisioner:前者驻留在工作区承载连接、应用与元数据,后者只在构建阶段执行 Terraform。
工作区则是模板的一次实例化。Running、Stopped 和 Deleted 不是“容器进程的三个状态”,而是 Terraform 对不同目标声明的收敛结果。工作区生命周期把停止后的资源分成临时与持久两类;删除最终会销毁该工作区仍由状态管理的资源。是否真的保留数据,取决于模板资源是否跟随启动计数以及云端删除策略,不能靠状态名称推断。
Coder 架构中的 coderd 是控制面:它提供 UI/API、保存模板版本与工作区记录,并把 build 排入队列;provisioner 是基础设施变更的执行面,持有 Terraform provider 所需的云、集群或 Docker 权限;workspace 与其中的 agent 是开发数据面,接触源码、构建产物、进程和持久盘。把内置 provisioner 与 coderd 部署在一起只是在进程部署上合并了边界,并不意味着模板代码可以信任控制面数据库权限。需要隔离高权限 provider 或不可信模板时,应使用受限的 external provisioner,并让工作区只获得开发任务需要的网络与身份。
先把 Coder 控制面部署成可恢复服务
个人验证可以使用 Coder 提供的快速入口,但团队部署不能把内置 PostgreSQL、临时访问地址和内置 provisioner 当成生产架构。Kubernetes 安装方式把 coderd 交给 Helm 管理,并要求生产环境连接外部 PostgreSQL。数据库保存用户、模板、工作区、构建与审计元数据;工作区文件通常位于云盘或容器卷中。备份数据库不能替代备份开发数据,备份工作区卷也不能重建控制面关系。
先准备独立数据库、DNS、TLS Secret 和专用 namespace,再把数据库连接串放入 Kubernetes Secret。连接串中的密码不能提交到 values.yaml:
kubectl create namespace coder
kubectl -n coder create secret generic coder-db-url \
--from-literal=url='postgres://coder:<password>@postgres.example.internal:5432/coder?sslmode=verify-full'
helm repo add coder-v2 https://helm.coder.com/v2
helm repo update
helm upgrade --install coder coder-v2/coder \
--namespace coder \
--values values.yaml
kubectl -n coder rollout status deployment/coder
kubectl -n coder get pods,servicevalues.yaml 只保存非敏感声明。CODER_ACCESS_URL 必须是开发者、agent 与回调系统共同可达的稳定地址;数据库连接从 Secret 注入;TLS 由 Chart 的 coder.tls.secretNames 管理,不能再额外设置会与 Chart 冲突的 CODER_HTTP_ADDRESS 或 CODER_TLS_* 环境变量:
coder:
env:
- name: CODER_ACCESS_URL
value: https://coder.example.com
- name: CODER_PG_CONNECTION_URL
valueFrom:
secretKeyRef:
name: coder-db-url
key: url
tls:
secretNames:
- coder-tlsPod Ready 只证明 HTTP 进程通过探针。部署完成还要从开发者网络访问登录页,从隔离测试节点解析 DNS、验证证书链,并确认数据库连接池没有持续重连。把 CODER_ACCESS_URL 临时改成 agent 无法解析的地址,会出现控制面可登录、Terraform 资源已创建、agent 却无法回连的分裂状态;恢复地址后滚动更新 coderd,再用测试工作区确认 agent 进入 ready。这个反例能提前暴露 split DNS、代理不支持 WebSocket、企业 CA 未下发等问题。
升级前应同时保存 Helm values 的版本、Chart 版本、Coder 数据库备份与恢复演练记录。控制面回滚只能回到与数据库 schema 兼容的版本;如果升级已经执行不可逆数据库迁移,直接回滚镜像可能让服务无法启动。高可用部署还要让多个 coderd 实例共享外部 PostgreSQL,并把 workspace build 交给可横向扩展的 external provisioner,避免控制面 Pod 重启同时中断 Terraform 执行。
从受控租户发布一个最小模板
动手时需要一个可访问的 Coder 部署、匹配服务端主版本的 coder CLI、可创建模板的 Template Admin 身份,以及一个能够运行 Docker provider 的 provisioner。Docker socket 等同于宿主机高权限入口,只适合隔离的验证节点;共享或正式环境应改用 Kubernetes、虚机或云实例模板,并把 provider 权限限制到专用项目、账号或命名空间。创建模板页面提供 Builder、starter template、上传归档和手写 Terraform 几种入口;第一次落地优先从与目标基础设施一致的 starter 开始,再把生成的 HCL 纳入版本库。
先安装 CLI 并登录目标部署。安装命令和支持平台可能随发行线变化,应从部署登录页或 Coder 的安装入口取得;执行后用服务端返回确认连接对象,而不是只看本机二进制存在。
coder version
coder login https://coder.example.com
coder whoami
coder templates initcoder version 应同时显示客户端信息;coder whoami 应返回当前用户与组织上下文。登录失败若表现为证书未知,修复动作是把企业 CA 加入受支持的系统或运行时信任链,不是关闭 TLS 校验。返回 403 时先检查角色和组织,避免换成 owner token 掩盖 RBAC 问题。
下面的 Docker 模板骨架刻意把计算与数据分开。它不是复制后即可运行的成品:镜像占位符必须替换成团队已扫描并锁定的镜像身份,provider 选择要由 .terraform.lock.hcl 固定,示例中的资源策略也只是验证入口,不能直接当作容量结论。
terraform {
required_providers {
coder = {
source = "coder/coder"
}
docker = {
source = "kreuzwerker/docker"
}
}
}
data "coder_workspace" "me" {}
data "coder_workspace_owner" "me" {}
resource "coder_agent" "main" {
arch = "amd64"
os = "linux"
dir = "/home/coder/your-project"
startup_script = <<-EOT
set -eu
mkdir -p /home/coder/your-project
EOT
}
resource "docker_volume" "home" {
name = "coder-${data.coder_workspace.me.id}-home"
}
resource "docker_container" "workspace" {
count = data.coder_workspace.me.start_count
image = "<approved-image>@sha256:<digest>"
name = "coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}"
hostname = data.coder_workspace.me.name
entrypoint = ["sh", "-c", coder_agent.main.init_script]
env = ["CODER_AGENT_TOKEN=${coder_agent.main.token}"]
volumes {
volume_name = docker_volume.home.name
container_path = "/home/coder"
}
}start_count 在启动目标中为 1,在停止目标中为 0,所以容器会随 Start/Stop 创建和销毁;卷没有使用这个 count,因此停止后仍存在,但工作区 Delete 的 terraform destroy 仍会删除它。coder_agent.main.init_script 负责启动 agent,CODER_AGENT_TOKEN 只用于该 agent 注册,不能替代用户登录或云 provider 凭据。dir 决定默认工作目录,不会自动创建持久盘。若给卷添加 prevent_destroy,正常 Delete 会被 Terraform 阻断;只有同时设计了备份、资源转交、解除保护和最终核销流程时才应启用,不能把无法退出误当成数据安全。Resource Persistence给出了不同 provider 的持久化写法和风险。
在模板目录执行初始化,让 .terraform.lock.hcl 锁住 provider 选择并提交到模板仓库。随后发布带提交身份的模板版本:
terraform fmt -check
terraform init
terraform validate
coder templates push your-project-dev \
--directory . \
--name "git-<short-sha>"
coder templates versions list your-project-dev预期结果是 init 生成或复用依赖锁,validate 返回成功,push 创建新版本,版本列表能看到名称、创建者与状态。terraform validate 不能验证 provisioner 是否持有 Docker socket 或云权限;真正的首次 build 必须在隔离模板和测试身份下观察 plan、apply、agent 日志及清理行为。没有可控租户时,可以完成 HCL 静态检查和 CLI 参数核对,但不能据此声称工作区已创建。
用正反实验验证生命周期
先用测试身份从新版本创建 coder-lifecycle-test,不要在含真实源码的工作区上试。创建工作区后,构建链应依次出现排队、provisioner 领取任务、Terraform plan/apply、资源创建和 agent 就绪;缺少其中任一段,都不能把 succeeded 简化理解为工作区可用:
coder create --template your-project-dev coder-lifecycle-test
coder ssh coder-lifecycle-test -- \
sh -lc "printf 'persist-me\\n' > /home/coder/coder-persistence-proof.txt"
coder stop coder-lifecycle-test
coder start coder-lifecycle-test
coder ssh coder-lifecycle-test -- \
sh -lc 'cat /home/coder/coder-persistence-proof.txt'正常结果是停止时容器资源被销毁或降为零、卷仍在,并且再次启动后输出 persist-me。若文件消失,先查 Terraform plan 中卷是否带 count = start_count、挂载目录是否与写入目录一致、云磁盘是否被实例终止策略连带删除。若 Stop 卡在 prevent_destroy,说明保护加在了停止阶段会触碰的资源上,需要重新划分临时计算与持久数据,而不是强制删除状态。
再做一次能稳定暴露 agent 链路的反向实验:复制模板为测试版本,把 agent token 环境变量名改成 CODER_AGENT_TOKEN_BROKEN,只让测试工作区更新到该版本。Terraform 仍可能成功创建容器,但 agent 进程拿不到注册 token,工作区会停在等待 agent 的阶段;构建日志、容器日志和工作区页面会共同证明“基础设施 apply 成功”和“工作区可用”是两件事。恢复正确变量后发布新版本、更新同一测试工作区,直到 agent 进入 ready,再删除错误版本对应的临时资源。
网络故障也应留下可分型证据。coder ping coder-lifecycle-test 若显示通过 DERP relay 而非 direct,不等于不可用,但意味着 UDP、STUN、NAT 或防火墙阻断了直连;持续高延迟时再结合 --verbose 判断客户端侧还是 agent 侧。Coder 网络模型要求客户端和 agent 都能通过 HTTP/HTTPS 到达 Coder 访问地址,中间代理支持 WebSocket;直连失败会回退到加密中继。禁止 UDP 的企业网络可以接受 relay 的成本与延迟,也可以部署受控中继,不能为了直连把工作区暴露到公网。
把项目接入放在可重建层
项目接入应区分三种数据。仓库源码可以从受控 Git 服务重新取得,适合由启动脚本在目录为空时 clone;依赖、编译缓存可以重建,适合独立缓存卷并设置容量与回收周期;未提交改动、个人配置和必要的本地数据库属于不可轻易重建的数据,必须进入明确的持久卷、备份或同步策略。把三者全塞进一个无限增长的 home volume,短期简单,长期会让恢复时间、恶意依赖污染和离职清理都失去边界。
启动脚本必须幂等。下面的片段只在仓库不存在时克隆,已有目录只做远端可达性检查;凭据由 Coder 的外部认证链按需提供,脚本里没有 token:
set -eu
repo_dir=/home/coder/your-project
if [ ! -d "$repo_dir/.git" ]; then
git clone https://github.com/your-org/your-project.git "$repo_dir"
else
git -C "$repo_dir" remote get-url origin >/dev/null
fi
cd "$repo_dir"
./scripts/bootstrap-dev.sh
./scripts/verify-dev.shbootstrap-dev.sh 应锁定工具或包的输入,重复执行不应覆盖未提交文件;verify-dev.sh 至少证明依赖解析、核心测试和本地服务健康。若仓库用 .devcontainer/devcontainer.json 描述工具与服务,可以让 Coder 基础模板提供计算、Docker 或 envbuilder,再消费仓库声明。Dev Containers 集成适合把项目级工具留在仓库,但宿主算力、网络策略、持久卷、agent 和生命周期仍由 Coder 模板负责。不要同时在镜像、agent 启动脚本和 Dev Container 三处安装同一套 SDK,否则版本漂移只能靠碰运气发现。
凭据要按使用者和阶段拆开
用户通过 OIDC 登录 Coder,解决的是“谁在使用平台”。CODER_OIDC_ISSUER_URL、client ID 和 client secret 属于 Coder server 配置;回调地址、email claim、group claim 与刷新令牌策略错误时,证据出现在登录回调和服务端认证日志,而不是 workspace build。启用 OIDC 后仍要保留受控的紧急管理员恢复路径,并验证停用用户是否同步为不可登录状态。
provisioner 调用云、Kubernetes 或 Docker API,解决的是“谁在创建基础设施”。Provider Authentication明确提醒模板对用户可见,不应把云密钥写进 HCL、变量默认值、README、镜像层或 startup script。优先让 provisioner 运行身份通过 workload identity、instance role 或专用 service account 取得短期权限;每个 pool 只允许操作指定项目、区域和资源类型。这样撤销 provider 权限不会同时破坏用户 OIDC,会话 token 泄漏也不能直接创建任意云资源。
工作区访问 Git 或制品服务,解决的是“开发任务代表谁访问外部系统”。Coder external auth 可把用户 OAuth 流接到 Git provider,模板用 coder_external_auth 声明所需连接;服务端保存的用户 token 不应被打印到 Terraform plan、环境转储和启动日志。对于非用户操作,使用可轮换、最小权限的机器人身份,并限制仓库和制品路径。测试故障时只记录 HTTP 状态、provider ID 和脱敏错误,不回显 access token。
RBAC 从模板入口一直落到资源账号
创建模板需要 Template Admin 或更高角色,普通开发者只应使用已发布模板创建自己的工作区。具备授权能力的部署可以在模板级把用户或组设为 Use 或 Admin;默认 Everyone 可使用模板的行为要在启用团队模板前检查。模板权限属于商业能力,实际许可不支持时,不能假设页面上的细粒度策略存在,应通过组织拆分、模板数量、provider 账号和外部策略补足隔离。
模板管理员也不应自动拥有云 owner。稳妥的权限链是:平台 owner 管身份与全局设置,模板管理员维护 HCL 和发布版本,provisioner service account 只创建允许的资源,开发者只使用模板,审计系统关联“用户请求、模板版本、build、provisioner 与云资源标签”。如果同一个长期 token 同时能发布模板、创建云资源和读取用户仓库,任何一处日志泄漏都会跨越整条链路。
Coder 审计日志应保留模板发布、workspace build、用户与权限变更的 actor、resource、action、状态码和 request ID。一次权限反证可以使用普通开发者尝试发布模板:预期返回拒绝,并能在审计记录中关联该用户和目标模板;随后由 Template Admin 发布同一提交,记录模板版本与 workspace build。若普通开发者成功发布,说明模板角色或组织继承范围过宽;若请求被拒绝却没有审计证据,则无法在事故后回答谁尝试改变了基础设施。审计日志本身也可能含资源名、用户名和差异字段,导出到 SIEM 时要限制读取角色、保留周期与脱敏规则。
模板版本不是一次全员覆盖
每次 push 都应产生可追踪的新版本,版本名使用提交 SHA 或发布标签,模板源码、.terraform.lock.hcl、镜像 digest 和迁移说明一起评审。模板变更管理建议把模板放进版本控制并从 CI 发布;CI token 设置短有效期、只授予目标组织和模板,失败日志禁止输出环境变量。
先让测试工作区更新,检查 Terraform plan 是否出现替换持久资源、参数是否需要重新输入、agent 是否能回连、项目验证是否通过。再放给小组使用,最后才把版本设为 active。普通模板更新通常提示用户更新;强制工作区在启动时采用最新版本的 Template Update Policies 属于商业能力,启用前必须证明旧工作区能跨版本迁移。镜像、磁盘类型、区域和资源名称这类 ForceNew 字段可能把“升级”变成替换,不能只看 HCL diff 行数。
需要回退时,重新激活已验证版本或从对应 Git 提交发布一个新版本,再让受影响工作区执行 update。Terraform 状态已经经过不可逆迁移、卷格式已经升级或旧镜像不再兼容数据时,降模板版本不会自动恢复数据;这类变更必须先快照或导出,并把恢复验证放在发布批准之前。
自动停止只省计算,不会自动省下一切
Workspace Scheduling可以设置默认 autostop,并在支持的许可中增加强制停止、休眠和休眠后删除。autostop 依据工作区活动推进截止时间,适合回收无人使用的计算;它不会天然删除持久盘、快照、固定公网地址、预构建实例、日志索引和镜像缓存。一个每天停止的工作区仍可能持续产生存储和控制面费用。
容量规划从四条可观测曲线开始:同时启动请求与 build 排队时间决定 provisioner 数量;Running 工作区的 CPU、内存和加速卡决定计算池;Stopped 工作区的卷总量与增长率决定存储预算;agent 连接路径、relay 流量和日志保留决定网络与观测成本。示例阈值不应脱离团队基线硬编码,治理目标可以写成“高峰构建队列年龄不持续增长”“停止后计算资源回到稳定基线”“单工作区持久卷增长可归因”“删除后云标签清单归零”。
成本参数要限制用户选择面。实例类型、区域、磁盘大小和 GPU 可用 coder_parameter 暴露,但选项应来自允许集合,并给昂贵规格设置审批、并发或配额。参数值进入 Terraform 状态,因此密码、token 和私钥不能伪装成普通参数。预构建能降低冷启动延迟,却会提前占用 provisioner 和资源;只有冷启动 SLO 与使用概率能覆盖闲置成本时才值得启用,并为未领取实例配置过期回收。
从故障现象追到保存状态
workspace build 长时间排队时,先看是否有匹配 tag 的 provisioner、daemon 是否在线、队列年龄是否持续增加;增加一个不匹配的 daemon 不会改善队列。build 在 provider 初始化失败并出现 checksum 或下载错误时,检查 .terraform.lock.hcl、registry 出站、代理和 CA;不要删除锁文件让线上随机选择新 provider。离线环境应使用审核过的 mirror,并把 mirror 身份与模板版本一起记录。
Terraform apply 已成功但 agent 未 ready 时,依次检查 agent 进程、token 注入、系统时间、DNS、CA、Coder access URL 与 WebSocket。若容器日志中根本没有 agent 启动输出,问题在 entrypoint 或 init script;若有反复注册失败,问题更可能在 token 或控制面可达性。修复后用同一模板版本的测试工作区重建,直到 build 成功、agent ready 和项目验证三项同时成立。
停止后数据丢失时,查看该次 Stop 的 plan:持久资源若出现 destroy,说明资源声明跟随 start_count 或 provider 删除策略错误;plan 没销毁卷而文件仍丢失,则检查实际挂载点、用户 home 和应用写入路径。恢复不能只重新启动,应从备份或快照还原并比较哨兵文件、仓库状态和应用数据。
删除工作区失败时,不要直接移除 Terraform state。先确认阻塞来自 prevent_destroy、云 API 权限、资源依赖还是网络;决定保留数据时,把卷转交给明确 owner 并记录资源身份,决定彻底退出时,临时解除保护、执行 Delete、再用云标签与审计记录确认没有遗留。直接 state remove 只会让 Coder 忘记资源,不会让云账单停止。
把删除演练当作上线的一部分
测试工作区完成正反实验后,先导出确需保留的数据,停止工作区并确认临时计算归零;若测试版另行启用了卷删除保护,先按审批解除,再删除工作区。随后检查 Coder 中的 build 记录、provisioner 日志、云资源标签、卷、快照、地址和外部凭据,确保没有孤儿资源。不要使用 --orphan 或移除 Terraform state 来伪造删除成功:它们会移除 Coder 的管理关系,却可能留下真实资源与账单。模板只有在没有运行中的关联工作区时才可删除;仍被团队使用的模板先撤销新建权限、迁移现有工作区,再归档旧版本。
平台退出比删除模板更长。源码必须已推送到组织代码库,个人未提交数据要有迁移窗口,持久卷需要导出格式与校验值,镜像和模板仓库要能在 Coder 之外读取,OIDC 应用、external auth、CI token、provisioner service account 与网络规则按依赖逆序撤销。最后保留脱敏审计记录和费用基线,确认工作区数量、云标签资源、持久盘容量和活跃凭据都回到预期值。只有资源、身份、数据和费用四条线都能闭合,声明式工作区才真正具备可控的生命周期。
