Ubuntu自建Nginx镜像:Dockerfile设计、瘦身与排错
容器化部署这块,我踩过最蠢的一个坑,是早年间直接docker pull nginx就上线,结果某次线上要加一个第三方模块、要统一时区、要把访问日志格式改成能对接内部统计系统的样子,才发现官方镜像里那些"看不见的约定"全都得推翻重来。后来我把基础镜像统一换成了自己维护的构建在 Ubuntu 系统上运行的 Nginx 镜像,从此配置、依赖、目录结构、退出信号全都握在自己手里。这篇就把这套东西从设计思路到 Dockerfile 逐层拆解、再到实操验证和排错,完整讲一遍。不管你是刚接触容器、还在照着教程敲docker run的新手,还是已经在维护一批自建镜像、天天跟构建缓存和体积较劲的老手,下面这些取舍逻辑和踩坑记录应该都能对上你的场景。
1. 需求拆解与整体设计思路
动手写 Dockerfile 之前,先得把"为什么要自己构建"这件事想明白。很多人第一步就走偏了:拿到需求就开始搜"Dockerfile nginx 教程",复制一份能跑起来就完事,结果三个月后要升级版本、要加模块、要换时区,发现当初那份文件根本没法改。我一般是先把需求列成三条线:功能线(跑什么、要哪些模块)、运维线(日志、信号、健康检查、时区)、安全线(用户、权限、体积、漏洞面)。三条线都想清楚了,Dockerfile 自然就长出来了,而且是能长期维护的那种。
1.1 官方镜像在什么场景下会让人不踏实
官方 Nginx 镜像本身质量很高,绝大多数场景直接用它完全没问题,我从不劝人无脑自建。真正让人不踏实的,是那些"官方镜像替你做主、但你的环境不认"的地方。第一是基础系统:官方镜像的主线基于 Debian,-slim和-alpine各有各的取舍,而很多内部环境的安全基线、合规扫描、运维脚本全是按 Ubuntu 体系写的,包管理习惯、目录约定、systemd 之外的初始化方式都对不上,这时候硬用 Debian 底座,排查问题时两边文档来回翻,效率极低。
第二是软件来源和版本锁定。官方镜像里 Nginx 是编译安装的,路径是/usr/sbin/nginx、配置在/etc/nginx,这些都没问题,但你要加一个非默认模块,就得自己从源码编译,那时候官方镜像的那套编译参数你未必能完整复现。第三是时间与语言环境,官方镜像默认 UTC、默认 C locale,日志时间戳跟业务侧对不上,中文文件名或中文错误信息偶尔会出乱码,虽然能通过挂载和环境变量绕过去,但每次部署都要绕一遍,早晚出事。
第四点最隐蔽:镜像里的默认配置和你的运维规范冲突。比如默认client_max_body_size 1m,业务一传图就 413;默认access_log写文件,容器日志里啥也看不到;默认daemon on,容器起来就退出。这些都不是缺陷,是默认值选择问题,但每一条都会在某个深夜把你叫起来。自己构建一版,把这些默认值一次性定死,比每次部署时打补丁靠谱得多。
1.2 基础镜像选型:ubuntu:22.04 还是 alpine、debian
这是第一个必须做决策的点,我把常见的几个选项拉出来对比过很多次,结论是:没有绝对最优,只有跟你的运维体系最匹配。下面这张表是我自己整理的经验值,体积是构建完成后docker images看到的量级,不同时间拉取会略有浮动。
| 基础镜像 | 最终镜像体积量级 | 包管理与生态 | 适合的场景 | 主要代价 |
|---|---|---|---|---|
| ubuntu:22.04 | 约 130–150 MB | apt,与主流 Linux 运维习惯一致 | 内部规范基于 Ubuntu、需要 apt 装依赖、需要 glibc | 体积偏大,需要主动清理缓存 |
| debian:bookworm-slim | 约 100–120 MB | apt,与 Ubuntu 基本同源 | 想要 apt 但更在意体积 | 部分包的版本比 Ubuntu 保守 |
| alpine:3.19 | 约 45–60 MB | apk,musl libc | 极致瘦身、静态资源托管、边缘节点 | musl 兼容性坑,某些第三方模块编译不过 |
| 官方 nginx:alpine | 约 45 MB | 官方预编译 | 无定制需求、快速起服务 | 定制能力弱,底座不是你的体系 |
我最终选ubuntu:22.04的原因很朴素:团队里所有人的排错肌肉记忆都是 Ubuntu 的——apt、/etc/nginx、/var/log/nginx、systemctl之外的进程管理方式,全都是同一套。选 alpine 能省 80MB,但一旦碰上 musl 编译问题,排查成本远超这 80MB 带来的收益。当然,如果你的场景就是纯静态资源托管、不需要任何额外模块、节点数量上千,那 alpine 的体积优势是实打实的,这时候选它完全正确。
还有一个容易被忽略的点:基础镜像的 tag 要写死到具体版本。写FROM ubuntu:22.04是可以的,因为 22.04 是个长期支持版本,补丁更新通常向后兼容;但如果你写FROM ubuntu:latest,某天上游一升级,你的构建可能直接挂掉,而且你完全不知道是哪次变更导致的。我这边所有 Dockerfile 的 FROM 行都要求带明确版本号,这是硬规矩。
1.3 构建方式:为什么坚持 Dockerfile 而不是 docker commit
新手最容易走的捷径是:起一个 Ubuntu 容器,进去apt install nginx,改改配置,然后docker commit存成镜像。这条路十分钟能出结果,但我强烈不建议用在任何需要长期维护的东西上。原因有三个:一是不可复现,commit 出来的镜像没有构建历史,你不知道里面到底装了什么、改了哪些文件,下次要升级只能重新手动来一遍;二是不可审计,安全扫描看到一个几百 MB 的黑盒镜像,根本没法判断风险点在哪;三是没法做缓存优化和分层设计,commit 出来的层是乱的,后续任何改动都会让整个镜像重新传输。
Dockerfile 的价值不在"能构建出镜像",而在于它是一份可执行的文档。任何人拿到这个文件,都能在任意一台装了 Docker 的机器上复现出一模一样的镜像,diff 一下就知道这次改了什么。我维护的镜像仓库里,每个镜像都必须有对应的 Dockerfile 入库,commit 出来的镜像一律打回重做。这个规矩定下来之后,线上"这个镜像到底怎么来的"这类问题基本绝迹了。
至于单阶段还是多阶段,要看你的安装方式。如果是用apt装预编译的 Nginx 包,单阶段就够了,多阶段反而增加复杂度——因为 apt 装出来的东西本身就是运行时需要的,没有"编译期依赖"和"运行期依赖"的明显区分。只有当你需要从源码编译 Nginx、要加第三方模块(比如某些图像处理、认证、限流模块)时,多阶段才有意义:第一阶段装编译器、源码、开发库,编译出二进制;第二阶段只拷二进制和运行库。这个区别很关键,很多人看教程看到多阶段就照抄,结果发现自己根本没用上。
1.4 分层设计与缓存策略的取舍
Docker 镜像是一层一层叠起来的,每一层对应 Dockerfile 里的一条指令。理解这一点,后面所有的优化才有依据。核心规则只有一条:任何一条 RUN 指令产生的文件,如果在同一条 RUN 里删掉,才不会进入最终镜像;如果分成两条 RUN,第一条装的东西在第二条里删掉,体积一点都不会减小。这是新手最常犯的错误,也是我见过最多的"我明明清理了缓存为什么镜像还是这么大"。
基于这条规则,我的分层设计原则是这样的:变化频率低的东西放前面,变化频率高的放后面。基础镜像、系统包安装、时区设置这些一年改不了一次的,放最前面;Nginx 版本号这种半年改一次的,放中间;配置文件、静态资源这种天天改的,放最后。这样日常改个配置只需要重新构建最后一两层,前面的全部命中缓存,构建时间从几分钟降到几秒。
另外还有两个必须注意的点。一是apt-get update和apt-get install必须在同一条 RUN里,分开写的话,update那一层会被缓存,等你下次加新包的时候,安装用的是几天前的包索引,很可能报Unable to locate package。二是COPY指令的顺序要精心安排,先拷贝不常变的配置文件,再拷贝经常变的静态资源目录,否则每次改个 HTML 都会让后面的所有层缓存失效。
2. Dockerfile 核心细节逐条拆解
Dockerfile 写起来就那么十几行,但每一行的写法都有讲究,写错了当时能跑,埋的雷可能几个月后才炸。这一章我按执行的先后顺序,把关键几行的设计意图、常见错误写法、以及为什么这么写,逐条讲清楚。
2.1 apt 层:一行搞定 update、install 与清理
系统包安装是整个构建里最容易写臃肿的一层。先看一个反面例子,我见过的真实写法:
RUN apt-get update RUN apt-get install -y curl gnupg tzdata RUN rm -rf /var/lib/apt/lists/*这三行分开写,结果是:第一行产生了一层包含完整包索引的层(大约 20–30MB),第二行产生了安装包的层,第三行虽然删了索引,但只是在新的一层里打了个"删除标记",第一层里的索引文件依然存在于镜像里。最终镜像白胖了二三十兆,而且你还以为自己清理过了。
正确的写法是把所有操作压缩到一条 RUN 里,用&&串起来,并且用set -eux开头,保证任何一步失败就整体失败(而不是默默跳过继续往下走,最后构建出一个残缺镜像还不知道):
RUN set -eux; \ apt-get update; \ apt-get install -y --no-install-recommends \ ca-certificates curl gnupg tzdata; \ ln -snf /usr/share/zoneinfo/$TZ /etc/localtime; \ echo $TZ > /etc/timezone; \ rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*这里有几个参数值得单独说。--no-install-recommends的作用是不装"推荐包",Ubuntu 的包依赖里有 recommended 这一档,不装它可能连带少装十几个非必需的包,体积能省下不少,代价是某些功能要自己确认。set -eux里的e是遇错退出,u是使用未定义变量时报错,x是打印执行的命令——构建失败时能直接从日志里看出是哪一步炸的,排查效率差别巨大。
tzdata这个包必须显式装,因为它是交互式的,安装过程中会弹时区选择界面,如果不装它、或者不设DEBIAN_FRONTEND=noninteractive,构建会卡在那里等输入,表现就是"构建卡住了"。所以 ENV 里那句DEBIAN_FRONTEND=noninteractive不是可选项,是必须的。时区的设置方式也有讲究:ln -snf建软链是标准做法,同时往/etc/timezone写一份,这样date命令和某些依赖/etc/timezone的程序都能拿到正确值。
注意:清理缓存的动作一定要和安装动作在同一个 RUN 里,跨 RUN 删除不会减小镜像体积。这条规则的例外只有一个——用 BuildKit 的 cache mount,那是另一套机制,后面会讲。
2.2 Nginx 从哪来:系统源与官方源的取舍
Ubuntu 自带的软件源里是有 nginx 的,apt install nginx就能装,但版本通常比上游落后一两个大版本。如果你的需求就是"能跑",系统源完全够用,而且省去了配源的麻烦。但如果需要特定版本、或者要用到新版本才有的特性,就得换成 Nginx 官方维护的 Ubuntu 软件源。
配官方源这一步,现在网上大量流传的还是apt-key add的写法,但那个方式在新版本里已经废弃了,会报 warning 甚至在未来的版本里直接失效。正确做法是把公钥转成 keyring 文件,然后用signed-by指向它:
RUN set -eux; \ curl -fsSL https://nginx.org/keys/nginx_signing.key \ | gpg --dearmor -o /usr/share/keyrings/nginx-archive-keyring.gpg; \ echo "deb [signed-by=/usr/share/keyrings/nginx-archive-keyring.gpg] \ http://nginx.org/packages/ubuntu jammy nginx" \ > /etc/apt/sources.list.d/nginx.listgpg --dearmor的作用是把 ASCII 格式的公钥转成二进制格式,apt 只认二进制。-fsSL那几个参数是 curl 的标准安全写法:f是 HTTP 错误时返回非零退出码(否则curl拿到一个 404 页面也照样返回成功,公钥就变成了一段 HTML,后面全崩),s静默,S显示错误,L跟随重定向。
这里有个坑必须提醒:源里的代号必须和基础镜像匹配。ubuntu:22.04的代号是jammy,如果你写成了focal(20.04)或者noble(24.04),apt 要么找不到包,要么装上了但依赖关系错乱。对应的仓库地址路径是http://nginx.org/packages/ubuntu,加上发行代号和组件名nginx,这三段缺一不可。
装的时候建议把版本号锁死,用ARG传进来:
ARG NGINX_VERSION=1.24.0-1~jammy ARG TZ=Asia/Shanghai ... apt-get install -y --no-install-recommends nginx=${NGINX_VERSION};版本号的具体格式不要凭记忆写,构建前先跑一句docker run --rm ubuntu:22.04 bash -c "apt-get update && apt-cache madison nginx"看一眼实际可用的版本清单,格式通常是主版本.次版本.补丁-发布号~代号。这一步花三十秒,能省掉后面半小时的Unable to locate package排查。锁版本的好处是可复现,坏处是每次升级要改文件,但这个改动是有意识的、可审查的,比"某天自动变成了新版本然后行为不一致"要好得多。
还有一个体积上的技巧:gnupg只在拉公钥那一步需要,装完就可以清掉。但注意,清理必须写在同一个 RUN 里,如果单独开一条RUN apt-get purge -y gnupg,体积不降反增。写在同一条 RUN 里的写法是apt-get purge -y --auto-remove gnupg,注意--auto-remove会把它带进来的依赖也一起清掉。如果清理之后发现 apt 源校验报错,说明某些校验工具被误删了,把那一步去掉、保留 gnupg 就行,几兆的体积不值得冒这个风险。
2.3 日志与信号:让容器日志和优雅退出都正常
这两个东西是容器化 Nginx 最容易出问题、也最少被讲清楚的地方。
先说日志。Nginx 默认把访问日志写到/var/log/nginx/access.log,错误日志写到同目录的error.log。在物理机上这没问题,但在容器里,这些文件的内容不会出现在docker logs的输出里,你只能docker exec进去tail,非常别扭,而且日志文件会随着时间不断增长,最终把容器写满。标准解法是把这两个文件路径软链到标准输出和标准错误:
RUN ln -sf /dev/stdout /var/log/nginx/access.log \ && ln -sf /dev/stderr /var/log/nginx/error.log这样 Nginx 以为自己还在写文件,实际写进了容器的 stdout/stderr,docker logs、日志采集组件全都能正常拿到。为什么访问日志走 stdout、错误日志走 stderr?因为这是 Unix 的约定,正常输出和异常输出分流,日志采集侧可以按流做不同的处理策略,比如错误日志直接触发告警。
再说信号。Nginx 的 master 进程对信号的处理是:SIGTERM是快速关闭,SIGQUIT是优雅关闭(等当前请求处理完再退出),SIGHUP是重新加载配置,SIGUSR1是重新打开日志文件。Docker 停止容器时默认发SIGTERM,如果不做处理,Nginx 会直接断掉正在处理的请求,用户看到的就是连接被重置。
所以 Dockerfile 里要加一行:
STOPSIGNAL SIGQUIT这行的意思是,docker stop的时候发SIGQUIT而不是默认的SIGTERM,Nginx 就会走优雅关闭流程。配合docker stop -t 30给的超时时间,绝大多数长连接请求都能正常收尾。这个改动看起来只有一行,但它决定了一次发布是"平滑"还是"抖一下",我把它列为自建镜像的必备项。
提示:
CMD里那句daemon off;是容器能跑起来的前提。Nginx 默认以守护进程方式运行,启动后自己 fork 到后台、前台进程退出,容器一看主进程没了就直接停止。加了daemon off;它才留在前台。
2.4 配置文件与静态资源的 COPY 顺序
COPY指令看着最简单,其实顺序设计直接影响日常构建效率。基本原则是:按变化频率从低到高排列。
COPY nginx.conf /etc/nginx/nginx.conf COPY conf.d/ /etc/nginx/conf.d/ COPY html/ /usr/share/nginx/html/nginx.conf是主配置,一年改不了几次,放最前面;conf.d/下的站点配置,可能一周改一次,放中间;html/是静态资源,前端一发包就变,放最后。这样前端更新的时候,前两层的缓存全部命中,只有最后一层重新构建,几秒钟搞定。反过来如果先 COPYhtml/,那每次前端发包,后面的配置层全部缓存失效,构建时间白白增加。
还有一个细节:COPY conf.d/ /etc/nginx/conf.d/结尾的斜杠表示拷贝目录内容而不是目录本身。这个语法细节很容易搞错,两者差一层目录,表现就是配置找不到、站点 404。我的习惯是本地目录结构和容器内目录结构保持一致,用 Mind Map 式的映射关系去记:本地conf.d/default.conf对应容器/etc/nginx/conf.d/default.conf,一一对应,不容易错。
至于默认站点,官方源装完之后/usr/share/nginx/html里有个欢迎页。我一般会用自己的index.html覆盖掉,内容哪怕只是一个服务名加版本号,也比留着 Nginx 的默认欢迎页强——默认页出现在生产环境,说明部署流程里有个环节没走通,是个明显的信号。
3. 从零到可运行镜像的完整实操
前面讲的是"为什么这么设计",这一章是"具体怎么敲"。我会把目录结构、完整文件内容、构建命令、验证步骤全都给出来,你照着做一遍就能得到一个可用的镜像。所有路径、版本号都写成了可直接复制的形式,唯一需要你替换的是镜像仓库地址。
3.1 目录结构与 .dockerignore
先在本地建一个干净的构建目录,结构如下:
nginx-ubuntu/ ├── Dockerfile ├── .dockerignore ├── nginx.conf ├── conf.d/ │ └── default.conf └── html/ └── index.html.dockerignore这个文件很多人不写,觉得构建上下文就几个小文件无所谓。但只要你的构建目录里混进了.git、node_modules、日志文件、或者前端构建产物,构建上下文可能一下子涨到几百兆,每次构建都要把这些东西打包发给 Docker daemon,速度慢得离谱。所以这个文件必须写:
.git .gitignore node_modules *.log .DS_Store README.md注意别把html/和conf.d/排除掉了,这两个是构建要用的。这个文件的作用范围和.gitignore类似,但是给 Docker 用的,两者不能混用——我见过把.gitignore的内容直接抄进.dockerignore结果把配置文件也排除掉的情况,构建出来的镜像里配置是空的,启动就报错。
3.2 完整 Dockerfile 与 nginx.conf
把前面所有设计拼起来,完整的 Dockerfile 长这样:
FROM ubuntu:22.04 LABEL org.opencontainers.image.description="Ubuntu 22.04 based Nginx image" \ org.opencontainers.image.vendor="internal" ARG NGINX_VERSION=1.24.0-1~jammy ARG TZ=Asia/Shanghai ENV DEBIAN_FRONTEND=noninteractive \ TZ=${TZ} RUN set -eux; \ apt-get update; \ apt-get install -y --no-install-recommends \ ca-certificates curl gnupg tzdata; \ ln -snf /usr/share/zoneinfo/$TZ /etc/localtime; \ echo $TZ > /etc/timezone; \ curl -fsSL https://nginx.org/keys/nginx_signing.key \ | gpg --dearmor -o /usr/share/keyrings/nginx-archive-keyring.gpg; \ echo "deb [signed-by=/usr/share/keyrings/nginx-archive-keyring.gpg] http://nginx.org/packages/ubuntu jammy nginx" \ > /etc/apt/sources.list.d/nginx.list; \ apt-get update; \ apt-get install -y --no-install-recommends nginx=${NGINX_VERSION}; \ apt-get purge -y --auto-remove gnupg; \ rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* RUN set -eux; \ ln -sf /dev/stdout /var/log/nginx/access.log; \ ln -sf /dev/stderr /var/log/nginx/error.log; \ mkdir -p /var/cache/nginx 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=3s --start-period=10s --retries=3 \ CMD bash -c '</dev/tcp/127.0.0.1/80 && exit 0 || exit 1' CMD ["nginx", "-g", "daemon off;"]对应的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" "$http_x_forwarded_for" ' 'rt=$request_time urt=$upstream_response_time'; access_log /var/log/nginx/access.log main; sendfile on; tcp_nopush on; keepalive_timeout 65; server_tokens off; client_max_body_size 20m; client_body_timeout 30s; gzip on; gzip_min_length 1024; gzip_comp_level 5; gzip_types text/plain text/css application/json application/javascript text/xml application/xml image/svg+xml; include /etc/nginx/conf.d/*.conf; }server_tokens off;是我每台机器都会加的,它把错误页和响应头里的 Nginx 版本号隐藏掉,成本为零,收益是少暴露一点信息。client_max_body_size 20m是对着业务实际的上传上限定的,1m 的默认值在现代 Web 里基本不够用,413 报错十有八九是它。log_format里加了rt和urt两个耗时字段,排查慢请求的时候,有没有这两个字段的差别是"能定位"和"只能猜"的差别。
conf.d/default.conf给一个最小可用的静态站点:
server { listen 80; server_name _; root /usr/share/nginx/html; index index.html; location / { try_files $uri $uri/ =404; } location = /healthz { access_log off; return 200 "ok\n"; } }健康检查这里用了一个独立路径/healthz,直接返回 200 不走磁盘,比检查首页更快也更稳,还顺手关掉了这个路径的访问日志,避免健康检查把日志刷满。
3.3 构建命令、打标签与体积核对
构建命令本身不复杂,但几个参数值得说:
docker build \ --build-arg NGINX_VERSION=1.24.0-1~jammy \ --build-arg TZ=Asia/Shanghai \ -t registry.internal/library/nginx-ubuntu:1.24.0 \ -t registry.internal/library/nginx-ubuntu:latest \ .--build-arg让版本号可以从外部注入,这样同一个 Dockerfile 能构建出不同版本的镜像,CI 里传参就行。-t打两个标签是常规做法:一个带具体版本的不可变标签,给生产环境用;一个latest,方便本地随手跑。生产环境永远只用带版本的标签,这是铁律,latest的语义是"最近一次构建",不可追溯,出了问题你连回滚到哪个版本都不知道。
还可以加上--progress=plain看详细构建日志,尤其是排查 apt 卡住、网络慢这类问题时很有用。
构建完成后核对体积和内容:
docker images registry.internal/library/nginx-ubuntu docker run --rm --entrypoint bash registry.internal/library/nginx-ubuntu:1.24.0 \ -c "du -sh /var/lib/apt/lists /var/cache/apt 2>/dev/null; nginx -v; nginx -V 2>&1 | tr ' ' '\n' | grep module"第一句看体积是不是在预期范围(100–150MB 之间),如果超过 200MB,说明清理那一步没生效,回去检查是不是把删除动作写到别的 RUN 里了。第二句同时做了三件事:确认 apt 缓存是空的、确认 Nginx 版本号、确认编译进了哪些模块。nginx -V输出的模块列表特别有用,比如你需要http_ssl_module、http_v2_module、http_realip_module,在这份输出里直接能看到有没有,不用猜。
如果想逐层看体积分布,可以用docker history:
docker history --no-trunc --format "{{.Size}}\t{{.CreatedBy}}" \ registry.internal/library/nginx-ubuntu:1.24.0 | head -20这个命令能把每一层的大小和对应指令列出来,哪一层异常大一眼就能看出来。我第一次做这个镜像的时候就是靠它发现apt-get purge gnupg单独一行反而增大了体积,改成同层清理后省了差不多十兆。
3.4 启动验证:curl、nginx -V、健康检查
镜像构建出来了不等于能用,下面这套验证流程我每次都会走一遍,五步之内能确认镜像是否健康。
第一步,前台试跑,确认能启动:
docker run --rm -p 8080:80 registry.internal/library/nginx-ubuntu:1.24.0终端上应该看到 Nginx 的启动日志,并且容器不退出。如果容器打印完nginx: configuration file /etc/nginx/nginx.conf test is successful就立刻退出了,多半是daemon off;没写或者被CMD覆盖了。
第二步,另开终端发请求:
curl -I http://127.0.0.1:8080/ curl -s http://127.0.0.1:8080/healthz应该看到HTTP/1.1 200 OK和响应头里没有Server: nginx/1.24.0这样的版本信息,只有Server: nginx。如果返回 403,检查静态目录权限和index.html是否存在;如果返回 404,检查root路径和try_files。
第三步,确认日志能进docker logs:
docker run -d --name nginx-demo -p 8080:80 registry.internal/library/nginx-ubuntu:1.24.0 curl -s http://127.0.0.1:8080/ > /dev/null docker logs nginx-demo能看到访问日志说明软链生效了。这一步没做的话,后面上线了排查问题会非常难受,因为日志采集侧什么都收不到。
第四步,验证优雅退出:
docker stop -t 30 nginx-demo正常情况下它会在几秒内退出,并且docker inspect看到的退出码是 0。如果是 137,说明被SIGKILL强杀了,检查STOPSIGNAL有没有生效。
第五步,确认健康检查状态:
docker inspect --format '{{.State.Health.Status}}' nginx-demo跑起来之后等几十秒,状态应该从starting变成healthy。如果一直是unhealthy,先用docker inspect --format '{{json .State.Health}}'看最后一次检查的输出。start-period设成 10 秒是因为 Nginx 启动很快,但如果你的配置里有大量 upstream 健康检查或者 DNS 解析,启动时间会拉长,这时候要适当调大。
4. 镜像瘦身、安全加固与非 root 运行
能跑的镜像和能上生产的镜像之间,隔着体积和安全两道关。这两块的优化手段有些是通用的,有些是 Nginx 特有的,分开讲。
4.1 体积从哪来:逐层核算与瘦身手段
先把体积账算清楚,才知道该从哪里下手。以ubuntu:22.04为底,构建完成后各部分的典型量级如下:
| 层内容 | 典型大小 | 说明 |
|---|---|---|
| 基础层 ubuntu:22.04 | 约 78 MB | 无法进一步压缩,除非换底座 |
| 工具与 Nginx 安装层 | 约 45–55 MB | 含 curl、tzdata、Nginx 及依赖 |
| 清理后的残留 | 几 MB | 主要是配置和软链 |
| 配置与静态资源 | 小于 1 MB | 取决于你的前端产物 |
| 合计 | 约 130–145 MB | 与官方 Debian 版同量级 |
第一刀砍在 apt 缓存上:/var/lib/apt/lists在apt-get update之后能占到 20–30MB,必须在同层删掉。第二刀砍在 gnupg 上,拉完公钥就没用了,同层 purge 掉能省几兆。第三刀是curl,如果健康检查用/dev/tcp就不需要它,但保留着调试方便,几兆的代价我认为值得。第四刀是文档和语言包,可以加--no-install-recommends之外,再在 apt 里配APT::Install-Recommends "0";全局关掉推荐包,效果更彻底。
如果还想再瘦,可以上 BuildKit 的缓存挂载,把 apt 缓存单独挂出去,不写进镜像层:
# syntax=docker/dockerfile:1.4 RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ --mount=type=cache,target=/var/lib/apt,sharing=locked \ apt-get update && apt-get install -y --no-install-recommends nginx这种写法的好处是多次构建之间能复用 apt 缓存,构建速度明显提升,同时缓存不进入镜像层,体积干净。注意第一行必须是# syntax=docker/dockerfile:1.4,否则语法不被识别。启用 BuildKit 的方式是构建时加DOCKER_BUILDKIT=1或者用新版 Docker 默认开启。
最后提一句多阶段构建。如果你的需求最终变成了"从源码编译 Nginx 并带上某个第三方模块",那就该上多阶段了。第一阶段用完整的构建工具链(gcc、make、libpcre3-dev、zlib1g-dev、libssl-dev)编译出二进制和模块,第二阶段从第一阶段只COPY --from=builder二进制文件,运行库靠 apt 装最低限度的版本。这样能把编译器那几百兆彻底甩掉。但要清楚:多阶段解决的是"编译期依赖"问题,如果你只是 apt 装包,多阶段没有任何收益,别为了显得专业硬上。
4.2 以非 root 用户运行的正确姿势
默认情况下,容器里的 Nginx master 进程是以 root 启动的,然后按user nginx;指令把 worker 进程降权到 nginx 用户。这在很多安全基线里是不合格的,要求 master 也以普通用户运行。改造起来有几个必须同步调整的地方,少改一个就起不来。
第一个是端口。Linux 里监听 1024 以下的端口需要特权,非 root 用户监听 80 会直接报Permission denied。解法是把listen 80;改成listen 8080;,然后容器启动时用-p 80:8080做端口映射。对外还是 80,容器内部是 8080,这个转换用户完全感知不到。
第二个是 pid 文件和缓存目录。/var/run/nginx.pid默认需要 root 才能写,非 root 启动会报nginx: [emerg] open() "/var/run/nginx.pid" failed (13: Permission denied)。解法是把 pid 路径改到可写位置,比如/tmp/nginx.pid,同时把/var/cache/nginx的临时目录也改到/tmp下:
pid /tmp/nginx.pid; http { client_body_temp_path /tmp/client_body; proxy_temp_path /tmp/proxy; fastcgi_temp_path /tmp/fastcgi; uwsgi_temp_path /tmp/uwsgi; scgi_temp_path /tmp/scgi; }第三个是user指令。非 root 启动 master 的时候,user nginx;这行会失效并打出一条警告the "user" directive makes sense only if the master process runs with super-user privileges, ignored。这条警告无害,但会污染日志,可以按条件删掉或者直接忽略。
对应的 Dockerfile 改造:
RUN useradd --system --uid 10001 --shell /usr/sbin/nologin nginxapp \ && chown -R nginxapp:nginxapp /usr/share/nginx/html /etc/nginx/conf.d USER 10001 EXPOSE 8080注意USER之后的所有指令(包括CMD)都以该用户身份执行。还有一点容易忘:日志软链指向/dev/stdout,这个在非 root 下依然可用,因为标准输出本来就属于该进程。但如果你改成写真实文件,就得确保目录可写。
4.3 安全扫描与最小化原则
镜像做完之后建议过一遍扫描,看有没有高危漏洞。常用的方式是在 CI 里挂一个扫描步骤,对构建出来的镜像做一次检查,把结果作为流水线的门禁——高危漏洞直接阻断发布。这个动作看起来麻烦,但比出事之后回溯要省事得多。
最小化原则体现在几个方面。一是不要装无关的包,vim、net-tools、iputils-ping这些调试工具在开发阶段很方便,进生产镜像就是多余的攻击面,需要调试的时候用docker exec临时装或者用专门的调试镜像。二是不要在镜像里放敏感信息,任何密码、密钥、证书私钥都不许进 Dockerfile,通过环境变量或者挂载注入。三是固定基础镜像版本并定期重建,基础镜像的补丁更新不会自动传导到你的镜像里,必须主动重新构建才能拿到。
还有一个实操建议:给镜像打上标准标签,包括版本、构建时间、源码提交号,用LABEL或--label注入。线上出问题的时候,docker inspect一眼就能看出这个镜像是哪个版本、什么时候构建的,这个信息在应急时非常值钱。
5. 常见问题与排查实录
下面这些坑,绝大多数我都亲身踩过至少一次。按现象、原因、解法整理成速查表,遇到问题先对照着看,能省不少时间。
5.1 启动即退出与端口冲突
容器刚启动就退出,docker ps看不到。这是最高频的问题,九成是主进程没留在前台。检查CMD里有没有-g "daemon off;",注意是分号结尾并且带引号,写成daemon off不加分号 Nginx 会报配置语法错误。另一个可能是CMD被docker run后面跟的命令覆盖了,比如你写了docker run image bash,那容器跑的就是 bash,跑完就退出。
报nginx: [emerg] bind() to 0.0.0.0:80 failed (98: Address already in use)。容器内的 80 被占了,常见原因是配置文件里有两个server块都写了listen 80;且没有server_name区分,或者conf.d/下有一份默认配置和你自己的配置重复了。排查方式是在容器里跑nginx -T把所有生效配置打印出来,看listen 80出现了几次。注意nginx -T是展开 include 之后的完整配置,比-t(只做语法检查)更适合排查冲突。
docker run -p 8080:80报端口已被占用。宿主机上的 8080 被别的进程占了。Linux 下用ss -lntp | grep 8080找出来,换端口或者停掉冲突进程。
5.2 日志、权限与 413 的典型坑
docker logs是空的。检查两条软链有没有建成功:docker exec <容器> ls -l /var/log/nginx/,应该看到access.log -> /dev/stdout。另一个可能是access_log off;覆盖了全局配置,或者请求根本没达到 Nginx(比如被上游代理拦了)。
报Permission denied打不开 pid 或缓存目录。如果你改成了非 root 运行,检查pid路径和所有*_temp_path是否指向可写目录。默认的/var/run和/var/cache/nginx对非 root 用户不可写,一定要改。
上传文件返回 413。直接改client_max_body_size,比如20m。这个值的单位是兆,不要写成20M,虽然大多数情况下大小写不敏感,但配置项里统一用小写更保险。改完记得nginx -s reload或者重启容器,配置不会自动生效。
时间显示不对,日志时间戳差八小时。检查TZ环境变量和/etc/localtime软链。有一种情况是tzdata装了但没建软链,另一种是容器继承了宿主机的时区但配置文件里写死了。最稳的方式是在 Dockerfile 里就设好,不要依赖运行时注入。
访问日志里的中文变成问号。Ubuntu 基础镜像默认 locale 是C,装一个locales包并生成en_US.UTF-8能解决大部分乱码,但如果你的业务真的需要处理中文路径,建议还是统一用英文命名,从源头上避免编码问题。
5.3 排查速查表
把上面这些整理成一张表,出问题的时候按现象查:
| 现象 | 最可能的原因 | 处理方式 |
|---|---|---|
| 容器启动即退出 | daemon off缺失或 CMD 被覆盖 | 检查 CMD,用docker run -it --entrypoint bash进去手动起 |
| 403 Forbidden | 静态目录权限不足或缺少 index 文件 | 检查ls -l /usr/share/nginx/html和目录可执行位 |
| 404 Not Found | root路径错误或try_files配置不当 | 用nginx -T看展开后的配置 |
| 413 Request Entity Too Large | client_max_body_size太小 | 调到业务实际上限,如20m |
open() pid failed | 非 root 用户无写权限 | pid 改到/tmp并同步改所有 temp_path |
Unable to locate package nginx | 源代号写错或 apt 索引过期 | 核对 jammy/noble,update 与 install 同层 |
| 构建卡住不动 | 装了交互式包未设DEBIAN_FRONTEND | 加ENV DEBIAN_FRONTEND=noninteractive |
| 镜像比预期大 30MB+ | 清理动作跨层了 | 把删除和安装合并到同一 RUN |
docker logs无输出 | 日志软链未建或被 off | 检查/var/log/nginx下软链 |
| 健康检查一直 unhealthy | start-period太短或检查路径返回非 200 | 调大启动宽限,检查/healthz配置 |
表格之外再补两个不太常见但很折磨人的。一是hash sum mismatch,通常发生在使用中间缓存或者网络环境不稳定的情况下,解法是--no-cache重新构建,如果还不行就在apt-get update前加一句rm -rf /var/lib/apt/lists/*。二是构建时公钥拉取失败,表现为gpg: no valid OpenPGP data found,原因多半是 curl 拿到了错误页面,加-f参数让它明确失败,然后检查网络是否可达。
还有一个我自己的习惯:每次镜像构建完成后,先跑一个"冒烟测试"脚本,用docker run -d起容器、curl打健康检查、拿到 200 再docker rm -f。这几十秒的验证,能挡掉绝大多数"镜像构建成功了但跑不起来"的问题,比推到仓库再被下游发现要体面得多。
我在实际维护这套东西的过程中体会最深的一点是:镜像这东西的价值不在于它第一次能不能跑起来,而在于半年后你要改一行配置时,它是让你花五分钟还是花五小时。把版本锁死、把清理写进同一层、把日志接到标准输出、把退出信号设对,这些动作每一个都只花几十秒,但它们决定了这个镜像是"能用"还是"好维护"。我现在给新同事的第一条建议也是这个:先别急着优化体积,把上面这四件事做对,剩下的再说。