六个容器,一扇门
整个栈是 docker compose 中的六个服务:postgres、minio、auth、blog、frontend 和坐在所有前面的 caddy。其中只有一个,caddy,实际上对外界暴露了一个端口。其他一切都通过 compose 网络互相交谈,容器名对容器名,从外面完全看不见。
这就是反向代理的整个思想,一旦你把行话剥掉:一扇进房子的门,然后根据你要求的路径门决定出你真正想要的那个房间。浏览器只知道一个地址。它不知道 auth 和 blog 甚至是分开的进程运行在分开的地方。
handle /media/* {
request_body {
max_size 2MB
}
reverse_proxy minio:9000
}
handle {
request_body {
max_size 2MB
}
reverse_proxy frontend:3000
}上面没有显式匹配的任何东西掉到最底下那个赤裸裸的 handle 块,它去往 frontend。frontend 然后对 auth 和 blog 做它自己的内部代理以做真正的 API 调用,所以从 Caddy 的观点直接真的只重要两个目的地:minio 做原始媒体文件,frontend 做绝对所有其他东西。
自动 TLS,我其实还没用过的东西
Caddy 的整个名声就是自动 HTTPS,它会从你在配置中命名一个真实域名就为你从 Let's Encrypt 获得一个真正的证书。本地什么都不适用,{$DOMAIN} 默认是 localhost 而 Caddy 就提供普通 HTTP,完全不需要证书舞蹈。时刻这真的运行在一个真实域名上,那个同样的配置行应该就开始请求和自己更新证书,没有独立的 certbot cron 工作,从没有手动更新。自从这个博客还没有运行在真实域名上,我还没有真正测试那条腿,诚实地说那是这里面我最不确定会第一次尝试时顺利的部分。所有东西在文档中看起来简单,直到你是真正运行它的那个人。
一跳,信任,背后什么也没有了
坐在一个代理后面打破两件你不会想到直到它们打破:frontend 自己的 CSRF 检查,它比较请求的来源对它认为自己地址的东西,和速率限制,需要真正访问者的 IP,不是 Caddy 的。
# 在 Caddy 后浏览器的来源是公共的 DOMAIN,不是 :3000。
# 从 Caddy 的转发头派生来源,所以 adapter-node 的
# CSRF/origin 在表单 POST 上的检查为 http://localhost 和
# https://<domain> 都通过。
PROTOCOL_HEADER: x-forwarded-proto
HOST_HEADER: x-forwarded-host
ADDRESS_HEADER: x-forwarded-for
XFF_DEPTH: "1"那最后一行,XFF_DEPTH: "1",花了最长的时间来实际理解。没有它,getClientAddress() 只返回无论什么容器碰巧打开了插座,那总是 Caddy,因为 Caddy 是唯一直接连接到 frontend 容器的东西。告诉它信任恰好一跳意味着它从 Caddy 自己附加的头读取客户端地址,不是从任何一个客户端可以通过在链的更早地方前置假条目来伪造的东西。把那个数字搞错以任何一个方向,信任零跳每个访问者看起来像 Caddy,信任太多而一个恶意客户端可以只说谎关于他们是谁。
健康检查:让"向上"意味着什么
compose 中的每个服务都有一个健康检查,我一开始没有认真对待这些,只是从一个例子复制了一个并继续前进:
# 我从一个例子复制,没有进一步思考它
frontend:
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/"]
interval: 5s
timeout: 5s
retries: 5然后我真的需要 depends_on: condition: service_healthy 来正确地工作,因为 blog 甚至不应该开始接受流量直到 postgres 真的准备好回答查询,不只是"容器进程启动了"。
frontend:
healthcheck:
# 使用 127.0.0.1,不是 localhost:在 Alpine localhost 解析到 IPv6 ::1
# 而 Node 服务器只在 IPv4 上监听,所以 localhost 探测失败而且
# 容器被错误地标记为不健康。
test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:3000/"]
interval: 5s
timeout: 5s
retries: 5那个 127.0.0.1 而不是 localhost 花了我一个真正困惑的半小时。在这里所有东西运行的基于 Alpine 的镜像上,localhost 解析到 IPv6 loopback 第一个,但 Node 服务器只在 IPv4 插座上监听,所以健康检查连接到什么都没有,超时,并标记了一个完全工作的容器不健康。Compose 然后拒绝让任何取决于它的东西起来,所以整个栈只是坐在那里,所有东西实际上很好里面,docker 被说服相反。用字面量 IP 交换它直接修复了它,现在我默认使用 127.0.0.1 在每个健康检查里习惯而不是信任 localhost 在一个容器内部意味着我认为它意味着什么。
Caddy 自己的健康检查打击它的内部管理员 API 而不是公共网站同样的原因,所以一旦 DOMAIN 变成一个真实的主机名它保持工作,而一个普通的 http://localhost 请求会停止匹配任何东西。
相同的推理运行通过 postgres,minio,auth 和 blog 太,每一个测试真的证明就绪的东西(pg_isready,MinIO 自己的健康 endpoint,每个 Go 服务的 /healthz)而不只是检查进程没有崩溃。一个崩溃的进程和一个向上的但仍在运行它自己的启动迁移的进程从外面看不出什么区别如果你检查的一切就是"它接受了一个 TCP 连接",我只在 compose 真的在 auth 的迁移对 blog 试图和数据库谈话不准备好它时比赛之后才在乎那个区别。
路径匹配陷阱
一件关于 Caddy 的事不明显从文档的扫一眼:handle 块不是在你写它们的顺序中被匹配的,它们被匹配通过路径模式有多具体。有一个块对于 /auth/permissions 返回一个赤裸裸的 404(那个 endpoint 提供一个内部角色对性能矩阵没有地方可从公共 GET 可到达的),坐在很好上面捕获所有 handle {} 在文件的最底。不重要它不是最后,最具体的路径总是赢无论它坐哪。花了实际上读 Caddy 的文档而不是猜测来信任那,因为它完全是那个种假设("肯定首先匹配赢,像我用过的其他一切东西")那会有静地打破什么最终,可能最坏的时间。
