上一篇 下一篇 分享链接 返回 返回顶部

一站多 CDN 怎么配?从 Nginx 到 Envoy 的香港服务器落地步骤与常见坑

发布人:Minchunlin 发布时间:2025-09-27 09:00 阅读量:1168


香港机房的业务监控里北京、上海、广州三地的延迟曲线不断抖动,用户投诉“视频偶发卡顿”,运营同事的手机一直在闪。老板一句话:“能不能一站多 CDN?坏一家别全掉线。”

我当时的边界是 Nginx 做边缘终止、上游是应用集群;但想把“流量治理”这层进一步抽象和自动化,于是决定在香港把入口从 Nginx 迁到 Envoy,同时打通多家 CDN 的接入与自动故障转移。下面是我那一夜的完整过程。

我们要解决什么

  • 一站多 CDN:同一主站域名接入 2~3 家 CDN(示例:CDN-A、CDN-B、CDN-C),做权重调度 + 健康切换。
  • 边缘代理演进:从 Nginx 迁到 Envoy,引入更灵活的路由、观测、限流与熔断能力。
  • 真实用户 IP 还原:多 CDN 头部不统一,源站要可靠恢复真实客户端 IP。
  • 证书与 TLS:在 CDN 与源站混合场景下,证书自动化 + TLS/HTTP2/HTTP3 配置优化。
  • 观测与回滚:全链路指标、可回滚发布、分钟级失效切换。

逻辑拓扑(ASCII)

[Client]
   |  (Anycast/Edge)
   v
+------------------------------+
|  CDN-A   CDN-B   CDN-C       |  <- 一站多CDN,DNS权重/地域/健康调度
+------------------------------+
           |  (X-Forwarded-For / CF-Connecting-IP / True-Client-IP)
           v
     [Hong Kong IDC]
           |
     +-----------+
     |  Envoy    |  <- L7入口: TLS终止/路由/限流/熔断/观测
     +-----------+
           |
     +-----------+
     |  Nginx    |  <- 应用前置/静态/缓存/本地健康
     +-----------+
           |
      [App Cluster]

实际环境与参数

硬件与网络(香港)

参数
机柜 1/4 Rack,220V,双路供电
服务器 2× 节点(可单节点起步):AMD EPYC 7302P 16C/32T,128GB RAM
存储 2× NVMe 企业盘(Intel P5510 3.84TB,RAID1 via mdadm)
网卡 Intel X710 双口 10GbE(其中 1 口上联,1 口备用/内网)
带宽 单机 1Gbps 保底,峰值 10Gbps(95 计费),多运营商混接
交换 ToR 10G,支持 LACP(但入口只做单口以简化问题)

注:配置并非“唯一正确”,重点是稳定+可扩展。NVMe 选企业盘是为保证随机读写与寿命;10G 是为后续横向扩容。

系统与版本

  • OS:CentOS 7.9(内核 3.10.x,稳定为主)
  • Nginx:1.24.x(自编译,启用 http_realip_module、http_v2_module、stream、brotli 可选)
  • Envoy:1.29.x(Docker 运行,规避 CentOS 7 glibc 版本问题)
  • 证书:Let’s Encrypt(DNS-01)或企业证书(ECDSA + RSA 双证)
  • DNS:Route53(演示用,任意支持权重/健康检查的 DNS 均可)
  • 监控:Prometheus + Grafana;日志:ELK/ClickHouse 二选一

步骤一:DNS 设计与一站多 CDN 调度

记录规划

记录 类型 说明
www.example.com CNAME 指向 edge.example.com(可被权重/故障转移)
edge.example.com CNAME 指向 CDN 权重池(如 pool.example.com
pool.example.com 多条 CNAME 分别指向 www.example.com.cdn-a.netwww.example.com.cdn-b.netwww.example.com.cdn-c.net

策略要点

  • TTL:建议 30~60s,降低切换成本(太短会增加权威 DNS 压力)。
  • 策略:起步用加权轮询;若 DNS 支持则加上故障剔除(基于健康检查 URL)。
  • 健康检查 URL:https://www.example.com/cdn/healthz 返回 200,正文短小且不可缓存。
  • 地域策略(可选):对中国大陆/东南亚/北美设置不同权重,基于 RTT 与错误率滚动调整。

小技巧:池子层面用“别名域”,能在大故障时一把切。

步骤二:证书与 TLS(CDN+源站混合场景)

为何 DNS-01

多数 CDN 在回源/回源验证时会挡住 HTTP-01 挑战,或者你不想暴露回源口。DNS-01 更稳。

使用 acme.sh(示例 Cloudflare DNS)

curl https://get.acme.sh | sh
~/.acme.sh/acme.sh --set-default-ca --server letsencrypt
export CF_Token="<token>"
export CF_Account_ID="<id>"
export CF_Zone_ID="<zone>"
~/.acme.sh/acme.sh --issue -d example.com -d *.example.com --dns dns_cf --keylength ec-256
~/.acme.sh/acme.sh --install-cert -d example.com \
  --ecc --fullchain-file /etc/ssl/example.com/fullchain.pem \
  --key-file /etc/ssl/example.com/privkey.pem --reloadcmd "docker exec envoy kill -HUP 1 || systemctl reload nginx"

TLS 最佳实践(边缘代理)

  • TLS1.3 优先,保留 TLS1.2 兼容;
  • 优先 ECDSA 证书,兼容 RSA;
  • 开启 OCSP Stapling;
  • ALPN:h2,http/1.1;
  • CDN 与源站之间若需双向 TLS(mTLS),在 Envoy 配置中定义验证 CA。

步骤三:Nginx 基线(作为上游应用前置)

编译与安装(CentOS 7)

yum install -y gcc gcc-c++ make pcre-devel zlib-devel openssl-devel git
useradd --system --no-create-home --shell /sbin/nologin nginx
curl -O http://nginx.org/download/nginx-1.24.0.tar.gz && tar xf nginx-1.24.0.tar.gz
cd nginx-1.24.0
./configure \
  --prefix=/etc/nginx \
  --sbin-path=/usr/sbin/nginx \
  --with-http_ssl_module \
  --with-http_v2_module \
  --with-http_realip_module \
  --with-stream \
  --with-pcre \
  --with-threads
make -j && make install

真实客户端 IP 还原(多 CDN 头部)

不同 CDN 头字段可能是:CF-Connecting-IP、True-Client-IP、X-Forwarded-For 等。原则:

  • 只信任 CDN 回源 IP 段(防止 XFF 伪造);
  • 统一回落到 X-Forwarded-For 最左侧。

自动维护 CDN IP 段(ipset)

# /usr/local/bin/update_cdn_ipset.sh
#!/usr/bin/env bash
set -euo pipefail
TMP=$(mktemp)
# 这里示例性抓取,你应替换为各CDN官方IP段获取地址或内部配置库
curl -fsSL https://example.com/cdn-a-ips.txt >> "$TMP"
curl -fsSL https://example.com/cdn-b-ips.txt >> "$TMP"
/sbin/ipset create cdn_ips hash:ip -exist
while read -r ip; do
  [[ -z "$ip" ]] && continue
  /sbin/ipset add cdn_ips "$ip" -exist || true
done < "$TMP"
rm -f "$TMP"

crontab:*/10 * * * * /usr/local/bin/update_cdn_ipset.sh >/dev/null 2>&1

Nginx 里只信任这些 IP

# /etc/nginx/conf.d/realip.conf
geo $trusted_proxy {
    default 0;
    # 通过 ipset 动态判断(需要 ngx_http_auth_request_module 或 lua;简化起见,这里直接列网段或用include文件)
}

# 简化:用 include 引用各 CDN 网段(手工或脚本更新)
# include /etc/nginx/trusted_cdn_a.conf;
# include /etc/nginx/trusted_cdn_b.conf;

real_ip_header X-Forwarded-For;
set_real_ip_from  10.0.0.0/8;     # 内网/自有代理
set_real_ip_from  103.21.244.0/22;  # 示例:某CDN网段(替换为真实)
# ... 其余网段
real_ip_recursive on;

多头字段回落

# 提前在server级用map统一抽取真实IP
map $http_cf_connecting_ip $client_ip_cand1 { default $http_cf_connecting_ip; }
map $http_true_client_ip  $client_ip_cand2 { default $http_true_client_ip; }
map $http_x_forwarded_for $client_ip_cand3 { default $proxy_add_x_forwarded_for; }
map "$client_ip_cand1$client_ip_cand2$client_ip_cand3" $client_real_ip {
    ~([0-9a-f:\.]+) $1; # 简化匹配
}

log_format main '\n$remote_addr => real:$client_real_ip - $remote_user [$time_local] '
                 '"$request" $status $body_bytes_sent "$http_referer" '
                 '"$http_user_agent" rt=$request_time uct=$upstream_connect_time uht=$upstream_header_time urt=$upstream_response_time';
access_log /var/log/nginx/access.log main;

经验:不要全信任 XFF。一定结合 IP 白名单或 ipset,否则有人可以伪造 XFF 打穿你所有限流与审计。

基线站点(含健康检查)

server {
    listen 127.0.0.1:8080;
    server_name _;

    location = /cdn/healthz {
        add_header Cache-Control "no-store";
        return 200 'ok';
    }

    location /static/ {
        root /data/www;
        expires max;
        add_header Cache-Control "public, max-age=31536000, immutable";
    }

    location / {
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $client_real_ip;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_pass http://app_cluster;
        proxy_read_timeout 30s;
    }
}

upstream app_cluster {
    server 10.10.10.11:9000 max_fails=3 fail_timeout=10s;
    server 10.10.10.12:9000 max_fails=3 fail_timeout=10s;
    keepalive 256;
}

步骤四:引入 Envoy 作为统一入口

我把 Envoy 放在最前面,Nginx 退为上游前置与静态/缓存层。Envoy 负责更复杂的路由、观测与限流。

容器运行(CentOS 7 推荐)

yum install -y yum-utils device-mapper-persistent-data lvm2
yum install -y docker-ce
systemctl enable --now docker

mkdir -p /etc/envoy
cat >/etc/envoy/envoy.yaml <<'YAML'
static_resources:
  listeners:
  - name: listener_https
    address: { socket_address: { address: 0.0.0.0, port_value: 443 } }
    filter_chains:
    - filters:
      - name: envoy.filters.network.http_connection_manager
        typed_config:
          "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
          stat_prefix: ingress_http
          use_remote_address: true
          xff_num_trusted_hops: 1  # 如果 CDN 只在最前面一跳
          normalize_path: true
          request_timeout: 30s
          common_http_protocol_options: { idle_timeout: 60s }
          http2_protocol_options: {}
          route_config:
            name: local_route
            virtual_hosts:
            - name: app
              domains: ["*"
              ]
              routes:
              - match: { prefix: "/cdn/healthz" }
                direct_response: { status: 200, body: { inline_string: "ok" } }
              - match: { prefix: "/" }
                route:
                  cluster: nginx_upstream
                  timeout: 30s
                  retry_policy: { retry_on: 5xx, num_retries: 2 }
          access_log:
          - name: envoy.access_loggers.file
            typed_config:
              "@type": type.googleapis.com/envoy.extensions.access_loggers.file.v3.FileAccessLog
              path: /var/log/envoy/access.log
      transport_socket:
        name: envoy.transport_sockets.tls
        typed_config:
          "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.DownstreamTlsContext
          common_tls_context:
            alpn_protocols: ["h2", "http/1.1"]
            tls_certificates:
            - certificate_chain: { filename: "/etc/ssl/example.com/fullchain.pem" }
              private_key: { filename: "/etc/ssl/example.com/privkey.pem" }
  clusters:
  - name: nginx_upstream
    connect_timeout: 1s
    type: STRICT_DNS
    lb_policy: ROUND_ROBIN
    load_assignment:
      cluster_name: nginx_upstream
      endpoints:
      - lb_endpoints:
        - endpoint: { address: { socket_address: { address: 127.0.0.1, port_value: 8080 } } }
    circuit_breakers:
      thresholds:
      - max_connections: 2048
        max_pending_requests: 2048
        max_requests: 2048
    health_checks:
    - timeout: 1s
      interval: 5s
      unhealthy_threshold: 3
      healthy_threshold: 2
      http_health_check: { path: "/cdn/healthz" }
admin:
  access_log_path: /var/log/envoy/admin.log
  address: { socket_address: { address: 127.0.0.1, port_value: 9901 } }
YAML

docker run -d --name envoy --net host \
  -v /etc/envoy:/etc/envoy \
  -v /etc/ssl/example.com:/etc/ssl/example.com \
  -v /var/log/envoy:/var/log/envoy \
  envoyproxy/envoy:v1.29-latest \
  -c /etc/envoy/envoy.yaml

本地限流示例(防抖突刺)

# 片段: 在 http_connection_manager 的 http_filters 中加入
- name: envoy.filters.http.local_ratelimit
  typed_config:
    "@type": type.googleapis.com/envoy.extensions.filters.http.local_ratelimit.v3.LocalRateLimit
    stat_prefix: http_local_rate_limiter
    token_bucket: { max_tokens: 2000, tokens_per_fill: 2000, fill_interval: 1s }
    filter_enabled: { runtime_key: local_rate_limit_enabled, default_value: { numerator: 100, denominator: HUNDRED } }
    filter_enforced: { runtime_key: local_rate_limit_enforced, default_value: { numerator: 100, denominator: HUNDRED } }

实战体会:把尖峰削平,能省下 CDN 与源站很多钱,还能减少“锯齿”报警。

步骤五:一站多 CDN 的健康切换落地

目标

  • 当某 CDN 区域失败时,DNS 在 1~2 分钟内权重降为 0;
  • 全局失败时,一键把 pool.example.com 指向“最后保险”的那家;
  • 切换基于 外部探测(非机房内部),避免假健康。

实施

  • 准备 2~3 个 探测点(香港/新加坡/美西的小云主机),cron 每分钟请求 https://www.example.com/cdn/healthz,记录 HTTP 状态与 RTT。
  • 写个小服务(例如 Python FastAPI)汇总结果,调用 DNS API 调整权重:
  • RTT > 500ms 或 5xx 错误率 > 5%:降低 10% 权重;
  • 连续 3 次失败:权重置 0,触发告警;
  • 恢复观测 5 分钟稳定才逐步加权回升。
  • 变更记录写入 Git(IaC),确保审计与回滚。

“不要迷信健康检查 URL”,一线流量才是健康。建议叠加业务指标(如 2xx/非 2xx 比例)。

性能优化清单(源站)

内核与系统(CentOS 7)

cat >>/etc/sysctl.d/99-tuning.conf <<'SYS'
net.core.somaxconn = 1024
net.core.netdev_max_backlog = 250000
net.ipv4.tcp_max_syn_backlog = 4096
net.ipv4.ip_local_port_range = 1024 65000
net.ipv4.tcp_tw_reuse = 1
net.ipv4.tcp_fin_timeout = 15
net.ipv4.tcp_nodelay = 1
net.core.rmem_max = 134217728
net.core.wmem_max = 134217728
net.ipv4.tcp_rmem = 4096 87380 134217728
net.ipv4.tcp_wmem = 4096 65536 134217728
net.ipv4.tcp_mtu_probing = 1
fs.file-max = 1048576
SYS
sysctl --system
ulimit -n 1048576

Nginx

worker_processes  auto;
worker_rlimit_nofile 1048576;
events { worker_connections  65535; use epoll; multi_accept on; }
http {
  sendfile on; tcp_nopush on; tcp_nodelay on;
  keepalive_timeout  65; keepalive_requests 10000;
  gzip on; gzip_comp_level 5; gzip_types text/plain text/css application/json application/javascript; # CDN 场景谨慎双压缩
  brotli on; brotli_comp_level 5; brotli_types text/plain text/css application/json application/javascript;
}

Envoy 关键参数

  • common_http_protocol_options.idle_timeout = 60s(避免长时间占用)
  • http2_protocol_options 开启(对 CDN/客户端更友好)
  • circuit_breakers 合理设置连接与请求上限(见上文)
  • preconnect(1.29+ 支持预连接)适度开启以降低首包延迟

观测与告警

指标(Prometheus)

  • Envoy:listener.downstream_cx_active、cluster.upstream_rq_time、http.ingress.downstream_rq_xx
  • Nginx:导出 stub_status 或使用 exporter
  • 系统:带宽、丢包、SLA;磁盘延迟(NVMe)

日志

  • 接入层日志需包含:真实客户端 IP、CDN 头部、上游时延、状态码;
  • 示例字段:client_ip, cdn_vendor, xff, req_time, upstream_time, status, route, ua

常见坑与解决过程

现象 定位与解决
真实 IP 错乱 统计里用户 IP 全是 CDN 回源 IP 只信任 CDN 网段 + 统一 XFF 最左侧;对不同 CDN 的自定义头设置回落逻辑;开启 real_ip_recursive on
证书续期失败 acme http-01 一直失败 切 DNS-01;或给回源开临时直连域名仅内网使用
回源 499/504 峰值期大量 499/504 Envoy 本地限流、上游 keepalive、内核队列增大;检查 CDN 到源站 RTT;提升 proxy_read_timeout
WebSocket 断流 页面可开但 WS 频繁断线 确保 CDN 支持 WS;Envoy upgrade_configs 打开;上游超时同步
CORS 混乱 小范围前端报 CORS 在边缘统一 Access-Control-Allow-*;避免 CDN/源站双重覆盖
HTTP/2 帧错 少量浏览器报 PROTOCOL_ERROR 关闭过老的 TLS1.0/1.1;检查 ALPN;升级有问题的中间盒
回源头过大 413/414 控制 Cookie/头大小;Nginx large_client_header_buffers;Envoy max_request_headers_kb
直接打源 绕 CDN 直连源站 源站仅放行特定 CDN 网段;源域名随机化;在 Envoy 做 SNI/Host

灰度与回滚:我当时的操作单

  • 新增 pool.example.com,把 www.example.com CNAME 指向它,TTL 60s;
  • 先只放 10% 流量到 CDN-B,观察 30 分钟:错误率、P95、带宽曲线;
  • Envoy 加入限流,逐步把 xff_num_trusted_hops 从 0 -> 1;
  • 夜间低峰切 Envoy 为默认入口,Nginx 退居上游;
  • 故障演练:手动置 CDN-A 权重 0,确认切换时延 < 2 分钟;
  • 留下“红钮”:pool.example.com 指向单一 CDN 的别名,必要时一键回滚。

成本与收益(粗略示例)

项目 之前(单 CDN + Nginx) 之后(多 CDN + Envoy)
峰值可用性 99.90% 99.97%
P95 首字节(CN 南部) 420ms 300ms
P95 首字节(SEA) 380ms 280ms
CDN 成本 基线 1× -8%(按区域分流与限流削峰)
运维干预次数/月 8 次 2 次

注:示例数据,真实值以你业务曲线为准。核心是“多 CDN + 动态权重 + 边缘限流”带来的稳定性与成本平衡。

安全补强

  • 源站仅放行 CDN 网段;
  • Envoy 对 Host/SNI 白名单校验;
  • 合规日志(含审计)外发到集中平台;
  • WAF 优先放在 CDN;特殊路径(如管理接口)强制绕 CDN,只走专线 + mTLS。

切换完成那一刻,曲线像熨斗熨过一样平顺。报警频道静了,运营同事回了个 “👍”。我站在风机后面看着那台机子,风从 10G 口呼啸着进来,又从 1G 账单里悄悄地省了出去。第二天的站会我只说了一句:“今晚别打扰我,睡觉。”

附录:关键文件模板

1)Route53 权重记录(思路示例)

{
  "Comment": "Weighted multi-CDN",
  "Changes": [
    {
      "Action": "UPSERT",
      "ResourceRecordSet": {
        "Name": "pool.example.com.",
        "Type": "CNAME",
        "SetIdentifier": "cdn-a",
        "Weight": 60,
        "TTL": 60,
        "ResourceRecords": [{"Value": "www.example.com.cdn-a.net."}]
      }
    },
    {
      "Action": "UPSERT",
      "ResourceRecordSet": {
        "Name": "pool.example.com.",
        "Type": "CNAME",
        "SetIdentifier": "cdn-b",
        "Weight": 30,
        "TTL": 60,
        "ResourceRecords": [{"Value": "www.example.com.cdn-b.net."}]
      }
    },
    {
      "Action": "UPSERT",
      "ResourceRecordSet": {
        "Name": "pool.example.com.",
        "Type": "CNAME",
        "SetIdentifier": "cdn-c",
        "Weight": 10,
        "TTL": 60,
        "ResourceRecords": [{"Value": "www.example.com.cdn-c.net."}]
      }
    }
  ]
}

2)Envoy 追加:上游 mTLS(如有需要)

transport_socket:
  name: envoy.transport_sockets.tls
  typed_config:
    "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext
    common_tls_context:
      validation_context:
        trusted_ca: { filename: "/etc/ssl/ca.crt" }
      tls_certificates:
      - certificate_chain: { filename: "/etc/ssl/client.crt" }
        private_key: { filename: "/etc/ssl/client.key" }

3)Nginx 大头优化(防 413/414)

http {
  client_max_body_size 100m;
  large_client_header_buffers 8 32k;
}
目录结构
全文