Безопасность подов: securityContext

Контейнер по умолчанию может работать от root и захватить ноду — если вы это не запретили.

securityContext — секция манифеста, которая ужесточает права контейнера и пода на уровне ядра Linux: под каким пользователем запускаться, какие привилегии иметь, можно ли писать в корневую файловую систему.

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

Зачем это на практике

Большинство приложений (веб-сервер, API, воркер) не нужны права суперпользователя: им достаточно слушать порт > 1024 и читать свой код. Но базовые образы часто запускают процесс от root «на всякий случай». Атакующий, получивший RCE в таком контейнере, сразу имеет root внутри и широкую поверхность для побега. Один блок securityContext закрывает большинство таких сценариев — это дёшево и обязательно для прода.

Важно понимать модель угроз. Изоляция контейнера держится на функциях ядра Linux — namespaces (отдельные представления процессов, сети, точек монтирования) и cgroups (ограничение ресурсов). Это не граница виртуальной машины: ядро у контейнера и ноды общее. Поэтому уязвимость в рантайме, монтирование сокета Docker внутрь пода или привилегированный режим способны превратить компрометацию одного контейнера в компрометацию всей ноды, а через неё — соседних подов. Снятие лишних привилегий не делает побег невозможным, но многократно сужает то, что атакующий сможет сделать, даже добравшись до контейнера.

securityContext: ключевые поля

Контекст можно задать на уровне пода (spec.securityContext, применяется ко всем контейнерам) и на уровне контейнера (containers[].securityContext, переопределяет под). Самые важные поля:

ПолеЭффект
runAsNonRoot: truekubelet не запустит контейнер, если внутри он стартует от UID 0
runAsUser: 1000принудительно запускает процесс под этим UID
readOnlyRootFilesystem: trueкорневая ФС только для чтения — запись лишь в явные тома
allowPrivilegeEscalation: falseпроцесс не сможет повысить привилегии (setuid, sudo)
privileged: falseзапрет привилегированного режима (доступ ко всем устройствам ноды)
capabilities.drop: ["ALL"]снять все Linux capabilities, добавить обратно лишь нужные

Эталонный «жёсткий» под

Соберём контейнер, у которого почти ничего нельзя, кроме его прямой работы:

apiVersion: v1
kind: Pod
metadata:
  name: hardened-web
spec:
  securityContext:
    runAsNonRoot: true
    runAsUser: 1000
    fsGroup: 2000
  containers:
    - name: web
      image: nginxinc/nginx-unprivileged:1.27
      ports:
        - containerPort: 8080
      securityContext:
        readOnlyRootFilesystem: true
        allowPrivilegeEscalation: false
        capabilities:
          drop: ["ALL"]
      volumeMounts:
        - name: cache
          mountPath: /tmp
  volumes:
    - name: cache
      emptyDir: {}

Обратите внимание: образ — nginx-unprivileged, слушающий порт 8080, а не привилегированный 80. При readOnlyRootFilesystem: true nginx не сможет писать во временные файлы, поэтому мы примонтировали emptyDir в /tmp. Это типичный приём: корень закрыт, а под запись выделены отдельные тома.

Linux capabilities

Capabilities — это «нарезка» прав root на отдельные способности (открыть порт < 1024 — NET_BIND_SERVICE, менять владельца файлов — CHOWN и т.д.). По умолчанию контейнеру выдаётся целый набор. Безопасный паттерн — снять ALL и вернуть точечно только необходимое:

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

Pod Security Standards и Pod Security Admission

Прописывать securityContext в каждом манифесте надёжно, но люди забывают. Поэтому в Kubernetes есть встроенный механизм, который проверяет поды на входе. Он опирается на три Pod Security Standards (PSS) — готовые профили требований:

ПрофильСмысл
privilegedбез ограничений (для системных компонентов)
baselineзапрещает явно опасное: privileged, hostNetwork, hostPID
restrictedжёсткий профиль: требует runAsNonRoot, drop ALL, запрет escalation

Применяются они через Pod Security Admission — встроенный контроллер, который включается простыми метками на namespace. Метка задаёт уровень и режим (enforce — блокировать, warn — предупреждать, audit — писать в журнал):

kubectl label namespace prod \
  pod-security.kubernetes.io/enforce=restricted \
  pod-security.kubernetes.io/warn=restricted

Теперь любой под в prod, не проходящий профиль restricted (например, с privileged: true), будет отклонён на этапе создания. Удобно сначала повесить warn/audit, посмотреть на нарушения, и только потом включать enforce.

Как это работает под капотом

За соблюдением профиля следит admission-стадия API-сервера — тот же этап, что валидирует и мутирует объекты после авторизации RBAC. Pod Security Admission читает метки namespace, сравнивает спецификацию пода с требованиями выбранного PSS и при нарушении в режиме enforce возвращает ошибку, не записывая объект в etcd. Сам же securityContext применяет уже kubelet: он передаёт рантайму UID, набор capabilities и флаги, а ядро Linux навешивает их через user namespaces, cgroups и seccomp.

Частые ошибки

  • privileged: true ради одной мелочи. Привилегированный контейнер видит все устройства ноды — это почти отказ от изоляции.
  • runAsNonRoot без подходящего образа. Если образ хардкодит запись в /var/run от root, под просто не стартует — нужен unprivileged-образ или примонтированные тома.
  • readOnlyRootFilesystem без emptyDir. Приложение падает на первой попытке записать лог или temp-файл; выделяйте запись отдельными томами.
  • hostNetwork/hostPID «для отладки». Они ломают сетевую и процессную изоляцию пода; в проде их быть не должно.
  • Метка PSA только enforce сразу на работающий namespace. Можно мгновенно заблокировать деплои; начинайте с warn/audit.

Итоги

  • Контейнер — процесс на ноде; root внутри легко превращается в root на ноде, поэтому привилегии снимают заранее.
  • runAsNonRoot, readOnlyRootFilesystem, allowPrivilegeEscalation: false и drop: ["ALL"] — обязательный минимум для прода.
  • Capabilities снимают целиком и добавляют точечно только нужные.
  • Pod Security Standards (privileged/baseline/restricted) задают готовые профили, а Pod Security Admission применяет их метками на namespace.
  • Включайте профиль через warn/audit, и только потом enforce, чтобы не сломать существующие деплои.
Проверьте себя
1. Что делает readOnlyRootFilesystem: true в securityContext контейнера?
AЗапрещает читать корневую файловую систему контейнера
BДелает корневую ФС доступной только для чтения; запись возможна лишь в явно примонтированные тома
CМонтирует корневую ФС ноды внутрь контейнера
DШифрует файловую систему контейнера
2. Зачем использовать паттерн capabilities.drop: ["ALL"], а затем add только нужного?
AЧтобы контейнер получил все права root и работал быстрее
BЧтобы по умолчанию у контейнера не было ни одной привилегии ядра, а возвращались только реально необходимые
CЧтобы полностью отключить сеть в контейнере
DЭто синоним privileged: true
3. Какой профиль Pod Security Standards требует runAsNonRoot, drop ALL и запрет повышения привилегий?
Aprivileged
Bbaseline
Crestricted
Ddefault