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: whenhostUsersis 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 applyDeployment.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, эта — что окажется внутри пода и на узле. Дальше кластер нужно уметь чинить, когда он всё-таки ломается.
Источники
- Configure a Security Context for a Pod or Container — поля
securityContext, capabilities, seccomp, AppArmor,allowPrivilegeEscalation. - Linux kernel security constraints for Pods and containers — capabilities, seccomp, AppArmor, SELinux,
no_new_privs. - Restrict a Container's Syscalls with seccomp — профили,
seccompDefault. - Restrict a Container's Access to Resources with AppArmor — поле
appArmorProfile. - User Namespaces —
hostUsers: false, требования и ограничения. - Pod Security Standards — уровни
privileged,baseline,restricted. - Pod Security Admission и Enforce Pod Security Standards with Namespace Labels — режимы, версии, метки.
- Enforce Pod Security Standards by Configuring the Built-in Admission Controller —
AdmissionConfigurationи исключения. - Validating Admission Policy и Mutating Admission Policy — политики на CEL.
- CEL in Kubernetes — функции строк и
matches. - containerd: CRI Plugin Config Guide —
enable_unprivileged_ports,disable_apparmor. - Kyverno и OPA Gatekeeper — внешние движки политик.
Дальше — диагностика: как читать события и состояния подов, разбирать Pending, CrashLoopBackOff и ImagePullBackOff, пользоваться kubectl logs, exec и kubectl debug, и что смотреть на самом узле через crictl, когда API уже не помогает.
Оставить комментарий могут только зарегистрированные пользователи.
Войдите на сайт или зарегистрируйтесь, чтобы оставить комментарий.