wth2jhl.online

Back

如果你写过 Nginx 或者 Apache,大概率经历过这种痛苦:

location / {
    try_files $uri /index.html;  # 先判断文件存在
    rewrite ^ /x/$ /z/;          # 再改写
    add_header X-Foo bar;        # 加 header
    proxy_pass localhost:5000;   # 最后代理
}
nginx

顺序对吗?如果你把 add_header 放在 rewrite 之前,有时候 header 会丢失;try_files 必须在 proxy_pass 之前,否则上游永远收到请求。每加一条新指令,就要重新捋一遍”顺序错了会不会出问题”。

Caddy 干了一件让人耳目一新的事:你在 site 块里想怎么写就怎么写,它帮你排好。下面这三段,在 Caddy 里都正确:

example.com {
    redir /old /new
    rewrite /x /z
    header X-Foo bar
    try_files path
    reverse_proxy localhost:5000
}
plaintext
example.com {
    rewrite /x /z
    reverse_proxy localhost:5000
    header X-Foo bar
    redir /old /new
    try_files path
}
plaintext
example.com {
    reverse_proxy localhost:5000
    redir /old /new
    try_files path
    header X-Foo bar
    rewrite /x /z
}
plaintext

这三段最终的 HTTP 处理管线完全一样。看起来像变魔术,实则是 caddyconfig/httpcaddyfile/directives.go 里 240 行 Go 代码精心编排的结果。

本文是一份”读 Caddy 源码做学习笔记”。我会从 11.5 万行 Go 代码里拎出真正有信息密度的部分 —— 五大核心子系统、route 中间件双向流、reverse_proxy 的 2252 行 handler、Admin API 的双阶段 Provision —— 把”它如何把零散配置变成可工作的 HTTP 管线”这件事讲透。

img1

一、为什么 Caddy 不是一个普通的 Web 服务器#

大多数 Web 服务器是这么设计的:

配置文件 → 解析 → 直接执行
plaintext

Nginx 把 nginx.conf 解析成内部数据结构,Apache 用 httpd.conf 解析成模块树。每条指令的顺序就是它执行的顺序,你想加条新指令就要知道它应该插在哪里。

Caddy 走了一条完全不一样的路:配置文件先编译成 JSON 中间表示,然后再交给模块系统执行。这个”两步编译”带来三个意外好处:

第一,配置可以脱离文件后自由替换 —— 同样的逻辑你可以用 JSON 写,可以用 Caddyfile 写,甚至可以用 TOML 写,因为中间表示是 JSON。Caddy 内置了 caddyfile 和 native (直接 JSON) 两种适配器,官方生态还有 yaml 和 kdl 等第三方适配器。

第二,模块系统是新的热扩展 —— 你写一个 Go module,init() 里 RegisterHandlerDirective("my_handler", ...) 一调,你的指令就有了。Module ID 命名空间(http.handlers.reverse_proxy)保证不会撞车。

第三,顺序无关的 directive 排序 —— 这是 Caddy 最妙的设计之一。后文会专门拆。

Caddy 总规模:115K 行 Go 代码,10 个核心模块(caddyhttp / caddytls / caddypki / caddyevents / filestorage / metrics / logging / caddyfs / standard / internal)。最新一次提交是 8507b63 caddytls: add not matcher for TLS handshakes (#8138)。代码规范遵循 CC0,Apache-2.0 双许可。

modules/
  caddyhttp/      ← HTTP 服务主体(app.go 951 行,routes.go 473 行)
  caddytls/       ← ACME 自动证书 + TLS termination
  caddypki/       ← 本地 CA,内网证书签发
  caddyevents/    ← 模块间事件
  filestorage/    ← 文件存储抽象
  metrics/        ← Prometheus 集成
  logging/        ← 日志配置
  standard/       ← 标准 imports,把常用模块打包
plaintext

三、Caddyfile → JSON 的全流程#

Caddyfile 不是直接被执行的。它先被编译成 JSON Config,然后再交给运行时。这条路径上有五个关卡,我把它们拆开看。

1.1 Lexer:把字符变成 Token#

caddyconfig/caddyfile/lexer.go 是个纯手写的 lexer,大约 280 行。它的工作就是按字符切出 Token { File, Line, Text, wasQuoted, heredocMarker }。

它有几个有意思的设计:

支持三段引号。"双引号"、反引号、<<HEREDOC 三种写法都识别。HEREDOC 的 marker 必须是 [A-Za-z0-9_-]+,并自动剥去公共 leading 缩进(看 closing marker 前的空白):

respond <<HTML
  <!doctype html>
  <h1>Hello, {host}</h1>
HTML
plaintext

执行时 <<HTML 和 HTML 之间的内容会变成一个多行 token,并且把里面的相对缩进扣掉 2 字符(以 closing marker 前的缩进为准)。

# 是注释。如果一个 token 在行首紧跟 #,lexer 会把这一行后面的字符全部吞掉。但 # 出现在引号内部时是字面字符。

反斜杠转义.\ 只用来跳出引号或 heredoc,不能转义其他字符。这点和大多数 shell 不同,作者故意把它收窄 —— 反斜杠在配置里出现往往意味着用户拼错。

{$ENV_VAR} 自动展开。replaceEnvVars() 函数在 lex 完成后扫一遍,把 {$HOME} / {$API_KEY:-default} 这种写法替换成环境变量值。: 后面是默认值。这是用 bytes.Index 暴力扫的(没建正则),因为我估计配置文件大小有限。

1.2 Parser:从 Token 序列到 ServerBlock[]#

caddyconfig/caddyfile/parse.go 的 Parse() 函数做的事不复杂,但它奠定了”指令块”的概念:

type ServerBlock struct {
    HasBraces    bool       // 是否有大括号
    Keys         []Token    // 地址键,如 ["example.com", ":443"]
    Segments     []Segment  // 指令片段,每个指令一个
    IsNamedRoute bool       // 是否是命名路由 &(name)
}

type Segment []Token  // 一行或一个块,第一个 token 是指令名
go

Parser 输出的就是 []ServerBlock。每个 server block 有 0 到 N 个键(域名)、M 个指令段。

但 Parser 不做任何”指令顺序检查”,它只负责语法层面:地址 → 大括号 → 指令 → 结束大括号。它甚至不知道 header 是什么意思。

最妙的是 import 指令在 parse 期就被展开 —— 它不是一个普通指令,而是直接 splice 进 token 流:import conf.d/*.caddyfile 会把 glob 匹配到的所有文件按字面意义插入当前位置,连文件路径都附在 token 上(错误信息能精准指到原文件)。

import 还支持变参替换和 block 替换:

(snippet) {
    header X-Snippet {args[0]}
    reverse_proxy {args[1]}
}

example.com {
    import snippet "my-app" 3000
}
plaintext

变参 {args[0]} 会被替换成 “my-app”,{args[1]} 替换成 3000。block 替换 {block} 会被替换成调用方大括号里的内容:

(common) {
    header X-Common true
}

example.com {
    import common {
        reverse_proxy localhost:5000
    }
}
plaintext

{block} 替换为 reverse_proxy localhost:5000 —— 这种叫 “block mapping”。

import 还有一个 graph 用来防止循环 import:importGraph 是个 node → [edge nodes] 的邻接表,任何新边加入前都查 addEdges() 是不是会成环。snippet 之间循环引用也能抓。

1.3 Adapter:把 ServerBlock 编译成 JSON Config#

Parser 的输出是 []ServerBlock —— 这是平台无关的中间表示。真正把 Caddyfile 编译成 JSON Config 的是 Adapter,目前内置 4 个(caddyfile、native、json、yaml),第三方还有 kdl、toml 等。

Caddyfile adapter 是最复杂的那个,它藏在 caddyconfig/httpcaddyfile/httptype.go 里,光这个文件就 2800 多行。它的核心问题是:

用户在一个 site 块里写一串”无序”的指令,我要按 Caddy 标准指令顺序把它们排好,然后输出 JSON Config。

下面这张图就是它要解决的问题:

img2

实现靠 4 张表 + 1 个 pile 模型:

registeredDirectives:map[string]UnmarshalFunc,指令名 → 解析函数。插件在 init() 里 RegisterDirective(...) 把自己塞进去。这是公开 API。

defaultDirectiveOrder:[]string 数组,标准指令顺序。Caddy 自带的 30+ 个指令都在里面:

tracing, map, vars, fs, root, log_append, skip_log, log_skip, log_name,
header, copy_response_headers, request_body, timeouts,
redir, method, rewrite, uri, try_files,
basicauth, basic_auth, forward_auth, request_header, encode, push,
intercept, templates, invoke, handle, handle_path, route,
abort, error, copy_response, respond, metrics, reverse_proxy,
php_fastcgi, file_server, acme_server
plaintext

directiveOrder:这是个可变的全局变量,plugin 在 init() 里 RegisterDirectiveOrder(dir, Before/After, anchor) 可以把自己的指令插入到标准指令前后。slices.Clone() 复制一次再写,不污染 defaultDirectiveOrder。

site.pile:map[string][]ConfigValue,每个 site 块一份。它的 key 是”class 名”(目前只有 "route"、"matcher_defs"、"tls.connection_policy"、"named_routes" 等少数),value 是该 class 下累积的 config values。

Adapter 的编译步骤大致如下:

关键的一步在 5 —— serversFromPairings 里:

// 把 site 块里所有的 "route" pile 取出来,按 directive 顺序排好
dirRoutes := sblock.pile["route"]
siteSubroute, err := buildSubroute(dirRoutes, groupCounter, directiveOrderFor(options))

// 然后把 Subroute 作为 handler 嵌到一个 Route 里,Route 的 matcher 是 site block 的 host/path
srv.Routes = appendSubrouteToRouteList(srv.Routes, siteSubroute, matcherSetsEnc, p, warnings)
go

buildSubroute 才是真正排序的地方:

func buildSubroute(routes []ConfigValue, groupCounter counter, order []string) (*caddyhttp.Subroute, error) {
    if order != nil {
        // 排序前校验:每个 directive 必须在 order 里,否则 panic
        for _, val := range routes {
            if !slices.Contains(order, val.directive) {
                return nil, fmt.Errorf("directive '%s' is not an ordered HTTP handler, ...", val.directive)
            }
        }
        sortRoutes(routes, order)
    }
    // ...
}
go

sortRoutes 是 Stable sort,排序键是 directive 在 defaultDirectiveOrder 数组中的索引。同 directive 的情况下,按 path matcher 长度降序 —— /api/v1/users 排在 /api/v1 前面,精确路径优先。

最后这一段:

mutuallyExclusiveDirs := map[string]*struct {
    count     int
    groupName string
}{
    "rewrite": {},  // rewrite 永远独占一个 group(防止被合并)
    "handle":  {},  // handle 块天生互斥
    "root":    {},  // root 只 set 变量,让能 win
}
go

rewrite 必须独占一个 group。注释里说得很明白:因为 rewrite 是内部重定向,改完后 match 不能再合并,这个细节只有真正踩过坑的人才会写进去。

1.4 插件怎么扩展默认顺序#

RegisterDirectiveOrder 是给插件作者用的接口。它有三条硬约束:

func RegisterDirectiveOrder(dir string, position Positional, standardDir string) {
    // 1. dir 必须在指令里没有排过,否则 panic
    if slices.Contains(directiveOrder, dir) { panic("directive already ordered") }
    
    // 2. position 必须是 Before 或 After
    if position != Before && position != After { panic(...) }
    
    // 3. anchor 必须是标准指令(不能依赖另一个插件)
    if !slices.Contains(defaultDirectiveOrder, standardDir) { panic(...) }
}
go

第三条最关键。插件不能依赖其他插件的顺序,因为插件加载顺序是不确定的。如果你开发的 rate_limit 插件想插在 reverse_proxy 前面,代码这么写:

func init() {
    RegisterDirectiveOrder("rate_limit", Before, "reverse_proxy")
}
go

reverse_proxy 是标准指令,任何时候都在,这就够了。但你不能写 RegisterDirectiveOrder("rate_limit", Before, "auth_check") —— 因为 auth_check 可能是别人写的,加载顺序一乱就 panic。

如果你实在需要在 site 块里覆盖默认顺序,可以用 order 全局选项:

{
    order redir after encode
    respond before reverse_proxy
}
plaintext

这种用法会修改 options["order"],directiveOrderFor(options) 返回这个 array 而不是全局 directiveOrder。注意:这是一个 adaptation-scoped 覆盖,每次重新 setup 都会重置,不污染全局。

三、Route 中间件:Request 向下,Response 向上#

Caddy 编译出的最终结构是 caddy.Config,里面的 http app 是一组 server,每个 server 包含一串 Route。Route 是 Caddy 最小的路由单位:

type Route struct {
    Group          string            // 同组互斥,首个匹配 wins
    MatcherSetsRaw []caddy.ModuleMap // 一组 matcher(组内 AND,组间 OR)
    HandlersRaw    []json.RawMessage // 中间件链
    Terminal       bool              // true 表示匹配后跳过后续路由
}
go

Handlers 链是经典的中间件模型 —— 但它和 Express / Koa 那种”单向 next() 调用”不一样。Caddy 的中间件是双向流:

img3

Request 从顶部往下:encode → templates → rewrite → basic_auth → file_server。Response 从底部往上:file_server → basic_auth → rewrite → templates → encode。

为什么?因为 encode 需要决定响应怎么压缩,所以它要包住所有响应源;templates 要在文件被读取之后、对它做模板替换,然后把替换后的文本向上交给 encode 压缩。

routes.go 的注释把这事讲得很透:

// The request flows DOWN (encode -> templates -> file_server).
//   1. First, encode will choose how to encode the response and wrap the response.
//   2. Then, templates will wrap the response with a buffer.
//   3. Finally, file_server will originate the content from a file.
//
// The response flows UP (file_server -> templates -> encode).
//   1. First, file_server will write the file to the response.
//   2. That write will be buffered and then executed by templates.
//   3. Lastly, the write from templates will flow into encode which will compress the stream.
plaintext

记忆口诀:编码后处理放在编码前处理之后。如果 B 需要 A 的输出才能工作,B 必须在 A 之后。

2.1 wrapRoute 怎么把 Route 变成中间件#

Route 本身不是 handler,执行时需要先编译。wrapRoute(route Route) Middleware 干的就是这件事:

这是个非常典型的洋葱模型实现。注意三点:

  1. matcher 在请求时 evaluate(不是 Provision 时),这是必须的 —— matcher 要看 request 的 URL / header / 客户端 IP,这些都是动态的。
  2. group 检查在 request ctx 里:req.Context().Value(routeGroupCtxKey).(map[string]struct{}),同请求不同 Route 共享 group map。这样”如果第一个 route 的 group 命中了,后续同 group 的 route 就跳过”。
  3. Terminal 用 empty handler 占位:emptyHandler 是个空 handler(func(...) error { return nil }),接到 next 之后什么都不做,直接让外层 handler 返回。这是 Terminal 路由”匹配后不继续走 next”的实现方式。

2.2 ReverseProxy 永远不调 next#

reverse_proxy 是 Caddy 最特殊的 handler —— 因为它不调用 next。这意味着放在它后面的 handler 永远不会被执行。

routes.go 注释里写得很清楚:

// Not all handlers call the next handler in the chain. For example, the reverse_proxy
// handler always sends a request upstream or returns an error. Thus, configuring
// handlers after reverse_proxy in the same route is illogical, since they would never be
// executed.
go

这就是为什么 reverse_proxy 总是写在最后一条 handler —— 它是 pipeline 的终点。这种”终结型 handler”在 Caddy 里还包括 respond、file_server 和 acme_server。把它们后面的 handler 都注释掉吧,不会执行的。

四、reverse_proxy:Caddy 最复杂的 handler(2252 行)#

modules/caddyhttp/reverseproxy/reverseproxy.go 单文件 2252 行,不是为炫技 —— 它把 HTTP 客户端重写了一遍,因为 Go 标准库 http.Transport.RoundTrip 不够用。

下面这张图是 ServeHTTP 的全貌:

img4

4.1 上游选择:5 个步骤并行#

ServeHTTP 入口只做一件事:不调用 next。然后它把请求扔进 proxyLoopIteration,这个循环里有 5 个步骤:

1. hosts 解析:entries / 比例 / 权重 配置决定 upstream list。最简单的就是写一个 URL,复杂的可以用 to 字段配多个 host + 权重。

2. 被动健康检查:fail_check 是被动检查 —— 如果上一次请求失败,就把它从 candidates 列表里临时剔除一段时间。这是个 hot path,所以放在主循环里快速判断。

3. 主动健康检查:Active 是独立 goroutine 在跑,周期性打 HEAD 请求,RTT 数据写到共享 map。主循环只是读取这个 map,不会做 IO,这样不会阻塞请求处理。

4. 选路策略:选哪个 backend 转发。round_robin、random、least_conn、ip_hash、client_ip_hash、uri_hash、header、cookie、query 都在 selectionpolicies.go 里实现。

5. DialInfo:携带 dial 超时、TLS 配置、连接池信息,真正去 dial backend 的对象。

5 个步骤里 1 / 2 / 4 是无 IO 的(纯计算),3 是 read shared state,5 是真正发起 IO。整个链路在请求时运行,所以 selection policy 的选择延迟直接体现在客户端 P99 上 —— 这是为什么 reverse_proxy 必须做得很细。

4.2 反向代理核心#

reverseProxy(rw, req, origReq, ...) 是真正的代理函数:

func (h *Handler) reverseProxy(rw http.ResponseWriter, req *http.Request, origReq *http.Request, repl *caddy.Replacer, di DialInfo, next caddyhttp.Handler) error {
    // 1. 准备请求:替换 host、写 X-Forwarded-For、补 headers
    req, err := h.prepareRequest(req, repl)
    
    // 2. 真正发出去
    res, err := h.transport.RoundTrip(req)
    
    // 3. 处理响应:处理 WebSocket 升级、trickle buffer 流式回写
    err = h.finalizeResponse(rw, status, res.Header(), res.Body, req, origReq, di)
    
    return err
}
go

prepareRequest 干了 6 件事:替换 path、改写 method、注入 X-Forwarded-For / X-Forwarded-Proto / X-Forwarded-Host、删除 hop-by-hop 头(Connection、Keep-Alive、Proxy-Authenticate 等)、转 req.URL。这一步的复杂度和 NGINX 的 proxy_set_header 块差不多。

finalizeResponse 同样复杂:

  • WebSocket 升级检测:Connection: Upgrade + Upgrade: websocket 时,把 response writer hijack 出来,直接做双向转发。
  • trickle buffer 流式回写:处理大文件响应时不能整个读到内存里,要把 response body 切分 chunk 边回写。
  • 移除 hop-by-hop 头:跟请求侧对称。
  • addXForwardedHeaders 补回:从 upstream 返回的 header 里去掉 X-Forwarded-* 再加上我们应用的。

4.3 tryAgain:重试决策#

func (lb LoadBalancing) tryAgain(ctx caddy.Context, start time.Time, retries int, proxyErr error, req *http.Request, logger *zap.Logger) bool {
    // 检查:
    // 1. 是不是还剩重试额度 (max_retries)
    // 2. 是不是还在 try_duration 窗口里
    // 3. 是不是 io error / 502 / 503 / 504
    // 都满足 -> 换一个 backend 重试
}
go

tryAgain 是个纯函数,判断当前错误是否值得重试。决策树有 3 层:

  1. 预算:retries < max_retries && time.Since(start) < try_duration
  2. 错误类型:net.OpError / 502 / 503 / 504 / EOF 都是可重试的
  3. 不重试:404 / 401 / 业务错误

为啥要拆这么细?因为代理服务器最容易踩的坑就是”上游业务 5xx 我要不要重试”。把决策做成纯函数,单元测试容易覆盖。

4.4 Cleanup:别让连接泄漏#

Cleanup() 干的事很少但很重要:

func (h *Handler) Cleanup() error {
    return h.tunnelTracker.cleanupAttachedConnections()
}
go

tunnelTracker 是 WebSocket / 长连接追踪器。Cleanup 是在配置重载时被调用的,如果当时还有活跃连接,就 push 到 context cancel,强制关掉 —— 不然这些 goroutine 会泄漏到下一个配置周期。

为什么 reverse_proxy 这么重?因为它是 Caddy 唯一做”主动 IO 客户端”行为的 handler。其他所有 handler 都是 pass-through(把请求 transform 然后继续),只有 reverse_proxy 真正发起了 TCP 连接。这就带来了连接池、健康检查、主动 IO 取消、流式回写这一整套复杂度。

五、Admin API:零掉线热重载的双阶段 Provision#

Caddy 启动后默认监听 localhost:2019,暴露一组 admin endpoint:

  • POST /config/ —— 直接推送 JSON Config
  • POST /load —— 推送 Caddyfile,内部转 JSON 等价于 POST /config/
  • POST /stop —— 优雅停止
  • GET /config/ —— 查看当前配置
  • GET /reverse_proxy/upstreams —— 查看后端状态
  • GET /metrics —— Prometheus 指标
  • GET /debug/pprof/ —— Go pprof

最有意思的是 POST /config/ —— 改一行 Caddyfile,curl 一下就能生效,不需要重启进程。

img5

5.1 四种触发来源#

POST /config/ 只是其中一种触发 reload 的入口。完整的触发来源有 4 类:

  • POST /config/ 直接推 JSON Config,body = *caddy.Config,走 json.Unmarshal 后替换
  • POST /load 推 Caddyfile 文本,适配器转 JSON,等价于 POST /config/
  • ConfigSettings.Load —— 在配置里配的 load_delay 定时拉模式,从外部拉配置(比如从 etcd 拉)
  • SIGHUP / SIGUSR1 —— 收到信号后重读磁盘上的 auto-config 文件

四种来源最终都汇聚到同一个函数:unsyncedDecodeAndRun(rawConfig, filename, watcher)。

5.2 unsyncedDecodeAndRun#

这个函数是 reload 链路的中央调度:

func unsyncedDecodeAndRun(rawConfig json.RawMessage, filename string, watcher bool) error {
    ctx, cancel := context.WithCancel(context.Background())
    ctx = context.WithValue(ctx, caddy.AdminConfigKey{...}, admin)
    
    // 解码成新 config struct
    newCfg, err := caddy.DecodeAdmin(rawConfig, ...)
}
go

四个关键步骤:

  1. 新建 context(老 ctx 不动)。如果新配置 provision 失败,我们还能保留老 ctx 继续运行 —— 这是”零掉线”的关键。
  2. json 解码新配置。DecodeAdmin 会调注册的 module unmarshaler,这是慢 IO,可能出现超时。
  3. 新 config 应用(下一节的 Phase 1)。
  4. 老 config cancel(Phase 2 之后才发生)。

unsynced 名字暗示它不加锁 —— 调用者需要自己保证不并发 reload。

5.3 Phase 1:Provision 新配置(出错则回滚)#

err = newCfg.Run(ctx)  // 这是 Phase 1
go

Run() 会做这些事:

  1. 遍历每个 app,调用 Provision(ctx)。Provision 是 module 的 setup 钩子,只做读 I/O(比如建连接池、加载证书、准备 DNS),不动 listener。
  2. automaticHTTPSPhase1:遍历 routes,把所有 host matchers 的域名收集起来,设置 HTTP→HTTPS 重定向 server。这是 Phase 1,因为它可能会往 config 里加新 server(自动 HTTPS 服务器),这些新 server 也需要 Provision。
  3. 失败回滚:如果 Provision 任何一步报错,调新 apps 的 Cleanup(),然后把新 ctx cancel 掉。老 ctx 不动,老进程继续运行。

这一步的设计关键:即使新配置有 syntax error,正在服务的请求一个都不丢。Caddy 把错误通过 response 返回,reload 失败,但服务没断。

5.4 Phase 2:Start 新配置 + 老配置 Shut Down#

Phase 1 通过后,Phase 2 才会执行:

err = newCfg.Start()  // Phase 2:把 listener 真正启动
go

Start 干了什么:

  1. 每个 app 调 Start()。这里才创建 net.Listener,把 socket bind 到端口。
  2. 自动 HTTPS Phase 2:触发 ACME 证书申请。
  3. 老 server 开始 graceful shutdown:Servers.ScheduleShutdown(gracePeriod)。gracePeriod 默认是 0,意思是无限等 —— 真正生产里要配一个合理的值(比如 30s)。
  4. 老 apps 调 Cleanup() / Stop():关闭 idle conn,释放资源。

注意 Phase 1 不动 listener, Phase 2 才动 listener。这就是”零掉线”的本质:

  • 在 Phase 1 失败时,listener 一直是老的,请求不会断
  • 在 Phase 1 → Phase 2 之间,新 listener 已经 ready 但还没开始接请求
  • 老 connections 在 grace_period 内继续服务,直到自然结束才关

但有一个细节要注意:shutdown_delay。这个参数是在 Stop 之前先等一段时,让你能在 LB 里先把 instance 摘掉。这样即便 reload 失败,你也不会被 LB 标记成 down:

{
    admin localhost:2019
    grace_period 30s
    shutdown_delay 10s
}
plaintext

这给了运维一个非常重要的双保险:重启之前先把 instance 从 LB 里摘掉,然后 reload,新 listener 立即就绪,然后老的 grace_period 走完退出。

六、Auto HTTPS:两阶段覆盖#

Auto HTTPS 是 Caddy 最有名的特性 —— 你只要在 site 块里写一个域名,证书就自动申请、续期、应用。这个特性的实现横跨 autohttps.go(859 行)和 caddytls/(整个 TLS 模块)。

err = app.automaticHTTPSPhase1(ctx, repl)  // 在 Provision 阶段
err = app.automaticHTTPSPhase2()          // 在 Start 阶段
go

Phase 1:收集所有”合格”的 host matchers。一个 host 是否合格走它是不是公网可解析的 DNS —— 走 IP 字符、localhost、单标签都跳过。然后给每个合格域名建一个 redirect server(http://example.com → https://example.com)。这一步不动 TLS。

Phase 2:在新 apps Start 之后,触发 ACME 颁发流程。如果要等证书(Let’s Encrypt 大概要等 60-90 秒),HTTP 请求会被 hold,直到证书就绪才放行。

这跟 Admin API 的 Phase 1 / Phase 2 是同一套抽象 —— Auto HTTPS 借用它来确保 cert ready 之后再开放 listener。

七、值得抄走的 5 个 Caddy 设计#

Caddy 的设计哲学里,有几个特别值得在你自己的项目里复刻:

1. 中间表示 + 多适配器。配置文件 → JSON → 运行时。这个套路在 Kubernetes(CUEF → GC → apiserver)、Terraform(HCL → JSON → plan)里都用。但 Caddy 把”adapter 是注册式的”这件事做到极致 —— 你可以自己写一个 kdl adapter,把它注册到 caddyconfig.RegisterAdapter("kdl", ...) 就能用,运行时一行代码不改。

2. 顺序无关的 directive 排序。这是 Caddy 最大的 UX 创新。用户不再需要记忆”rewrite 在 header 之前”,框架替你排。代价是 directive 必须有”严格序”的语义约束(redirect 之前 header 和 redirect 之后 header 行为不同),但只要约定下来,框架能算出正确顺序。

3. Module 命名空间。caddy.RegisterModule(App{}) + App.CaddyModule().ID = "http" + Module ID namespace=...。模块既可以是 handler,也可以是 listener wrapper,也可以是 storage —— 不需要为每种插件类型造不同的框架,统一通过 New() caddy.Module 接口注册。

4. 双阶段 Provision。Phase 1 失败不影响服务,Phase 2 才接 listener。这种”安全迁移”模式在你设计任何长连接服务(reload / hot config)时都应该抄。

5. Open by default。localhost:2019 admin endpoint 默认监听,你可以直接 curl 看配置。这个”先给你 80% 用例,复杂场景再配”的设计哲学是 Caddy 用户体验的核心 —— 1 行 caddy 就能跑 HTTP server,7 行能上 HTTPS + 反向代理 + 自动续期。

八、一点学习笔记#

读 Caddy 给我留下最深印象的不是它代码有多优雅(其实不算优雅,有几处是历史包袱),而是它把”Web 服务器”这个问题的每个面都思考过一遍:

  • 配置怎么写最舒服? → Caddyfile
  • 配置怎么编译成可执行的中间表示? → Adapter
  • 中间件怎么串? → Route + Group + Terminal
  • 怎么处理长连接、流式响应? → trickle buffer + WebSocket hijack
  • 怎么 reload 不掉请求? → 双阶段 Provision + grace_period + shutdown_delay
  • 怎么自动管证书? → 两阶段覆盖在 Provision + Start 上

115K 行 Go 代码,看上去很大;但每一行基本都在解决上面 6 个问题里的某一个。Caddy 不是一个”另一个 Web 服务器”,它是一份对”现代 Web 服务器应该是怎样的”这个问题的工程答复。


学习笔记来源:

  • caddyconfig/caddyfile/lexer.go —— 280 行手写 Lexer,支持 Heredoc / 引号 / 转义 / 环境变量
  • caddyconfig/caddyfile/parse.go —— 530 行 Parser,ServerBlock / Segment / import / snippet
  • caddyconfig/httpcaddyfile/directives.go —— 240 行默认指令顺序 + pile 分类桶
  • caddyconfig/httpcaddyfile/httptype.go —— 2800 行 Caddyfile adapter 核心
  • modules/caddyhttp/app.go —— 951 行 HTTP app,Provision / Start
  • modules/caddyhttp/routes.go —— 473 行 Route / Subroute / 中间件链
  • modules/caddyhttp/reverseproxy/reverseproxy.go —— 2252 行 reverse_proxy
  • modules/caddyhttp/autohttps.go —— 859 行 Auto HTTPS 两阶段
  • admin.go —— 1308 行 Admin API + reload 调度

仓库统计(深度 = 1,无 git history):

  • 总规模:115,823 行 Go 代码
  • 10 个核心模块目录
  • 最新提交:8507b63 caddytls: add not matcher for TLS handshakes (#8138)
  • License: Apache-2.0
  • GitHub: https://github.com/caddyserver/caddy ↗
深入解析 Caddy:Caddyfile 如何用「顺序无关」自动重排成可工作的 HTTP 管线
https://wth2jhl.online/blog/caddy-order-independent-config/
Author 小吴同学
Published at October 6, 2026