把CDN节点容器化,本质上是要解决一个分布式的部署一致性难题。自建CDN往往意味着十几个甚至上百个边缘节点散落在不同机房,如果每个节点都靠SSH登上去手动装Nginx、改配置、清缓存,一次全量升级可能要折腾一整天,还容易漏改配置导致节点行为不一致。容器化的思路是把整个CDN节点运行时环境打包成一个不可变的镜像制品,节点上的部署动作简化为拉取镜像并启动容器,升级和回退都变成了切换镜像版本的问题。这篇文章将从镜像构建、私有Registry搭建、镜像分发三个环节,完整讲一遍这套流程。

构建CDN节点的Docker镜像
自建CDN的核心组件通常是Nginx(或OpenResty)做反向代理和边缘缓存,配合缓存清理脚本和日志采集工具。构建镜像的第一步是把这些东西固化到Dockerfile里。一个典型的CDN节点镜像包含四层内容:基础系统层、Nginx及其模块、站点配置、缓存管理脚本。基础镜像建议选择nginx:stable-alpine,体积小、攻击面也小,非常适合跑在配置不高的边缘服务器上。
下面是一个可直接使用的Dockerfile示例,它基于官方Nginx镜像,加入了缓存目录、自定义配置和健康检查工具:
FROM nginx:stable-alpine
# 移除默认配置,加载CDN专用配置
RUN rm -f /etc/nginx/conf.d/default.conf
COPY nginx-cdn.conf /etc/nginx/conf.d/cdn.conf
# 创建缓存目录并修正权限,缓存写入路径必须与配置中proxy_cache_path一致
RUN mkdir -p /data/nginx/cache /data/nginx/tmp \
&& chown -R nginx:nginx /data/nginx
# 安装curl用于健康检查脚本
RUN apk add --no-cache curl
COPY purge_cache.sh /usr/local/bin/purge_cache.sh
RUN chmod +x /usr/local/bin/purge_cache.sh
# 缓存目录建议挂载为卷,避免容器重建后缓存全失效
VOLUME ["/data/nginx/cache"]
HEALTHCHECK --interval=30s --timeout=3s \
CMD curl -fs http://127.0.0.1/healthz || exit 1
对应的nginx-cdn.conf里要定义好缓存路径和代理回源逻辑,关键部分如下:
proxy_cache_path /data/nginx/cache levels=1:2 keys_zone=cdn_cache:200m
max_size=50g inactive=7d use_temp_path=off;
server {
listen 80;
server_name cdn.example-ipipp.com;
location /healthz {
return 200 "ok";
}
location / {
proxy_cache cdn_cache;
proxy_cache_valid 200 12h;
proxy_cache_key $uri$is_args$args;
proxy_set_header Host $proxy_host;
# 多边缘节点回源时带上节点标识,便于源站排查
proxy_set_header X-Edge-Node $hostname;
proxy_pass http://origin.ipipp.com;
}
}
这个配置里有一个容易被忽视的细节:proxy_cache_path指定的目录必须和Dockerfile中创建的目录一致,并且通过VOLUME声明出来。如果不这样做,容器每次重建后缓存都会清空,所有请求都会穿透到源站,高峰期可能直接把源站打挂。另外keys_zone的内存大小决定了缓存索引的上限,一般每1MB内存可以索引约8000个缓存对象,按文件数量预估即可。
用多阶段构建给镜像瘦身
边缘节点的服务器配置往往不高,镜像体积直接影响分发速度。一个粗暴的Dockerfile可能把编译工具链也打进镜像里,动辄几百MB。多阶段构建可以只把编译产物复制到最终的运行镜像中,构建环境不进入最终制品。如果你的CDN需要编译第三方模块,比如给Nginx加ngx_cache_purge模块来实现主动清缓存,就可以这样写:
# 第一阶段:编译带purge模块的Nginx
FROM alpine:3.19 AS builder
RUN apk add --no-cache gcc make openssl-dev pcre-dev zlib-dev \
linux-headers curl gnupg
ARG NGINX_VERSION=1.26.1
RUN curl -fsSL https://nginx.org/download/nginx-$NGINX_VERSION.tar.gz | tar xz \
&& cd nginx-$NGINX_VERSION \
&& ./configure --prefix=/etc/nginx --with-http_ssl_module \
--add-module=/tmp/ngx_cache_purge \
&& make && make install
# 第二阶段:只保留运行所需文件
FROM alpine:3.19
RUN apk add --no-cache pcre zlib openssl curl
COPY --from=builder /etc/nginx /etc/nginx
COPY nginx-cdn.conf /etc/nginx/conf.d/cdn.conf
COPY purge_cache.sh /usr/local/bin/purge_cache.sh
RUN mkdir -p /data/nginx/cache && adduser -D -H nginx 2>/dev/null || true
EXPOSE 80 443
CMD ["nginx", "-g", "daemon off;"]
除了多阶段构建,还有几个瘦身技巧值得做:其一,合并RUN指令减少层数,把apk add和清理缓存的命令写在同一条里,用--no-cache避免apk缓存残留;其二,不要把测试文件、文档、备份配置COPY进镜像;其三,镜像里只装运行依赖,编译依赖全部留在builder阶段。经过这些处理,一个带自定义模块的CDN镜像通常能控制在100MB以内,上百个节点并发拉取时的带宽压力会小很多。
搭建私有Registry并管理镜像分发
CDN节点分散在多个机房,把镜像推到公共Docker Hub再让节点拉取,速度和安全性都不理想。自建私有Registry是更稳妥的选择。Registry本身就是一个容器,部署非常简单:
docker run -d --name registry --restart=always \ -p 5000:5000 \ -v /data/registry:/var/lib/registry \ registry:2
这条命令会在本地5000端口启动一个Registry,镜像数据持久化到/data/registry目录。裸HTTP的Registry只适合内网环境,Docker客户端默认要求HTTPS,内网使用时需要在每个节点的/etc/docker/daemon.json中把Registry地址加入insecure-registries白名单,然后重启Docker守护进程。如果Registry需要跨公网访问,建议用Nginx做一层反向代理并配置TLS证书,或者直接使用带认证的方案:
# 生成基础认证文件,用户名admin,密码自行输入 mkdir -p /data/registry-auth docker run --rm --entrypoint htpasswd httpd:2 \ -Bbn admin YourStrongPassword > /data/registry-auth/htpasswd # 带TLS和认证启动Registry docker run -d --name registry --restart=always \ -p 443:443 \ -e REGISTRY_HTTP_TLS_CERTIFICATE=/certs/registry.crt \ -e REGISTRY_HTTP_TLS_KEY=/certs/registry.key \ -e REGISTRY_AUTH=htpasswd \ -e REGISTRY_AUTH_HTPASSWD_REALM="Registry Realm" \ -e REGISTRY_AUTH_HTPASSWD_PATH=/auth/htpasswd \ -v /data/certs:/certs \ -v /data/registry-auth:/auth \ -v /data/registry:/var/lib/registry \ registry:2
Registry跑起来之后,镜像的版本管理策略比工具本身更重要。强烈建议用不可变的版本Tag,而不是长期复用latest。可以在CI流水线里用Git的短commit哈希加日期来命名,例如cdn-node:20240615-a1b2c3d,每次发布都产生新Tag。这样回退操作就是把节点上的镜像Tag切回上一个版本,几秒钟就能完成,而且不存在“latest到底是哪个版本”的混乱问题。
推送和拉取的标准流程如下:
# 登录私有Registry docker login registry.ipipp.com # 给镜像打上带Registry地址的完整标签 docker tag cdn-node:20240615-a1b2c3d registry.ipipp.com/cdn/cdn-node:20240615-a1b2c3d # 推送到私有仓库 docker push registry.ipipp.com/cdn/cdn-node:20240615-a1b2c3d # 在边缘节点上拉取并启动 docker pull registry.ipipp.com/cdn/cdn-node:20240615-a1b2c3d docker run -d --name cdn-node --restart=always \ -p 80:80 -p 443:443 \ -v /data/edge/cache:/data/nginx/cache \ registry.ipipp.com/cdn/cdn-node:20240615-a1b2c3d
节点滚动更新与常见问题
镜像统一之后,CDN节点的更新可以做成滚动式:写一个简单的分发脚本,通过节点列表逐台执行docker pull和容器重建,配合健康检查接口确认新容器正常后再处理下一台。相比传统的Ansible批量改配置,容器化方案的原子性更好——镜像拉取失败或新容器健康检查不过,旧容器仍在运行,节点不会进入半新半旧的中间状态。
有几个坑在实际运维中比较常见。第一,缓存卷的宿主机路径要提前规划好磁盘配额,Nginx的max_size只控制缓存目录,但日志和容器层写入也会占磁盘,建议给缓存目录单独挂盘;第二,节点上要配置定时任务执行docker image prune,否则多次升级后旧镜像会把磁盘堆满;第三,如果Registry部署在中心机房,跨机房的镜像拉取可以配置Docker的registry-mirrors或者在各地机房各部署一个Registry实例做级联同步,把分发流量控制在本机房内部。
整体来看,自建CDN容器化的收益不在单次部署省了多少时间,而在于把“节点上跑的是什么”这个问题从一堆配置文件变成了一串可以追溯的镜像版本号。镜像构建、私有Registry、版本Tag三者形成闭环之后,后续无论是扩节点还是排查线上问题,都有了统一的抓手。
自建CDNDocker镜像构建私有Registry修改时间:2026-09-11 13:54:49