使用 HAProxy 代理 Strapi

页面摘要: 使用 server.url 和 server.proxy 选项告知 Strapi 它的公开地址以及前方存在代理。然后定义一个终止 TLS 的 HAProxy 前端,以及一个在 /_health 上对 Strapi 进行健康检查的 backend。

Strapi 监听一个普通的 HTTP 端口,自身并不终止 TLS。诸如 HAProxy 这样的反向代理位于其前方,用于处理 HTTPS、在 443 端口提供你的应用,并将请求转发给 Strapi 进程。当你还需要跨多个 Strapi 实例进行健康检查和负载均衡时,HAProxy 是一个合适的选择。本指南先介绍让你的应用感知代理的 Strapi 配置,然后是将来流量路由到它的 HAProxy 配置。Strapi 的更改属于你的项目,因此请在部署前完成。HAProxy 的更改则在运行 HAProxy 的机器或容器上进行。

WARNING
  • 一个能够在本地启动并运行的 Strapi 5 应用(参见 部署指南)。
  • HAProxy 2.2 或更高版本,运行在与 Strapi 相同的主机上,或作为能够访问 Strapi 的容器运行(参见 HAProxy 安装说明)。本指南中的健康检查语法需要 2.2 或更高版本。
  • 一个 DNS A 记录指向 HAProxy 主机的域名。
  • 一个 TLS 证书和私钥,已拼接为单个 PEM 文件。
  • 具有 sudo 权限的 Shell 访问。

为反向代理配置 Strapi

Strapi 需要知道它对外提供服务的公开地址,并且需要信任代理添加的头部。如果没有这两项设置,Strapi 会从 localhost:1337 构建 URL,并将代理的 IP 地址读取为客户端 IP。

设置公开 URL

服务器配置中的 url 选项定义了应用的公共地址。Strapi 使用它构建密码重置邮件、第三方登录提供方和媒体资源路径的绝对 URL。

将其设置为你应用访问者在浏览器中使用的地址:

JavaScript

module.exports = ({ env }) => ({
  host: env('HOST', '0.0.0.0'),
  port: env.int('PORT', 1337),
  url: env('PUBLIC_URL', 'https://api.example.com'),
  app: {
    keys: env.array('APP_KEYS'),
  },
});

TypeScript

export default ({ env }) => ({
  host: env('HOST', '0.0.0.0'),
  port: env.int('PORT', 1337),
  url: env('PUBLIC_URL', 'https://api.example.com'),
  app: {
    keys: env.array('APP_KEYS'),
  },
});
WARNING

更改 /config/server.js 需要重新构建管理面板。保存文件后请运行 yarn build 或 npm run build。

信任代理头部

HAProxy 会添加一个携带原始客户端 IP 地址的 X-Forwarded-For 头部。在你开启代理支持之前,Strapi 会忽略该头部。

通过服务器配置中的 proxy 选项启用代理支持:

JavaScript

module.exports = ({ env }) => ({
  host: env('HOST', '0.0.0.0'),
  port: env.int('PORT', 1337),
  url: env('PUBLIC_URL', 'https://api.example.com'),
  // highlight-start
  proxy: {
    koa: true,
    maxIpsCount: 1,
  },
  // highlight-end
  app: {
    keys: env.array('APP_KEYS'),
  },
});

TypeScript

export default ({ env }) => ({
  host: env('HOST', '0.0.0.0'),
  port: env.int('PORT', 1337),
  url: env('PUBLIC_URL', 'https://api.example.com'),
  // highlight-start
  proxy: {
    koa: true,
    maxIpsCount: 1,
  },
  // highlight-end
  app: {
    keys: env.array('APP_KEYS'),
  },
});

每个选项起不同作用:

选项作用
proxy.koa当为 true 时,Strapi 信任 X-Forwarded-* 请求头。客户端 IP、协议和主机从代理读取,而非 socket。
proxy.maxIpsCount(v5.52.0+)从转发请求头链末尾读取的地址数量。单个代理设为 1,若请求经过多个代理则设为代理数量。
proxy.ipHeader(v5.52.0+)用于读取客户端 IP 的请求头。默认为 X-Forwarded-For,仅在代理发送其他请求头(如 CF-Connecting-IP)时设置。
IP 欺骗

将 proxy.koa 设为 true 但未设置 proxy.maxIpsCount 时,计数会保留其默认值 0,即无限制。这样一来,客户端可以发送 X-Forwarded-For: 203.0.113.9,一旦 HAProxy 附加上真实地址,Strapi 会从链条前端读取伪造的值,而不是末尾的真实地址。请始终将 maxIpsCount 设为 Strapi 前方代理的真实数量。

Strapi 读取由 proxy.ipHeader 指定的头部,其默认值为 X-Forwarded-For。下面配置中的 option forwardfor 指令设置的正是这个头部,因此你无需更改它。

提高上传的体积上限

默认情况下 HAProxy 不限制请求体大小,因此适用的是 Strapi 的限制。如果你通过媒体库上传文件,请提高这些限制。

在 Strapi 侧,body 中间件解析传入的请求。上传的文件以 multipart 数据到达,因此 formidable.maxFileSize 是限制它们的选项。formLimit 和 jsonLimit 选项覆盖普通表单字段和 JSON 负载,而非文件本身:

module.exports = [
  // ...
  {
    name: 'strapi::body',
    config: {
      formLimit: '100mb', // form body
      jsonLimit: '100mb', // JSON body
      textLimit: '100mb', // text body
      formidable: {
        maxFileSize: 100 * 1024 * 1024, // uploaded file size, in bytes
      },
    },
  },
  // ...
];

媒体库提供方执行单独的 sizeLimit,默认值为 1 GB。要更改它,请参阅本地上传提供方配置和最大文件大小。

大型上传还需要 HAProxy 超时时间留有富余,下面的配置将其设置为 60 秒。如果上传完成所需时间超过该值,请提高 timeout client 和 timeout server。

配置 HAProxy

在 Strapi 感知到代理之后,下一步就是将来流量承载到它的 HAProxy 前端和后端。

编写配置

HAProxy 从 /etc/haproxy/haproxy.cfg 读取其配置。以下定义了一个接收公开流量的前端,以及一个将流量转发到 Strapi 的后端:

global
    log /dev/log local0

    # 验证设置时使用的 `show stat` 命令需要此项
    stats socket /var/run/haproxy.sock mode 660 level admin

defaults
    mode http
    log global
    option httplog
    option forwardfor
    timeout connect 5s
    timeout client 60s
    timeout server 60s

frontend strapi_front
    bind :80
    bind :443 ssl crt /etc/haproxy/certs/api.example.com.pem

    # 将所有纯 HTTP 请求重定向到 HTTPS
    http-request redirect scheme https unless { ssl_fc }

    # 告知 Strapi 原始请求是通过 HTTPS 到达的
    http-request set-header X-Forwarded-Proto https if { ssl_fc }

    default_backend strapi_back

backend strapi_back
    option httpchk
    http-check send meth GET uri /_health
    http-check expect status 204

    server strapi1 127.0.0.1:1337 check

该文件中有 3 个指令承担了 Strapi 所依赖的工作。

option forwardfor 添加携带客户端地址的 X-Forwarded-For 头部。没有它,Strapi 只会看到 HAProxy 的地址。

http-request set-header X-Forwarded-Proto https if { ssl_fc } 需要手动设置,因为 HAProxy 不会自动添加它。Strapi 使用此头部将管理面板的刷新令牌 cookie 标记为 Secure。密码重置、登录提供方回调以及媒体资源的绝对 URL 是基于 url 选项构建的,而非基于此头部。

bind :443 ssl crt 期望证书和私钥已拼接为单个 PEM 文件。这与 Nginx 不同,Nginx 将它们作为 2 个独立路径接收。

校验文件并重新加载 HAProxy:

sudo haproxy -c -f /etc/haproxy/haproxy.cfg
sudo systemctl reload haproxy

haproxy -c 命令会在不应用配置的情况下检查它。重新加载错误的配置会导致站点宕机,因此请勿跳过这一步。

代理到运行在容器中的 Strapi

当 HAProxy 和 Strapi 都作为容器运行时,server 行通过名称指向 Strapi 服务。在 HAProxy 容器内部,127.0.0.1 指向的是该容器自身,而非 Strapi:

backend strapi_back
    option httpchk
    http-check send meth GET uri /_health
    http-check expect status 204

    server strapi1 strapi:1337 check

Strapi 也必须绑定到 0.0.0.0。如果绑定到 localhost,它只接受来自自身容器内部的连接,HAProxy 将无法访问它。设置公开 URL 时配置的 host 值已经使用了 0.0.0.0。

下面的 Compose 文件将这两个服务放在同一网络上,并且只发布 HAProxy:

services:
  strapi:
    image: my-strapi-app
    environment:
      HOST: 0.0.0.0
      PORT: 1337
      PUBLIC_URL: https://api.example.com
    # expose 使端口仅在网络内部可达
    expose:
      - '1337'
    networks:
      - web

  haproxy:
    image: haproxy:2.8-alpine
    ports:
      - '80:80'
      - '443:443'
    volumes:
      - ./haproxy/haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro
      - ./haproxy/certs:/etc/haproxy/certs:ro
    depends_on:
      - strapi
    networks:
      - web

networks:
  web:

HAProxy 在启动时解析一次 strapi。如果 Strapi 容器以新地址被重建,请重新加载 HAProxy,或声明一个 resolvers 段,使其自行重新解析名称。

健康检查 Strapi

Strapi 在 /_health 暴露了一个以 HTTP 204 No Content 作为响应的健康检查路由。上面的后端使用它来决定某个实例是否应接收流量:

backend strapi_back
    option httpchk
    http-check send meth GET uri /_health
    http-check expect status 204

    server strapi1 127.0.0.1:1337 check

http-check send 这一行很重要。如果保留默认值,option httpchk 会通过 HTTP/1.0 发送一个 OPTIONS 请求,因此显式指定方法和 URI 才能让检查以 GET 方式命中 /_health。随后的 http-check expect status 204 则要求恰好返回 Strapi 返回的 204 状态。如果没有它,HAProxy 会接受任何 2xx 或 3xx 响应,从而将无关的重定向也视为健康。

运行多个 Strapi 实例

为每个实例添加一行 server,并添加负载均衡算法以将流量分散到它们之间:

backend strapi_back
    balance roundrobin
    option httpchk
    http-check send meth GET uri /_health
    http-check expect status 204

    server strapi1 10.0.0.11:1337 check
    server strapi2 10.0.0.12:1337 check

由于这些实例共享同一个数据库,在横向扩展之前,有几个 Strapi 行为需要留意。

并发模式同步

Strapi 在启动时运行其模式同步,每个进程运行一次,实例之间不共享锁。如果重启后内容类型不变则是安全的,因为同步会检测到模式未变而不做任何操作。

同步在两种情况下不安全:发布会更改内容类型的版本,以及带有待处理迁移的版本。此时同时启动的实例会对同一数据库发出并发模式更改和迁移。

对于此类版本,先启动单个实例并让其完成启动,再启动下一个实例。这是在多个进程针对一个数据库运行时的固有属性,因此将实例分布在不同主机上也无法避免。

运行多个实例还有另外 3 个值得了解的后果:

  • CRON 任务在每个 Strapi 进程内调度,因此任务在每个实例上运行一次,而不是整体运行一次。设置为夜间运行的任务会运行与实例数量相同次数。如果项目设置 cron.enabled 为 true,要么将任务移出 Strapi,要么将它们保留在单个实例上。
  • Strapi 在内存中保存的任何内容都只属于一个实例,因为每个实例都是独立进程。Strapi 核心不会在它们之间复制任何状态。
  • 默认的本地上传提供方将文件写入实例自身的磁盘。共享同一台机器和项目目录的实例共享该磁盘,但不同机器或不同容器中的实例则不共享。在这种情况下,请使用基于对象存储的媒体库提供方之一。

验证代理设置

通过代理请求健康检查路由可以确认 HAProxy 能够访问 Strapi:

curl -I https://api.example.com/_health

正常的设置会返回状态行和头部:

HTTP/2 204
strapi: You are so French!

然后确认链条的其余部分:

  1. 在浏览器中打开 https://api.example.com/admin 并登录。管理面板通过 HTTPS 加载,没有混合内容警告。
  2. 在媒体库中上传一张图片。其 URL 使用的是你的域名,而非 localhost:1337。
  3. 检查 Strapi 的输出,确认显示的是真实的客户端 IP 地址,而非 HAProxy 的地址。
  4. 确认 HAProxy 认为后端健康。show stat 命令会报告每个服务器的状态:
echo "show stat" | sudo socat stdio /var/run/haproxy.sock

故障排查

以下每个症状都指向设置中的某一方面。症状以粗体显示,其后为导致该症状的原因以及需要更改的内容:

  • HAProxy 返回 503 Service Unavailable。 没有后端服务器通过其健康检查。请确认 Strapi 进程正在运行,并且 curl -i http://127.0.0.1:1337/_health 从 HAProxy 主机返回 204。

  • 即使 Strapi 有响应,健康检查仍然失败。 http-check expect status 204 要求恰好为 204。如果该路由前方的某个代理或中间件更改了状态,请调整期望值以匹配 Strapi 实际返回的状态。

  • 上传失败或超时。 请提高 Strapi body 中间件中的 formidable.maxFileSize,以及 HAProxy 中的 timeout client 和 timeout server,以便传输有充足时间完成。

  • Strapi 将 HAProxy 地址记录为客户端 IP。 HAProxy 配置中缺少 option forwardfor,或 Strapi 中 proxy.koa 未设为 true。

  • 管理面板的刷新 cookie 未被标记为 Secure。 X-Forwarded-Proto 头部未被设置,因此 Strapi 将请求视为纯 HTTP。该 cookie 仍会通过 HTTPS 到达浏览器,因为它默认为 SameSite=Lax,但它失去了将其排除在纯 HTTP 之外的 Secure 标志。请在前端添加 http-request set-header 这一行。

  • 密码重置邮件链接到 localhost:1337。 url 选项未设置,或仍指向本地地址。请将其设为公开 URL 并重新构建管理面板。

后续步骤

后续步骤

### 在进程管理器下运行 Strapi

查看服务器配置选项

阅读部署指南