
如果你平时用 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-30MBmusl低资源环境、快速分发动态模块编译需额外处理部分第三方模块依赖 glibcnginx:debian-slim约 80-100MBglibc兼容性优先、需编译扩展模块体积中等apt 依赖管理方便nginx:centos约 200MBglibc已有 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 maintaineryournameexample.com ENV TZAsia/Shanghai \ NGINX_VERSION1.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 --interval30s --timeout5s --start-period10s --retries3 \ 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 offCMD [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.confuser 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 100Nginx 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 里已经通过TZAsia/Shanghai和复制/usr/share/zoneinfo/Asia/Shanghai到/etc/localtime来处理。不过ENV TZAsia/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 nginxyum装的用yum remove nginx编译安装的就得回到原来的源码目录执行make uninstall。这个切换过程不算复杂但在端口冲突这件事上一定要提前处理不然排查半天还以为镜像有问题。我自己在把这个镜像落地到生产环境后最大的感受是别把 Dockerfile 当成“安装脚本”抄一遍就完事每一行指令都有可能在半年后的某次故障里等着你。比如daemon off、HEALTHCHECK、STOPSIGNAL这些细节平时不起眼但碰上docker stop卡住、容器假死、证书替换不生效时你就知道它们值多少钱了。最后分享一个我保持至今的习惯把不变的配置 COPY 进镜像把经常改的内容挂载出来。这样你既有一个开箱即用的基础镜像又保留了运行时灵活性。如果你也想动手构建自己的 Nginx 镜像建议从nginx:alpine起步把上面的配置拿过去改一改先在本地把nginx -t跑通确认无误再上生产。踩过几次坑之后你会明白构建 Nginx 镜像本身并不难难的是把每个细节都搞清楚——而这些细节恰恰是镜像在线上稳定运行的真正底气所在。