Kubernetes для самых маленьких. Часть 30: Как защитить поды?

Все части серии «Kubernetes для самых маленьких»

В прошлой части мы закончили на том, что RBAC решает, может ли субъект создать под, но не решает, какой под он создаст. Контейнер — обычный процесс на узле, изолированный namespaces, cgroups, урезанными capabilities и профилями seccomp и AppArmor (часть 1, часть 6). Каждая из этих границ управляется полями манифеста, и каждую можно снять одной строкой YAML. Сегодня — какие это поля, что делают с ними kubelet и containerd, какие значения правильные, и как заставить весь неймспейс им следовать: стандарты Pod Security, контроллер допуска Pod Security Admission и собственные правила на CEL. Учебный неймспейс demo в конце части переедет на уровень restricted.

Всё в этой части проверено на учебном кластере: поля securityContext на настоящих подах (с проверкой на узле через /proc), user namespaces, Pod Security Admission с метками неймспейса и с конфигурацией уровня кластера, ValidatingAdmissionPolicy. Без проверки остались профили seccomp и AppArmor типа Localhost, SELinux, MutatingAdmissionPolicy, Kyverno и OPA Gatekeeper — о них по документации.


securityContext: что и где

Поля безопасности задаются в securityContext — на уровне пода (действуют на все контейнеры) и на уровне контейнера (переопределяют под). Часть полей есть только в одном месте:

Поле Под Контейнер Что делает
runAsUser, runAsGroup да да UID и основной GID процесса
runAsNonRoot да да kubelet откажется запускать контейнер, если UID окажется 0
supplementaryGroups, fsGroup да — дополнительные группы; группа-владелец томов
seccompProfile да да фильтр системных вызовов
appArmorProfile да да профиль AppArmor
seLinuxOptions да да метка SELinux
sysctls да — параметры ядра в пространствах имён пода
allowPrivilegeEscalation — да запрет повышать права через setuid-файлы
capabilities — да добавить и убрать capabilities
privileged — да снять почти всю изоляцию
readOnlyRootFilesystem — да корневая ФС контейнера только для чтения
procMount — да маскировать ли части /proc

Отдельно, не в securityContext, а прямо в spec пода: hostNetwork, hostPID, hostIPC, hostUsers и тома типа hostPath.

Значения по умолчанию в Kubernetes выбраны ради совместимости, а не безопасности: если ничего не указать, контейнер работает от того пользователя, что записан в образе (часто root), с набором capabilities по умолчанию containerd, может повышать права через setuid-файлы и работает без фильтра системных вызовов, если у kubelet не включён seccompDefault. Разберём поля по одному.


Пользователь: runAsUser, runAsGroup, runAsNonRoot

Процесс в контейнере с UID 0 — это root ядра узла, ограниченный только тем, что у него отобрали. Если изоляция окажется дырявой (ошибка в ядре, в runc, неосторожно смонтированный том), процесс-root окажется на узле root-ом. Процесс с UID 10001 окажется пользователем, у которого на узле ничего нет.

securityContext:
  runAsNonRoot: true
  runAsUser: 10001
  runAsGroup: 10001

runAsUser и runAsGroup заменяют пользователя из образа (USER в Dockerfile, часть 5). runAsNonRoot: true — проверка, а не установка: kubelet перед стартом контейнера смотрит, с каким UID он запустится, и при нуле отказывается. Если UID не задан в манифесте, а в образе записано имя пользователя (USER curl_user), kubelet не может проверить, что это не 0, и тоже откажется. Оба отказа — состояние CreateContainerConfigError с причиной в сообщении:

$ kubectl -n demo get pod nonroot-check -o jsonpath='{.status.containerStatuses[0].state.waiting.message}{"\n"}'
container has runAsNonRoot and image will run as root (pod: "nonroot-check_demo(41f90b68-f083-48d8-8355-13fd488955cd)", container: c)
$ kubectl -n demo get pod named-user -o jsonpath='{.status.containerStatuses[0].state.waiting.message}{"\n"}'
container has runAsNonRoot and image has non-numeric user (curl_user), cannot verify user is non-root (pod: "named-user_demo(979e565d-87cd-40c5-a3a4-355fb4063cbe)", container: c)

Первый под — busybox:1.37 (его пользователь — root), второй — curlimages/curl:8.17.0 с USER curl_user. Лечится числовым USER в образе или runAsUser в манифесте.

Лучшая практика — собирать образ сразу с непривилегированным пользователем (USER 10001:10001) и всё равно ставить runAsNonRoot: true, чтобы случайная смена образа не запустила root. Наш hello так и собран: в базовом образе distroless (минимальном образе без оболочки и пакетного менеджера) есть готовый пользователь nonroot, и в конфигурации образа записано "User": "65532:65532" — числом, поэтому проверка runAsNonRoot проходит и без runAsUser.


Capabilities

Права root в Linux разбиты на capabilities — около сорока отдельных привилегий: CAP_NET_ADMIN (настраивать сеть), CAP_SYS_ADMIN (монтировать и десятки других операций), CAP_NET_BIND_SERVICE (слушать порты ниже 1024) и т. д. containerd по умолчанию оставляет контейнеру 14 из них:

Capability Что разрешает
CHOWN, FOWNER, FSETID, DAC_OVERRIDE менять владельца файлов, обходить проверки прав на файлы
SETUID, SETGID, SETFCAP, SETPCAP менять свои UID и GID, ставить capabilities файлам
KILL посылать сигналы любым процессам своего PID namespace
NET_BIND_SERVICE открывать порты меньше 1024
NET_RAW сырые сокеты: ping, но и формирование произвольных пакетов
MKNOD создавать файлы устройств
SYS_CHROOT chroot
AUDIT_WRITE писать в журнал аудита ядра

Достаются они целиком только root-у в контейнере. У процесса с ненулевым UID действующий набор пуст, а 14 capabilities остаются лишь в ограничивающем наборе (CapBnd) — потолке, выше которого процесс не поднимется даже через setuid-файл. Сравним busybox от root и наш hello от UID 65532, оба без securityContext:

$ kubectl -n demo exec root-plain -- grep -E 'Cap(Eff|Bnd)' /proc/1/status
CapEff:	00000000a80425fb
CapBnd:	00000000a80425fb
user@k8s-node-1:~$ grep -E 'Cap(Eff|Bnd)' /proc/$PID/status    # процесс /hello
CapEff:	0000000000000000
CapBnd:	00000000a80425fb

Маска a80425fb — те самые 14 capabilities (capsh --decode=00000000a80425fb перечислит их по именам). Почти ничего из этого обычному приложению не нужно. NET_RAW остался в наборе исторически ради ping, а DAC_OVERRIDE позволяет root-у в контейнере читать любые файлы на смонтированных томах независимо от прав на них.

Правильно — убрать всё и добавить только необходимое:

securityContext:
  capabilities:
    drop: ["ALL"]
    add: ["NET_BIND_SERVICE"]   # только если правда нужно

Нюанс с add: добавленные так capabilities действуют для root в контейнере. У процесса с ненулевым UID add: [NET_BIND_SERVICE] попадает только в ограничивающий набор (CapBnd: 0000000000000400, бит 10), а действующий остаётся пустым — если у исполняемого файла нет файловой capability (её ставят при сборке образа командой setcap). А на containerd 2.x порты ниже 1024 в сети пода доступны и без неё: containerd по умолчанию ставит в сетевом namespace пода net.ipv4.ip_unprivileged_port_start=0 (подробно — в части 22). Процесс с UID 10001 и drop: [ALL] спокойно слушает порт 80:

$ kubectl -n demo exec caps -- sh -c 'nc -l -s 0.0.0.0 -p 80 & sleep 1; netstat -ltn; kill $!'
Active Internet connections (only servers)
Proto Recv-Q Send-Q Local Address           Foreign Address         State
tcp        0      0 0.0.0.0:80              0.0.0.0:*               LISTEN

Поэтому NET_BIND_SERVICE приложению в поде обычно не нужен вовсе, а проще всего слушать 8080 и не просить ничего.

privileged: true — не «ещё больше capabilities», а снятие изоляции почти целиком: все capabilities, все устройства узла в /dev, без seccomp и AppArmor, /sys на запись. Привилегированный контейнер по возможностям равен root на узле. Он нужен только системным компонентам, которые управляют узлом: calico-node настраивает маршруты и правила netfilter, CSI-драйверы монтируют тома (часть 21). Приложению — никогда.

allowPrivilegeEscalation: false ставит процессу флаг ядра no_new_privs: ни setuid-файлы, ни файловые capabilities больше не дают прав сверх тех, что были при запуске. Даже если в образе остался su или sudo, ими не воспользоваться. С privileged: true это поле несовместимо, и API такой под не примет:

The Pod "priv-ape" is invalid: spec.containers[0].securityContext: Invalid value: {…}: cannot set `allowPrivilegeEscalation` to false and `privileged` to true

Документация предупреждает, что и с CAP_SYS_ADMIN флаг не действует. На containerd 2.2 он в этом случае всё же ставится (NoNewPrivs: 1), но процессу с CAP_SYS_ADMIN он мало что запрещает, так что полагаться на это не стоит.


seccomp и AppArmor

seccomp — фильтр системных вызовов в ядре: процессу заранее задаётся, какие из нескольких сотен вызовов ему можно. Профиль RuntimeDefault у containerd запрещает несколько десятков опасных и редко нужных — монтирование, загрузку модулей и нового ядра, часть вызовов для создания пространств имён и отладки. Это закрывает целые классы уязвимостей ядра, доступных только через редкие вызовы. Обычные приложения разницы не замечают.

securityContext:
  seccompProfile:
    type: RuntimeDefault        # или Localhost с localhostProfile: путь относительно /var/lib/kubelet/seccomp

Без явного профиля под работает без фильтра (Unconfined): в /proc/<pid>/status такого процесса Seccomp: 0. Это можно изменить для всего узла: в KubeletConfiguration параметр seccompDefault: true делает RuntimeDefault значением по умолчанию для всех подов узла. Хорошая настройка для нового кластера; на существующем стоит сначала проверить, что ничего не сломается. На нашем стенде он не включён.

AppArmor — модуль безопасности ядра, включённый в Ubuntu по умолчанию: профиль ограничивает доступ процесса к файлам, сети и capabilities по путям. containerd на узлах с AppArmor применяет к контейнерам свой профиль cri-containerd.apparmor.d, который, например, запрещает запись в /proc/sys и монтирование, — и делает это даже без явного поля: у любого контейнера стенда /proc/1/attr/current показывает cri-containerd.apparmor.d (enforce). Задаётся полем appArmorProfile (стабильным с 1.30; раньше это были аннотации):

securityContext:
  appArmorProfile:
    type: RuntimeDefault        # Localhost с localhostProfile: имя профиля, загруженного на узле; Unconfined

SELinux — аналог AppArmor с метками вместо путей, основной в RHEL и производных; на Ubuntu обычно не используется, поле seLinuxOptions оставляют пустым.


Файловая система: readOnlyRootFilesystem

Слой контейнера по умолчанию доступен на запись. Тот, кто получил выполнение кода в приложении, первым делом попытается сохранить свои файлы где-нибудь в /tmp или поменять файлы приложения. readOnlyRootFilesystem: true делает корневую ФС контейнера только для чтения — вместе с /tmp:

$ kubectl -n demo exec secure -- touch /tmp/x
touch: /tmp/x: Read-only file system
command terminated with exit code 1

Каталоги, куда приложению действительно нужно писать, монтируются отдельно:

containers:
  - name: hello
    securityContext:
      readOnlyRootFilesystem: true
    volumeMounts:
      - name: tmp
        mountPath: /tmp
volumes:
  - name: tmp
    emptyDir:
      sizeLimit: 64Mi

Заодно становится видно, куда приложение пишет: если что-то сломалось после включения, ошибка Read-only file system покажет путь.


Поля, снимающие изоляцию: hostNetwork, hostPID, hostIPC, hostPath

Четыре поля в spec пода, каждое из которых убирает одну из стен между контейнером и узлом:

  • hostNetwork: true — под в сетевом namespace узла: видит все интерфейсы и трафик узла, слушает порты на адресах узла, ходит наружу с адресом узла. Политики Calico для подов на него не действуют (часть 20): для сети это уже трафик самого узла. Нужен kube-proxy, calico-node, speaker MetalLB (часть 17).
  • hostPID: true — под видит все процессы узла, включая их командные строки и переменные окружения, где нередко лежат пароли; вместе с правами root — может вмешиваться в них.
  • hostIPC: true — общая с узлом разделяемая память и очереди сообщений System V.
  • hostPath — каталог узла в контейнере. Каталог узла с правами root в контейнере — это доступ к тому, что там лежит: в /var/lib/kubelet — ключи kubelet и смонтированные тома и токены всех подов узла, в /etc — конфигурация узла. Даже «безобидный» hostPath: /var/log даёт больше, чем кажется.

Вместе с privileged или с правами root любое из этих полей превращает под в административный доступ к узлу, а через ключи kubelet и токены соседних подов — и к части кластера. Поэтому главный вывод части 29 стоит повторить: право create pods без ограничений на содержимое пода — это права root на узлах. Ограничения на содержимое и есть предмет остальной части.


User namespaces: hostUsers: false

Всё выше уменьшает, что может root в контейнере. User namespaces делают так, что root в контейнере вообще не root на узле. С hostUsers: false kubelet создаёт поду собственное пространство имён пользователей и выделяет ему диапазон из 65536 UID и GID узла, не пересекающийся с другими подами: UID 0 внутри пода — это, например, UID 675741696 снаружи, у которого на узле нет ни одного файла и ни одной привилегии. Capabilities внутри действуют только на ресурсы самого пода.

spec:
  hostUsers: false
  containers:
    - name: app
      image: ghcr.io/example/legacy-app:2.3   # образ, которому нужен root внутри

Как это выглядит изнутри и снаружи — под с busybox от root:

$ kubectl -n demo exec userns -- sh -c 'id; cat /proc/self/uid_map'
uid=0(root) gid=0(root) groups=0(root),10(wheel)
         0  675741696      65536
user@k8s-node-2:~$ ps -o uid,gid,cmd -p "$PID"
  UID   GID CMD
675741696 675741696 sleep infinity

uid_map читается так: UID 0 внутри соответствует UID 675741696 снаружи, и так 65536 подряд.

В Kubernetes 1.36 user namespaces стали стабильными (включены по умолчанию с 1.33). Требования: ядро Linux 6.3 и новее (на Ubuntu Server 26.04 с ядром 7.0 выполнено), containerd 2.0 и runc 1.2 и новее, и поддержка idmap-монтирования (сопоставления владельцев файлов при монтировании) файловыми системами каталога подов и томов — ext4, xfs, btrfs, tmpfs, overlayfs её имеют. Ограничения важно знать заранее:

  • с hostUsers: false нельзя hostNetwork, hostPID, hostIPC и блочные тома volumeDevices — API отвергает такой под сразу: spec.hostNetwork: Forbidden: when hostUsers is false;
  • тома NFS в таких подах не монтируются: клиент NFS в ядре Linux не поддерживает idmap-монтирование. Под создаётся, kubelet монтирует NFS на узле, но контейнер не стартует:
Error: failed to create containerd task: failed to create shim task: OCI runtime create failed: runc create failed: unable to start container process: error during container init: failed to fulfil mount request: failed to set MOUNT_ATTR_IDMAP on /var/lib/kubelet/pods/84466711-6518-4a89-8a48-75a020530347/volumes/kubernetes.io~nfs/data: invalid argument (maybe the filesystem used doesn't support idmap mounts on this kernel?)

Тот же том в обычном поде работает. Для приложений с томами из csi-driver-nfs (часть 23) этот механизм сегодня недоступен.

Pod Security (ниже) учитывает user namespaces: для пода с hostUsers: false проверки runAsNonRoot и runAsUser не применяются даже в restricted, потому что root внутри не root снаружи. Это главный путь для образов, которые нельзя переделать под непривилегированного пользователя.


Безопасный под целиком

Собираем всё вместе — так выглядит под hello, который пройдёт самый строгий уровень проверок:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: hello
  namespace: demo
spec:
  replicas: 2
  selector:
    matchLabels:
      app.kubernetes.io/name: hello
  template:
    metadata:
      labels:
        app.kubernetes.io/name: hello
    spec:
      serviceAccountName: hello
      automountServiceAccountToken: false
      securityContext:
        runAsNonRoot: true
        runAsUser: 10001
        runAsGroup: 10001
        seccompProfile:
          type: RuntimeDefault
      containers:
        - name: hello
          image: ghcr.io/example/hello:1.0
          ports:
            - name: http
              containerPort: 8080
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities:
              drop: ["ALL"]
          volumeMounts:
            - name: tmp
              mountPath: /tmp
      volumes:
        - name: tmp
          emptyDir:
            sizeLimit: 64Mi

ServiceAccount hello без токена — из части 29; чтобы скачать частный образ, к нему привязан Secret ghcr-pull (imagePullSecrets в ServiceAccount). Чарт из части 27 уже содержит эти поля. А патч из части 26, добавлявший только runAsNonRoot и seccompProfile, — нет: без allowPrivilegeEscalation и drop: [ALL] под в restricted не пустят, и ниже мы это увидим.


Pod Security Standards

Требовать от каждой команды помнить десяток полей бесполезно; нужна проверка при создании. Проект Kubernetes описал три уровня — Pod Security Standards:

Уровень Для кого Что запрещено
privileged системные компоненты узлов ничего
baseline любые приложения без переделки известные способы выйти из изоляции
restricted приложения, написанные с учётом безопасности всё, что не нужно обычному приложению

baseline запрещает: privileged, hostNetwork, hostPID, hostIPC, тома hostPath, hostPort; добавление capabilities сверх набора по умолчанию (NET_RAW в нём уже не разрешён к явному добавлению, а SYS_ADMIN и подобные — тем более); явный Unconfined в seccomp и AppArmor; нестандартные типы SELinux и procMount; sysctls вне короткого списка безопасных; поле host в пробах и хуках жизненного цикла (с 1.34). Под, нарушающий три правила сразу, получает их все в одном отказе:

pods "b" is forbidden: violates PodSecurity "baseline:latest": forbidden AppArmor profile (pod must not set AppArmor profile type to "Unconfined"), non-default capabilities (container "c" must not include "NET_RAW" in securityContext.capabilities.add), probe or lifecycle host (container "c" uses probe or lifecycle host "192.0.2.10")

restricted добавляет к этому:

  • runAsNonRoot: true обязателен (кроме подов с hostUsers: false);
  • allowPrivilegeEscalation: false обязателен;
  • capabilities.drop обязан содержать ALL, а в add разрешён только NET_BIND_SERVICE;
  • seccompProfile.type обязан быть RuntimeDefault или Localhost (не пустой);
  • тома только из безопасного списка: configMap, secret, downwardAPI, projected, emptyDir, ephemeral, persistentVolumeClaim, csi — то есть никаких hostPath.

Стандарты — это описание в документации, а не объект в кластере. Применяет их контроллер допуска.


Pod Security Admission

Pod Security Admission (PSA) — встроенный контроллер допуска (admission, третье звено из части 29), который проверяет каждый создаваемый под на соответствие уровню. Он пришёл на смену удалённому в 1.25 механизму PodSecurityPolicy и работает проще: уровень задаётся метками неймспейса, никаких отдельных объектов и привязок.

У метки три режима и значение уровня и версии:

pod-security.kubernetes.io/enforce: restricted      не пустить нарушающий под
pod-security.kubernetes.io/enforce-version: v1.36
pod-security.kubernetes.io/audit: restricted        пропустить, но записать в журнал аудита
pod-security.kubernetes.io/audit-version: v1.36
pod-security.kubernetes.io/warn: restricted         пропустить, но предупредить того, кто создаёт
pod-security.kubernetes.io/warn-version: v1.36
  • enforce — жёсткий запрет: под, не прошедший уровень, не создаётся. Проверяется только под; если нарушает шаблон в Deployment, сам Deployment создастся, а поды — нет, и это будет видно в событиях ReplicaSet, а не в ошибке kubectl apply. Поэтому warn ставят вместе с enforce: предупреждение приходит сразу на kubectl apply Deployment.
  • audit — запись в журнал аудита apiserver, пользователь ничего не замечает.
  • warn — предупреждение в ответе API тому, кто создаёт объект (в том числе на Deployment).

Версия (enforce-version) фиксирует, по правилам какой версии Kubernetes проверять: правила уровней со временем ужесточаются, и привязка к версии не даёт обновлению кластера внезапно отвергнуть поды, которые раньше проходили. Ставить стоит текущую версию кластера.

Типичный приём — мягкий ввод: сначала весь кластер предупреждает и пишет аудит на restricted, не запрещая ничего, по логам находят нарушителей, а enforce включают, когда нарушений не осталось. На уровне кластера умолчания задаются в конфигурации PSA (файл AdmissionConfiguration, передаваемый apiserver), и там же — список исключённых неймспейсов (kube-system исключают всегда: там системные поды с hostNetwork и правами).

# AdmissionConfiguration, передаётся apiserver через --admission-control-config-file
apiVersion: apiserver.config.k8s.io/v1
kind: AdmissionConfiguration
plugins:
  - name: PodSecurity
    configuration:
      apiVersion: pod-security.admission.config.k8s.io/v1
      kind: PodSecurityConfiguration
      defaults:
        enforce: baseline
        enforce-version: latest
        warn: restricted
        audit: restricted
      exemptions:
        namespaces: [kube-system]

Здесь весь кластер по умолчанию обязан соблюдать baseline, а restricted — предупреждением и аудитом. Это разумная отправная точка: baseline отсекает грубое (привилегированные поды, hostPath, hostNetwork), не ломая большинство готовых образов. В kubeadm файл подключается так же, как AuthenticationConfiguration в части 29: флаг в extraArgs и каталог в extraVolumes. С такой конфигурацией под с hostPath в неймспейсе без меток не создаётся, обычный — создаётся с предупреждением, а в kube-system проходит всё:

$ kubectl -n default apply --dry-run=server -f logreader.yaml
Error from server (Forbidden): error when creating "logreader.yaml": pods "logreader" is forbidden: violates PodSecurity "baseline:latest": hostPath volumes (volume "l")
$ kubectl -n kube-system apply --dry-run=server -f logreader.yaml
pod/logreader created (server dry run)

Метки неймспейса важнее умолчаний, но только те, что заданы: у неймспейса calico-system, где Tigera operator поставил одну метку enforce=privileged, кластерные warn и audit на restricted продолжают действовать, и каждый под там создаётся с предупреждением.


Переводим demo на restricted

Учебный неймспейс demo (часть 11) переведём на самый строгий уровень. Сначала посмотрим, кто нарушит, не запрещая, — warn и audit:

$ kubectl label namespace demo \
    pod-security.kubernetes.io/warn=restricted \
    pod-security.kubernetes.io/warn-version=v1.36 \
    pod-security.kubernetes.io/audit=restricted \
    pod-security.kubernetes.io/audit-version=v1.36
namespace/demo labeled

Перекатываем старый Deployment hello (без securityContext) и сразу видим предупреждение:

$ kubectl -n demo rollout restart deploy/hello
Warning: would violate PodSecurity "restricted:v1.36": allowPrivilegeEscalation != false (container "hello" must set securityContext.allowPrivilegeEscalation=false), unrestricted capabilities (container "hello" must set securityContext.capabilities.drop=["ALL"]), runAsNonRoot != true (pod or container "hello" must set securityContext.runAsNonRoot=true), seccompProfile (pod or container "hello" must set securityContext.seccompProfile.type to "RuntimeDefault" or "Localhost")
deployment.apps/hello restarted

Deployment перекатывается (warn не запрещает), а предупреждение перечислило ровно четыре недостающих поля. Заметьте runAsNonRoot != true: образ hello и так работает от UID 65532, но PSA смотрит только на манифест, а не на образ. Включаем запрет — и PSA сразу сообщает о подах, которые уже работают в нарушение нового уровня:

$ kubectl label namespace demo \
    pod-security.kubernetes.io/enforce=restricted \
    pod-security.kubernetes.io/enforce-version=v1.36
Warning: existing pods in namespace "demo" violate the new PodSecurity enforce level "restricted:v1.36"
Warning: hello-54c6c68779-qdhrp (and 1 other pod): allowPrivilegeEscalation != false, unrestricted capabilities, runAsNonRoot != true, seccompProfile
namespace/demo labeled

Работающие поды PSA не трогает — он проверяет только создание. Но следующая выкатка старого Deployment застрянет: kubectl получит то же предупреждение, Deployment изменится, а новый ReplicaSet не сможет создать ни одного пода. Причина — в его событиях:

$ kubectl -n demo rollout restart deploy/hello
Warning: would violate PodSecurity "restricted:v1.36": …
deployment.apps/hello restarted
$ kubectl -n demo get rs
NAME               DESIRED   CURRENT   READY   AGE
hello-54c6c68779   2         2         2       56s
hello-688cb9b6f7   0         0         0       19m
hello-d99998cf4    1         0         0       12s
$ kubectl -n demo describe rs hello-d99998cf4 | tail -1
  Warning  FailedCreate  1s (x3 over 9s)  replicaset-controller  (combined from similar events): Error creating: pods "hello-d99998cf4-mbchp" is forbidden: violates PodSecurity "restricted:v1.36": allowPrivilegeEscalation != false (container "hello" must set securityContext.allowPrivilegeEscalation=false), unrestricted capabilities (container "hello" must set securityContext.capabilities.drop=["ALL"]), runAsNonRoot != true (pod or container "hello" must set securityContext.runAsNonRoot=true), seccompProfile (pod or container "hello" must set securityContext.seccompProfile.type to "RuntimeDefault" or "Localhost")

Старые поды при этом продолжают работать — выкатка просто не идёт. Серверный dry-run (apiserver прогоняет объект через весь допуск и ничего не записывает) для Deployment тоже покажет только предупреждение: PSA проверяет под, а не шаблон. Чтобы получить настоящий отказ заранее, dry-run делают на манифест пода — пример ниже в «Смотрим руками».

А наш безопасный манифест из раздела выше проходит без предупреждений:

$ kubectl -n demo apply -f deploy-hello-secure.yaml
deployment.apps/hello configured
$ kubectl -n demo get pods -l app.kubernetes.io/name=hello
NAME                     READY   STATUS    RESTARTS   AGE
hello-68f476f876-8dlgk   1/1     Running   0          36s
hello-68f476f876-vptvv   1/1     Running   0          42s

Что при переходе на restricted ломается чаще всего:

  • образы, работающие от root — многие официальные образы баз и инструментов: им нужен runAsUser на непривилегированный UID, а иногда и правка образа (стандартный образ nginx слушает 80 и пишет в /var/cache/nginx от root; для Kubernetes есть вариант nginx-unprivileged на порту 8080);
  • приложения, пишущие в корневую ФС — readOnlyRootFilesystem в самом restricted не требуется, но если его включили, нужны emptyDir под /tmp и рабочие каталоги;
  • то, чему правда нужен root или capability — системные агенты, старые приложения: для них — отдельный неймспейс с нужным уровнем, а не ослабление общего.

Исключения

Не всё переделывается под restricted. Правильный способ сделать исключение — отдельный неймспейс с более мягкими метками, а не ослабление общего правила:

$ kubectl create namespace infra-agents
namespace/infra-agents created
$ kubectl label namespace infra-agents \
    pod-security.kubernetes.io/enforce=privileged \
    pod-security.kubernetes.io/warn=privileged \
    pod-security.kubernetes.io/audit=privileged
namespace/infra-agents labeled

Все три метки — чтобы кластерные умолчания warn и audit не сыпали предупреждениями на каждый под агента. Так привилегированный агент (например, сборщик логов с hostPath: /var/log, часть 33) живёт в своём неймспейсе под privileged, а приложения остаются в demo под restricted. Доступ в неймспейс infra-agents ограничивается RBAC: право create pods там — это фактически root на узлах (часть 29), и выдаётся оно считанным людям и системным ServiceAccount.

Гранулярные исключения уровня кластера (exemptions в AdmissionConfiguration) — по неймспейсам, по пользователям или по RuntimeClass — существуют, но ими пользуются скупо: исключённый субъект не проверяется вообще, и это легко забыть.


Свои правила: ValidatingAdmissionPolicy

Pod Security покрывает безопасность изоляции, но у команды бывают свои требования: запретить образы с тегом latest (часть 5), требовать метку team, разрешать образы только из своего реестра, требовать requests и limits (часть 10). Для этого есть ValidatingAdmissionPolicy — встроенный контроллер допуска, проверяющий объекты правилами на CEL прямо в apiserver, без внешнего сервиса. Стабилен с 1.30.

Политика — два объекта. ValidatingAdmissionPolicy описывает, что проверять и каким выражением, ValidatingAdmissionPolicyBinding — к каким неймспейсам применять и что делать при нарушении (Deny, Warn, Audit):

apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
  name: disallow-latest-tag
spec:
  failurePolicy: Fail
  matchConstraints:
    resourceRules:
      - apiGroups: ["apps"]
        apiVersions: ["v1"]
        operations: ["CREATE", "UPDATE"]
        resources: ["deployments", "statefulsets", "daemonsets"]
  validations:
    - expression: >-
        object.spec.template.spec.containers.all(c,
          c.image.contains('@') ||
          (c.image.matches(':[^/:]+$') && !c.image.endsWith(':latest')))
      message: "образ не должен использовать тег latest; укажите версию или дайджест"
      reason: Invalid
---
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicyBinding
metadata:
  name: disallow-latest-tag
spec:
  policyName: disallow-latest-tag
  validationActions: [Deny]
  matchResources:
    namespaceSelector:
      matchLabels:
        kubernetes.io/metadata.name: demo

Выражение пропускает образ с дайджестом (@sha256:…), а без дайджеста требует тег — двоеточие, после которого до конца строки нет ни /, ни :, — и чтобы этот тег не был latest. Так отсекается и ghcr.io/example/hello, и registry.example.com:5000/hello без тега (двоеточие перед портом реестра тегом не считается), а registry.example.com:5000/hello:1.0 проходит. Проверяются только containers; для initContainers нужно такое же второе выражение.

$ kubectl -n demo set image deploy/hello hello=ghcr.io/example/hello:latest
error: failed to patch image update to pod template: deployments.apps "hello" is forbidden: ValidatingAdmissionPolicy 'disallow-latest-tag' with binding 'disallow-latest-tag' denied request: образ не должен использовать тег latest; укажите версию или дайджест

Выражения CEL имеют доступ к object (новый объект), oldObject (прежний, при обновлении), request (кто и что делает), namespaceObject и params (параметры из отдельного ресурса, если политика параметризована). Поскольку это тот же CEL, что в валидации CRD (часть 28) и в проверках аутентификации (часть 29), набор выражений в кластере единообразен. Ошибку в выражении apiserver ловит при создании политики: она не создастся, а сообщение укажет строку и позицию.

Для правил, которые нужно не только проверять, но и дописывать (проставить недостающую метку, добавить securityContext), в 1.36 стала стабильной парная MutatingAdmissionPolicy — то же на CEL, но меняющая объект, замена мутирующим вебхукам для простых случаев.

Когда правил много и нужны сложные сценарии (генерация объектов, проверка по внешним данным, отчёты о нарушениях во всём кластере), ставят отдельный движок политик: Kyverno — оператор с правилами в виде YAML-ресурсов, без программирования, или OPA Gatekeeper — на языке Rego поверх Open Policy Agent. Оба работают как admission-вебхуки; при их оценке применимо всё из части 28 про вебхуки, включая риск заблокировать кластер при failurePolicy: Fail и падении движка. Для простых правил на одном кластере встроенные ValidatingAdmissionPolicy проще и надёжнее: нечему падать.


Связь с частью 22

В части 22 при разборе NFS-сервера был неприятный вывод: любой процесс в поде — даже не root и без capabilities — может открыть соединение с NFS-сервером с порта ниже 1024 и сам собрать удостоверение AUTH_SYS (UID и GID, которые клиент вписывает в каждый запрос и которым сервер с sec=sys просто верит). restricted этого не исправляет: там это и было показано на поде, запущенном ровно по его требованиям. Что он даёт — под не смонтирует NFS ядром (нужен CAP_SYS_ADMIN), не станет root на узле и не получит hostNetwork, а значит, не обойдёт через сеть узла политику Calico, которая не пускает поды к адресу сервера. Главный слой против подов — эта сетевая политика, а restricted не даёт её обойти. Ни одного слоя не хватает в одиночку, но вместе они закрывают путь. То же касается SMB-сервера из части 24.


Смотрим руками

Тестовые поды в этой части — busybox с разными securityContext, все созданы в demo до включения меток Pod Security: root-plain без полей, caps с UID 10001 и drop: [ALL], add: [NET_BIND_SERVICE], secure со всеми полями безопасного пода. Метки неймспейса после перевода:

$ kubectl get namespace demo --show-labels
NAME   STATUS   AGE     LABELS
demo   Active   19m     kubernetes.io/metadata.name=demo,pod-security.kubernetes.io/audit-version=v1.36,pod-security.kubernetes.io/audit=restricted,pod-security.kubernetes.io/enforce-version=v1.36,pod-security.kubernetes.io/enforce=restricted,pod-security.kubernetes.io/warn-version=v1.36,pod-security.kubernetes.io/warn=restricted

Проверим, что привилегированный под в demo действительно не пройдёт, — серверным dry-run на манифест пода, не создавая его:

$ cat <<'EOF' | kubectl -n demo apply --dry-run=server -f -
apiVersion: v1
kind: Pod
metadata:
  name: probe
spec:
  containers:
    - name: c
      image: busybox:1.37
      securityContext:
        privileged: true
EOF
Error from server (Forbidden): error when creating "STDIN": pods "probe" is forbidden: violates PodSecurity "restricted:v1.36": privileged (container "c" must not set securityContext.privileged=true), allowPrivilegeEscalation != false (container "c" must set securityContext.allowPrivilegeEscalation=false), unrestricted capabilities (container "c" must set securityContext.capabilities.drop=["ALL"]), runAsNonRoot != true (pod or container "c" must set securityContext.runAsNonRoot=true), seccompProfile (pod or container "c" must set securityContext.seccompProfile.type to "RuntimeDefault" or "Localhost")

А под с hostUsers: false проходит restricted без runAsNonRoot — так работает исключение для user namespaces:

$ cat <<'EOF' | kubectl -n demo apply --dry-run=server -f -
apiVersion: v1
kind: Pod
metadata:
  name: legacy
spec:
  hostUsers: false
  containers:
    - name: c
      image: busybox:1.37
      command: [sleep, infinity]
      securityContext:
        allowPrivilegeEscalation: false
        capabilities: {drop: [ALL]}
        seccompProfile: {type: RuntimeDefault}
EOF
pod/legacy created (server dry run)

Без строки hostUsers: false тот же под получает violates PodSecurity "restricted:v1.36": runAsNonRoot != true.

Как под выглядит на узле, когда поля применены. UID процесса — заданный, не root:

user@k8s-node-1:~$ CID=$(sudo crictl ps --name hello -q | head -1)
user@k8s-node-1:~$ PID=$(sudo crictl inspect --output go-template --template '{{.info.pid}}' "$CID")
user@k8s-node-1:~$ ps -o uid,gid,cmd -p "$PID"
  UID   GID CMD
10001 10001 /hello

Оставшиеся capabilities и флаг no_new_privs — в /proc процесса:

user@k8s-node-1:~$ grep -E 'Cap(Eff|Bnd)|NoNewPrivs' /proc/$PID/status
CapEff:	0000000000000000
CapBnd:	0000000000000000
NoNewPrivs:	1

CapEff и CapBnd равны нулю — не осталось ни одной capability, даже в потолке (drop: [ALL]), NoNewPrivs: 1 — повышение прав запрещено (allowPrivilegeEscalation: false). У того же hello без securityContext было CapEff: 0 (не root), но CapBnd: 00000000a80425fb и NoNewPrivs: 0: setuid-файл в образе мог бы поднять процесс до всех 14 capabilities.

Профиль seccomp тоже виден:

user@k8s-node-1:~$ grep Seccomp /proc/$PID/status
Seccomp:	2
Seccomp_filters:	1

Seccomp: 2 — режим фильтра (а не 0, отключено), один загруженный фильтр — RuntimeDefault, который применил containerd.

И отдельный неймспейс-исключение для системного агента среди остальных:

$ kubectl get ns -L pod-security.kubernetes.io/enforce
NAME              STATUS   AGE   ENFORCE
calico-system     Active   8d    privileged
default           Active   8d
demo              Active   13m   restricted
infra-agents      Active   51s   privileged
kube-node-lease   Active   8d
kube-public       Active   8d
kube-system       Active   8d
tigera-operator   Active   8d    privileged

demo под строгим запретом, infra-agents — под privileged для агентов, calico-system и tigera-operator пометил Tigera operator, остальные без метки. На кластере без AdmissionConfiguration неймспейс без метки — это privileged, то есть никаких проверок: default стоит либо не использовать, либо тоже пометить, а kube-system — исключить в конфигурации PSA, как в примере выше.

Уборка: kubectl delete namespace infra-agents, kubectl delete validatingadmissionpolicybinding,validatingadmissionpolicy disallow-latest-tag, тестовые поды root-plain, caps, secure — kubectl -n demo delete pod. Метки на demo оставляем: дальше серия работает в нём под restricted.


На этом блок о безопасности закончен: часть 29 определила, кто и что может сделать с API, эта — что окажется внутри пода и на узле. Дальше кластер нужно уметь чинить, когда он всё-таки ломается.


Источники


Дальше — диагностика: как читать события и состояния подов, разбирать Pending, CrashLoopBackOff и ImagePullBackOff, пользоваться kubectl logs, exec и kubectl debug, и что смотреть на самом узле через crictl, когда API уже не помогает.

Оставить комментарий могут только зарегистрированные пользователи.

Войдите на сайт или зарегистрируйтесь, чтобы оставить комментарий.