把 Pod 的 securityContext.readOnlyRootFilesystem 设为 true 之后,最快的反馈往往不是安全加固的成就感,而是一串启动失败:nginx 起不来、Java 进程报 /tmp 不可写、Node 应用在写日志时崩溃。原因很直接——容器根文件系统变成只读以后,所有原本”随手写到容器层”的路径都失效了,而镜像里绝大多数程序都默认依赖这些路径。
本文给出的方案是:根文件系统只读 + 针对每个真正需要写入的路径挂载 emptyDir。听起来简单,但真正落地时要回答三个问题:到底哪些路径需要挂?挂载点会不会被镜像目录里的内容覆盖?/tmp 和 /var/tmp 都挂上是不是多余?下面从原理到最小清单,再到排错方法,逐层拆开。
为什么 readOnlyRootFilesystem 会打断容器启动
容器镜像的分层文件系统在运行时会在最上面叠一层可写层(overlayfs 的 upperdir)。程序写文件时,如果目标路径在镜像里不存在,就在可写层新建;如果已存在,就把文件从下层 copy_up 到上层再改。这层可写层是临时的,容器一删就没了,但它对运行时是必需的。
打开 readOnlyRootFilesystem: true 后,kubelet 传给容器运行时的挂载参数里,根挂载点变成 ro。此时所有写操作返回的错误是内核级的 EROFS(Read-only file system),在应用层表现为各种具体报错:
- nginx:
nginx: [emerg] mkdir() "/var/cache/nginx/client_temp" failed (30: Read-only file system) - Java / Spring Boot:
java.io.IOException: Read-only file system,堆栈落在java.io.File.createNewFile或FileOutputStream - Node.js:
Error: EROFS: read-only file system, open '/app/logs/app.log' - Python:
OSError: [Errno 30] Read-only file system: '/tmp/tmpXXXX'
注意 errno 30 就是 EROFS,看到这个数字基本可以断定是只读根文件系统导致,而不是权限问题(权限问题通常是 EACCES / errno 13)。这个区分在排错时很有价值:EROFS 说明路径本身根本不可写,EACCES 说明 fsGroup、runAsUser 或目录 mode 有问题,两者修法完全不同。
哪些写入是”非必要”的,哪些是”必要”的
并非所有写入都需要保留。区分标准是数据是否有跨进程、跨重启的价值:
| 路径类型 | 典型例子 | 处理方式 |
|---|---|---|
| 进程间通信的 socket 目录 | nginx 的 /var/run、PHP-FPM 的 pid 文件 |
emptyDir(内存型可选) |
| 临时文件 | /tmp、/var/tmp、Tomcat 的 work 目录 |
emptyDir(建议 medium: Memory 并设 sizeLimit) |
| 运行时缓存 | nginx 的 /var/cache/nginx、字体缓存 |
emptyDir |
| 应用日志(stdout 之外) | 写文件的 logback / log4j2 appender | 优先改为 stdout;确实要留则 emptyDir + sidecar 收走 |
| 需要持久化的业务数据 | 数据库数据目录、上传文件 | PVC,不能用 emptyDir |
第一、二、三类正是本文要覆盖的范围。第四类如果无法改成 stdout,说明日志采集链路需要重构,而不是简单挂个 emptyDir——emptyDir 的内容随 Pod 删除而消失,日志会丢。
最小化配置:先从一个真实失败的例子改起
以 nginx 为例,先看不加任何挂载时的 Pod 定义:
apiVersion: v1
kind: Pod
metadata:
name: web-hardened
spec:
securityContext:
runAsNonRoot: true
runAsUser: 101
runAsGroup: 101
seccompProfile:
type: RuntimeDefault
containers:
- name: nginx
image: nginx:1.27-alpine
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
这个 Pod 会反复重启,kubectl logs 里是:
2024/08/12 09:11:03 [emerg] 1#1: mkdir() "/var/cache/nginx/client_temp" failed (30: Read-only file system)
nginx: [emerg] mkdir() "/var/cache/nginx/client_temp" failed (30: Read-only file system)
同时还会遇到第二个问题:默认的 nginx:1.27-alpine 镜像监听 80 端口,非 root 用户(uid 101)无法 bind 1024 以下的端口,报 bind() to 0.0.0.0:80 failed (13: Permission denied)。这属于 runAsNonRoot 引入的独立问题,和只读无关,但在加固场景里通常会一起出现,需要配合 NET_BIND_SERVICE capability 或直接改用 8080。
加上 emptyDir 后的完整版
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
spec:
replicas: 2
selector:
matchLabels:
app: web
template:
metadata:
labels:
app: web
spec:
securityContext:
runAsNonRoot: true
runAsUser: 101
runAsGroup: 101
fsGroup: 101
seccompProfile:
type: RuntimeDefault
containers:
- name: nginx
image: nginx:1.27-alpine
ports:
- containerPort: 8080
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: nginx-cache
mountPath: /var/cache/nginx
- name: nginx-run
mountPath: /var/run
- name: tmp
mountPath: /tmp
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
memory: 128Mi
volumes:
- name: nginx-cache
emptyDir:
sizeLimit: 64Mi
- name: nginx-run
emptyDir: {}
- name: tmp
emptyDir:
medium: Memory
sizeLimit: 32Mi
配套的 nginx 配置片段(挂到 ConfigMap 覆盖默认配置)需要把 pid 和临时目录指到已挂载路径,并把监听端口改为 8080:
# /etc/nginx/conf.d/default.conf
server {
listen 8080;
server_name _;
client_body_temp_path /tmp/client_temp;
proxy_temp_path /tmp/proxy_temp;
fastcgi_temp_path /tmp/fastcgi_temp;
uwsgi_temp_path /tmp/uwsgi_temp;
scgi_temp_path /tmp/scgi_temp;
location / {
root /usr/share/nginx/html;
index index.html;
}
}
主配置里 pid 默认是 /var/run/nginx.pid,已由 nginx-run 挂载覆盖,不需要额外改。如果镜像版本把 pid 放在 /run/nginx.pid,注意 /var/run 通常是 /run 的符号链接,只挂 /var/run 时行为可能不可预期——稳妥做法是把 /run 也挂上,或者用 pid /tmp/nginx.pid; 直接统一到 /tmp。
验证配置生效
$ kubectl apply -f web.yaml
$ kubectl rollout status deploy/web
deployment "web" successfully rolled out
# 确认根文件系统确实是只读
$ kubectl exec deploy/web -- sh -c 'touch /probe.txt'
touch: /probe.txt: Read-only file system
# 确认挂载点可写
$ kubectl exec deploy/web -- sh -c 'touch /tmp/probe.txt && ls -l /tmp/probe.txt'
-rw-r--r-- 1 nginx nginx 0 Aug 12 09:24 /tmp/probe.txt
# 查看挂载明细
$ kubectl exec deploy/web -- cat /proc/mounts | grep -E ' /(tmp|var|etc)'
/proc/mounts 里根挂载点会显示 ro,relatime,emptyDir 则是 rw。这是最直接的证据,比看 YAML 更可信。
挂载点覆盖问题:为什么有时候挂了还是不生效
这是最容易踩的坑。emptyDir 挂载会替换目标目录下的原有内容:挂载前镜像里的 /var/cache/nginx 有若干子目录和权限设置,挂载后看到的是一个全新的空目录,owner 由 kubelet 依据 fsGroup 与 Pod 安全上下文决定。
典型后果:
- 镜像里预先
chown过的目录,挂载后变成root:root 0755,非 root 进程写不进去,报EACCES而非EROFS。 - 镜像里预置的配置文件(例如把整个
/etc/nginx挂成 emptyDir)全部消失,进程直接找不到配置。
所以原则是:只挂”纯写入”的叶目录,不要挂包含镜像内容的父目录。/var/cache/nginx、/tmp、/var/run 符合;/etc、/usr/share/nginx 不符合。
用 fsGroup 解决 owner 问题
emptyDir 的目录属主由 kubelet 决定,机制是:kubelet 在挂载完成后对卷做 chown/chmod,依据是 Pod 级 securityContext.fsGroup(以及 fsGroupChangePolicy)。因此必须同时设置:
spec:
securityContext:
runAsUser: 10001
runAsGroup: 10001
fsGroup: 10001
fsGroupChangePolicy: OnRootMismatch
fsGroupChangePolicy: OnRootMismatch 表示只在卷根目录的 group 不匹配时才递归改权限,能显著缩短大卷的挂载耗时;默认值 Always 会在每次挂载时递归遍历,对大 emptyDir(例如放了大量缓存文件后重建 Pod)可能拖慢启动。
注意:
fsGroup只对支持它的卷类型生效,emptyDir、hostPath、PVC(取决于 CSI 驱动)都支持,但medium: Memory的 tmpfs 同样会经过这一步。若使用readOnlyRootFilesystem且runAsUser为随机 UID 的平台(如 OpenShift 的 restricted-v2 SCC),需要确认 SCC 允许的fsGroup范围,否则 Pod 会卡在CreateContainerConfigError或直接被拒绝。
emptyDir 的参数怎么选:磁盘还是内存
emptyDir 有两个关键字段:medium 和 sizeLimit。
| 字段 | 取值 | 行为 | 适用 |
|---|---|---|---|
medium |
省略(默认) | 落在节点磁盘,通常是 kubelet 的 root-dir 下 | 体积较大、写放大明显的缓存 |
medium |
Memory |
tmpfs,占用容器内存,计入 memory limit | /tmp 之类小量高频写入 |
sizeLimit |
如 64Mi |
超过后触发驱逐(默认卷为磁盘时)或写入失败(tmpfs 达上限) | 所有 emptyDir 都建议设置 |
选择 medium: Memory 时有个必须知道的细节:tmpfs 的用量计入容器的 memory cgroup。也就是说,如果容器 memory limit 是 128Mi,而 /tmp 是 tmpfs 且被写满 32Mi,剩余可用堆内存只剩约 96Mi。这会让一个原本正常的 Java 应用频繁 OOMKilled,而且 kubectl describe pod 里只显示 OOMKilled,不会告诉你是 tmpfs 吃掉的。
实操建议:
/tmp用小容量 tmpfs(16~64Mi)确实能提升安全性与性能,但必须保证resources.limits.memory >= 应用堆 + tmpfs sizeLimit + 余量。- 如果应用会往
/tmp写大文件(例如图像处理、导出 PDF),改用默认磁盘型 emptyDir,并设置sizeLimit防止写满节点磁盘。 sizeLimit对磁盘型 emptyDir 的语义是:超限时 kubelet 驱逐 Pod(Evicted,reason 为EmptyDirSizeLimitExceeded),而不是让写入返回 ENOSPC。想提前发现问题,配合ephemeral-storage的 requests/limits 与告警更可靠。
resources:
requests:
cpu: 100m
memory: 256Mi
ephemeral-storage: 128Mi
limits:
memory: 384Mi
ephemeral-storage: 512Mi
按运行时给出可写目录清单
不同基础镜像的”必要写入路径”差异很大。下面几份是经过验证的常见组合,可直接参考。
Java / Spring Boot(以 eclipse-temurin 为例)
/tmp:JVM 的java.io.tmpdir默认指向这里,内嵌 Tomcat 解压、Files.createTempFile都依赖它。/opt/app/logs或应用自定义日志目录:仅在 logback 配置为文件 appender 时需要。- Spring Boot 3 用
spring-boot-maven-plugin打出的镜像常把工作目录设为/workspace,若应用在启动时生成config/或解压 native 库,也需要挂。
env:
- name: JAVA_TOOL_OPTIONS
value: "-Djava.io.tmpdir=/tmp -XX:MaxRAMPercentage=70"
volumeMounts:
- name: tmp
mountPath: /tmp
volumes:
- name: tmp
emptyDir:
medium: Memory
sizeLimit: 64Mi
显式设置 -Djava.io.tmpdir=/tmp 不算多余:某些镜像会把该属性改到别处,显式写死后挂载点与 JVM 认知一致,能避免”我明明挂了 /tmp 它却去写别的目录”。
Node.js
/tmp:os.tmpdir()的返回值。- npm/pnpm 缓存目录:仅在容器启动时
npm install的镜像里需要,生产镜像应该把依赖打进镜像层,不需要挂。 - 日志目录:除非用
pino写文件或winston配 File transport,否则日志走 stdout 即可。
volumeMounts:
- name: tmp
mountPath: /tmp
- name: app-cache
mountPath: /home/node/.cache
volumes:
- name: tmp
emptyDir:
sizeLimit: 64Mi
- name: app-cache
emptyDir: {}
Python / Gunicorn
Python 的 tempfile.gettempdir() 会依次检查 TMPDIR、TEMP、TMP 环境变量,再回退到 /tmp、/var/tmp、/usr/tmp。只读根文件系统下最稳的做法是显式设置 TMPDIR=/tmp 并挂载 /tmp,而不是依赖回退顺序。
env:
- name: TMPDIR
value: /tmp
- name: PYTHONUNBUFFERED
value: "1"
- name: PYTHONDONTWRITEBYTECODE
value: "1"
PYTHONDONTWRITEBYTECODE=1 值得加:非 root 用户导入模块时若无法写 __pycache__,Python 会静默跳过(不会报错),但每次启动都要重新编译字节码,启动时间变长。加了这个变量后行为明确,也不产生无谓的写入尝试。
排错手册:三类高频故障
1. CrashLoopBackOff 且日志里是 EROFS
定位缺失路径的方法是把容器的写入行为抓出来,而不是靠猜。两个实用手段:
# 手段一:临时以可写根文件系统启动一个调试容器,用 strace 看写操作
$ kubectl debug -it pod/web-hardened --image=nicolaka/netshoot --target=nginx -- sh
$ strace -f -e trace=openat,open -o /tmp/trace.log /docker-entrypoint.sh nginx -g 'daemon off;'
$ grep -E 'O_WRONLY|O_CREAT|O_RDWR' /tmp/trace.log | grep -v ENOENT | head -50
# 手段二:用 ebpf 或简单点,把容器的 /proc/<pid>/fd 观察一轮(适合长驻进程)
$ kubectl exec deploy/web -- sh -c 'ls -l /proc/1/fd 2>/dev/null | head'
更省事的办法是临时把 readOnlyRootFilesystem 改为 false,运行成功后用 kubectl diff 对比容器层变化,或者直接在容器里 find / -newer /etc/hostname -type f 找出启动过程中新建的文件。找到路径后,只把该路径的叶目录加入 emptyDir 列表,然后立即改回 true。
2. 挂了目录但仍然 EACCES
报错从 EROFS 变成 EACCES 说明挂载生效了,但属主不对。检查顺序:
kubectl exec进容器ls -ld /tmp,看 owner 和 mode。- 对照
spec.securityContext.runAsUser与fsGroup,两者应一致或同组。 - 若使用
runAsNonRoot: true且镜像是root(uid 0),kubelet 会直接拒绝启动,报container has runAsNonRoot and image will run as root。这类镜像必须换成非 root 变体(如nginxinc/nginx-unprivileged)或用runAsUser显式指定。 - 用
fsGroupChangePolicy: OnRootMismatch时,若卷根目录 group 恰好匹配就跳过递归,可能存在子目录仍不可写的情况——临时切回Always验证。
3. Pod 被驱逐,reason 是 EmptyDirSizeLimitExceeded
$ kubectl get pod web-7d9c8f6b5-xxxxx -o jsonpath='{.status.reason}{"\n"}'
Evicted
$ kubectl describe pod web-7d9c8f6b5-xxxxx | grep -A3 'Status:'
Status: Failed
Reason: Evicted
Message: Pod was evicted due to emptyDir size limit exceeded on volume "tmp"
这不是错误,是 sizeLimit 在起作用。处理方式取决于业务语义:临时文件被写满说明有代码在不断累积而不清理,先修应用;确属正常用量再上调 sizeLimit 与 ephemeral-storage limits,两者要同步改,否则会先在 ephemeral-storage 上被驱逐。
策略层面:如何保证新工作负载默认只读
手工给每个 Deployment 加 readOnlyRootFilesystem 不可持续。用 ValidatingAdmissionPolicy(K8s 1.30 GA)或 Kyverno 做强制校验更靠谱。Kyverno 的最小规则:
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: require-ro-rootfs
spec:
validationFailureAction: Enforce
rules:
- name: check-readonly-rootfs
match:
any:
- resources:
kinds: ["Pod"]
validate:
message: "containers must set securityContext.readOnlyRootFilesystem=true"
pattern:
spec:
containers:
- securityContext:
readOnlyRootFilesystem: true
先用 validationFailureAction: Audit 跑一周,把存量工作负载的失败清单拉出来挨个修,再切 Enforce。直接上 Enforce 会让大量已有 Deployment 无法滚动更新,风险很高。
还有一个常被忽略的点:
readOnlyRootFilesystem: true只对容器根文件系统生效,hostPath、PVC、/proc、/sys等特殊挂载不受影响。也就是说该字段不能替代 Pod Security Standards 的restricted配置档,两者应一起启用。
小结
把根文件系统设为只读的核心工作量,不在那一行 YAML,而在于找出容器运行时真正需要写入的路径并逐个用 emptyDir 承接。落地时抓住四点:只挂叶目录不挂父目录;用 fsGroup 保证挂载后属主正确;medium: Memory 时把 tmpfs 用量算进 memory limit;所有 emptyDir 都配 sizeLimit 并同步设置 ephemeral-storage。从 /tmp 和一个运行时缓存目录开始改,跑通后再按 EROFS 报错逐个补齐,比一次性铺开要稳得多。
FAQ
Q1:/tmp 和 /var/tmp 都要挂吗?
看应用。POSIX 语义上 /var/tmp 用于重启后仍需保留的临时文件,容器场景基本用不到。多数程序(包括 JVM 的 java.io.tmpdir)走 /tmp。先只挂 /tmp,等日志里真出现 /var/tmp 的 EROFS 再补,避免无效挂载。
Q2:emptyDir 的内容会不会被 kubelet 清理?
容器重启(同一 Pod 内)不会清空,数据保留;Pod 被删除(包括被驱逐、被 Deployment 滚动替换)时清空。这也是它不能存日志和业务数据的原因。需要跨 Pod 生命周期保留的内容必须用 PVC。
Q3:为什么加了 readOnlyRootFilesystem 后启动变慢?
常见于两个原因:一是 fsGroupChangePolicy 为默认 Always 且卷里有大量文件,kubelet 递归 chown 耗时;二是应用每次启动都要写缓存(如字体缓存、字节码缓存)却发现不可写,退化为每次重新生成。前者改 OnRootMismatch,后者把缓存目录也挂成 emptyDir 并考虑在镜像构建阶段预热。
Q4:设了 sizeLimit 但没看到驱逐,写入直接失败是为什么?
如果该 emptyDir 是 medium: Memory(tmpfs),sizeLimit 通过 tmpfs 的 size 参数实现,超限时写入返回 ENOSPC 而不是驱逐 Pod。磁盘型 emptyDir 才走驱逐路径。所以 tmpfs 卷满会直接让应用抛 No space left on device,排查时别和节点磁盘满混淆。
Q5:kubectl exec 进容器后 touch /tmp/x 失败,但 Pod 明明在 Running?
说明该路径没有被 emptyDir 覆盖,仍落在只读根上,而应用可能只用到了别的可写路径所以没崩。用 cat /proc/mounts 确认挂载点列表,比在容器里凭直觉试更快。另外注意,kubectl exec 默认进入的是容器的第一个 command 决定的 namespace,特殊情况下(用了 shareProcessNamespace)会看到其他容器的挂载视图,确认目标容器名后再执行。