从零搭 ydstree.com:一天的工程化折腾史

从零搭 ydstree.com:一天的工程化折腾史

Go + HTMX + AWS Lambda + Cloudflare Worker,一天从空目录到线上可访问个人站的完整复盘。

这是一篇事后复盘。2026 年 4 月 14 日一整天,我和 Claude 结对把 ydstree.com 从零搭了起来——从空目录到全自动 CI/CD,从零 AWS 账号到 Lambda 跑 Go 二进制,从 Cloudflare 注册到 Worker 做边缘路由。中间踩了至少五个坑,每个都教会了我一点新东西。

技术栈

先说结果,之后再说过程。

  • 后端:Go 标准库 net/http + chi 路由
  • 视图html/template + HTMX 局部刷新
  • 计算:AWS Lambda(provided.al2023,arm64,256MB)通过 Function URL 直出
  • 静态资源:S3 bucket(公网可读),通过 Cloudflare Worker 路由到
  • 边缘:Cloudflare Worker 免费层(100k 请求/天),充当 Host header 重写代理
  • IaC:Terraform 管理 AWS 侧全部资源(Lambda / S3 / IAM OIDC / Function URL)
  • CI/CD:GitHub Actions 三条流水线,lambda 用 OIDC 免密部署,worker 用 wrangler CLI 部署

架构流程:

浏览器 → ydstree.com (Cloudflare DNS)
       → Cloudflare Worker (改 Host header)
       → 路由分流:
           /static/*  → S3 bucket (长缓存)
           其他       → Lambda Function URL → Go binary

原计划 vs 实际

原计划:Go + HTMX + AWS Lambda + S3 + Cloudflare 反代。简单直接,同事建议的路线。

实际:每个组件都在某个时刻反手给了一下。

踩坑 1:AWS Free Plan 默认阻挡 Lambda 公网访问

2025 年 7 月 AWS 改革了免费套餐,新账号默认开启 Lambda Public Access Block。我第一次 terraform apply 把 Lambda + Function URL 建出来后,curl 直接 403 AccessDeniedException,但我明明把 authorization_type = "NONE" 配上了。

查了半天发现:Function URL 的公网调用不只需要 lambda:InvokeFunctionUrl,从 2025 年 10 月起还隐式要求 lambda:InvokeFunction。两个权限都要显式授予 Principal: "*"。修复后立刻通了。

这个踩坑教会我:AWS 的"free plan"不是"老 free tier",规则不同。而"默认安全"有时会以"你以为配对了但其实没"的形式出现。

踩坑 2:Go directive 是最低版本不是本地版本

我本地装的是 Go 1.26.2,go mod init 默认给 go 1.26.2。CI 跑 golangci-lint v2.5.0 直接 panic:lint 二进制是用 Go 1.25 编译的,无法解析 1.26 的 stdlib 源码。

搞明白后才意识到:go.mod 里的 go 1.X directive 是最低要求版本,不是"你本地用什么版本"。把它降到 go 1.25,本地 Go 1.26.2 依然正常工作(向后兼容),而且 lint 工具链也能跟上。

踩坑 3:Cloudflare 把 Host header rewrite 锁到 Enterprise

这是今天最戏剧性的一个。

计划是:Cloudflare 反代 ydstree.com → Lambda Function URL。Lambda Function URL 严格校验 Host header,默认 Cloudflare 转发 Host: ydstree.com,Lambda 拒绝。解决办法是在 Cloudflare 建一条 Origin Rule 把 Host 改成 Lambda URL 的 hostname。

这个功能曾经免费,2024 年某次改版把它挪到 Enterprise 套餐。Free 用户点 Origin Rules 能建规则,但选"Rewrite Host Header"时弹窗"Upgrade to Enterprise"。

一度以为要付 $20/月上 Pro 套餐——但 Pro 也没有这个功能,只有 Enterprise $xxx/月

最后绕过方案:写一个 30 行的 Cloudflare Worker。Worker 在 Free 套餐完全免费(100k 请求/天),能在边缘对请求做任何改写。核心逻辑:

const LAMBDA_HOST = "xxx.lambda-url.us-east-1.on.aws";

export default {
  async fetch(request) {
    const incoming = new URL(request.url);
    const upstream = new URL(incoming.toString());
    upstream.hostname = LAMBDA_HOST;

    const headers = new Headers(request.headers);
    headers.set("host", LAMBDA_HOST);

    return fetch(new Request(upstream.toString(), {
      method: request.method,
      headers,
      body: request.body,
      redirect: "manual",
    }));
  },
};

30 行 JS 打败 $200/月的付费功能。Cloudflare 大概没想到这条路径,也可能故意留着。

踩坑 4:S3 public read + Lambda public invoke 是两套不同的 public access block

建 S3 bucket 时我以为配了 aws_s3_bucket_public_access_block 就够了。结果 bucket policy 生效但对象仍然 403——因为 BucketOwnerEnforced 下的 public read 还需要 bucket policy 显式允许。排查花了 10 分钟。

踩坑 5:Worker 只转发 Lambda 会 404 静态资源

Worker 第一版我写的是"所有请求转给 Lambda",结果 /static/css/site.css 返回 404——因为 Go 后端根本没有 /static/* 路由,它是 S3 的事情。

修复是在 Worker 里加路由分流:/static/* 走 S3(并剥离 /static/ 前缀),其他走 Lambda。顺手还给静态资源加了 1 年 immutable 缓存 header,让 Cloudflare 边缘缓存永久持有。

工程化细节

虽然是个人站,但我坚持按生产标准做:

CI 三条流水线

触发 Workflow 干什么
任何 push ci.yml gofmt / lint (golangci-lint v2) / test -race / govulncheck / lambda 跨编译
main 分支且 CI 成功 deploy.yml OIDC assume role → lambda update-function-code → s3 sync → 烟雾测试
worker/** 变更 worker-deploy.yml wrangler deploy → 烟雾测试

关键点:Deploy 通过 workflow_run 依赖 CI 成功,所以坏代码不会进 Lambda。这个 gating 是我今天最后加的,算是把闭环画完的一笔。

OIDC 免密部署:GitHub Actions 不存 AWS 长期密钥,通过 OIDC trust policy 直接 assume 一个 IAM role,trust policy 里锁死只允许 repo:xiaomoziyi/yds_zone:ref:refs/heads/main。即使仓库公开,也没有凭据可泄露。

零容忍 lint:gosec / errorlint / gocritic / nilerr / bodyclose 全开。第一次 deploy 就抓出来 main.go 里一个 defer cancel()os.Exit 跳过的 bug——deferos.Exit 之后确实不会执行。这是我这辈子读了十年 Go 代码都没主动想到的细节。

成本

一天的折腾成本:

  • AWS:$0(Free Plan 给了 $100 credit + 6 个月)
  • Cloudflare:$0(Free 套餐所有东西够用)
  • 域名:ydstree.com 约 $10/年
  • Lambda 二进制:7.9MB(arm64,stripped)
  • 首屏冷启动:实测 ~150ms,warm 后 <10ms

稳态月成本:$0,直到 AWS Free Plan 窗口结束。

结尾一点思考

一天能把一个带自动化 CI/CD、带 IaC、带 OIDC 免密、带边缘缓存、带跨云路由的个人站从零跑起来,很大程度上得益于 AI 结对。但也要承认:一个人盯着一天,踩这么多坑是常态。每个坑在文档里都有,但你不到那一步是不知道它会发生的。

下次再搭类似的东西,我会:

  1. 先查"新 AWS Free Plan 对 Lambda 的限制"再动手——省 20 分钟
  2. go.mod 直接用次新版(如 1.25),别紧贴 1.26——省 15 分钟 lint 诡异问题
  3. 如果目标用户在国内,直接放弃 Lambda 改腾讯云 HK 轻量服务器,省掉 Worker 那一层

但这次没白折腾。最后跑通时的那个 HTTP/2 200x-served-by: ydstree-worker header,值了。