把 NGINX 放进容器不难,难的是把它做成一个「体积小、权限低、可审计、不写盘」的镜像。默认的 nginx:latest 有 190MB、以 root 运行、根文件系统可写,直接上生产等于把攻击面白送出去。本文从镜像构建讲到运行时约束,给出一份可直接落地的加固清单。
1. 基础镜像选型
第一步是决定用哪个基础镜像,它决定了后续所有优化空间。
| 镜像 | 体积 | 特点 | 适用场景 |
|---|---|---|---|
nginx:latest | ~190MB | 基于 Debian,含完整工具链 | 开发调试 |
nginx:alpine | ~50MB | 基于 Alpine + musl | 通用生产 |
nginx:alpine-slim | ~20MB | 精简 Alpine,无额外包 | 追求最小体积 |
nginxinc/nginx-unprivileged | ~50MB | 官方非 root 变体 | 需要非 root |
distroless 自建 | ~15MB | 无 shell、无包管理器 | 高安全要求 |
Alpine 的注意点:它用 musl libc 而非 glibc,绝大多数场景没问题,但加载第三方模块(尤其是预编译的 .so)时 ABI 不匹配会导致 dlopen 失败,此时要么自己编译模块,要么改用 Debian 基础镜像。
distroless 的取舍:没有 shell 意味着无法 kubectl exec 进去调试,诊断只能靠日志与 kubectl debug 临时容器。安全性提升明显,但排障成本上升,需要团队有相应的可观测性基础。
2. 多阶段构建与镜像精简
即使选了 Alpine,构建过程仍会带入不必要的文件。多阶段构建能把构建产物与运行时分离。
# ---------- 阶段一:构建自定义模块 ----------
FROM nginx:1.25-alpine AS builder
RUN apk add --no-cache gcc make libc-dev pcre-dev zlib-dev curl tar
RUN curl -fsSL https://nginx.org/download/nginx-1.25.3.tar.gz \
-o /tmp/n.tar.gz && tar -xzf /tmp/n.tar.gz -C /tmp
RUN cd /tmp/nginx-1.25.3 \
&& ./configure --with-compat --add-dynamic-module=/tmp/njs \
&& make modules && cp objs/*.so /tmp/modules/
# ---------- 阶段二:运行时 ----------
FROM nginx:1.25-alpine-slim
COPY --from=builder /tmp/modules/ /etc/nginx/modules/
# 清理默认站点与示例配置
RUN rm -rf /usr/share/nginx/html/* /etc/nginx/conf.d/default.conf
COPY nginx.conf /etc/nginx/nginx.conf
COPY conf.d/ /etc/nginx/conf.d/
EXPOSE 8080
几个精简动作的效果:
- 删除
/usr/share/nginx/html:默认的 index.html 与 50x.html 没有生产价值,还暴露 NGINX 版本。 - 删除
conf.d/default.conf:默认配置监听 80 且需要 root 能力。 --no-cache与合并 RUN:避免 apk 缓存留在层里,减少层数。
docker history --no-trunc mynginx:1.0 | head -20 # 逐层看体积,找膨胀点
dive mynginx:1.0 # 交互式分析层内容
2.1 关掉版本号泄露
Server: nginx/1.25.3 这个响应头给了攻击者精确的版本信息,用于匹配已知漏洞,在 http 块里加一行 server_tokens off; 即可去掉版本号。注意它只去掉版本号,Server: nginx 仍在;若想完全隐藏,需要 more_clear_headers(headers-more 模块)或第三方补丁。
3. 非 root 运行
默认 NGINX 镜像的 master 进程以 root 启动,原因是它要绑定 80/443 端口并切换 worker 用户。容器里这两个理由都可以规避。
3.1 方案一:用非特权端口
# nginx.conf
server {
listen 8080;
# ...
}
FROM nginx:1.25-alpine
# 创建非 root 用户
RUN addgroup -g 101 -S nginx-user \
&& adduser -u 101 -S -G nginx-user nginx-user
# 把需要写的目录交给该用户
RUN chown -R nginx-user:nginx-user /var/cache/nginx /var/log/nginx \
&& touch /var/run/nginx.pid \
&& chown nginx-user:nginx-user /var/run/nginx.pid
USER nginx-user
EXPOSE 8080
同时 nginx.conf 顶部的 user 指令要注释掉(非 root 启动时它会报 warning,且无法切换用户)。
# user nginx; # 非 root 运行时必须注释
worker_processes auto;
pid /var/run/nginx.pid;
error_log /dev/stderr warn;
http {
access_log /dev/stdout main;
# 临时目录全部改到 /tmp,否则非 root 用户写不进去
client_body_temp_path /tmp/client_temp;
proxy_temp_path /tmp/proxy_temp;
fastcgi_temp_path /tmp/fastcgi_temp;
}
临时目录必须显式指向 /tmp:默认路径在 /var/lib/nginx 下,非 root 用户没有写权限,上传大文件或代理缓存时会报 permission denied,而且错误发生在运行时而非启动时,容易漏测。
3.2 方案二:用官方非特权镜像
nginxinc/nginx-unprivileged 已经处理好了端口(默认 8080)、用户(UID 101)、临时目录。基于它构建可以少踩很多坑。
FROM nginxinc/nginx-unprivileged:1.25-alpine
COPY --chown=101:101 nginx.conf /etc/nginx/nginx.conf
COPY --chown=101:101 conf.d/ /etc/nginx/conf.d/
EXPOSE 8080
3.3 容器运行时的权限收紧
镜像层面的非 root 还不够,运行时还要禁止提权:
apiVersion: v1
kind: Pod
spec:
securityContext:
runAsNonRoot: true
runAsUser: 101
runAsGroup: 101
fsGroup: 101
seccompProfile:
type: RuntimeDefault
containers:
- name: nginx
image: mynginx:1.0
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
add: ["NET_BIND_SERVICE"] # 仅在需要绑 <1024 端口时
allowPrivilegeEscalation: false 阻止 setuid 提权;capabilities.drop: ALL 去掉所有 Linux capabilities;readOnlyRootFilesystem: true 让容器根文件系统只读——这三条合起来能挡掉绝大多数容器逃逸手法。
4. 只读根文件系统
readOnlyRootFilesystem: true 之后,任何需要写盘的操作都会失败。NGINX 需要写的位置有四处,必须用 emptyDir 挂载:
volumeMounts:
- { name: nginx-cache, mountPath: /var/cache/nginx }
- { name: nginx-run, mountPath: /var/run }
- { name: nginx-tmp, mountPath: /tmp }
- { name: nginx-log, mountPath: /var/log/nginx }
volumes:
- { name: nginx-cache, emptyDir: {} }
- { name: nginx-run, emptyDir: {} }
- name: nginx-tmp
emptyDir:
medium: Memory # /tmp 落 tmpfs,避免写节点磁盘
sizeLimit: 64Mi # 防止上传大文件吃光内存
- { name: nginx-log, emptyDir: {} }
日志目录的处理:若已把日志指向 /dev/stdout 与 /dev/stderr,就不必挂 /var/log/nginx。但 /dev/stdout 是符号链接,某些场景下非 root 用户打不开,此时用 /proc/1/fd/1 更可靠。
验证只读是否真的生效:
kubectl exec -it deploy/nginx -- touch /etc/nginx/test # 应报 Read-only file system
kubectl exec -it deploy/nginx -- mount | grep -v 'ro,' # 确认无可写目录
5. 配置注入与健康探针
5.1 配置注入的三种方式
| 方式 | 变更生效 | 适用 |
|---|---|---|
| 打进镜像 | 需重新构建与发布 | 配置几乎不变的场景 |
| ConfigMap 挂载 | 文件更新(有延迟),需 reload | 配置偶尔变更 |
| 环境变量 + 模板 | 需重启容器 | 参数化配置 |
ConfigMap 挂载的问题是 subPath 挂载不会自动更新,且 NGINX 不会自动感知文件变化。标准做法是用 sidecar 或 entrypoint 监听变化后 reload。
#!/bin/sh
# docker-entrypoint.d/30-watch-config.sh
set -eu
CONF_DIR=/etc/nginx/conf.d
last_sum=$(find "$CONF_DIR" -type f -exec md5sum {} \; | md5sum)
while true; do
sleep 10
cur_sum=$(find "$CONF_DIR" -type f -exec md5sum {} \; | md5sum)
if [ "$cur_sum" != "$last_sum" ]; then
if nginx -t 2>/dev/null; then
nginx -s reload && last_sum="$cur_sum"
else
echo "invalid config, skipping reload" >&2
fi
fi
done
校验失败时不要 reload,否则会把一个语法错误的配置推上线,导致所有 worker 退出。nginx -s reload 在配置有语法错误时实际上不会生效(新 worker 启动失败,老 worker 继续服务),但显式检查能避免日志被污染。
5.2 健康探针
NGINX 的探针要区分「进程活着」和「能正常服务」。
# 一个专用的健康检查 location
server {
listen 8080;
location = /healthz {
access_log off;
return 200 "ok\n";
add_header Content-Type text/plain;
}
# 就绪探针:确认能连上上游
location = /readyz {
access_log off;
proxy_pass http://backend/health;
proxy_connect_timeout 1s;
proxy_read_timeout 1s;
proxy_next_upstream off;
}
}
livenessProbe:
httpGet: { path: /healthz, port: 8080 }
initialDelaySeconds: 5
periodSeconds: 10
failureThreshold: 3
readinessProbe:
httpGet: { path: /readyz, port: 8080 }
initialDelaySeconds: 3
periodSeconds: 5
failureThreshold: 2
startupProbe:
httpGet: { path: /healthz, port: 8080 }
failureThreshold: 30
periodSeconds: 2
区分 liveness 与 readiness 的意义:/healthz 只检查 NGINX 自身,返回 200 说明进程健康;/readyz 检查上游可达性,失败时把 Pod 从 Service 摘除但不重启——上游故障时重启 NGINX 毫无帮助,只会放大故障。
startupProbe 防止误杀:大配置的 NGINX 启动需要几秒,没有 startupProbe 时 liveness 会在启动完成前就判定失败并重启,形成 CrashLoopBackOff。
Kubernetes 环境下的 Ingress 与 Service 集成方式参见 Nginx Kubernetes Ingress 。
6. 资源限制与日志
6.1 资源限制的配置
NGINX 的 worker_processes auto 会按 CPU 核心数启动 worker。容器里若没有限制 CPU,它会看到宿主机全部核心,启动过多 worker 导致上下文切换开销。
resources:
requests: { cpu: "200m", memory: "128Mi" }
limits: { cpu: "2", memory: "512Mi" }
env:
- { name: NGINX_WORKER_PROCESSES, value: "2" }
关键点:
- worker 数应与 CPU limit 匹配,不是 request。limit 为 2 核时设 2 个 worker。
- 内存 limit 要留余量:连接内存、缓存、临时文件都占内存,
proxy_cache尤其吃内存,需按keys_zone大小估算。 - 不要给 NGINX 设 CPU limit 过低:限流会引入调度延迟,直接影响 P99。可以只设 request 不设 limit,或把 limit 设为 request 的 3~5 倍。
worker_processes 2; # 与 CPU limit 对齐
worker_rlimit_nofile 65535;
events { worker_connections 8192; multi_accept on; }
http {
upstream backend {
server app:8080;
keepalive 32; # 上游连接池要与并发匹配
}
}
6.2 日志必须走标准输出
容器的最佳实践是「应用只写 stdout/stderr,由容器运行时收集」。这要求 NGINX 日志不落盘。
http {
log_format json_combined escape=json
'{'
'"time":"$time_iso8601",'
'"remote_addr":"$remote_addr",'
'"request":"$request",'
'"status":$status,'
'"body_bytes_sent":$body_bytes_sent,'
'"request_time":$request_time,'
'"upstream_time":"$upstream_response_time",'
'"request_id":"$request_id"'
'}';
access_log /dev/stdout json_combined;
error_log /dev/stderr warn;
}
JSON 格式是容器场景的刚需:容器日志收集器(Fluent Bit、Vector)解析结构化日志的效率远高于正则解析,也让字段级检索成为可能。request_id 用于串联同一条请求在各服务间的日志。
escape=json 不可省:请求 URI 里若含引号或反斜杠,不转义会产出非法 JSON,导致日志管道丢弃整行。
6.3 用日志定位性能问题
NGINX 的 $request_time 与 $upstream_response_time 是定位性能问题的核心指标。容器里 Pod 频繁重建,日志的关联性依赖 request_id 与 Pod 标签。
# 找出最慢的上游请求
kubectl logs deploy/nginx --since=1h \
| jq -r 'select(.upstream_time != null) | [.upstream_time, .request] | @tsv' \
| sort -rn | head -20
镜像供应链环节(SBOM 生成、镜像签名、漏洞扫描)是加固的最后一环,参见 容器镜像供应链安全 与 容器镜像优化 。
7. 加固清单
把上面的实践浓缩成一份可勾选的清单,用于上线前评审:
镜像层
- 使用
alpine-slim或 distroless 基础镜像,多阶段构建 - 删除默认站点文件与示例配置,
server_tokens off - 镜像有固定 tag 或 digest,不用
latest
运行时层
- 非 root 用户运行(UID 固定且 > 1000)
-
allowPrivilegeEscalation: false+capabilities.drop: ["ALL"] -
readOnlyRootFilesystem: true+ 必要的 emptyDir 挂载 -
seccompProfile: RuntimeDefault,临时目录指向/tmp并设 sizeLimit
配置层
- 配置注入可校验,语法错误时不 reload
- liveness 与 readiness 分离,各有独立 endpoint 与 startupProbe
- 日志输出到 stdout/stderr,JSON 格式
-
worker_processes与 CPU limit 对齐
供应链层
- 镜像有 SBOM 与签名,准入时校验
- 定期漏洞扫描,有修复 SLA
8. 总结
NGINX 容器加固的本质是「最小权限 + 最小镜像 + 最小可写面」。非 root、只读根文件系统、能力全删这三条能挡掉绝大多数攻击面,代价只是构建时多写几行配置、运行前多挂几个 emptyDir。
最容易漏的是运行时与镜像的对齐:镜像里做了非 root,但 securityContext 没设 runAsNonRoot,Kubernetes 仍可能以 root 启动;配置里临时目录还指向 /var/lib/nginx,只读根文件系统下才报错。加固完成后务必用「只读 + 非 root + 无能力」的组合做一次完整功能回归,不要只测启动。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。