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

我们业务刚刚做完一轮大促预热,国内用户通过专线和公网混合流量打到香港机房,首页首屏图像和前端包的 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,比阳光还暖。