NGINX 容器镜像精简与加固

讲解 NGINX 容器化部署的镜像精简与安全加固实践,覆盖多阶段构建与基础镜像选型、非 root 运行与只读根文件系统、能力裁剪与安全上下文、配置注入与健康探针、资源限制与结构化日志输出,以及 SBOM 与镜像签名等供应链环节与上线前加固清单。

把 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 + 无能力」的组合做一次完整功能回归,不要只测启动。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「nginx」更多文章

  1. 多租户虚拟主机与配置生成
  2. 从 Apache 与 Traefik 迁移到 NGINX
  3. njs 模块与 JavaScript 扩展