tva
← 洞察

叠加反向代理:一种生产架构

在单个应用前放置四层反向代理,乍看像是一个需要简化的系统。大多数介绍生产环境 Web 基础设施的教程只采用一层——Nginx、Caddy 或 Traefik——并将更多层视为过度设计。但现实中,这些工具各自解决不同的问题。理解其分工后,看似冗余的架构就变得条理清晰。

我们为多个客户部署并运行的架构是:Cloudflare 位于边缘,Traefik 作为可感知容器的入口,Varnish 负责 HTTP 缓存,Nginx 承载应用,应用本身位于链路末端。请求流程如下:

Cloudflare → Traefik → Varnish → Nginx → Application

下文说明各层的贡献、职责边界和性能影响,也包括这套技术栈相对于简单方案增加开销的地方。

各层实际负责什么

Cloudflare 位于公网边缘。它吸收 DDoS 流量、应用 WAF 规则,在全球分布的接入点终止 TLS,并在靠近最终用户的位置缓存静态资源。它不擅长根据容器情况做出智能路由决策,也不擅长缓存需要检查 cookie 的应用响应。这些工作应由其他层负责。

Traefik 是基础设施内部可感知容器的入口。在运行多租户 Docker 技术栈时,它的价值在于读取容器标签并据此路由,无需手工维护会因容器重启或扩容而失效的配置文件。它为内部或预发布域名管理 Let’s Encrypt TLS 证书,删除或重写 HTTP 头,并执行 IP 白名单、基本身份验证等中间件链。Traefik 不负责缓存,在高负载下压缩表现也不理想。它的职责是路由和中间件,并能高效完成这些工作。

Varnish 是 HTTP 加速器,位于入口与应用服务器之间,专门承担读取流量。调优后的 Varnish 实例可从内存每秒响应数万次请求,在不到一毫秒内返回完整 HTML 页面。其 VCL(Varnish Configuration Language)可以精确控制缓存什么、缓存多久、在什么条件下缓存,粒度远比 Cloudflare 的缓存规则精细。这对于通过自定义哈希计算可按用户缓存某些响应的身份验证应用(下方示例并未这样做,而是将已登录用户的流量直接转发,不使用缓存),或需要根据查询参数设置不同 TTL 的页面,尤为重要。

Nginx 在这套技术栈中承担它最初设计的职责:提供文件,并向上游应用进程代理请求。CloudPanel 将 Nginx 用作其托管 Web 服务器,为每个虚拟主机自动生成配置,处理 PHP-FPM、静态资源服务和 gzip 压缩。这些配置由 CloudPanel 维护,不应直接编辑。这里的 Nginx 不负责策略判断,只接收请求、转发给应用,再返回响应。

Traefik 配置:容器标签

这套技术栈的 Traefik 路由完全通过 Docker Compose 标签配置。在 Traefik 后加入 Varnish 时,关键在于让 Traefik 转发到 Varnish 容器,而非直接转发到 Nginx。这也是最容易令人困惑的地方:常见 Traefik 示例展示的都是直接连接应用的模式,而如何在这一流程中加入 Varnish,相关文档并不充分。

services:
  varnish:
    image: varnish:7.5
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.myapp.rule=Host(`www.example.com`)"
      - "traefik.http.routers.myapp.entrypoints=websecure"
      - "traefik.http.routers.myapp.tls=true"
      - "traefik.http.routers.myapp.tls.certresolver=letsencrypt"
      - "traefik.http.services.myapp.loadbalancer.server.port=80"
      - "traefik.http.middlewares.myapp-proto.headers.customrequestheaders.X-Forwarded-Proto=https"
      - "traefik.http.middlewares.myapp-proto.headers.customrequestheaders.X-Forwarded-Port=443"
      - "traefik.http.routers.myapp.middlewares=myapp-proto"
    networks:
      - traefik-public
      - internal

X-Forwarded-Proto 头至关重要。Varnish 到 Nginx 的通信在 Docker 内部网络上使用明文 HTTP,因此没有这个头时,应用会将每个请求都视为 HTTP,导致任何强制 HTTPS 的应用出现重定向循环。Traefik 在请求进入内部网络前设置这个头,Varnish 原样传递,Nginx 再转发给应用。配套的 X-Forwarded-Port 头,可避免同时检查端口的应用出现同类重定向循环。

第二个注意点是:Traefik 的 passHostHeader 默认值为 true,这意味着 Varnish 会收到原始 Host 头。这正是所需行为:Varnish 的 VCL 可以用它构建缓存键,Nginx 则用它选择虚拟主机。如果覆盖这一设置,Varnish 和 Nginx 都会收到错误的主机名,产生难以追踪的静默故障。

Varnish VCL:缓存逻辑

下面的 VCL 可作为 CloudPanel 后方 WordPress 或 PHP 应用的起点,并非完善的生产环境策略。它涵盖常见场景:登录用户和非幂等请求绕过缓存,延长静态资源缓存时间,对追踪参数进行归一化,并设置宽限期,在 Varnish 更新页面时提供稍旧的页面。投入生产前,还应让 WooCommerce 的购物车、结账和账户页面以及 wp_woocommerce_session_ cookie 绕过缓存。由于此处的 vcl_backend_response 以 return(deliver) 结束,Varnish 内置的逻辑不会执行,因而不会自动将带有 Set-Cookie 或 Cache-Control: private/no-store 的响应设为不可缓存,所以需要添加等效检查。

vcl 4.1;

backend default {
    .host = "nginx";  # service name in docker-compose
    .port = "80";
    .connect_timeout = 5s;
    .first_byte_timeout = 30s;
    .between_bytes_timeout = 10s;
}

sub vcl_recv {
    # Pass admin and authenticated requests
    if (req.url ~ "^/wp-(admin|login|cron|json)" ||
        req.http.Cookie ~ "wordpress_logged_in_" ||
        req.http.Cookie ~ "woocommerce_items_in_cart") {
        return(pass);
    }

    # Only cache GET and HEAD
    if (req.method != "GET" && req.method != "HEAD") {
        return(pass);
    }

    # Normalise tracking parameters out of the cache key
    if (req.url ~ "[?&](utm_source|utm_medium|utm_campaign|fbclid|gclid)=") {
        set req.url = regsuball(req.url,
            "&(utm_source|utm_medium|utm_campaign|fbclid|gclid)=[^&]+", "");
        set req.url = regsuball(req.url,
            "\?(utm_source|utm_medium|utm_campaign|fbclid|gclid)=[^&]+&", "?");
        set req.url = regsub(req.url,
            "\?(utm_source|utm_medium|utm_campaign|fbclid|gclid)=[^&]+$", "");
    }

    # Remove cookies for static assets — allows caching
    if (req.url ~ "\.(css|js|png|jpg|jpeg|gif|ico|woff2?|svg|webp)(\?.*)?$") {
        unset req.http.Cookie;
    }

    return(hash);
}

sub vcl_backend_response {
    # Cache static assets for 7 days
    if (bereq.url ~ "\.(css|js|png|jpg|jpeg|gif|ico|woff2?|svg|webp)(\?.*)?$") {
        set beresp.ttl = 7d;
        set beresp.grace = 24h;
        unset beresp.http.Set-Cookie;
        return(deliver);
    }

    # Cache HTML pages for 5 minutes with a 60-second grace period
    if (beresp.status == 200 && beresp.http.Content-Type ~ "text/html") {
        set beresp.ttl = 300s;
        set beresp.grace = 60s;
    }

    # Never cache error responses
    if (beresp.status >= 500) {
        set beresp.ttl = 0s;
    }

    return(deliver);
}

sub vcl_deliver {
    # Expose cache status for debugging
    if (obj.hits > 0) {
        set resp.http.X-Cache = "HIT";
        set resp.http.X-Cache-Hits = obj.hits;
    } else {
        set resp.http.X-Cache = "MISS";
    }

    # Remove server identity headers
    unset resp.http.Via;
    unset resp.http.X-Varnish;

    return(deliver);
}

vcl_recv 中的追踪参数归一化很容易被忽视,而忽视它代价很高。除非在 Cloudflare 配置 URL 重写,否则 UTM 值和 fbclid 标记仍会随原始 URL 到达 Varnish。如果不做归一化,每次 Facebook 广告点击都会为应用眼中的同一个页面创建独立缓存项。在一次推广活动期间,Varnish 缓存会因此分裂为数千个冷缓存项,完全抵消缓存命中率带来的收益。

vcl_backend_response 中的 grace 设置,是另一个值得深入理解的机制。缓存对象的 TTL 到期后,下一个请求通常必须等待 Nginx 返回新响应,Varnish 才能交付内容。在邮件群发或社交媒体提及等流量高峰中,多个请求会同时访问已到期的缓存项。没有宽限期时,对于可缓存对象,Varnish 仍会合并请求,只向 Nginx 发起一次获取,但其他请求必须等到该获取完成。设置 60 秒宽限期后,Varnish 会立即向这些请求提供过期缓存,同时由一次后台获取更新缓存。结果是在到期时,Nginx 只需处理一个请求,而非数百个,访问者也无需等待。

性能影响

这套技术栈的性能表现并不均匀。有些地方表现出色,有些地方则增加了可衡量的开销。

表现出色的地方:任何从 Varnish 内存提供的可缓存页面,在 Traefik 层的响应时间都低于 2ms。一个由 PHP 生成需要 400ms 的 WordPress 首页,在 TTL 到期前,后续每次请求都只需 1–2ms。吞吐量的改善不是小幅提升,而是数量级的增长。Nginx 提供静态资源本来就很快,Cloudflare 也会在边缘缓存这些资源,因此生产环境中 Nginx 很少需要处理它们。

增加开销的地方:缓存未命中的请求现在需要经过四跳,而非一跳。无法缓存的 PHP 请求,例如登录用户会话、POST 提交或 WooCommerce 购物车页面,在到达 PHP-FPM 前会经过 Traefik、Varnish 和 Nginx。同一宿主机上的每次 Docker 网络跳转,大约增加 0.1–0.3ms 延迟。对于 50ms 的 PHP 响应,这可以接受;对于应在 5ms 内响应的 API 端点,则不合适。此时,正确做法是通过独立的 Traefik 路由规则,将 API 路径直接路由到应用容器,完全绕过 Varnish。

内存容量比大多数参数更重要:Varnish 从 RAM 分配对象存储空间,官方 Docker 镜像默认分配 100MB。对于独立 URL 很多的网站,这些空间很快就会填满,淘汰率上升,无论如何设置 TTL,缓存命中率仍然偏低。部署后首先应采取的性能优化措施是增加分配容量,例如使用启动参数 -s malloc,2G,或 Docker 中的 VARNISH_SIZE=2G 环境变量,再通过 varnishstat -1 | grep hit 观察效果。

SSL 终止:公网 TLS 在 Cloudflare 终止。使用 Cloudflare 的“Full (strict)”SSL 模式时,Cloudflare 随后通过 HTTPS 连接 Traefik,使用上方所示路由器的 Let’s Encrypt 证书。在 Traefik 后方,Traefik 到 Varnish、Varnish 到 Nginx 的链路在 Docker 内部桥接网络上使用明文 HTTP。这是有意的设计。在所有容器都由您控制的单一宿主机上,为容器间内部通信增加 TLS 会带来 CPU 开销,但安全收益有限,因为桥接流量不会离开宿主机;它也无法防范容器或宿主机被攻陷。如果威胁模型要求容器间通信加密,则需要另行讨论架构。

这套技术栈的适用场景

这并非通用建议。单个容器化应用如果没有缓存需求,也不受 CloudPanel 约束,让 Traefik 直接路由到应用更简单,也足够正确。四层架构的复杂性只有在特定场景才有价值:Nginx 无法替换的 CloudPanel 托管环境,全页缓存能让吞吐量提升一个数量级的 CMS 网站,或 Traefik 已为十多个容器提供路由的多服务部署。在后一种情况下,为其中一个容器加入 Varnish,只是增加有限成本,而非引入新系统。

清晰分工让复杂性长期可控。Traefik 配置放在 Docker Compose 标签中;Varnish 逻辑放在与 Compose 技术栈共同进行版本管理的 VCL 文件中;Nginx 配置由 CloudPanel 管理。这些系统互不侵入其他层的职责范围。行为异常时,诊断路径很清楚:X-Cache 头说明响应是否来自 Varnish;Traefik 访问日志记录路由器、后端服务,以及上游返回的状态码和发送给客户端的状态码;Nginx 错误日志记录 PHP-FPM 的报告。每层都产生自己的可观测信号,故障不会悄然跨层叠加。

看似冗余的结构,实际上划清了边缘防护、路由、缓存和应用服务四项职责的边界。这四项职责可以独立扩展,也可以独立发生故障。为这样的边界增加配置,是值得的。

相关洞察

延伸阅读