← Kubernetes: безопасность
8 мин · средне · Урок 1 из 3

securityContext: запуск Pod без root

Поля securityContext, которые превращают обычный контейнер в non-root процесс с урезанными правами и readonly-корнем.

Что такое securityContext

Это блок настроек безопасности процесса в контейнере. Живёт на двух уровнях:

Не каждое поле есть на обоих уровнях. На Pod-уровне: runAsUser, runAsGroup, runAsNonRoot, fsGroup, seccompProfile. На контейнерном уровне дополнительно: capabilities, allowPrivilegeEscalation, readOnlyRootFilesystem, privileged.

Ниже разобрано каждое поле по одному, а в конце собран готовый жёсткий шаблон из них всех.

ПолеЧто запрещаетЧто видно, если приложению это нужно
runAsNonRoot: trueстарт от UID 0CreateContainerConfigError
capabilities.drop: ["ALL"]дефолтный набор root-привилегийping не работает без NET_RAW
allowPrivilegeEscalation: falsesetuid-бинариsudo, passwd, mount падают
readOnlyRootFilesystem: trueзапись в /, /etc, /usr, /varRead-only file system
seccompProfile: RuntimeDefaultоколо 60 опасных системных вызововобычно ничего, фильтр незаметен

Запретить root: runAsNonRoot + runAsUser

spec:
  securityContext:
    runAsNonRoot: true
    runAsUser: 1000
    runAsGroup: 1000

runAsNonRoot: true. Kubelet проверяет UID процесса при старте контейнера. Kubelet - агент Kubernetes на узле, который запускает контейнеры. Если UID = 0, Pod падает в CreateContainerConfigError: container has runAsNonRoot and image will run as root. Защита от дурака: кто-то накатил образ, в котором зашит USER 0, и контейнер просто не стартует.

runAsUser: 1000. Kubelet форсит UID процесса. Работает, только если образ не требует root для запуска. Многие официальные образы (nginx, redis) требуют root для bind на 80 порт или setuid. Для non-root запуска нужны специальные сборки (nginxinc/nginx-unprivileged, bitnami/redis).

Альтернатива: указать USER в Dockerfile при сборке образа.

Контейнер под runAsUser: 1000 может получить permission denied на файлах из образа: они принадлежат root, и UID 1000 их не читает. Помогает USER в Dockerfile или fsGroup для смонтированных volume.

Сбросить capabilities

Linux capabilities делят root-привилегии на куски. CAP_NET_BIND_SERVICE разрешает bind на порты <1024, CAP_NET_RAW даёт ping, CAP_SYS_ADMIN даёт почти всё (его иногда называют "новый root").

По умолчанию контейнер получает дефолтный набор: CAP_CHOWN, CAP_DAC_OVERRIDE, CAP_FOWNER, CAP_NET_BIND_SERVICE, CAP_SETGID, CAP_SETUID, ещё пара. Веб-приложению из них ничего не нужно. Сбрасывают все:

containers:
  - name: app
    securityContext:
      capabilities:
        drop:
          - ALL

Если конкретный cap нужен (например NET_BIND_SERVICE для bind на 80 порт), его добавляют через add:

capabilities:
  drop: ["ALL"]
  add: ["NET_BIND_SERVICE"]

allowPrivilegeEscalation: false

Флаг ядра no_new_privs. Запрещает процессу получить больше привилегий, чем у родителя. Главное практическое последствие: не работают setuid-бинари. sudo, ping, passwd, mount внутри контейнера будут падать. Это правильно: в боевом контейнере им делать нечего. Большинство дистрибутивов под Kubernetes (distroless, alpine без setuid) уже не имеют suid-битов.

containers:
  - name: app
    securityContext:
      allowPrivilegeEscalation: false

readOnlyRootFilesystem

Монтирует корневую FS контейнера в readonly. Любая запись в /, /etc, /usr, /var падает с Read-only file system. Для приложений, которые пишут логи, кэш или PID-файлы, это сразу ломает запуск.

Решение: подмонтировать emptyDir на каталоги, куда нужна запись.

containers:
  - name: app
    securityContext:
      readOnlyRootFilesystem: true
    volumeMounts:
      - name: tmp
        mountPath: /tmp
volumes:
  - name: tmp
    emptyDir: {}

/tmp нужен почти всегда (даже busybox shell пишет туда историю). Дополнительно частые точки: /var/run, /var/log, /var/cache/nginx для nginx.

seccompProfile

seccomp фильтрует системные вызовы. До v1.25 контейнеры по умолчанию бежали без seccomp-фильтра, что давало доступ к экзотике (add_key, keyctl, bpf), которой настоящие приложения не пользуются. С v1.25 в качестве best practice ставится:

spec:
  securityContext:
    seccompProfile:
      type: RuntimeDefault

RuntimeDefault это фильтр, который заблокирует около 60 опасных системных вызовов. Большинство приложений работает без вопросов.

Альтернативы: Unconfined (без фильтра) и Localhost (свой профиль из файла).

Одна деталь про умолчания. Возможность включить RuntimeDefault для всего узла разом дошла до GA в 1.27, но это опция kubelet seccompDefault, и включают её вручную. Пока администратор её не поставил, контейнер без seccompProfile в спеке бежит как Unconfined. Поэтому поле пишут в манифесте, а не надеются на кластер.

Готовый «жёсткий» шаблон

apiVersion: v1
kind: Pod
metadata:
  name: secured
spec:
  securityContext:
    runAsNonRoot: true
    runAsUser: 1000
    seccompProfile:
      type: RuntimeDefault
  containers:
    - name: app
      image: busybox:1.36
      command: ["sh", "-c", "sleep 3600"]
      securityContext:
        allowPrivilegeEscalation: false
        readOnlyRootFilesystem: true
        capabilities:
          drop: ["ALL"]
      volumeMounts:
        - name: tmp
          mountPath: /tmp
  volumes:
    - name: tmp
      emptyDir: {}

Это близкий к Pod Security Standards restricted минимум, который дальше принимается в admission-контроллерах.

Что часто ломается

Список полей и их описания всегда под рукой в самом kubectl:

kubectl explain pod.spec.securityContext
kubectl explain pod.spec.containers.securityContext --recursive | head -40

Домашка

Теорию закрепляет задание на своём кластере: 1 шаг с проверкой. Кластер поднимается по треку Kubernetes локально, задания лежат в репозитории k8s-practice.

./run.sh start k8s-security-context     # развернуть стартовое состояние
./run.sh verify k8s-security-context    # проверить текущий шаг

Коротко

Прошли материал до конца? Отметьте урок пройденным.