自建 CDN 服务器:取一次,分发千万次

发布于 阅读时间 13 分钟

CDN 服务器把每个文件从源站取一次,之后成千上万次都由它自己分发。源站只会看到其中一小部分请求,访客则拿到很短的加载时间。本文讲怎么把它搭起来。

访客从您站点上下载的每个文件,都要由源站从文件系统里取出来、装进一个响应再发走。每天十位访客时谁都察觉不到。而在一万位访客同时在线时,这恰恰就是页面变慢的原因,哪怕真正的应用程序根本无事可做。

CDN 服务器就摆在它前面。它把每个文件从源站取一次,之后需要多少次就自己分发多少次。源站因此只会看到其中一小部分请求,而访客拿到文件的那台服务器除了分发什么都不干。本文说明这到底能带来多少好处、怎么用 nginx 搭这样一台服务器,以及流量和机房位置上要注意什么。

CDN 服务器到底做什么

整个流程用三句话就能讲完:

  • 一位访客向 CDN 服务器请求某个文件。
  • 文件已经在那里,就立刻发出去。这叫命中。
  • 文件不在那里,CDN 服务器就去源站取、存下来再发出去。这叫未命中,每个文件在每个有效期里只发生一次。

原理和流媒体服务器一样:取一次,分发多次。区别只在于流媒体服务器分发的是持续的数据流,而 CDN 服务器分发的是现成的文件。

算一算:缓存能减掉多少负载

决定性的是命中率,也就是缓存自己答掉的请求占比。按每天一百万次请求算,减负效果是这样:

命中率到源站的请求减负
50%500,0002 倍
80%200,0005 倍
95%50,00020 倍
99%10,000100 倍

对静态文件来说,95% 以上是常态,因为图片、脚本和字体很少改动。而从 95% 提到 99% 的意义比数字看起来更大:它把剩下的负载又砍到五分之一。

同时流量也会转移。源站几乎不再传东西,CDN 服务器则传得更多。每天 500,000 次图片请求、每个 400 KB,就是每天 200 GB,也就是每月约 6 TB,仅图片一项。所以对一个 CDN 节点来说,重要的数字不是 CPU,而是网络接入和流量。

自建 CDN 服务器什么时候划算

商业 CDN 服务商按传输的每 GB 收费。这很方便,流量少时也便宜。但超过一定量,关系就反过来了:

情形自建 CDN 服务器按 GB 计费
每月几个 GB几乎不划算更便宜
每月好几个 TB月费固定线性增长
大体积下载、视频存档明显占优很快就贵了
对规则和协议的完全控制完全掌握受限

还有一点很难用数字衡量:在自己的节点上,什么被缓存、缓存多久、什么时候消失,都由您自己定。您不受别家服务商的规则约束,也可以分发那些塞不进别人既定方案的东西。

用 nginx 作缓存的搭法

安装

apt update
apt install -y nginx

就这些,缓存功能是 nginx 自带的。建好存放文件的目录:

mkdir -p /var/cache/cdn
chown www-data:www-data /var/cache/cdn

配置

缓存在 http 块里描述一次,然后在各个 location 里使用:

proxy_cache_path /var/cache/cdn
                 levels=1:2
                 keys_zone=cdn:100m
                 max_size=200g
                 inactive=30d
                 use_temp_path=off;

proxy_cache_key "$scheme$request_method$host$request_uri";

这些值分别表示:keys_zone=cdn:100m 够放大约 800,000 条记录,max_size 限制占用的硬盘空间,inactive=30d 会丢掉 30 天无人请求的文件,use_temp_path=off 则避免在目录之间做多余的搬移。

下面是真正的 server 段:

server {
    listen 443 ssl;
    http2 on;
    server_name cdn.example.com;

    ssl_certificate     /etc/letsencrypt/live/cdn.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/cdn.example.com/privkey.pem;

    location / {
        proxy_pass https://origin.example.com;
        proxy_set_header Host origin.example.com;
        proxy_http_version 1.1;

        proxy_cache cdn;
        proxy_cache_valid 200 301 302 24h;
        proxy_cache_valid 404 1m;

        # 把多个同时发生的未命中合并成一次请求
        proxy_cache_lock on;
        proxy_cache_lock_timeout 10s;

        # 源站出问题时继续分发
        proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
        proxy_cache_background_update on;

        # 会话 Cookie 不能把缓存搞坏
        proxy_ignore_headers Set-Cookie;
        proxy_hide_header Set-Cookie;

        add_header X-Cache-Status $upstream_cache_status always;
    }
}

其中三行值得特别注意。proxy_cache_lock 保证在遇到新文件时,不会有一百位访客同时去问源站,而是只有一位去问、其余等着。proxy_cache_use_stale 在源站重启或响应变慢时维持分发不中断。而隐去 Set-Cookie 则防止某一条带会话 Cookie 的响应把整个缓存弄成废物。

检查是否真的生效

nginx -t && systemctl reload nginx
curl -sI https://cdn.example.com/bild.jpg | grep -i x-cache-status
curl -sI https://cdn.example.com/bild.jpg | grep -i x-cache-status

第一次请求显示 MISS,第二次显示 HIT。如果第二次还是 MISS,几乎总是三个常见嫌疑之一:响应里带了 Set-Cookie、源站发了 Cache-Control: no-store,或者缺少长度信息。

什么会被缓存,什么不会

干净的做法是让源站自己说。它了解自己的内容,缓存不了解。为此它要设置合适的响应头:

内容源站上的响应头
文件名里带校验和的文件Cache-Control: public, max-age=31536000, immutable
没有版本化的图片或下载包Cache-Control: public, max-age=86400
公开页面的 HTMLCache-Control: public, max-age=300
登录区、购物车Cache-Control: private, no-store

如果您想在缓存端自己决定,可以按文件类型写各自的 location:

location ~* \.(jpg|jpeg|png|gif|webp|avif|svg|ico|css|js|woff2|mp4|zip)$ {
    proxy_pass https://origin.example.com;
    proxy_cache cdn;
    proxy_cache_valid 200 7d;
    add_header Cache-Control "public, max-age=604800";
    add_header X-Cache-Status $upstream_cache_status always;
}

定点清掉内容

最简单的路是根本不用删:谁给每张改动过的图片都起个新文件名,比如 logo.a1b2c3.png,就没有这个问题。旧文件迟早会自己从缓存里掉出去。

做不到的话,nginx 这边可以用自由软件模块 ngx_cache_purge。不加额外模块也行,路径可以从缓存键算出来,因为 nginx 用它的 MD5 值当文件名:

KEY="httpsGETcdn.example.com/bild.jpg"
H=$(printf '%s' "$KEY" | md5sum | cut -d' ' -f1)
rm -f "/var/cache/cdn/${H: -1}/${H: -3:2}/$H"

日常用一个小脚本,把这几行套在一份路径清单上执行,通常就够了。

大文件和视频

文件超过几百 MB 时,就需要切块,否则观众每次在视频里跳转,缓存都会重新下载整个文件:

location /video/ {
    proxy_pass https://origin.example.com;
    proxy_cache cdn;
    proxy_cache_valid 200 206 7d;

    slice 1m;
    proxy_set_header Range $slice_range;
    proxy_cache_key "$uri$slice_range";
}

这样 nginx 只取真正被请求的那些块。观众跳到影片中间时,线路上走的是几 MB,而不是好几 GB。

TLS、HTTP/2 与 HTTP/3

CDN 节点要把很多小文件分发到很多条连接上,新协议正好在这种场合划得来。HTTP/2 一行 http2 on; 就搞定。HTTP/3 主要在移动网络上有效果,因为换网不会把连接断掉:

listen 443 quic reuseport;
listen 443 ssl;
http2 on;
add_header Alt-Svc 'h3=":443"; ma=86400';

别忘了在防火墙里放行 UDP 443。这是 HTTP/3 已经配好却依然没人用得上的最常见原因。

多个机房

单个节点就已经带来大部分收益,也就是给源站减负。第二份收益,即到访客的路程更短,要有多个机房才拿得到。

分流通过 DNS 完成:同一个名字会按访客来源指向不同的节点。为此您需要一家支持地理解析的 DNS 服务商。所有节点用同一份配置、同一个源站,彼此之间毫不知情,而这恰恰让整个架构很结实:某个节点挂了,您把它从 DNS 里摘掉,其余的接着干。

KernelHost 提供德国、美国西海岸、美国东海岸以及新加坡和韩国的机房。带延迟数据的概览见服务器机房位置。经验法则是:受众里有相当一部分人所在的每个大洲,放一个节点。

测量,别猜

把缓存状态写进日志,这样随时都能统计命中率:

log_format cdn '$remote_addr $status $body_bytes_sent '
               '$upstream_cache_status "$request"';
access_log /var/log/nginx/cdn.log cdn;

一行命令就能统计出来:

awk '{print $4}' /var/log/nginx/cdn.log | sort | uniq -c | sort -rn

如果静态文件的 HIT 占比低于 90%,几乎总值得去找原因:多半是响应里的 Cookie、有效期太短,或者地址末尾带着变化参数的请求,这些在缓存看来每次都像一个新文件。

该选哪种服务器

CDN 节点是一台分发机器。所以重要特性的排序和应用服务器不一样:

  1. 流量与网络接入。全部负载都压在这里。一个节点在运行中每月经常要传好几个 TB。
  2. 硬盘。NVMe 明显占优,因为缓存由大量零碎的随机访问构成。容量取决于您的内容里有多少是热数据,而不是内容总共有多大。
  3. 内存。操作系统会把空闲内存当作硬盘前面的第二层缓存。内存越大,这里就直接越快。
  4. CPU。最不关键,只在 TLS 上用得到,而现代处理器顺手就做完了。

我们的无限流量根服务器正是为这种场景做的:每台服务器真正的 2 × 1 Gbit/s,不限速,无限流量且月费固定,RAID 下的数据中心级 NVMe,以及出厂自带的 3.2 Tbps 洪水防护。开通不到 30 秒,带完整 Root 权限、IPv4 和一个 /64 的 IPv6 网段。因为计费采用预付费、按月可退订,所以您可以先从一个节点起步,等受众分散开来,随时再加第二个机房。

常见问题

CDN 服务器具体做什么?
它位于您的访客和源站之间。对某个文件的第一次请求,它转给源站,同时把响应存下来。之后对同一文件的所有请求,它自己就答了,不再去打扰源站。
实际能减掉多少负载?
这取决于命中率。95% 时,源站只会看到二十分之一的请求,负载也就降到二十分之一。80% 时还是五分之一。对图片、脚本、字体和下载包这类静态文件,95% 以上很正常。
我必须用商业服务商吗?
不一定。自建 CDN 服务器主要在文件大、流量多的时候划算,因为商业服务商按传输的每 GB 收费。而在一台无限流量的服务器上,第一百个 TB 和第一个 TB 一样贵,也就是不额外收费。
哪些文件该缓存,哪些不该?
凡是对所有访客长得一样的都该缓存:图片、视频、CSS、JavaScript、字体、PDF、安装包。不该缓存的是个性化页面、购物车、登录区,以及任何带会话 Cookie 的东西。这个决定最好由源站通过 Cache-Control 头来下。
缓存该把文件留多久?
文件名里带版本号或校验和的,放一年也无妨,它们永远不会变。没有版本化的图片和下载包,常见是几小时到几天。HTML 如果您真要缓存,就几分钟。重要的不是具体数字,而是您有办法定点删掉单个文件。
怎么把一个文件从缓存里删掉?
最简单的办法走文件名:每次改动都换个新名字,就根本不用删。做不到的话,nginx 这边要么用商业版的 purge 模块,要么用自由软件 ngx_cache_purge。实在不行就直接在文件系统里删掉缓存文件,路径可以从缓存键算出来。
源站宕掉会怎样?
设置得当的话,缓存会继续分发,即便条目其实已经过期。这由带 error 和 timeout 的 proxy_cache_use_stale 以及 proxy_cache_background_update 负责。这样在源站检修或重启期间,您的站点依然可访问。
大视频文件也行吗?
行,但必须用分片设置。没有它,观众每次在视频里跳转,缓存都会从源站重新下载整个文件。用 slice,nginx 会把文件切成例如 1 MB 的块,只存真正被请求的那些部分。
我需要多少个机房?
先从一个开始,放在访客最多的那个地方。只有当相当一部分受众坐在另一个大洲时,第二个机房才划算。分流随后通过 DNS 完成,每位访客都落到最近的节点。
我怎么知道缓存在起作用?
把 upstream_cache_status 这个变量作为响应头输出。第二次请求同一文件时,那里必须是 HIT。如果再把同一个值写进日志,就能随时统计命中率。静态文件低于 90% 时,通常是响应头或 Cookie 出了问题。

CDN Caching nginx Reverse Proxy Performance HTTP Unlimited Traffic