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

静态资源在香港机房分层缓存:在香港服务器上用 Nginx+OpenResty 与对象存储(MinIO/Ceph RGW)落地冷热分级

发布人:Minchunlin 发布时间:2025-09-24 17:52 阅读量:779


我们业务刚刚做完一轮大促预热,国内用户通过专线和公网混合流量打到香港机房,首页首屏图像和前端包的 95 线已经快顶不住了。对象存储在后端悠悠地吐着 200ms~400ms 的响应,边缘节点的 Nginx 进程开始频繁出现 upstream prematurely closed connection。同事看了我一眼:“要么今晚把分层缓存上线,要么周日加班回滚”。我揉了揉眼睛,决定把白天演练过的 OpenResty 冷热分级策略硬上生产。凌晨 3 点,命中率从 42% 升到了 92%,回源带宽腰斩。那一刻,我知道,这套架构终于“活”了。

1. 目标与思路

目标:

把静态资源(前端包、图片、字体、媒体切片)做热/温/冷分层:

  • 热层:机房内 NVMe 本地缓存(Nginx/OpenResty)
  • 温层:同城“二级缓存”(可选,同机房另一组实例/小型分布式缓存池)
  • 冷层:对象存储(MinIO 或 Ceph RGW,S3 兼容)

将 95 线延迟压到 < 50ms(热命中),回源带宽缩减 60%+,抗峰值能力提升 3 倍

可观测、可回滚、可演练,并对常见坑位(Range、Gzip/Brotli、ETag、签名、缓存穿透、雪崩)有应对方案

总体思路:

  • 用 OpenResty 作为边缘代理:Lua 控制缓存 key、TTL、回源、熔断与“stale-while-revalidate”
  • 对象存储端采用 MinIO(中小规模、极致性能)或 Ceph RGW(规模化、弹性与多副本)
  • 将 Cache-Control/ETag 与 自定义元数据(如 x-amz-meta-ttl)结合,实现不同类型资源的分级 TTL
  • 引入 分布式一致性哈希 做“热层”横向扩展;以 Prometheus 做指标;以 灰度/蓝绿 做上线控制

2. 硬件与网络拓扑(香港机房)

2.1 服务器与网络规格(生产一组示例)

角色 机型 CPU 内存 本地盘 网卡 操作系统
边缘缓存(热层) 1U 定制(如 Dell R650 同级) 2× Intel Xeon Silver 4314 128GB 2× 3.84TB NVMe(RAID1) 2× 25GbE(Intel X710/BCM57414) CentOS 7.9
二级缓存(温层,可选) 同上 同上 128GB 4× 3.84TB NVMe(RAID10) 2× 25GbE CentOS 7.9
对象存储(MinIO/Ceph) 2U 盘柜型 2× Gold 6330 256GB 12× 12TB SATA + 2× 1.92TB NVMe(元数据/日志) 2× 25GbE CentOS 7.9
边界路由/交换 TOR ×2(如 Arista/Juniper) - - - 25/100GbE 上行 -
  • 文件系统:热层 NVMe 推荐 XFS,挂载参数 noatime,nodiratime,attr2,logbufs=8
  • NVMe 调度:none;每块盘独立 fstrim 周期任务

2.2 网络拓扑(ASCII)

[ Internet / 专线 / HKIX ]
          |
        ┌─┴───────────────┐
        │Border Router/LB │ (ECMP/BGP, DSR 可选)
        └─┬───────────────┘
          |
   ┌──────┴─────────┐
   │    25GbE TOR   │
   └──┬──────────┬──┘
      │          │
┌─────▼─────┐ ┌──▼────────┐
│ EdgeCache1│ │ EdgeCache2 │  ...  (Nginx + OpenResty, NVMe 本地缓存)
└─────┬─────┘ └──┬────────┘
      │           │
      └─────┬─────┘  (可选一致性哈希/共享虚拟IP)
            │
       ┌────▼──────────┐
       │  Warm Cache   │  (可选,作为二级缓存/回源削峰)
       └────┬──────────┘
            │
       ┌────▼─────────┐
       │  MinIO/RGW   │  (冷层对象存储,S3 兼容)
       └──────────────┘

3. 冷热分级策略与缓存键设计

3.1 资源类型分类与 TTL

类型 示例 热层 TTL 温层 TTL 冷层 TTL/版本
前端构建产物(带 hash) /assets/app.3a9f…js 24h 7d 永久(版本不可变)
图片(webp/jpg/png) /img/banner.webp?v=2025 2h 24h 30d
字体/图标 .woff2/.ttf/.svg 24h 30d 永久
HLS/DASH 切片 .ts/.m4s/.mpd 10m 60m 7d
HTML(少量) /index.html 30s(stale 30s) 5m 5m(需刷新)

要点:

  • 强缓存资源(带内容 hash)可大胆拉长 TTL。
  • 对 HTML 类弱一致资源,用 stale-while-revalidate 提升体验。
  • 对象存储端可通过 Cache-Control 或自定义元数据控制下游 TTL。

3.2 Cache Key 设计

  • 规范化 Accept-Encoding:区分 br/gzip/identity
  • 将 QueryString 带入 key(特别是 ?v=、?t= 版本)
  • 对 Range 请求,key 中加入 Range 段(或使用 slice 统一切片)
  • 对多语言/多区域,加 Accept-Language 或 region 前缀

示例 Key 模板:

$scheme://$host$uri|qe=$is_small_query|ae=$ae_normalized|rg=$range_bucket|vhash=$arg_v

4. 系统与内核调优(CentOS 7)

/etc/sysctl.d/99-tuning.conf(节选)

net.core.somaxconn = 65535
net.core.netdev_max_backlog = 250000
net.ipv4.tcp_max_syn_backlog = 262144
net.ipv4.tcp_fin_timeout = 10
net.ipv4.tcp_tw_reuse = 1
net.ipv4.ip_local_port_range = 10240 65535
net.ipv4.tcp_mtu_probing = 1
net.ipv4.tcp_syncookies = 0
net.core.rmem_max = 268435456
net.core.wmem_max = 268435456
net.ipv4.tcp_rmem = 4096 87380 268435456
net.ipv4.tcp_wmem = 4096 65536 268435456
fs.file-max = 10485760
vm.swappiness = 1

网卡与中断亲和:

  • irqbalance 自定义 banlist,将 NIC 中断 Pin 到对应 CPU NUMA;
  • ethtool -G ethX rx 4096 tx 4096;启用 RPS/RFS;
  • ulimit -n 1048576;OpenResty worker_rlimit_nofile 1048576;

5. OpenResty 安装与模块

OpenResty 1.21.x(稳定)

依赖:lua-resty-http、lua-resty-lock、lua-resty-string、lua-resty-lrucache、lua-resty-prometheus

可选:ngx_brotli(若编译支持)、headers-more、stream(如需 L4)

6. Nginx/OpenResty 关键配置(精简示例)

6.1 缓存区与键映射

# /etc/nginx/nginx.conf
worker_processes auto;
events { worker_connections 65535; use epoll; }

http {
  include       mime.types;
  default_type  application/octet-stream;
  sendfile      on;
  tcp_nopush    on;
  tcp_nodelay   on;
  keepalive_timeout  65;
  server_tokens off;

  # Brotli/Gzip 归一化
  map $http_accept_encoding $ae_norm {
    default "identity";
    "~*br" "br";
    "~*gzip" "gzip";
  }

  # Range 归入桶(每 1MB 一个桶,便于切片缓存)
  map $http_range $range_bucket {
    default "full";
    "~bytes=(\d+)-(\d+)" "r$1_$2";
  }

  # 切片(可选)
  # slice 1m;

  # 缓存目录与 zone
  proxy_cache_path /data/cache levels=1:2 keys_zone=STATIC:4g max_size=3000g
                   inactive=24h use_temp_path=off;

  # 避免缓存写放大
  proxy_cache_min_uses 1;
  proxy_cache_revalidate on;
  proxy_cache_lock on;
  proxy_cache_lock_timeout 10s;
  proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;

  # 上游对象存储(示例:RGW/MinIO 的 S3 网关域名)
  upstream s3_backend {
    server 10.10.20.11:9000 max_fails=3 fail_timeout=30s;
    server 10.10.20.12:9000 max_fails=3 fail_timeout=30s;
    keepalive 128;
  }

  log_format main '$remote_addr $request $status $body_bytes_sent $request_time '
                  'cache:$upstream_cache_status ae:$ae_norm rg:$range_bucket';

  access_log /var/log/nginx/access.log main;
  error_log  /var/log/nginx/error.log warn;

  include /etc/nginx/conf.d/*.conf;
}

6.2 站点与 Lua 控制(TTL、签名、SWr)

# /etc/nginx/conf.d/static.conf
server {
  listen 80 reuseport;
  server_name static.example.com;

  # 统一加速头
  add_header X-Cache $upstream_cache_status always;

  # 通过 Lua 动态设置回源 URI、签名、TTL
  set $backend_host "s3.example.internal";
  set $bucket "static-prod";

  # 缓存键:uri + 查询 + 编码 + range 桶
  set_by_lua_block $cache_key {
    local uri = ngx.var.uri
    local args = ngx.var.args or ""
    local ae   = ngx.var.ae_norm or "identity"
    local rb   = ngx.var.range_bucket or "full"
    return uri .. "?" .. args .. "|ae=" .. ae .. "|rg=" .. rb
  }

  location / {
    # 仅缓存 GET/HEAD
    if ($request_method !~ ^(GET|HEAD)$) { return 405; }

    # 由 Lua 计算 TTL(基于路径、后缀、元数据)
    set_by_lua_block $cache_ttl 'return "600s"';  # 默认 10min,可在 access_by_lua 修改

    proxy_cache STATIC;
    proxy_cache_key $cache_key;
    proxy_cache_valid 200 206 304 10m;
    proxy_ignore_headers Set-Cookie;
    proxy_hide_header Set-Cookie;

    # stale-while-revalidate:由 cache_lock + background timer 实现
    proxy_cache_background_update on;

    # Range/切片回源
    proxy_force_ranges on;

    # 回源前做签名(S3 v4)或拼桶路径
    access_by_lua_block {
      local uri = ngx.var.uri
      local bucket = ngx.var.bucket
      local host = ngx.var.backend_host

      -- 简化:公共读桶不需要签名,私有桶可在此使用 aws v4 签名
      -- 将上游路径定为 /bucket/$uri
      ngx.var.upstream_uri = "/" .. bucket .. uri

      -- 动态 TTL 示例:hash 资源拉长、HTML 缩短
      if uri:match("%.html$") then
        ngx.var.cache_ttl = "30s"
      elseif uri:match("%.[a-f0-9]{8,}%.") or uri:match("%?v=") then
        ngx.var.cache_ttl = "24h"
      elseif uri:match("%.(png|jpg|jpeg|webp|gif)$") then
        ngx.var.cache_ttl = "2h"
      end
    }

    # 透传客户端 Accept-Encoding,由热层分别缓存 br/gzip
    proxy_set_header Accept-Encoding $http_accept_encoding;

    proxy_set_header Host $backend_host;
    proxy_set_header Connection "";
    proxy_read_timeout 15s;
    proxy_send_timeout 15s;

    proxy_pass http://s3_backend$upstream_uri;
  }

  # 授权的缓存清理接口(仅内部使用)
  location = /__purge__ {
    allow 10.10.0.0/16; deny all;
    content_by_lua_block {
      local key = ngx.var.arg_key
      if not key then
        ngx.status = 400; ngx.say("need key"); return
      end
      local zone = "STATIC"
      local res = ngx.location.capture("/internal_purge", { args = { key = key, zone = zone } })
      ngx.say(res.body)
    }
  }

  # Prometheus 指标
  location /metrics {
    content_by_lua_file /etc/nginx/lua/metrics.lua;
  }
}

内部清理(示例)

OpenResty 原生不支持“按键清理”文件系统缓存,可通过记录 key→文件路径映射(如本地 LRU/MDB)或引入外部键值存储(如 Redis)再由管理脚本清理。生产里我们做了“软清理”(标记过期+主动刷新)为主、“硬清理”(rm 文件)为辅。

7. 与对象存储(MinIO / Ceph RGW)的对接

7.1 MinIO 要点

  • 公共读桶:mc policy set download myminio/static-prod
  • 对于私有桶:在 Edge 保存受限 AK/SK(IP/路径最小权限),Lua 做 v4 签名
  • Cache-Control、ETag、Content-Type 在上传时写好;可用 x-amz-meta-ttl 辅助热层 TTL

7.2 Ceph RGW 要点

  • 版本建议 Nautilus+(提供更好的一致性);多站点可做异地增量同步
  • RGW 前加 HA(Keepalived/HAProxy)做 VIP;RGW civetweb/beast 端口 80/8080/7480
  • 若做 Range/大对象分片,注意 RGW 的 rgw_max_chunk_size 与后端 OSD 带宽

8. 防雪崩与穿透:Lua 版 SWR/锁

-- /etc/nginx/lua/swr.lua
local lock = require "resty.lock"
local http = require "resty.http"

local function fetch_with_lock(key, upstream_uri)
  local l = lock:new("locks") -- 需在 nginx.conf 配置 lua_shared_dict locks 10m
  local elapsed, err = l:lock(key)
  if not elapsed then
    -- 无法加锁,直接按旧缓存返回(由 proxy_cache_use_stale 控制)
    return
  end

  -- 后台刷新:这里也可用 ngx.timer.at 异步刷新
  local httpc = http.new()
  httpc:set_timeout(5000)
  local res, err = httpc:request_uri("http://s3_backend"..upstream_uri, { method = "GET" })
  l:unlock()
  if not res then
    ngx.log(ngx.ERR, "refresh failed: ", err)
    return
  end
  -- 刷新由 Nginx proxy_cache_background_update 完成,我们只需要触发
  return
end

return { fetch = fetch_with_lock }

在 access_by_lua 或 header_filter_by_lua 中适时调用,配合 proxy_cache_background_update on; 达到 stale-while-revalidate 的效果。

9. 预热与灰度

预热脚本(节选):根据清单批量 GET,带并发与超时回退。

#!/usr/bin/env bash
set -euo pipefail
LIST=${1:-warmup.list}
PARALLEL=${PARALLEL:-64}
cat "$LIST" | xargs -n1 -P $PARALLEL -I {} curl -sS -o /dev/null -m 5 "http://static.example.com{}"

灰度策略:

  • LVS/四层 LB 按权重 5%→20%→50%→100%
  • 指标门槛:$upstream_cache_status MISS 比例、回源带宽、5xx、P95 延迟
  • 失败即刻回滚:权重置零 + 清理新版本缓存目录软链

10. 可观测性(Prometheus 指标)

  • nginx_http_requests_total、nginx_http_request_duration_seconds
  • 自定义:cache_hits_total{status=HIT|MISS|BYPASS|STALE}、origin_errors_total{code}
  • 回源带宽 vs 出口带宽(Exporter 收集 ifHCOutOctets)
  • 文件系统使用率、inode、NVMe SMART、丢包/重传
  • Top N Key(采样/日志落盘再汇总)

11. 风控与演练清单(精简版)

11.1 风险点

类别 风险 缓解
安全 边缘持 AK/SK 使用受限子账号+IP 限制+仅 GET 权限;密钥轮换
一致性 HTML 缓存陈旧 SWR + 短 TTL + 主动刷新接口(发布后触发)
穿透 大量 MISS 回源 访问锁 + 热点保护(单 URL QPS 限流)
存储 NVMe 坏块/满盘 SMART 监控+水位预警+多盘条带/镜像
RGW/MinIO 节点故障 多副本/EC+HA;故障演练切换
DDoS 小包/CC 四层限速+WAF 规则+Geo/IP 黑白名单
法规 跨境传输 业务合规评审、跨境专线备案、日志脱敏

11.2 演练脚本(每月/每季度)

  • 缓存雪崩演练:批量清空热层目录 → 观察回源带宽与保护是否生效
  • 单节点故障:Down 掉 EdgeCache1 → 验证流量均衡与会话保持
  • 对象存储降级:模拟 500/超时 → 验证 SWR、错误比率报警、回滚策略
  • 发布联动:构建产物更新 → 触发 /__purge__(软清理)→ 命中率恢复曲线
  • 密钥轮换:Edge AK/SK 替换流程与失效验证

12. 坑与现场解决

Range + Gzip/Brotli 冲突:

  • 对已压缩资源(.br/.gz)避免服务端再压;Range 直接针对压缩后实体。
  • 解决:对前端产物使用预压缩文件并映射 Content-Encoding,禁用二次 gzip。

ETag 与分片:

  • MinIO/Ceph RGW 的 ETag 对多段上传可能是 multipart 的 MD5 组合,与本地文件 MD5 不同。
  • 解决:以 Last-Modified + Cache-Control 为主,ETag 仅作对比,不强行复用客户端 ETag。

upstream prematurely closed:

  • 常因后端 read 超时或 Range 组合。
  • 解决:调大 proxy_read_timeout 针对大文件,检查 RGW client_body_buffer_size/ chunk_size。

缓存目录 inode 爆:

  • 小文件过多导致 inode 耗尽。
  • 解决:levels=1:2 合理分层,定期合并小资源包;XFS inode 预留。

雪崩:

  • 热点 key 过期同一时间 MISS。
  • 解决:proxy_cache_lock + lua-resty-lock + 抖动 TTL(随机 ±10%)。

Brotli 兼容性:

  • 少量旧设备不识别 br。
  • 解决:map 严格识别 Accept-Encoding,对不可识别客户端回落 gzip/identity。

13. 性能数据(一次真实上线窗口)

指标 上线前 上线后(30min)
命中率(HIT/ALL) 42% 92%
回源带宽 4.5 Gbps 1.6 Gbps
出口带宽 9.8 Gbps 10.2 Gbps
P95 延迟(静态资源) 180ms 38ms
5xx 比例 0.18% 0.03%

注:热资源(带 hash)拉长 TTL 后命中率提升最明显;HTML 采用 SWR 后 P95 抖动显著收敛。

14. 成本与扩展

  • 边缘横向扩展:一致性哈希(Maglev/ketama)将 key 均匀分布;单机 max_size 控制水位。
  • 温层是否需要:业务峰值很尖锐/回源链路贵 → 建议加;否则可直连冷层。
  • 对象存储:中小规模优先 MinIO(简单高效),大规模/多站点优先 Ceph RGW。
  • 跨境/跨 ISP:香港建议接入 HKIX、PCCW/HKT、本地多家运营商;国内用户流量可走 CN2/专线回源。

15. 运维清单(上线前/后)

上线前(T-1)

  •  LB 权重灰度方案与回滚预案
  •  /metrics 与日志字段校验(含 $upstream_cache_status)
  •  目录水位报警(容量/ inode)
  •  /__purge__ 权限与审计
  •  预热清单覆盖首屏关键路径

上线后(T+0/T+1)

  •  10/30/60 分钟观察:命中率、回源带宽、5xx
  •  抽样验证:Brotli/Gzip/Range/大文件
  •  对象存储端指标:读 QPS、延迟、4xx/5xx
  •  一周内轮换 AK/SK(新旧并行)

16. 我们用到的一份完整站点片段(汇总)

lua_shared_dict locks 10m;
lua_shared_dict prometheus_metrics 32m;

server {
  listen 80 reuseport;
  server_name static.example.com;

  set $backend_host "s3.internal";
  set $bucket "static-prod";
  add_header X-Cache $upstream_cache_status always;

  location / {
    if ($request_method !~ ^(GET|HEAD)$) { return 405; }

    proxy_cache STATIC;
    proxy_cache_key $request_uri|ae=$ae_norm|rg=$range_bucket;
    proxy_cache_background_update on;
    proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
    proxy_cache_valid 200 206 304 10m;

    proxy_set_header Host $backend_host;
    proxy_set_header Accept-Encoding $http_accept_encoding;
    proxy_read_timeout 15s; proxy_send_timeout 15s;
    proxy_force_ranges on;

    access_by_lua_block {
      local uri = ngx.var.uri
      ngx.var.upstream_uri = "/" .. ngx.var.bucket .. uri
      -- TTL 抖动避免雪崩
      math.randomseed(ngx.now()*1000)
      local jitter = math.random(-10,10) / 100
      if uri:match("%.html$") then
        ngx.var.cache_ttl = "30s"
      elseif uri:match("%.[a-f0-9]{8,}%.") or uri:match("%?v=") then
        ngx.var.cache_ttl = "24h"
      else
        ngx.var.cache_ttl = "600s"
      end
    }

    proxy_pass http://s3_backend$upstream_uri;
  }

  location /metrics { content_by_lua_file /etc/nginx/lua/metrics.lua; }
  location = /__purge__ { deny all; } # 实际按内网放行
}

上线那晚,机房空调像潮汐一样呼吸。Grafana 的命中率曲线缓缓抬头,像一条终于找对方向的河。我们在震动模式里等了 10 分钟,没人说话,只有 NVMe 的 LED 灯在默默闪烁。回源带宽从 4.5G 掉到 1.6G 的瞬间,大家同时“呼”了一口气。

我把演练清单重新贴回墙上:缓存雪崩、节点故障、对象存储降级、密钥轮换。是的,系统上线了,但我们永远在路上。

第二天早上,我给自己泡了一杯港式奶茶,窗外开始下起了小雨。日志里一行行的 X-Cache: HIT,比阳光还暖。

目录结构
全文