如果你平时用 Nginx 做网站或接口代理,大概率在某个时间点会产生一个念头:要不要把它容器化?我早期图省事,直接docker run -d nginx:latest,跑起来确实快,但真到要改配置、换 SSL 证书、加代理规则的时候,发现官方镜像只是个“毛坯房”——每次都得原地改完再重启,改错了还不好回滚。后来我花了一整个下午把 Dockerfile 写清楚,构建出自己的 Nginx 镜像,发现这件事带来的收益远不止“省一次安装”:配置、证书、日志和健康检查全部固化进镜像,团队成员 clone 下来就能直接构建部署,而且换环境的时候再也不用从头配一遍。这篇文章就把我完整的实践过程梳理一遍,包括每一行 Dockerfile 的取舍逻辑、从零构建的实操步骤,以及我在 SSL 证书替换、反向代理超时和 Alpine 挂载配置时踩过的那些坑。
1. 为什么要自己构建 Nginx 镜像:先把需求想明白
1.1 官方镜像够用吗?自建镜像的核心价值
很多人最开始用的是官方 Nginx 镜像,我承认它在“跑起来”这件事上确实快得离谱,一条命令就能起一个能访问的 Web 服务。但如果你认真用一段时间,就会发现几个很尴尬的问题。
第一,官方镜像的默认配置只适合展示静态页面。你要做反向代理、加 SSL、开 gzip、配缓存,每一步都得改容器里的/etc/nginx/nginx.conf或conf.d下的文件。如果只是临时调试还好,但如果是生产环境,你总不能每次部署都进容器手改配置,那样既不可追踪也不可回滚。
第二,官方镜像不会自动带上你的业务配置。团队里每个人拉同样的镜像,出来的行为是一模一样的,一旦有人改了配置没有同步到镜像里,环境之间就会出现“在我本地能跑,到服务器就 502”的经典事故。构建自定义镜像的核心价值,就是把“环境差异”这个变量干掉——镜像就是唯一可信的交付物,配置、依赖、权限全在里边。
第三,体积和依赖是不可控的。官方nginx:latest基于 Debian,带着一大堆你用不到的库;如果你追求镜像分发速度和磁盘占用,换成 Alpine 基础镜像能把体积压到原来的十分之一。这些优化,只靠一条docker run是做不到的。
1.2 基础镜像选型:Alpine、Debian Slim 还是 CentOS
基础镜像的选择,直接决定了后续一层层依赖的兼容性和最终镜像体积。我实际对比过三种常见方案,差别还是很明显的。
| 基础镜像 | 最终体积 | 库类型 | 适用场景 | 注意事项 |
|---|---|---|---|---|
nginx:alpine | 约 20-30MB | musl | 低资源环境、快速分发 | 动态模块编译需额外处理,部分第三方模块依赖 glibc |
nginx:debian-slim | 约 80-100MB | glibc | 兼容性优先、需编译扩展模块 | 体积中等,apt 依赖管理方便 |
nginx:centos | 约 200MB+ | glibc | 已有 CentOS 环境习惯的团队 | 体积大,yum 安装源在国内有时不稳定 |
我个人主力环境选的是 Alpine。原因很直接:它小,启动快,攻击面也小。对于 Nginx 这种本身就足够轻量的软件,用 Alpine 当底座是“门当户对”的选择。但要说清楚一点,Alpine 用的 musl 库和大多数编译环境里的 glibc 存在差异,如果你后面要挂第三方 Nginx 模块(比如一些需要编译的认证模块或 Lua 模块),可能会遇到兼容性问题。这时候老老实实用debian-slim反而更省心。
提示:拿不定主意的时候就选
nginx:alpine起步,大多数场景它都不会让你失望;一旦踩到模块兼容性的坑,再换成debian-slim,也就是改一行FROM的事。
另外我建议选带具体版本号的镜像,比如nginx:1.27-alpine,不要用latest。latest会漂移,今天构建出来的镜像和半年后构建出来的镜像可能行为完全不一样,这在生产环境是大忌。
2. Dockerfile 核心设计:每一行指令背后的取舍逻辑
2.1 基础指令的整体骨架
我构建 Nginx 镜像的 Dockerfile 长这样,先整体贴出来,再逐行拆解每个指令“为什么要这么写”。
FROM nginx:1.27-alpine LABEL maintainer="yourname@example.com" ENV TZ=Asia/Shanghai \ NGINX_VERSION=1.27.0 # 安装时区数据和健康检查工具 RUN apk add --no-cache tzdata curl \ && cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \ && echo "Asia/Shanghai" > /etc/timezone # 拷贝主配置和站点配置,确保镜像开箱即用 COPY nginx.conf /etc/nginx/nginx.conf COPY conf.d/ /etc/nginx/conf.d/ COPY html/ /usr/share/nginx/html/ EXPOSE 80 443 STOPSIGNAL SIGQUIT HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ CMD wget -q -O /dev/null http://127.0.0.1/ || exit 1 CMD ["nginx", "-g", "daemon off;"]先看FROM。我特意把NGINX_VERSION写进环境变量,不是为了好看,而是为了以后排查问题时能快速确认当前镜像里跑的具体版本。容器内执行docker exec my-nginx env | grep NGINX,一眼就知道该查哪个版本的文档。
接着看RUN这一层的设计。Alpine 里加tzdata是为了解决容器内时间默认 UTC 的问题;加curl是为了后面做健康检查、平时调试排查方便。注意我用的--no-cache,这个参数会阻止 apk 在本地留下缓存文件,能直接省掉几 MB 的镜像层。如果你用的是 Debian 系,对应做法是apt-get update && apt-get install -y --no-install-recommends curl,装完再rm -rf /var/lib/apt/lists/*。卸载不干净的话,体积就是这么一点一点臃肿起来的。
2.2 为什么必须 daemon off
CMD ["nginx", "-g", "daemon off;"]是 Nginx 镜像里最重要的一行,却是很多人容易忽略的一行。
容器的设计哲学是:一个容器里应该只有一个前台进程,并且这个进程是 PID 1。docker stop发出信号时,只有 PID 1 能直接收到,其他进程是收不到这个信号的。Nginx 默认会 fork 出一个 master 进程和多个 worker 进程,master 进程在后台运行——如果容器启动时的命令是nginx,那这个命令本身很快会退出,而真正的 Nginx 进程变成了孤儿进程,不再挂在 PID 1 下面。结果就是docker stop发信号找不到对象,容器只能硬等若干秒后强制杀掉,日志里全是异常终止记录。
daemon off让 Nginx 以前台模式运行,Nginx master 进程本身就成了 PID 1。docker stop nginx时,主进程能第一时间收到信号并优雅退出,正在处理的请求不会悬崖式中断。我用这个模式跑了大半年,最直观的感受就是:reload 和 stop 都干净利落,不会再出现进程僵死或者端口不释放的情况。
注意:不要手动在 Dockerfile 里改成
CMD ["nginx"]或者用 systemd 的方式去启动 Nginx,两种做法在容器里都不成立。前者丢了前台进程,后者在容器里根本没有 systemd 环境。
2.3 COPY、RUN 与构建缓存的关系
Docker 构建镜像是分层缓存的,RUN、COPY、ADD每一条指令都会生成一个新层。但缓存命中是有前提的:如果这一层相关的上下文没变化,Docker 才会直接用旧缓存。
所以 Dockerfile 里指令的排列顺序很有讲究。我把RUN apk add --no-cache tzdata curl放在最前面,把COPY放在后面,原因就是把“不容易变的操作”放在前面,“容易变的内容”放在后面。你以后改了conf.d下的配置,重新构建时 Docker 会直接命中第一层的缓存,只需要重新生成COPY之后的层,构建速度会快非常多。反过来,如果你先把COPY写在前面,RUN写在后面,那每次改动配置,连 apk 安装都得重新跑一遍,既浪费时间又容易踩到网络源抖动导致的失败。
另外,COPY和ADD的语义我建议区分清楚。ADD会自动解压本地 tar 包,听着方便,但隐式行为太多;COPY就是纯粹的拷贝,不做任何加工。构建 Nginx 镜像这种场景,COPY完全够用,也更符合“镜像构建过程可预测”的原则。至于那些要在RUN里下载安装的编译包,也不要一股脑塞进镜像,尽量在完成编译后清理掉,别把临时文件留在层里。
3. 完整实操:从零构建一个开箱即用的 Nginx 镜像
3.1 准备目录与配置文件
我先在本地建一个干净的项目目录,结构如下:
nginx-custom/ ├── Dockerfile ├── nginx.conf ├── conf.d/ │ └── default.conf └── html/ └── index.html我的习惯是:主配置nginx.conf保持精简,把具体的 server 配置全部放到conf.d/下。主配置文件里只保留全局运行参数和 http 公共配置。下面是我使用的nginx.conf:
user nginx; worker_processes auto; error_log /var/log/nginx/error.log warn; pid /var/run/nginx.pid; events { worker_connections 1024; } http { include /etc/nginx/mime.types; default_type application/octet-stream; log_format main '$remote_addr - $remote_user [$time_local] "$request" ' '$status $body_bytes_sent "$http_referer" ' '"$http_user_agent"'; access_log /var/log/nginx/access.log main; sendfile on; keepalive_timeout 65; include /etc/nginx/conf.d/*.conf; }简单说几个点。worker_processes auto让 Nginx 自动按容器可用的 CPU 核数来启动 worker,容器环境里的 CPU 限制是动态分配的,写死数字反而不合适。user nginx这行配合官方 Alpine 镜像里已经创建好的 nginx 用户,避免主进程以 root 权限跑业务。mime.types的 include 不能省,否则访问静态资源时响应头里没有 Content-Type,浏览器会直接下载文件而不是正常渲染。
接着是conf.d/default.conf,这里我放了一个同时覆盖静态站点、反向代理和 gzip 的配置:
server { listen 80; server_name example.com www.example.com; root /usr/share/nginx/html; index index.html; gzip on; gzip_types text/plain text/css application/javascript application/json; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://backend:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } }try_files这行是给单页应用准备的兜底方案。如果$uri对应的文件不存在,就回退到/index.html,让前端路由接管;不做这一步的话,前端页面刷新到某个子路由就会 404。proxy_set_header四个头是反向代理的标配——后端程序判断协议和真实 IP 都靠它们,少了 X-Forwarded-For,后端拿到的全是容器内网地址。
3.2 编写 Dockerfile 并执行构建
配置文件就绪后,在项目根目录执行构建:
docker build -t my-nginx:v1 .这里有个小细节:docker build默认会把整个项目目录作为构建上下文发送给 Docker 守护进程。如果你的目录里有node_modules、logs、.git这类大型内容,构建会被拖得很慢。我建议在项目根目录放一个.dockerignore文件:
.git node_modules logs *.md Dockerfile.dockerignore的唯一作用就是把无关内容挡在构建上下文之外。很多人写 Dockerfile 非常严谨,却忘了这个文件,结果每次构建都上传几百兆的临时文件,完全没必要。
构建完成后,确认镜像已经出现在本地列表里:
docker images | grep my-nginx看到my-nginx和v1的条目,就说明镜像构建成功了。如果你改完配置想重新构建,注意同一台机器上v1这个 tag 会被覆盖,旧镜像会变成<none>的悬空镜像。定期执行docker image prune清掉这些即可,别让本地的悬空镜像越堆越多。
3.3 运行容器:端口映射、日志挂载与健康检查
镜像构建出来之后,运行命令是这样的:
docker run -d --name my-nginx \ -p 80:80 -p 443:443 \ -v $(pwd)/logs:/var/log/nginx \ --restart unless-stopped \ my-nginx:v1-v $(pwd)/logs:/var/log/nginx这行我要重点强调一下。很多人习惯把整个日志目录挂到宿主机,但在接 Docker 的卷挂载时有个坑:如果你把日志目录挂载出来,宿主机上的目录权限默认是 root,而容器内写日志的是 nginx 用户,UID 是 100。这会导致 Nginx 启动后无法写日志,报open() /var/log/nginx/error.log failed: Permission denied。
我的解决办法是挂载前先在宿主机上调整目录属主:
mkdir -p logs sudo chown -R 100:101 logs提示:如果你不想纠结权限问题,也可以先不挂载日志目录,等排查确认没问题了再加挂载。日志挂载属于运行态需求,不影响镜像本身的可用性。
启动之后检查容器状态:
docker ps正常应该能看到STATUS这一列显示Up。我在 Dockerfile 里加了HEALTHCHECK,所以这里会显示(healthy);如果某次配置写坏了导致 Nginx 启动失败,状态会变成(unhealthy),这时候不要犹豫,直接docker logs my-nginx看错误日志,把配置改回来再重启。
4. SSL 证书替换与反向代理配置:镜像落地后的三个高频场景
4.1 替换 SSL 证书不生效的排查实录
“nginx 替换 SSL 证书不生效”是我在维护这套镜像时被问到最多的问题。现象通常是这样:你更新了宿主机上的证书文件,容器里也 reload 了,但浏览器一访问,看到的还是旧证书。
容器环境下这个问题的答案往往出人意料地简单——你挂载的路径和 Nginx 实际读取的路径不是同一个。比如你明明把证书放在/etc/nginx/ssl/里,然后挂载时写的是-v /opt/ssl:/etc/nginx/cert,路径不一致,Nginx 自然读的还是镜像里旧的那份。
完整的排查顺序应该是:
# 第一步:确认容器里实际有哪些证书文件 docker exec my-nginx ls -l /etc/nginx/ssl/ # 第二步:看当前证书的有效期和指纹 docker exec my-nginx openssl x509 -in /etc/nginx/ssl/example.crt -noout -dates # 第三步:验证配置是否能通过 docker exec my-nginx nginx -t # 第四步:重新加载配置 docker exec my-nginx nginx -s reload另外有一个隐蔽但很常见的因素:浏览器缓存。尤其是证书更新后,浏览器持有的旧连接可能还没到期,移动端 App 更是如此。不要一看“不生效”就忙着改服务器,先在命令行用openssl s_client -connect yourdomain:443 -servername yourdomain验证一下实际返回的证书指纹,和你要替换的新证书对一下,立刻就能定位问题在服务器端还是客户端。
还有一点,如果证书和私钥的内容不匹配,nginx -t往往不一定报错,但浏览器访问时会出现不信任告警。我在一次性换完证书后都会做一个校验动作:把证书和私钥分别导出指纹,确认一致再 reload。
docker exec my-nginx openssl x509 -in /etc/nginx/ssl/example.crt -noout -fingerprint docker exec my-nginx openssl rsa -in /etc/nginx/ssl/example.key -noout -fingerprint两个指纹对上,才算真正换完。
4.2 反向代理与超时参数的合理设置
默认的 Nginx 配置里,反向代理相关的超时时间是偏保守的,生产环境经常要主动调整。我自己在做代理转发时关注三个参数:
proxy_connect_timeout 30s; proxy_send_timeout 60s; proxy_read_timeout 60s;proxy_connect_timeout是 Nginx 和后端建立 TCP 连接的超时,系统默认 60 秒。proxy_read_timeout是 Nginx 收到后端响应头之前的等待时间,默认 60 秒——注意这里指的是两次读操作之间的间隔,不是整个请求的总时长。如果后端是慢接口,超过这个时间没有任何新数据,Nginx 直接 504,日志里会记upstream timed out。
我的经验是,根据业务接口的真实耗时来调。如果后端是报表生成类接口,单次耗时可能超过一分钟,那就把proxy_read_timeout调到 120s 或更长,否则用户在页面上看到的永远是 504。如果后端是内部服务且有网关层,可以适当缩短,快速失败反而比长时间占用连接更好排查。
proxy_pass的斜杠细节也要注意。proxy_pass http://backend:8080;不带斜杠时,Nginx 会把完整的原始 URI 原样传给后端;而proxy_pass http://backend:8080/;带斜杠时,匹配到的 location 前缀会被替换掉。以location /api/为例,请求/api/user/list会被后端当成/user/list处理。骨架配置里我用的是不带斜杠的写法,后端能直接拿到完整路径,这种方式对后端路由更友好。
4.3 CORS 跨域问题的处理思路
“nginx invalid cors request”这个报错属于浏览器跨域报错的服务端版。前端在浏览器里访问接口时,如果后端没有返回正确的Access-Control-Allow-Origin头,浏览器会拦下响应,控制台里出现类似CORS policy的提示;而后端日志里则可能出现 invalid cors request。
我在 Nginx 层处理 CORS 的配置是这样的:
location /api/ { if ($request_method = 'OPTIONS') { add_header Access-Control-Allow-Origin $http_origin; add_header Access-Control-Allow-Methods 'GET, POST, OPTIONS'; add_header Access-Control-Allow-Headers 'Authorization, Content-Type'; add_header Access-Control-Max-Age 86400; return 204; } add_header Access-Control-Allow-Origin $http_origin always; proxy_pass http://backend:8080; }这里两个细节容易被细节党忽略。第一,OPTIONS预检请求要单独处理,直接返回 204,不再往后端转发,避免后端业务代码对预检请求不知所措。第二,普通响应里的add_header需要加always参数,否则当响应状态码不是 200、204、301、302、304 时(比如 500 错误),CORS 头不会出现,浏览器照样拦截。$http_origin变量会动态回显请求方的 Origin,比写死*更安全,允许携带 Cookie 的跨域请求也靠它。
5. 常见问题与排错速查:我踩过的五个坑
5.1 Alpine 挂载 conf.d 报错的根源与解决
用nginx:alpine镜像挂载conf.d时报错,是我最开始踩过最深的坑之一。具体表现是容器能启动,但访问时返回 403 或直接 404,再一看日志才发现 Nginx 根本没读到挂载进来的配置。
排查下来有两个层面的原因。第一,Alpine 镜像里 Nginx 的配置目录默认存在,但conf.d目录下有官方自带的default.conf,如果你挂载时还把宿主机目录挂在同一个路径上,挂载会覆盖全目录,官方默认配置直接被隐藏了。第二,宿主机上创建的conf.d文件属主是当前用户(UID 可能是 1000),而容器内访问配置的是 nginx 用户(UID 100),Nginx master 进程在读取目录列表时会遇到权限不足的问题。
解决办法也很简单,先确认宿主机文件的属主,再统一改掉:
chown -R 100:101 conf.d/或者更干脆一点,在挂载时不覆盖整个/etc/nginx/conf.d,而是把单个配置文件挂到固定路径上:
-v $(pwd)/conf.d/default.conf:/etc/nginx/conf.d/default.conf但这里又有一个新的坑:挂载单个文件时,如果宿主机上的文件不存在,Docker 会自动在目标路径创建一个目录,而不是文件。这样一来 Nginx 加载default.conf时只会看到default.conf/目录,直接报错。所以挂载单个文件之前,一定要先确认宿主机的文件真实存在,并且名字完全一致。
5.2 时区、日志轮转与容器内清理
容器内时间默认是 UTC,这会导致 Nginx 的$time_local日志时间比北京时间慢 8 个小时。你排查问题时看到的“五点报错”,实际发生在下午一点,容易造成误判。Dockerfile 里已经通过TZ=Asia/Shanghai和复制/usr/share/zoneinfo/Asia/Shanghai到/etc/localtime来处理。
不过ENV TZ=Asia/Shanghai对 Nginx 本身不一定生效,Nginx 读取的是系统本地时间,所以复制/etc/localtime那步才是关键。如果你用了 Debian 系镜像,没有tzdata也一样会碰到这个问题。
日志轮转是另一个容易忘的点。容器里的 Nginx 会把日志写到/var/log/nginx/,如果不做任何处理,日志会无限增长,最后把磁盘撑爆。最简单的办法是我在上面提到的:把日志目录挂载到宿主机,然后用宿主机自身的logrotate或定时任务处理。如果不想挂载,就在容器里配置access_log off或限制单条日志体积,但这对线上排查不友好,我建议还是挂出去更稳妥。
5.3 镜像体积与安全加固
最后聊一下镜像体积控制和安全加固。我用 Alpine 基础镜像构建出来的 Nginx 镜像,最终体积在 25MB 上下,而默认 Debian 镜像动辄上百 MB。差别主要来自基础镜像本身,而不是 Nginx 程序,所以“基础镜像选型”这一层已经决定了体积下限。
安全加固方面,我一般做四件事。一是把RUN里的工具装到所需即用,不要留多余的调试工具,减少被利用面。二是确保 Nginx master 进程不是 root,官方 Alpine 镜像里已经默认创建了 nginx 用户,只要nginx.conf里写了user nginx;就足够了。三是不要在生产镜像里放任何私钥文件,私钥通过运行时挂载注入,镜像只保留证书也可以——严格来说,私钥连镜像里都不要进,走挂载最安全。四是暴露端口尽量收敛,只开 80 和 443,健康检查使用容器内的127.0.0.1访问,不额外绑定端口到宿主机。
如果你之前是在 Linux 主机上直接装的 Nginx,现在想切到容器方案,别忘了先停掉系统里的 Nginx 服务,否则 80 和 443 端口会被抢占,容器一直起不来。卸载方向主要看你原来的安装方式:apt装的用apt purge nginx,yum装的用yum remove nginx,编译安装的就得回到原来的源码目录执行make uninstall。这个切换过程不算复杂,但在端口冲突这件事上一定要提前处理,不然排查半天还以为镜像有问题。
我自己在把这个镜像落地到生产环境后,最大的感受是:别把 Dockerfile 当成“安装脚本”抄一遍就完事,每一行指令都有可能在半年后的某次故障里等着你。比如daemon off、HEALTHCHECK、STOPSIGNAL这些细节,平时不起眼,但碰上docker stop卡住、容器假死、证书替换不生效时,你就知道它们值多少钱了。
最后分享一个我保持至今的习惯:把不变的配置 COPY 进镜像,把经常改的内容挂载出来。这样你既有一个开箱即用的基础镜像,又保留了运行时灵活性。如果你也想动手构建自己的 Nginx 镜像,建议从nginx:alpine起步,把上面的配置拿过去改一改,先在本地把nginx -t跑通,确认无误再上生产。踩过几次坑之后你会明白,构建 Nginx 镜像本身并不难,难的是把每个细节都搞清楚——而这些细节,恰恰是镜像在线上稳定运行的真正底气所在。