Kubernetes 基础体系 · 第 47/83 篇。示例基于 Kubernetes 当前稳定 API;弃用、版本偏差、云厂商差异和生产风险会明确说明。
Kubernetes 运行时安全:非 root、Capabilities、Seccomp、AppArmor 和只读根
容器运行时安全的目标,不是让进程“看起来像普通用户”,而是限制进程在以下几个维度上的能力:
- 它以哪个 Linux UID、GID 运行;
- 它可以执行哪些需要特权的内核操作;
- 它可以调用哪些系统调用;
- 它可以访问哪些文件、目录、设备和内核接口;
- 即使应用被利用,是否还能通过
setuid、文件能力或子进程继续提升权限; - 根文件系统是否能被修改,从而限制持久化、篡改和落地攻击。
Kubernetes 通过 securityContext 把部分 Linux 安全机制暴露给 Pod 和容器。它不是一个独立的安全边界:最终效果还取决于 Linux 内核、容器运行时、节点配置、Pod 使用的主机命名空间以及准入策略。
可以把一个容器进程的有效权限粗略表示为:
这里的“交集”不是 Linux 内核中的单个数据结构,而是一个便于理解的模型:
- UID/GID 决定传统文件权限检查中的身份;
- Capabilities 决定一部分原本属于 root 的特权操作;
- Seccomp 决定系统调用是否允许进入内核;
- AppArmor 决定进程对路径、信号、网络等对象的访问;
- 文件系统挂载和只读根文件系统决定哪些路径可以实际写入。
只配置其中一层,不能推导出“容器已经安全”。
一、先区分 Kubernetes 对象、Linux 进程和容器运行时
Kubernetes 中的 Pod 不是一个 Linux 进程。Pod 由 kubelet 请求容器运行时创建,运行时再通过 Linux namespace、cgroup、capabilities、seccomp、LSM 和挂载配置启动容器进程。
典型路径如下:
sequenceDiagram
participant API as Kubernetes API Server
participant K as Kubelet
participant R as Container Runtime
participant L as Linux Kernel
API->>K: PodSpec + SecurityContext
K->>R: CRI CreateContainer
R->>L: 配置 namespace、mount、UID、capabilities
R->>L: 加载或选择 seccomp、AppArmor
R->>L: exec 容器入口进程
L-->>R: 创建结果或拒绝某项操作
R-->>K: 容器状态
K-->>API: Pod status / events
关键点有两个。
第一,securityContext 是声明,不是所有字段都由 API Server 或 kubelet完整验证。某些配置只有在节点运行时真正创建容器时才会失败。例如:
runAsNonRoot: true不能保证镜像内的入口程序一定以非 root 运行;seccompProfile.type: Localhost要求每个可能调度到的节点都有对应文件;appArmorProfile.type: Localhost要求节点加载了对应的 AppArmor profile;readOnlyRootFilesystem: true可能在容器启动成功后,应用第一次写临时文件时才失败。
第二,Kubernetes 的安全策略和 Linux 的实际强制执行是两层不同的事情。Pod Security Admission 可以拒绝某个 Pod,但它不替代 Linux 内核;反过来,即使 Pod 被准入,节点也可能没有可用的 AppArmor,导致容器启动失败或只能使用较弱的配置,具体取决于字段、版本和运行时。
二、非 root:UID 身份,不是“权限全部消失”
2.1 runAsUser、runAsGroup 和 runAsNonRoot
Kubernetes 中常用的 Pod 或容器级配置如下:
securityContext:
runAsUser: 10000
runAsGroup: 10000
runAsNonRoot: true
它们含义不同:
runAsUser:设置进程的 Linux UID;runAsGroup:设置进程的主 GID;runAsNonRoot:要求容器最终不能以 UID 0 运行。
runAsNonRoot: true 不是把 UID 改成某个非零值。它更像一个约束:
如果没有显式设置 runAsUser,运行时通常会参考镜像配置中的 USER,但具体验证发生在节点创建容器时。镜像没有 USER、入口程序通过 setuid 变成 root,或者镜像元数据与实际行为不一致,都可能造成启动失败或安全假设失效。
因此,更明确的配置是同时指定固定的非零 UID:
securityContext:
runAsUser: 10000
runAsGroup: 10000
runAsNonRoot: true
但固定 UID 仍然不是权限模型的全部。非 root 进程如果保留了 CAP_NET_ADMIN、CAP_SYS_ADMIN 等能力,仍然可以执行许多普通 UID 无法执行的操作。
2.2 非 root 与文件权限的真实关系
假设容器进程是 UID 10000,某个目录权限为:
drwx------ 1 root root /var/lib/app
那么进程通常不能写入该目录,因为传统 DAC 文件权限检查首先看到的是:
- 进程 UID:10000;
- 文件所有者 UID:0;
- 进程不属于文件所属组;
- 目录对其他用户没有权限。
这解释了非 root 应用最常见的启动失败:
permission denied
cannot create /var/lib/app/data
修复方式不应简单地把容器改回 root。应该明确哪个目录需要写入,并通过镜像构建、卷挂载或权限初始化解决:
FROM alpine:3.20
RUN addgroup -S -g 10000 app \
&& adduser -S -D -H -u 10000 -G app app \
&& mkdir -p /var/lib/app \
&& chown -R 10000:10000 /var/lib/app
USER 10000:10000
ENTRYPOINT ["/bin/sh"]
这里的因果链是:
- 镜像创建
/var/lib/app; chown将目录所有权交给 UID/GID 10000;USER让默认进程以该身份启动;- Kubernetes 的
runAsNonRoot对运行时身份再增加一层约束。
如果目录来自 PersistentVolume,镜像中的 chown 不会改变卷上的已有权限。此时可能需要:
- 在卷创建阶段设置正确的所有权;
- 使用
fsGroup或 CSI 驱动提供的卷权限处理; - 使用专门的初始化流程;
- 避免让每次启动都以 root 执行递归
chown。
fsGroup 主要影响支持该机制的卷和挂载权限,不等于把容器进程 UID 改成某个组,也不等于所有 CSI 驱动都会以相同方式处理权限。生产环境必须使用实际存储驱动验证。
2.3 非 root 的反例:UID 不是安全边界的完整替代品
下面的进程不是 root:
uid=10000(app) gid=10000(app) groups=10000(app)
但如果它具有 CAP_SYS_ADMIN,其权限模型与一个“只保留普通用户权限”的进程完全不同。CAP_SYS_ADMIN 覆盖的内核操作范围很大,常被视为接近“万能能力”的高风险能力。
另一个反例是容器使用了宿主机命名空间:
hostPID: true
hostNetwork: true
此时非 root 身份并不能消除对主机进程、主机网络或主机接口的额外可见性。非 root 是一项重要约束,但不能代替禁止主机命名空间、禁止特权容器和限制宿主机路径挂载。
三、Linux Capabilities:把 root 拆成多个特权集合
3.1 为什么要拆分 root
传统 Unix 权限把大量特权集中在 UID 0。Linux capabilities 将其中一部分拆成独立能力,例如:
CAP_NET_BIND_SERVICE:绑定低于 1024 的 TCP/UDP 端口;CAP_NET_ADMIN:执行一系列网络配置操作;CAP_SYS_PTRACE:对其他进程进行某些调试和跟踪操作;CAP_SYS_CHROOT:执行chroot;CAP_SYS_ADMIN:大量系统管理类操作,范围非常广;CAP_DAC_OVERRIDE:绕过部分文件读写和执行权限检查。
容器运行时通常不会让普通容器获得宿主机 root 的全部 capabilities,但默认集合由运行时和发行版决定,不能把某个运行时的默认集合当成 Kubernetes API 的永久保证。
Kubernetes 通过以下字段调整容器 capabilities:
securityContext:
capabilities:
drop:
- ALL
add:
- NET_BIND_SERVICE
drop 表示删除能力,add 表示增加能力。能力是容器级配置,通常放在 containers[].securityContext 下,而不是把它理解成整个 Pod 进程共享的单一开关。
3.2 “drop ALL,再按需 add”的推导
假设应用实际只需要:
- 以 UID 10000 运行;
- 监听 80 端口;
- 不需要修改路由、不需要调试其他进程、不需要挂载文件系统。
则合理的能力集合是:
而不是保留运行时默认集合:
配置为:
capabilities:
drop: ["ALL"]
add: ["NET_BIND_SERVICE"]
这并不意味着应用拥有“绑定任意端口”的权限,只是允许执行需要 CAP_NET_BIND_SERVICE 的低端口绑定操作。应用仍然需要满足其他条件,例如端口没有被占用、监听地址有效、容器网络配置允许该操作。
同时,低端口是否真的需要这个 capability 与节点内核参数有关。Linux 的 net.ipv4.ip_unprivileged_port_start 可以改变非 root 允许绑定的最低端口。如果该值已经是 0,非 root 应用可能无需 NET_BIND_SERVICE 也能监听 80。因此,是否添加 capability 应以实际节点行为和应用需求为准,而不是因为“端口小于 1024”就机械添加。
3.3 能力集合不是只有一个集合
Linux 内核实际区分 permitted、effective、inheritable、bounding 等 capability 集合。对 Kubernetes 使用者而言,最重要的是:
- effective 集合:当前系统调用检查时可直接使用的能力;
- permitted 集合:进程可以使哪些能力进入 effective;
- bounding 集合:后代进程可获得的能力上限。
Kubernetes 的 add 和 drop 最终由运行时转换为这些 Linux 进程属性,但不同运行时的具体实现细节不应在应用代码中假设。诊断时可以查看:
kubectl exec -it <pod> -c <container> -- sh -c '
id
grep -E "^(Cap(Inh|Prm|Eff|Bnd|Amb)|NoNewPrivs|Seccomp):" /proc/self/status
'
预期会看到十六进制 capability 集合,例如:
uid=10000 gid=10000 groups=10000
CapInh: 0000000000000000
CapPrm: 0000000000000400
CapEff: 0000000000000400
NoNewPrivs: 0
Seccomp: 2
十六进制值不能直接凭肉眼判断能力名称。可以在有 capsh 的调试容器中解码:
capsh --decode=0000000000000400
容器镜像通常没有 capsh,不要为了诊断而临时给生产容器安装额外工具;可以使用节点上的受控调试环境或同版本工具镜像。
3.4 allowPrivilegeEscalation 与 capabilities 的关系
securityContext:
allowPrivilegeEscalation: false
这个字段通常通过 Linux no_new_privs 机制实现。它阻止进程通过 set-user-ID、set-group-ID 或文件 capabilities 等方式获得比当前进程更多的权限。
它不等于:
- 自动删除已有 capabilities;
- 自动切换为非 root;
- 自动启用 Seccomp;
- 自动阻止所有特权系统调用。
因此,应将它与其他设置组合:
securityContext:
runAsUser: 10000
runAsGroup: 10000
runAsNonRoot: true
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
一个常见误解是:“allowPrivilegeEscalation: false 已经足以防止容器提权。”如果进程启动时已经具有过多能力,no_new_privs 不会把这些能力凭空删除;它只阻止进一步获得更多权限。
privileged: true 是另一条完全不同的路径。特权容器通常会获得非常宽的设备、能力和隔离例外,可能使 seccomp、AppArmor、capabilities 等限制失去预期效果。具体细节受运行时实现影响,但不能把特权容器当作普通容器再叠加几个安全字段来“修复”。
四、Seccomp:限制进程可以请求内核执行什么
4.1 seccomp 的工作位置
Seccomp,即 secure computing mode,是 Linux 内核提供的系统调用过滤机制。用户态程序不能直接完成文件读写、网络通信、进程创建等操作,通常需要通过系统调用请求内核。
Seccomp 过滤器在系统调用进入内核时做判断:
Kubernetes 常用的返回行为包括:
SCMP_ACT_ALLOW:允许;SCMP_ACT_ERRNO:拒绝并向进程返回错误;SCMP_ACT_KILL_PROCESS或相关 kill 行为:终止进程;SCMP_ACT_TRAP:触发信号,通常用于诊断或特殊处理。
Seccomp 通常按进程继承。容器入口进程安装的过滤器会影响其后代进程,除非通过特定机制进一步收紧;它不是按文件路径授权的系统,而是按系统调用和参数过滤。
4.2 Kubernetes 的 seccomp 配置
当前 Kubernetes API 中常见的配置是:
securityContext:
seccompProfile:
type: RuntimeDefault
可用类型通常包括:
RuntimeDefault:使用容器运行时默认 seccomp profile;Localhost:使用节点本地已配置的 profile;Unconfined:不使用 seccomp 过滤。
RuntimeDefault 是一个合理的起点,但它不是跨节点、跨运行时字节级相同的固定策略。containerd、CRI-O、不同发行版和不同版本可能提供不同的默认 profile。它也不能证明应用只使用了最小系统调用集合。
Localhost 示例:
securityContext:
seccompProfile:
type: Localhost
localhostProfile: profiles/app-deny-mount.json
这里的 localhostProfile 是相对于节点上 kubelet seccomp profile 目录的路径,常见目录为 /var/lib/kubelet/seccomp,但实际路径和节点管理方式应以集群配置为准。所有可能调度到该 Pod 的节点都必须有同名、同内容且可被运行时读取的文件。
4.3 一个可加载的最小示例:拒绝高风险系统调用
以下是一个“默认允许、明确拒绝少数调用”的 OCI seccomp profile:
{
"defaultAction": "SCMP_ACT_ALLOW",
"syscalls": [
{
"names": [
"mount",
"umount2",
"setns",
"ptrace"
],
"action": "SCMP_ACT_ERRNO",
"errnoRet": 1
}
]
}
将它保存到每个目标节点的:
/var/lib/kubelet/seccomp/profiles/app-deny-mount.json
然后在 Pod 中引用:
apiVersion: v1
kind: Pod
metadata:
name: seccomp-example
spec:
securityContext:
runAsUser: 10000
runAsGroup: 10000
runAsNonRoot: true
seccompProfile:
type: Localhost
localhostProfile: profiles/app-deny-mount.json
containers:
- name: app
image: busybox:1.36
command: ["/bin/sh", "-c"]
args:
- |
echo "application started"
sleep 3600
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
创建并验证:
kubectl apply -f seccomp-example.yaml
kubectl get pod seccomp-example
kubectl exec seccomp-example -- sh -c '
grep -E "^(NoNewPrivs|Seccomp):" /proc/1/status
'
在支持 seccomp 的 Linux 节点上,Seccomp: 2 表示进程处于 filter mode。这个 profile 的意义是:
- 应用仍可以调用默认允许的系统调用;
- 进程调用
mount、umount2、setns或ptrace时,内核返回EPERM; - 由于返回的是错误而不是直接杀死进程,应用可能继续运行,也可能因为处理失败而退出。
这个 profile 不是一个完整的最小权限 profile。它故意采用 defaultAction: SCMP_ACT_ALLOW,用于展示拒绝规则和错误路径。真正的生产 profile 通常需要根据应用观测到的系统调用生成 allowlist,并在架构、初始化流程、动态加载器、线程库和运行时差异上进行测试。盲目使用极小 allowlist 的反例是:应用启动时调用了未列出的 futex、openat、mmap 或 clone,结果在启动阶段直接失败。
4.4 seccomp 失败时如何诊断
应用可能只报告:
operation not permitted
这并不能单独证明是 capabilities、seccomp、AppArmor 还是普通文件权限导致的。应按层排查:
kubectl describe pod <pod>
kubectl logs <pod> -c <container>
kubectl get pod <pod> -o yaml
在容器内查看:
grep -E "^(NoNewPrivs|Seccomp):" /proc/1/status
在节点上查看 kubelet 和运行时日志。若 profile 通过 SCMP_ACT_ERRNO 拒绝,应用通常看到 EPERM;若使用 kill 行为,容器可能显示非零退出码或被信号终止。不能仅根据“Pod 被重启”就断言是 seccomp。
五、AppArmor:按路径和行为控制 Linux 进程
5.1 AppArmor 与 seccomp 的不同抽象
AppArmor 是 Linux Security Module(LSM)之一,采用以路径为核心的强制访问控制模型。它可以约束:
- 进程能读取、写入或执行哪些路径;
- 能否创建特定文件;
- 能否发送或接收某些信号;
- 能否执行程序并以何种 profile 转换;
- 部分网络和能力操作。
Seccomp 关注“系统调用是否允许”,AppArmor 关注“该进程对什么对象、以什么方式进行操作”。
例如,一个进程调用 openat:
- Seccomp 可以根据系统调用号和参数进行过滤;
- AppArmor 可以进一步判断它是否有权打开
/etc/shadow或写入/var/lib/app; - DAC 文件权限还会进行传统 UID/GID 检查。
这些控制可以同时生效。一个操作必须通过所有有效的限制层。
5.2 Kubernetes 中的 AppArmor 配置
在支持该字段的 Kubernetes 版本中,可以使用结构化的 appArmorProfile:
securityContext:
appArmorProfile:
type: RuntimeDefault
也可以引用节点上的本地 profile:
securityContext:
appArmorProfile:
type: Localhost
localhostProfile: app-deny-sensitive-files
某些集群仍使用旧的 Pod annotation 形式,例如:
metadata:
annotations:
container.apparmor.security.beta.kubernetes.io/app: app-deny-sensitive-files
这种 annotation 形式属于历史兼容方式。新集群应优先使用当前 API 字段,但必须考虑版本偏差:API Server、kubelet 和准入组件不一定同时升级。若目标集群版本尚不支持结构化字段,直接提交可能被拒绝;若使用旧 annotation,又可能触发弃用策略或被平台策略禁止。
AppArmor profile 不是存储在镜像中的普通文件。它需要在节点上由系统管理员加载。例如节点可能通过:
sudo apparmor_parser -r -W /etc/apparmor.d/app-deny-sensitive-files
sudo aa-status
加载 profile,再让 Pod 通过 Localhost 引用。上述命令必须在启用 AppArmor 的 Linux 节点上执行;没有 AppArmor 的节点无法通过这些命令提供该能力。
5.3 一个 AppArmor profile 的因果示例
下面的 profile 允许一个简单应用读取常见系统文件、读写 /tmp,但禁止写入 /etc 和 /var:
#include <tunables/global>
profile app-deny-sensitive-files flags=(attach_disconnected,mediate_deleted) {
#include <abstractions/base>
/bin/** rix,
/usr/bin/** rix,
/lib/** r,
/lib64/** r,
/etc/** r,
/tmp/** rwk,
deny /etc/** w,
deny /var/** w,
deny /proc/sys/** w,
}
profile 语句的核心效果是:
/etc/** r允许读取;/tmp/** rwk允许读取、写入和创建;deny /etc/** w明确拒绝写入配置文件;deny /var/** w拒绝写入状态目录;deny /proc/sys/** w拒绝修改部分内核参数接口。
真实 profile 往往需要根据程序的解释器、共享库、证书目录、日志目录、DNS 配置、Unix socket 和子进程执行路径扩展。不能把这个例子直接当作所有语言运行时的生产 profile;例如动态链接程序可能需要访问特定的库文件,JVM、Python、Node.js 和 Go 程序的访问集合也不同。
Pod 中引用:
apiVersion: v1
kind: Pod
metadata:
name: apparmor-example
spec:
containers:
- name: app
image: busybox:1.36
command: ["/bin/sh", "-c"]
args:
- |
echo "write to tmp: ok"
echo test > /tmp/test
echo "write to etc: should fail"
echo test > /etc/security-demo
sleep 3600
securityContext:
runAsUser: 10000
runAsGroup: 10000
runAsNonRoot: true
appArmorProfile:
type: Localhost
localhostProfile: app-deny-sensitive-files
预期结果是:
write to tmp: ok
write to etc: should fail
/bin/sh: can't create /etc/security-demo: Permission denied
但如果 profile 没有加载、节点不支持 AppArmor、字段不被当前版本识别,结果可能变成 Pod 创建失败,而不是命令返回错误。应使用以下方式区分:
kubectl describe pod apparmor-example
kubectl get pod apparmor-example -o yaml
节点侧可以查看:
sudo aa-status
sudo journalctl -k | grep -i apparmor
容器内如果权限允许,也可检查:
cat /proc/1/attr/current
常见输出包括 profile 名称或 unconfined。unconfined 表示当前进程没有处于预期的 AppArmor profile 中;但不能仅用它判断整个容器没有任何其他安全限制,因为 seccomp、capabilities 和文件权限仍可能生效。
六、只读根文件系统:禁止写根挂载,不是禁止所有写入
6.1 readOnlyRootFilesystem 的精确定义
配置:
securityContext:
readOnlyRootFilesystem: true
会使容器的根文件系统挂载为只读。它限制的是镜像对应的根文件系统层,不是所有挂载点。
因此,以下路径是否可写,取决于它们的实际挂载:
/tmp:如果只是根文件系统中的目录,则不可写;/tmp:如果挂载了emptyDir,则可以写;/var/log/app:如果挂载了可写卷,则可以写;- Secret、ConfigMap:通常作为只读卷挂载;
- PersistentVolume:由卷和挂载选项决定,可能可写。
一个可运行的示例:
apiVersion: v1
kind: Pod
metadata:
name: readonly-root-example
spec:
securityContext:
runAsUser: 10000
runAsGroup: 10000
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
containers:
- name: app
image: busybox:1.36
command: ["/bin/sh", "-c"]
args:
- |
echo "temporary data" > /tmp/data
echo "state data" > /var/lib/app/state
sleep 3600
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: tmp
mountPath: /tmp
- name: app-state
mountPath: /var/lib/app
volumes:
- name: tmp
emptyDir: {}
- name: app-state
emptyDir: {}
这个例子的写入路径如下:
/tmp/data -> emptyDir,可写
/var/lib/app/state -> emptyDir,可写
/etc/... -> 根文件系统,只读
/usr/... -> 根文件系统,只读
emptyDir 的生命周期是 Pod 生命周期。容器重启时,Pod 不一定被重新创建,因此数据可能仍在;Pod 被删除后,emptyDir 数据会被删除。它不适合持久化业务数据。
内存型临时卷可以写成:
volumes:
- name: tmp
emptyDir:
medium: Memory
这样数据存储在 tmpfs 中,会消耗节点内存,生产环境应设置合理的资源限制,避免把大量临时数据写入内存造成 OOM。
6.2 只读根文件系统为什么会暴露真实依赖
应用启动失败的典型路径是:
- 容器成功创建;
- 入口程序启动;
- 程序尝试创建 PID 文件、缓存、临时文件或运行时 socket;
- 目标目录位于只读根文件系统;
- 内核返回
EROFS,通常显示为Read-only file system; - 应用退出,kubelet 按重启策略重新启动容器。
诊断命令:
kubectl logs <pod> -c <container>
kubectl describe pod <pod>
kubectl exec <pod> -c <container> -- sh -c '
mount
touch /tmp/test
touch /var/lib/app/test
'
注意 touch 的结果要结合挂载点解释。若 /tmp 未单独挂载,失败是预期行为;若已经挂载 emptyDir 仍失败,则应继续检查:
- 卷是否真的挂载到目标路径;
- 容器 UID 是否有卷目录写权限;
- AppArmor 是否拒绝;
- SELinux 标签是否不匹配;
- 应用是否使用了另一个未发现的写入路径。
只读根文件系统也不能阻止所有持久化方式。攻击者仍可能:
- 写入可写的
emptyDir或 PersistentVolume; - 修改应用允许写入的挂载卷;
- 向外部服务发送数据;
- 利用网络或凭据进行横向移动;
- 在内存中驻留直到容器结束。
因此,它主要减少对镜像根层的篡改和落地位置,不能代替网络、身份、密钥和卷权限控制。
七、把五种机制组合成一个可验证的 Pod
下面的示例将非 root、能力收缩、no_new_privs、RuntimeDefault seccomp 和只读根文件系统组合起来:
apiVersion: v1
kind: Pod
metadata:
name: runtime-security-demo
labels:
app: runtime-security-demo
spec:
securityContext:
runAsUser: 10000
runAsGroup: 10000
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
containers:
- name: app
image: busybox:1.36
command: ["/bin/sh", "-c"]
args:
- |
echo "started"
echo "temporary data" > /tmp/data
sleep 3600
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
volumeMounts:
- name: tmp
mountPath: /tmp
volumes:
- name: tmp
emptyDir: {}
应用配置:
kubectl apply -f runtime-security-demo.yaml
kubectl wait --for=condition=Ready pod/runtime-security-demo --timeout=60s
kubectl logs runtime-security-demo
预期输出包含:
started
验证身份和内核状态:
kubectl exec runtime-security-demo -- sh -c '
id
grep -E "^(Cap(Inh|Prm|Eff|Bnd|Amb)|NoNewPrivs|Seccomp):" /proc/self/status
touch /tmp/works
touch /etc/should-fail
'
预期现象:
id显示 UID/GID 为 10000;- capabilities 集合为空或明显少于运行时默认集合;
NoNewPrivs: 1;Seccomp: 2;/tmp/works成功;/etc/should-fail因根文件系统只读而失败。
最后一个失败不一定产生完全相同的文本;shell、BusyBox 版本和运行时可能显示 Read-only file system 或 Permission denied。判断依据应是挂载状态和内核日志,而不是固定错误字符串。
如果该 Pod 无法启动,应按以下顺序区分失败层:
kubectl describe pod runtime-security-demo
kubectl get pod runtime-security-demo -o yaml
kubectl logs runtime-security-demo --previous
| 现象 | 可能层次 | 进一步验证 |
|---|---|---|
| 创建阶段提示 profile 不存在 | Localhost seccomp/AppArmor 或节点能力 |
检查节点文件、kubelet/runtime 日志 |
| 入口程序提示 UID 不允许 | runAsNonRoot 与镜像实际用户冲突 |
查看镜像 USER、入口程序行为 |
operation not permitted |
capability、seccomp、LSM 或特权操作 | /proc/self/status、节点内核日志 |
Read-only file system |
根文件系统不可写 | mount、检查目标路径是否有卷 |
Permission denied |
DAC、AppArmor、SELinux 或卷所有权 | 文件权限、/proc/1/attr/current、节点日志 |
| 容器被信号终止 | seccomp kill、应用自身退出、OOM 等 | Pod 退出码、dmesg、运行时日志 |
八、Pod 级和容器级 SecurityContext 的继承关系
Pod 级字段为多个容器提供默认值,例如:
spec:
securityContext:
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
容器级字段可以覆盖支持覆盖的同类配置:
containers:
- name: app
securityContext:
capabilities:
drop: ["ALL"]
但不是所有字段都具有同样的继承语义。Capabilities、readOnlyRootFilesystem 和 allowPrivilegeEscalation 通常应明确写在容器级,因为它们直接作用于具体容器;Pod 级的 UID、GID 和 seccomp 设置适合表达 Pod 默认身份和默认过滤策略。
多容器 Pod 还要注意:
- 每个容器都有自己的根文件系统;
emptyDir等卷可以被多个容器共享;- Pod 内容器通常共享网络命名空间;
- 一个 sidecar 如果拥有更宽权限,可能成为攻击路径;
shareProcessNamespace: true会提高容器之间的进程可见性,必须重新评估 ptrace、信号和/proc风险。
“主应用已非 root”不等于“整个 Pod 的所有进程都非 root”。
九、与 Pod Security Standards 和 Admission 的关系
Pod Security Standards(PSS)定义了 Privileged、Baseline 和 Restricted 等策略级别;Pod Security Admission(PSA)负责在 API Server 准入阶段根据命名空间标签执行这些标准。
典型命名空间配置:
kubectl label namespace apps \
pod-security.kubernetes.io/enforce=restricted \
pod-security.kubernetes.io/enforce-version=latest
也可以先使用审计或告警模式评估现有工作负载:
kubectl label namespace apps \
pod-security.kubernetes.io/audit=restricted \
pod-security.kubernetes.io/warn=restricted
具体允许或拒绝的字段随 Kubernetes 版本对应的 PSS 版本变化,生产环境不应只依赖 latest 而忽略升级影响。应在集群升级前固定并测试目标版本。
Restricted 通常会要求或限制以下方向:
- 禁止特权容器;
- 禁止部分主机命名空间和危险挂载;
- 要求使用
RuntimeDefault或Localhostseccomp; - 要求非 root 运行;
- 要求关闭权限提升;
- 限制 capabilities,只允许很小的增加集合。
但 PSS/PSA 不是完整运行时安全方案:
- 它不检查应用实际访问了哪些文件;
- 不会为你生成 AppArmor profile;
- 不会确认镜像是否真的适合非 root;
- 不会替代供应链验证、网络策略和 RBAC;
- 不会消除节点内核或运行时漏洞;
- 不会自动为
Localhostprofile 分发节点文件。
因此,Admission 的作用是防止明显不符合组织规则的 Pod 进入集群;Linux 运行时配置才负责在节点上实际限制进程。
十、常见误解和反例
误解一:runAsNonRoot: true 就等于没有 root 能力
错误。非 root UID 仍可能拥有 capabilities,也可能访问共享卷、主机网络或敏感服务。正确的判断至少要同时查看 UID、capabilities、主机命名空间和挂载。
误解二:drop: ["ALL"] 后应用绝对不能做危险操作
错误。Capabilities 只是一个权限层。应用仍可能利用:
- 容器运行时或内核漏洞;
- 可写卷中的敏感数据;
- 过宽的 AppArmor 或 seccomp 配置;
- 服务账户凭据;
- 网络可达的其他组件。
反过来,某些操作即使不需要 capability,也可能造成业务层面的严重影响。
误解三:RuntimeDefault 在所有节点上完全一致
错误。它由容器运行时提供,具体 profile 会随运行时、版本和发行版变化。它通常比 Unconfined 更安全,但不是应用专属 allowlist。
误解四:只读根文件系统会让应用不能写任何东西
错误。独立挂载的 emptyDir、PersistentVolume 或其他可写卷仍可写。必须审查所有可写挂载点,而不是只看 readOnlyRootFilesystem。
误解五:所有 Permission denied 都是 Linux 文件权限
错误。可能来自 DAC、capabilities、AppArmor、SELinux、seccomp 返回的 EPERM,也可能来自应用自身的权限检查。必须结合错误码、挂载信息和节点日志诊断。
误解六:配置了 AppArmor 字段,就一定启用了 AppArmor
错误。节点必须使用支持 AppArmor 的 Linux 内核和运行时,profile 也必须存在并加载。没有这些前置条件,Pod 可能启动失败,也可能因版本或配置差异无法得到预期限制。
十一、生产环境中的取舍:严格程度必须与可验证性匹配
11.1 非 root 的成本通常最低
优先在镜像层解决目录所有权和默认用户:
USER 10000:10000
再在 Kubernetes 中声明:
runAsUser: 10000
runAsGroup: 10000
runAsNonRoot: true
这能同时覆盖镜像默认行为和部署层约束。若镜像依赖 root 启动后降权,应明确记录其入口逻辑,并评估是否能改造成从一开始就以非 root 运行。
11.2 Capabilities 应以实际系统调用和功能验证为依据
先删除全部能力,再只添加应用明确需要的能力。每增加一个 capability,都应回答:
- 哪段代码需要它;
- 哪个系统调用会触发它;
- 是否可以改用高端口、用户命名空间或其他设计消除需求;
- 删除后失败的错误是什么;
- 该能力是否会扩大其他攻击路径。
特别谨慎对待 SYS_ADMIN、SYS_PTRACE、NET_ADMIN、SYS_MODULE 和 SYS_RAWIO 等高风险能力。
11.3 Seccomp 需要“先观测,再收紧”
直接构造极小 allowlist 容易导致不可诊断的启动失败。更稳妥的流程是:
- 在与生产相同的架构和运行时上运行应用;
- 记录初始化、健康检查和正常请求路径的系统调用;
- 区分必须调用与偶发调试调用;
- 先使用
RuntimeDefault; - 再为稳定工作负载制作
Localhostprofile; - 在升级 libc、语言运行时、内核和容器运行时后重新验证。
seccomp profile 是节点本地资源,因此节点池、CPU 架构和运行时差异必须纳入发布策略。一个 profile 在 x86_64 上可用,不代表 ARM64 上的系统调用名称和架构处理完全相同。
11.4 AppArmor 需要节点配置管理
AppArmor profile 必须像节点基础设施一样被版本化、分发和验证。否则会出现以下故障:
- Pod 调度到没有 profile 的节点;
- profile 名称相同但内容版本不同;
- 节点重启后 profile 未重新加载;
- 应用升级后增加了新的文件访问路径;
- 审计日志显示大量拒绝,但团队误将其当成应用 bug。
AppArmor 审计日志应与应用版本绑定分析。只写一条宽泛的 deny 规则而不验证允许路径,通常会把问题推迟到生产启动阶段。
11.5 只读根文件系统要求应用明确区分状态
应用应把数据分为:
- 配置:通常只读;
- 临时文件:挂载
emptyDir或 tmpfs; - 运行时状态:明确挂载可写目录;
- 持久化业务数据:使用经过权限和备份设计的 PersistentVolume;
- 日志:优先输出标准输出和标准错误,而不是写入根文件系统。
如果应用必须修改自身二进制、动态下载插件到镜像目录或依赖根目录缓存,开启只读根后会暴露设计问题。应优先改变应用目录布局,而不是关闭安全控制。
十二、这些控制与身份、供应链不是同一层
即使 Pod 使用了:
runAsNonRoot: true
capabilities:
drop: ["ALL"]
seccompProfile:
type: RuntimeDefault
readOnlyRootFilesystem: true
攻击者仍可能通过被污染的镜像获得恶意代码。运行时安全控制限制的是恶意代码在节点上的部分动作,不负责证明镜像来源和内容可信。
因此应与供应链安全配合:
- 镜像签名和来源验证,确认镜像由可信流水线发布;
- SBOM,了解镜像中包含的库和系统组件;
- 漏洞扫描,识别已知漏洞;
- 准入策略,拒绝未签名、来源不明或不符合版本规则的镜像;
- 运行时身份限制,避免应用获得不必要的 ServiceAccount 权限。
同样,Kubernetes Authentication and Authorization 控制的是“谁能访问 API、能对哪些对象执行什么操作”;它不等同于容器内进程的 Linux UID 或 capabilities。一个拥有过宽 RBAC 权限的非 root 应用,仍可能通过 Kubernetes API 修改其他工作负载。
完整边界应至少区分三类问题:
API 身份与 RBAC
-> 谁能调用 Kubernetes API
Pod Admission / PSS
-> 哪些 PodSpec 可以进入集群
Linux Runtime Security
-> 已运行进程能做哪些内核、文件和设备操作
三层分别解决不同威胁,不能互相替代。
十三、一个实际的验证闭环
对于一个准备上线的工作负载,可以按以下闭环验证,而不是只检查 YAML 是否包含某几个字段。
第一步:验证身份
kubectl exec <pod> -c <container> -- id
确认 UID/GID 是预期的非零值,并检查应用是否会启动子进程或执行 setuid 程序。
第二步:验证 capability
kubectl exec <pod> -c <container> -- \
grep -E "^Cap(Prm|Eff|Bnd):" /proc/self/status
确认 effective 和 bounding 集合没有不必要的高风险能力。
第三步:验证权限提升控制和 seccomp
kubectl exec <pod> -c <container> -- \
grep -E "^(NoNewPrivs|Seccomp):" /proc/self/status
一般情况下:
NoNewPrivs: 1
Seccomp: 2
分别表示启用了 no-new-privs 和 seccomp filter mode。若结果不同,应结合 PodSpec、运行时、节点内核和版本判断,而不是直接修改为更宽松配置。
第四步:验证可写路径
kubectl exec <pod> -c <container> -- sh -c '
for p in / /tmp /var/tmp /var/lib/app /etc; do
echo "== $p =="
touch "$p/.security-test" 2>&1 || true
rm -f "$p/.security-test" 2>/dev/null || true
done
'
该测试会改变文件系统状态,生产环境不能无审慎地直接执行。更安全的方式是在预生产环境使用专用测试路径,并对 PersistentVolume 做隔离。
第五步:验证拒绝行为
测试应用不需要的操作,例如:
- 尝试修改
/proc/sys; - 尝试
mount或setns; - 尝试写入根文件系统;
- 尝试读取明确禁止的敏感路径;
- 尝试通过 setuid 或文件 capability 提升权限。
测试必须在与生产一致的节点、架构、运行时和内核条件下完成。否则“测试通过”可能只是测试环境没有真正启用目标控制。
结语
非 root 解决的是进程身份问题;Capabilities 解决的是 root 特权拆分问题;Seccomp 解决的是系统调用入口问题;AppArmor 解决的是基于路径和行为的强制访问控制问题;只读根文件系统解决的是根挂载上的写入和篡改问题。
它们之间的关系不是替代,而是叠加:
非 root
+ 删除不必要的 capabilities
+ allowPrivilegeEscalation=false
+ RuntimeDefault 或经过验证的 Localhost seccomp
+ 经过节点管理和测试的 AppArmor
+ 只读根文件系统与明确的可写卷
+ PSS/Admission、RBAC 和供应链验证
真正可靠的配置必须能回答三个问题:
- 应用正常运行时需要哪些身份、系统调用、文件和目录;
- 应用异常或被利用时,哪些操作会被哪一层拒绝;
- 节点升级、运行时更换、镜像升级或调度到另一节点后,这些限制是否仍然成立。
只有把声明、内核执行、节点前置条件和失败诊断连成闭环,运行时安全配置才不是一组看似严格但未经验证的 YAML 字段。
系列导航与关联阅读
- 系列入口:Kubernetes 完整学习路线:从 Pod 与控制面到安全、运维和 Operator
- 上一篇:Kubernetes Pod Security:Standards、Admission、SecurityContext 和基线
- 下一篇:Kubernetes Secret 治理:etcd 加密、KMS、External Secrets、轮换和泄漏
- 延伸:Kubernetes 供应链安全:镜像签名、SBOM、扫描、准入和来源验证
官方资料
本文依据 Kubernetes、CNCF 与相关项目官方文档重新梳理;正文和生产清单由 WR BLOG 编写。

评论
0 条讨论