Kubernetes 基础体系 · 第 47/83 篇。示例基于 Kubernetes 当前稳定 API;弃用、版本偏差、云厂商差异和生产风险会明确说明。

Kubernetes 运行时安全:非 root、Capabilities、Seccomp、AppArmor 和只读根

容器运行时安全的目标,不是让进程“看起来像普通用户”,而是限制进程在以下几个维度上的能力:

  1. 它以哪个 Linux UID、GID 运行;
  2. 它可以执行哪些需要特权的内核操作;
  3. 它可以调用哪些系统调用;
  4. 它可以访问哪些文件、目录、设备和内核接口;
  5. 即使应用被利用,是否还能通过 setuid、文件能力或子进程继续提升权限;
  6. 根文件系统是否能被修改,从而限制持久化、篡改和落地攻击。

Kubernetes 通过 securityContext 把部分 Linux 安全机制暴露给 Pod 和容器。它不是一个独立的安全边界:最终效果还取决于 Linux 内核、容器运行时、节点配置、Pod 使用的主机命名空间以及准入策略。

可以把一个容器进程的有效权限粗略表示为:

Peffective=PUIDPcapabilitiesPseccompPAppArmorPmount/filesystemP_{\text{effective}} = P_{\text{UID}} \cap P_{\text{capabilities}} \cap P_{\text{seccomp}} \cap P_{\text{AppArmor}} \cap P_{\text{mount/filesystem}}

这里的“交集”不是 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 runAsUserrunAsGrouprunAsNonRoot

Kubernetes 中常用的 Pod 或容器级配置如下:

securityContext:
  runAsUser: 10000
  runAsGroup: 10000
  runAsNonRoot: true

它们含义不同:

  • runAsUser:设置进程的 Linux UID;
  • runAsGroup:设置进程的主 GID;
  • runAsNonRoot:要求容器最终不能以 UID 0 运行。

runAsNonRoot: true 不是把 UID 改成某个非零值。它更像一个约束:

UIDeffective0UID_{\text{effective}} \neq 0

如果没有显式设置 runAsUser,运行时通常会参考镜像配置中的 USER,但具体验证发生在节点创建容器时。镜像没有 USER、入口程序通过 setuid 变成 root,或者镜像元数据与实际行为不一致,都可能造成启动失败或安全假设失效。

因此,更明确的配置是同时指定固定的非零 UID:

securityContext:
  runAsUser: 10000
  runAsGroup: 10000
  runAsNonRoot: true

但固定 UID 仍然不是权限模型的全部。非 root 进程如果保留了 CAP_NET_ADMINCAP_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"]

这里的因果链是:

  1. 镜像创建 /var/lib/app
  2. chown 将目录所有权交给 UID/GID 10000;
  3. USER 让默认进程以该身份启动;
  4. 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”的推导

假设应用实际只需要:

  1. 以 UID 10000 运行;
  2. 监听 80 端口;
  3. 不需要修改路由、不需要调试其他进程、不需要挂载文件系统。

则合理的能力集合是:

C={CAP_NET_BIND_SERVICE}C = \{\text{CAP\_NET\_BIND\_SERVICE}\}

而不是保留运行时默认集合:

Cdefault={若干默认能力}C_{\text{default}} = \{\text{若干默认能力}\}

配置为:

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 的 adddrop 最终由运行时转换为这些 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 过滤器在系统调用进入内核时做判断:

f(syscall,args,architecture){allow,errno,kill,trap,notify}f(\text{syscall}, \text{args}, \text{architecture}) \rightarrow \{\text{allow}, \text{errno}, \text{kill}, \text{trap}, \text{notify}\}

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 的意义是:

  1. 应用仍可以调用默认允许的系统调用;
  2. 进程调用 mountumount2setnsptrace 时,内核返回 EPERM
  3. 由于返回的是错误而不是直接杀死进程,应用可能继续运行,也可能因为处理失败而退出。

这个 profile 不是一个完整的最小权限 profile。它故意采用 defaultAction: SCMP_ACT_ALLOW,用于展示拒绝规则和错误路径。真正的生产 profile 通常需要根据应用观测到的系统调用生成 allowlist,并在架构、初始化流程、动态加载器、线程库和运行时差异上进行测试。盲目使用极小 allowlist 的反例是:应用启动时调用了未列出的 futexopenatmmapclone,结果在启动阶段直接失败。

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 语句的核心效果是:

  1. /etc/** r 允许读取;
  2. /tmp/** rwk 允许读取、写入和创建;
  3. deny /etc/** w 明确拒绝写入配置文件;
  4. deny /var/** w 拒绝写入状态目录;
  5. 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 名称或 unconfinedunconfined 表示当前进程没有处于预期的 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 只读根文件系统为什么会暴露真实依赖

应用启动失败的典型路径是:

  1. 容器成功创建;
  2. 入口程序启动;
  3. 程序尝试创建 PID 文件、缓存、临时文件或运行时 socket;
  4. 目标目录位于只读根文件系统;
  5. 内核返回 EROFS,通常显示为 Read-only file system
  6. 应用退出,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 systemPermission 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、readOnlyRootFilesystemallowPrivilegeEscalation 通常应明确写在容器级,因为它们直接作用于具体容器;Pod 级的 UID、GID 和 seccomp 设置适合表达 Pod 默认身份和默认过滤策略。

多容器 Pod 还要注意:

  • 每个容器都有自己的根文件系统;
  • emptyDir 等卷可以被多个容器共享;
  • Pod 内容器通常共享网络命名空间;
  • 一个 sidecar 如果拥有更宽权限,可能成为攻击路径;
  • shareProcessNamespace: true 会提高容器之间的进程可见性,必须重新评估 ptrace、信号和 /proc 风险。

“主应用已非 root”不等于“整个 Pod 的所有进程都非 root”。


九、与 Pod Security Standards 和 Admission 的关系

Pod Security Standards(PSS)定义了 PrivilegedBaselineRestricted 等策略级别;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 通常会要求或限制以下方向:

  • 禁止特权容器;
  • 禁止部分主机命名空间和危险挂载;
  • 要求使用 RuntimeDefaultLocalhost seccomp;
  • 要求非 root 运行;
  • 要求关闭权限提升;
  • 限制 capabilities,只允许很小的增加集合。

但 PSS/PSA 不是完整运行时安全方案:

  • 它不检查应用实际访问了哪些文件;
  • 不会为你生成 AppArmor profile;
  • 不会确认镜像是否真的适合非 root;
  • 不会替代供应链验证、网络策略和 RBAC;
  • 不会消除节点内核或运行时漏洞;
  • 不会自动为 Localhost profile 分发节点文件。

因此,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,都应回答:

  1. 哪段代码需要它;
  2. 哪个系统调用会触发它;
  3. 是否可以改用高端口、用户命名空间或其他设计消除需求;
  4. 删除后失败的错误是什么;
  5. 该能力是否会扩大其他攻击路径。

特别谨慎对待 SYS_ADMINSYS_PTRACENET_ADMINSYS_MODULESYS_RAWIO 等高风险能力。

11.3 Seccomp 需要“先观测,再收紧”

直接构造极小 allowlist 容易导致不可诊断的启动失败。更稳妥的流程是:

  1. 在与生产相同的架构和运行时上运行应用;
  2. 记录初始化、健康检查和正常请求路径的系统调用;
  3. 区分必须调用与偶发调试调用;
  4. 先使用 RuntimeDefault
  5. 再为稳定工作负载制作 Localhost profile;
  6. 在升级 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
  • 尝试 mountsetns
  • 尝试写入根文件系统;
  • 尝试读取明确禁止的敏感路径;
  • 尝试通过 setuid 或文件 capability 提升权限。

测试必须在与生产一致的节点、架构、运行时和内核条件下完成。否则“测试通过”可能只是测试环境没有真正启用目标控制。


结语

非 root 解决的是进程身份问题;Capabilities 解决的是 root 特权拆分问题;Seccomp 解决的是系统调用入口问题;AppArmor 解决的是基于路径和行为的强制访问控制问题;只读根文件系统解决的是根挂载上的写入和篡改问题。

它们之间的关系不是替代,而是叠加:

非 root
  + 删除不必要的 capabilities
  + allowPrivilegeEscalation=false
  + RuntimeDefault 或经过验证的 Localhost seccomp
  + 经过节点管理和测试的 AppArmor
  + 只读根文件系统与明确的可写卷
  + PSS/Admission、RBAC 和供应链验证

真正可靠的配置必须能回答三个问题:

  1. 应用正常运行时需要哪些身份、系统调用、文件和目录;
  2. 应用异常或被利用时,哪些操作会被哪一层拒绝;
  3. 节点升级、运行时更换、镜像升级或调度到另一节点后,这些限制是否仍然成立。

只有把声明、内核执行、节点前置条件和失败诊断连成闭环,运行时安全配置才不是一组看似严格但未经验证的 YAML 字段。


系列导航与关联阅读

官方资料

本文依据 Kubernetes、CNCF 与相关项目官方文档重新梳理;正文和生产清单由 WR BLOG 编写。