Перейти к основному содержимому

Безопасность в K8s

Презентация​

Презентация
Последняя редакция: 23.10.26

Теория​

Кто обращается к API-серверу и как это проверяется​

Любое действие в Kubernetes - хоть kubectl get pods, хоть создание пода изнутри другого пода - в итоге превращается в HTTP-запрос к API-серверу
Прежде чем что-то выполнить, API-сервер проходит через три последовательных шага - аутентификация (кто ты), авторизация (что тебе можно) и admission control (финальные проверки и, возможно, изменение объекта перед сохранением)

К API-серверу обращаются два принципиально разных типа участников

  • Пользователи - человек с kubectl и kubeconfig
    В managed-кластере (см. 06-K8s-cloud-cluster) ваша личность на этапе аутентификации - это ваша IAM-учётка в облаке, а не отдельный пароль от Kubernetes
    Именно роли k8s.cluster-api.viewer/editor/admin из той статьи и есть то, что связывает вашу IAM-личность с правами внутри самого кластера
  • ServiceAccount - учётная запись для программ, которые работают внутри кластера (под, который обращается к API-серверу от своего имени)
    У каждого пода есть ServiceAccount - если не указать свой явно, под получает default в своём namespace, и у этого default по умолчанию нет вообще никаких прав, кроме факта аутентификации

Авторизация (второй шаг) в Kubernetes почти всегда означает RBAC - именно ему посвящён следующий раздел

RBAC: Role, RoleBinding, ClusterRole, ClusterRoleBinding​

RBAC описывает права через два разделённых понятия - "что можно" (роль) и "кому можно" (привязка роли)

  • Role - список разрешений, ограниченный одним namespace
  • RoleBinding - привязывает Role (или ClusterRole) к конкретным субъектам внутри одного namespace
  • ClusterRole - то же самое, что Role, но не привязан к namespace - его можно применить к кластерным ресурсам (нодам, namespace) или переиспользовать в нескольких namespace через RoleBinding
  • ClusterRoleBinding - привязывает ClusterRole ко всем namespace сразу
apiVersion: v1
kind: ServiceAccount
metadata:
namespace: app
name: pod-lister
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
namespace: app
name: pod-reader
rules:
- apiGroups: [""] # "" - core API (поды, сервисы, конфигмапы)
resources: ["pods"]
verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: read-pods
namespace: app
subjects:
- kind: ServiceAccount
name: pod-lister
namespace: app
roleRef:
kind: Role
name: pod-reader
apiGroup: rbac.authorization.k8s.io

apiGroups: [""] - это ядро API (поды, сервисы, конфигмапы, секреты), а для остальных ресурсов нужна своя группа - что это такое, разберём ниже в разделе про apiGroups

Главное практическое правило - минимально необходимые права
Сервису, который только читает список подов, не нужны create/delete/update, и уж тем более не нужен доступ к secrets - каждый лишний verb или resource в правиле расширяет то, что можно сделать, если под будет скомпрометирован

Проверить, что у конкретного ServiceAccount реально есть, можно без пробной попытки удалить что-нибудь важное

kubectl auth can-i list pods --as=system:serviceaccount:app:pod-lister -n app
kubectl auth can-i delete pods --as=system:serviceaccount:app:pod-lister -n app

apiGroups: к какой группе API относится ресурс​

Kubernetes делит свой API на группы, и в правиле Role каждый ресурс указывается вместе со своей группой
Группу узнать просто - это часть apiVersion до слэша: у apps/v1 группа apps, а у v1 без слэша группы нет вовсе - это ядро (core) API, и в правиле оно записывается пустой строкой ""
Какие ресурсы в какой группе лежат, показано в таблице ниже, в разделе про resources

В resources пишут не Kind, а имя ресурса во множественном числе строчными буквами: Deployment превращается в deployments, Pod - в pods
Группа и ресурс работают только вместе: правило resources: ["deployments"] при apiGroups: [""] ничего не разрешает, потому что в ядре такого ресурса нет, а ошибки при создании Role не будет - просто доступа не окажется
Чтобы разрешить и поды, и Deployment, в rules нужны две записи

apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: workload-reader
namespace: app
rules:
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list", "watch"]
- apiGroups: ["apps"]
resources: ["deployments"]
verbs: ["get", "list"]

Группу любого ресурса можно посмотреть в самом кластере, в колонке APIVERSION

kubectl api-resources # все ресурсы с группами
kubectl api-resources --api-group=apps # только ресурсы группы apps

resources: на какие ресурсы можно выдать права​

Закрытого списка ресурсов нет, он зависит от кластера: установленные расширения (CRD, например cert-manager) добавляют свои ресурсы и свои группы
Полный список вашего кластера показывает kubectl api-resources, а в правило идёт значение из колонки NAME

Самые частые ресурсы

resourcesapiGroupsГде живёт
pods""namespace
pods/log, pods/exec""namespace (подресурсы пода)
services""namespace
configmaps""namespace
secrets""namespace
persistentvolumeclaims""namespace
serviceaccounts""namespace
events""namespace
namespaces""весь кластер
nodes""весь кластер
persistentvolumes""весь кластер
deployments, statefulsets, daemonsetsappsnamespace
jobs, cronjobsbatchnamespace
ingresses, networkpoliciesnetworking.k8s.ionamespace
roles, rolebindingsrbac.authorization.k8s.ionamespace
clusterroles, clusterrolebindingsrbac.authorization.k8s.ioвесь кластер
certificatesigningrequestscertificates.k8s.ioвесь кластер
storageclassesstorage.k8s.ioвесь кластер

Ресурсы без привязки к namespace (nodes, namespaces, certificatesigningrequests) через Role выдать нельзя: Role действует внутри одного namespace
Для них нужны ClusterRole и ClusterRoleBinding
Список таких ресурсов даёт kubectl api-resources --namespaced=false

У ресурса бывают подресурсы, и права на них выдаются отдельно: логи пода - это pods/log, выполнение команды в поде - pods/exec
Поэтому pod-reader из примеров выше позволяет увидеть поды, но не прочитать их логи, а в kubectl api-resources подресурсов нет
Значение * в apiGroups и resources означает "все" - так пишут админские роли, а для сервисов от него отказываются в пользу минимальных прав

Выдать права можно и не на весь ресурс, а на конкретные объекты - для этого есть resourceNames

apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: settings-reader
namespace: app
rules:
- apiGroups: [""]
resources: ["configmaps"]
resourceNames: ["app-settings"]
verbs: ["get"]

Эта Role позволяет прочитать только ConfigMap app-settings и никакой другой
У resourceNames есть ограничения: для create имя не проверить, потому что имя нового объекта ещё не известно, а для list и watch клиент должен сам указать то же имя в запросе (фильтр по metadata.name)

verbs: какие действия можно разрешить​

Каждый запрос к API превращается ровно в один verb, который зависит от HTTP-метода и от того, к чему обращаются - к одному объекту или ко всей коллекции

verbsHTTP-запросЧто делаетПример
getGET одного объектапрочитать один объект по имениkubectl get pod <имя>
listGET коллекцииполучить список объектовkubectl get pods
watchGET с ?watch=trueследить за изменениямиkubectl get pods -w
createPOSTсоздать объектkubectl apply нового объекта
updatePUTзаменить объект целикомkubectl replace
patchPATCHизменить часть объектаkubectl patch
deleteDELETE одного объектаудалить объект по имениkubectl delete pod <имя>
deletecollectionDELETE коллекцииудалить всю коллекцию одним запросомзапросы клиентов и контроллеров

Для kubectl get pods -w нужны сразу list и watch: сначала он получает текущий список, потом следит за изменениями
Так же устроен pod-watcher: скрипт делает list, затем watch, поэтому ему нужны именно эти два verb, а get в нём не используется и оставлен в Role по привычке
По строгому минимуму хватило бы list и watch

Есть редкие verb для особых ресурсов, они опасны, и выдавать их без необходимости не стоит

  • impersonate - действовать от имени другого пользователя, группы или ServiceAccount (флаг --as, который мы используем в auth can-i, работает именно благодаря ему)
  • bind - создавать RoleBinding на роль, даже если у самого нет всех её прав
  • escalate - создавать и менять Role с правами, которых нет у автора
  • approve и sign - одобрять CertificateSigningRequest и подписывать сертификаты, в практике это делает администратор командой kubectl certificate approve

Несколько ловушек, из-за которых права оказываются шире, чем кажется

  • list и watch на secrets отдают объекты целиком, вместе со значениями, поэтому читать секреты можно через любой из get, list, watch, а не только через get
  • create на pods позволяет запустить под от имени любого ServiceAccount этого namespace и смонтировать в него любой его Secret
  • pods/exec пускает внутрь контейнера, а значит даёт всё, что есть у процесса в нём

Посмотреть, какие verb поддерживает ресурс, можно в колонке VERBS, а проверить конкретное право, в том числе на подресурс, - через auth can-i

kubectl api-resources -o wide | grep -E "^(NAME|pods |deployments |secrets )"
kubectl auth can-i create deployments.apps -n app --as=system:serviceaccount:app:pod-watcher # ожидаем: no
kubectl auth can-i get pods --subresource=log -n app --as=system:serviceaccount:app:pod-watcher # ожидаем: no

В практике это видно сразу: Role pod-reader разрешает три verb на один ресурс pods из ядра, поэтому ivan и pod-watcher читают поды
delete deployment для ivan запрещён, потому что в правиле нет ни группы apps, ни ресурса deployments, ни verb delete
get secrets запрещён, потому что в правиле нет ресурса secrets (он тоже в ядре, но это другой ресурс)

Пользователь с ограниченными правами: сертификат + RBAC​

В самом Kubernetes нет объекта "User" - в отличие от ServiceAccount, обычного пользователя нигде не создают как ресурс, кластер просто доверяет любому клиентскому TLS-сертификату, подписанному его CA
Имя пользователя в RBAC берётся из поля CN сертификата, а каждое поле O - это группа (её тоже можно использовать в RBAC вместо конкретного имени)
Это договорённость Kubernetes, а не свойство самих сертификатов: X.509 про пользователей и группы ничего не знает, а полей O в сертификате может быть несколько, и тогда у пользователя несколько групп

На локальном kubeadm-подобном кластере (таким и является minikube) можно попросить сам кластер подписать сертификат через встроенный API CertificateSigningRequest, без стороннего удостоверяющего центра

openssl genrsa -out ivan.key 2048
openssl req -new -key ivan.key -out ivan.csr -subj "/CN=ivan/O=developers"

Содержимое ivan.csr в манифест кладётся в base64 одной строкой - получить её можно так (работает и в Linux, и в macOS)

cat ivan.csr | base64 | tr -d "\n"
apiVersion: certificates.k8s.io/v1
kind: CertificateSigningRequest
metadata:
name: ivan-csr
spec:
request: "<ivan.csr в base64, без переносов строк>"
signerName: kubernetes.io/kube-apiserver-client
expirationSeconds: 86400
usages:
- client auth

Что означают поля манифеста

  • request - сам запрос из ivan.csr в base64: открытый ключ и Subject (CN и O), подписанные закрытым ключом, чтобы доказать, что запрос сделал владелец ключа (сам ivan.key в кластер не попадает)
  • signerName - кто должен подписать сертификат, каждый подписант сам определяет, какие subject, usages и срок жизни ему допустимы
    • kubernetes.io/kube-apiserver-client - клиентские сертификаты для входа в API-сервер (наш случай), автоматически никогда не одобряется
    • kubernetes.io/kube-apiserver-client-kubelet - клиентские сертификаты kubelet, такие запросы может одобрять контроллер автоматически
    • kubernetes.io/kubelet-serving - серверные сертификаты kubelet, автоматически не одобряется
    • Своё имя тоже можно придумать, но сертификат выпустит только тот, кто это имя обслуживает (например, cert-manager или собственный контроллер), иначе подписывать запрос будет некому
  • usages - для чего сертификат можно использовать (расширения Key Usage и Extended Key Usage), для входа в API нужен client auth, а для TLS-сервера был бы server auth
  • expirationSeconds - желаемый срок жизни в секундах, не меньше 600
    Подписант вправе выдать другой срок, поэтому реальный срок смотрят в самом сертификате

Поля username, groups и uid в spec API-сервер заполняет сам: там записан тот, кто создал объект CertificateSigningRequest (администратор кластера), а не ivan из Subject запроса

Запрос нужно явно одобрить - автоматического одобрения по умолчанию нет, и это осознанное решение, а не недоработка
Подписант выпускает сертификат с тем Subject, который указан в запросе, поэтому перед approve Subject проверяют: одобренный запрос с O=system:masters дал бы владельцу ключа права администратора кластера

kubectl apply -f ivan-csr.yaml
kubectl get csr ivan-csr -o jsonpath='{.spec.request}' | base64 -d | openssl req -noout -subject # что именно просят подписать (вид вывода зависит от версии openssl)
kubectl certificate approve ivan-csr
kubectl get csr ivan-csr -o jsonpath='{.status.certificate}' | base64 -d > ivan.crt
openssl x509 -in ivan.crt -noout -subject -dates # Subject и срок действия выданного сертификата

В манифесте стоит expirationSeconds: 86400 (сутки): для занятия этого достаточно, а через сутки сертификат перестанет приниматься и контекст ivan-context придётся выпускать заново

Дальше сертификат прописывается в kubeconfig как отдельный user, и из него собирается контекст

kubectl config set-credentials ivan --client-certificate=ivan.crt --client-key=ivan.key --embed-certs=true
kubectl config set-context ivan-context --cluster=minikube --namespace=app --user=ivan

Флаг --embed-certs=true кладёт сертификат и ключ внутрь самого kubeconfig, а не ссылается на файлы ivan.crt и ivan.key
Так контекст работает из любого каталога, а ещё его видят программы, которые читают ~/.kube/config, например Headlamp

Сам по себе сертификат не даёт никаких прав - он только подтверждает личность (аутентификация)
Права появляются ровно так же, как и для ServiceAccount из раздела выше - через RoleBinding, только субъектом может быть User (из CN) или Group (из O)
Выдадим права группе developers: они будут у любого, чей сертификат содержит O=developers, и для нового разработчика достаточно выпустить сертификат с этой группой, отдельный RoleBinding ему не нужен

apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: developers-read-pods
namespace: app
subjects:
- kind: Group
name: developers
apiGroup: rbac.authorization.k8s.io
roleRef:
kind: Role
name: pod-reader
apiGroup: rbac.authorization.k8s.io

Если права нужны одному человеку, субъектом указывают kind: User и name: ivan, остальной манифест остаётся тем же

kubectl --context=ivan-context auth whoami # Username ivan, Groups developers и system:authenticated (вторую группу кластер добавляет каждому сам)
kubectl --context=ivan-context get pods # сработает - есть Role pod-reader
kubectl --context=ivan-context delete deployment frontend-deployment # откажет - delete не входит в pod-reader
kubectl --context=ivan-context get secrets # откажет - secrets в pod-reader нет

То же самое видно в графическом интерфейсе
У Headlamp нет собственных прав - он работает с теми, что есть у выбранного контекста, поэтому с ivan-context покажет ровно то, что разрешает pod-reader, а действия, которые RBAC запрещает, не предложит
Одна особенность: Headlamp исходит из того, что пользователь может перечислить все namespace, а у ivan такого права нет - если интерфейс отвечает Access Denied, доступные namespace нужно указать в настройках кластера

Важная оговорка: это работает потому, что у локального кластера (minikube) есть свой CA и он сам подписывает такие запросы
На managed-кластере в облаке (см. 06-K8s-cloud-cluster) личность пользователя приходит не через самодельный сертификат, а через IAM - именно поэтому там за это отвечают роли k8s.cluster-api.*, а не CertificateSigningRequest

NetworkPolicy: кто с кем может говорить по сети​

По умолчанию в Kubernetes сеть полностью открыта - любой под может обратиться к любому другому поду в кластере, если знает его адрес
NetworkPolicy ограничивает это, но с двумя важными оговорками

  • Правило начинает действовать только для подов, которые попали под её podSelector - под, не выбранный ни одной NetworkPolicy, по-прежнему полностью открыт
  • Как только под попал под podSelector хотя бы одной policy, для него включается deny-by-default по тем направлениям (Ingress/Egress), которые перечислены в policyTypes - а по не перечисленному направлению всё остаётся разрешено
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: postgres-allow-back
namespace: app
spec:
podSelector:
matchLabels:
app: postgres
policyTypes:
- Ingress
ingress:
- from:
- podSelector:
matchLabels:
app: back
ports:
- protocol: TCP
port: 5432

Эта policy применяется к подам с меткой app: postgres и разрешает входящий трафик только от подов с меткой app: back на порт 5432
Всё остальное входящее для этих подов теперь запрещено, а исходящий трафик остаётся полностью открытым - раз Egress не указан в policyTypes, эта policy его не касается

Выбирая, что закрывать, смотрите на то, кто на самом деле ходит по сети
Фронтенд из нашего стека - SPA: nginx отдаёт браузеру статику, а в API ходит уже браузер пользователя, причём снаружи, через Ingress-контроллер, а не из пода front
Поэтому политика "бэкенд принимает только от подов фронтенда" сломала бы приложение
Настоящая связь пода с подом в нашем стеке одна - бэкенд ходит в базу, и закрывать в первую очередь стоит именно её

Отдельная ловушка - podSelector и namespaceSelector внутри одного элемента списка from работают как И (трафик должен подходить под оба условия одновременно), а два отдельных элемента списка from работают как ИЛИ (достаточно подойти под любой из них) - при усложнении правила легко случайно поменять смысл, просто сдвинув отступ

NetworkPolicy - это API, который создаёт объект, но не более того
Кто реально его исполняет - CNI-плагин кластера, и не любой это умеет
Если плагин политики не поддерживает, манифест применится без ошибки, но не будет иметь никакого эффекта
В документации minikube сказано, что CNI по умолчанию (kindnet) NetworkPolicy не исполняет, при этом сам kindnet умеет это начиная с версии v1.3.0
Какой CNI подключится на minikube start без флагов, зависит от версии minikube, драйвера и числа нод, поэтому результат на вашем кластере может отличаться от документации
Чтобы не гадать, для NetworkPolicy берут CNI с гарантированной поддержкой - проще всего поднять minikube заново с minikube start --cni calico, а не пытаться заменить CNI на уже работающем кластере
И в любом случае правило проверяют: из пода, которому доступ закрыт, пробуют подключиться до применения policy и после - если ничего не изменилось, политика не работает

Причину ищут по порядку

  • NetworkPolicy действует только на поды своего namespace, поэтому namespace в манифесте указывают явно, а где политика лежит на самом деле, показывает kubectl get netpol -A
  • CNI-плагин, который исполняет политики, должен работать (для Calico это calico-node в статусе Running)
  • podSelector должен совпасть с метками настоящего пода: kubectl get pods -l app=postgres обязан вернуть нужный под

Отличить CNI от неверных меток помогает временная policy с podSelector: {} и policyTypes: [Ingress] без правил - она закрывает весь входящий трафик в namespace, и если подключиться удаётся даже после неё, политики не исполняются

Pod Security: securityContext​

Даже с закрытыми правами в API и в сети процесс внутри контейнера по умолчанию может оказаться root'ом и иметь больше возможностей ядра, чем ему реально нужно
Контейнер - это обычный процесс на ядре узла, а не отдельная виртуальная машина, поэтому каждое лишнее право процесса увеличивает ущерб, если приложение взломали
securityContext сужает это - его можно задать и на уровне пода (действует на все контейнеры), и на уровне отдельного контейнера (переопределяет настройки пода)

apiVersion: v1
kind: Pod
metadata:
name: api
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1000
containers:
- name: api
image: conduit-api:1.4
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
  • runAsNonRoot: true - под откажется стартовать, если образ пытается запуститься от root (полезно ловить это на уровне манифеста, а не постфактум)
  • readOnlyRootFilesystem: true - файловая система контейнера доступна только для чтения, процесс не может дописать себе исполняемый файл во время работы
    Если приложению всё же нужно куда-то писать (кэш, временные файлы, загрузки) - для этого отдельно монтируют emptyDir на нужный путь
    Приложению, которое ничего не пишет на диск, том не нужен: emptyDir добавляют, когда в логе появляется Read-only file system, а не заранее
  • allowPrivilegeEscalation: false - процесс не может получить больше прав, чем были у него на старте
  • capabilities.drop: ["ALL"] - у контейнера по умолчанию есть небольшой набор Linux capabilities даже без флага privileged
    drop: ["ALL"] их убирает, а add при необходимости возвращает только то, что реально нужно (например NET_BIND_SERVICE для процесса, которому нужно слушать порт < 1024 не от root)

Поля securityContext можно разделить на четыре группы

Что ограничиваемПоляЧто даёт
Под каким пользователем работает процессrunAsUser, runAsGroup, runAsNonRootПроцесс не root, у него меньше прав на файлы и устройства; runAsNonRoot пользователя сам не меняет, он только проверяет образ и не даёт стартовать от UID 0
Привилегии процессаallowPrivilegeEscalation: false, capabilities.drop и add, privilegedfalse не даёт получить больше прав, чем были на старте, в том числе через setuid-программы вроде su; drop: ["ALL"] убирает привилегии ядра; privileged: true даёт процессу почти root-доступ к узлу, и его стараются не ставить
Файловая системаreadOnlyRootFilesystem, fsGroupНельзя подменить код и дописать себе файлы; fsGroup нужен обратному случаю - чтобы не-root процесс мог писать в смонтированный том
Ядро и политики безопасности узлаseccompProfile, appArmorProfile, seLinuxOptionsФильтр системных вызовов (seccompProfile: RuntimeDefault) и обязательные политики доступа Linux

Не все поля можно задать на обоих уровнях

Где задаётсяПоля
Только на уровне подаfsGroup, fsGroupChangePolicy, supplementalGroups, sysctls
Только на уровне контейнераallowPrivilegeEscalation, capabilities, privileged, readOnlyRootFilesystem, procMount
На обоих (значение контейнера главнее)runAsUser, runAsGroup, runAsNonRoot, seccompProfile, appArmorProfile, seLinuxOptions

У runAsNonRoot есть две ловушки, которые сразу встречаются на настоящих образах
Образ, который по умолчанию работает от root (например обычный nginx), под runAsNonRoot: true не стартует - под остаётся в CreateContainerConfigError, а describe называет причину (container has runAsNonRoot and image will run as root), и для таких приложений берут образы, рассчитанные на обычного пользователя (например nginx-unprivileged)
Если в Dockerfile пользователь задан именем (USER app), а не числом, kubelet не может проверить, что это не root, и тоже отказывает с сообщением про non-numeric user - в этом случае числовой UID указывают явно через runAsUser

Чтобы эти поля не забывали ставить, в Kubernetes есть встроенный контроль Pod Security Admission: на namespace вешают метку вида pod-security.kubernetes.io/enforce: restricted, и API-сервер отклоняет поды, которые не проходят выбранный профиль (privileged, baseline или restricted)
Профиль restricted требует, в частности, runAsNonRoot: true и allowPrivilegeEscalation: false, поэтому на таком namespace образ вроде обычного nginx без переделки не запустится

Secrets: то, что уже есть, и то, чего Secret сам по себе не даёт​

Механику Secret - создание, data/stringData, монтирование как volume или через envFrom/secretKeyRef - уже разобрали в 04-DB-deploy
Здесь важна только одна вещь с точки зрения безопасности - Secret в base64, а не в шифровании
Base64 - это кодирование, а не шифрование, и любой, у кого есть доступ на чтение Secret через RBAC (или доступ к etcd напрямую), получает исходное значение одной командой

Это не значит, что Secret бесполезен - он по-прежнему лучше переменной окружения прямо в манифесте или значения, закоммиченного в git, потому что доступ к нему уже стоит за RBAC и он не попадает в системы контроля версий вместе с остальными манифестами
Но полноценное управление секретами - ротация, аудит, выдача с ограниченным сроком жизни - это то, для чего в курсе дальше появится отдельный инструмент (тема Vault)

Примеры​

Один сквозной пример на стеке курса из 04-DB-deploy: в namespace app живут front, back и postgres, и к ним добавляется сервис pod-watcher
Он следит за подами своего namespace через API (нужно только читать список), присылает уведомление в ntfy, когда с подом что-то не так, и не должен иметь root-доступ внутри контейнера
Первый блок описывает права pod-watcher в API и безопасность его контейнера, второй закрывает базу от всех, кроме бэкенда, а код самого сервиса приведён после них

apiVersion: v1
kind: ServiceAccount
metadata:
name: pod-watcher
namespace: app
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: pod-reader
namespace: app
rules:
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: pod-watcher-read-pods
namespace: app
subjects:
- kind: ServiceAccount
name: pod-watcher
namespace: app
roleRef:
kind: Role
name: pod-reader
apiGroup: rbac.authorization.k8s.io
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: pod-watcher
namespace: app
spec:
replicas: 1
selector:
matchLabels:
app: pod-watcher
template:
metadata:
labels:
app: pod-watcher
spec:
serviceAccountName: pod-watcher
securityContext:
runAsNonRoot: true
runAsUser: 1000
containers:
- name: pod-watcher
image: conduit-pod-watcher:1.0
env:
- name: NTFY_TOPIC
valueFrom:
secretKeyRef:
name: ntfy
key: topic
- name: POD_NAMESPACE
valueFrom:
fieldRef:
fieldPath: metadata.namespace
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: postgres-allow-back
namespace: app
spec:
podSelector:
matchLabels:
app: postgres
policyTypes:
- Ingress
ingress:
- from:
- podSelector:
matchLabels:
app: back
ports:
- protocol: TCP
port: 5432

Три механизма здесь решают разные задачи и не заменяют друг друга
ServiceAccount + Role + RoleBinding ограничивают, что pod-watcher может сделать через API-сервер (только читать поды своего namespace)
NetworkPolicy ограничивает, кто может обратиться по сети к базе (только back)
securityContext ограничивает, что процесс может сделать внутри собственного контейнера, даже если оба предыдущих слоя почему-то подвели

Код pod-watcher - четыре небольших файла на Python с официальной библиотекой kubernetes, у каждого своя задача

  • logger.py - общий логгер
  • notifier.py - отправка сообщений в ntfy
  • watcher.py - слежение за подами через API
  • main.py - точка входа

Файлы лежат в одном каталоге и импортируют друг друга по имени модуля
Логгер пишет в стандартный вывод, а не в файл: в поде именно оттуда kubectl logs берёт записи

Файл logger.py:

import logging
import sys

logging.basicConfig(
level=logging.INFO,
stream=sys.stdout,
format="%(asctime)s %(levelname)s %(message)s",
)

logger = logging.getLogger(__name__)

Класс Notifier отправляет сообщения в ntfy
Тему ntfy он получает из переменной окружения NTFY_TOPIC, которую kubelet заполняет из Secret ntfy - на права ServiceAccount это не влияет
Если сообщение не ушло, сервис продолжает работать, а в лог попадает только тип ошибки: в тексте самой ошибки есть адрес, а в адресе тема
Заголовок Title пишут латиницей: кириллицу в заголовке HTTP библиотека requests отправить не может
Функция get_notifier() с lru_cache отдаёт один и тот же экземпляр всем, кто его запросил

Файл notifier.py:

import os
from functools import lru_cache

import requests

from logger import logger


class Notifier:
__NTFY_URL = os.environ.get("NTFY_URL", "https://ntfy.sh")
__NTFY_TOPIC = os.environ["NTFY_TOPIC"]

def notify(self, message: str) -> None:
try:
response = requests.post(
url=f"{self.__NTFY_URL}/{self.__NTFY_TOPIC}",
data=message.encode("utf-8"),
headers={"Title": "Pod watcher report"},
timeout=10,
)
response.raise_for_status()
logger.info("ntfy: отправлено")
except requests.RequestException as error:
logger.error(f"ntfy: не отправлено: {type(error).__name__}")


@lru_cache
def get_notifier() -> Notifier:
return Notifier()

Класс Watcher следит за подами своего namespace
config.load_incluster_config() берёт из пода токен ServiceAccount, CA кластера и адрес API-сервера, каждый запрос к API идёт с этим токеном, и RBAC проверяет его на каждом запросе
Имя namespace библиотека не отдаёт, поэтому оно приходит в переменную окружения POD_NAMESPACE через Downward API: Kubernetes сам подставляет значение из fieldRef с metadata.namespace (в манифесте это блок env), и читать файлы в поде скрипту не нужно
То же имя Kubernetes кладёт и в файл /var/run/secrets/kubernetes.io/serviceaccount/namespace рядом с токеном, но переменная проще и не зависит от пути в файловой системе

Метод run() сначала делает list, а потом через watch следит за изменениями после него
Если resourceVersion устарел, Kubernetes отвечает 410, и цикл просто начинается заново с list
Сообщение уходит один раз на каждый под: его имя запоминается в __alerted и забывается, когда под удалён
ContainerCreating и PodInitializing - обычные стадии запуска, а не поломка, поэтому по ним сообщений нет, а init-контейнеры (например, миграции) проверяются раньше основных

Файл watcher.py:

import os

from kubernetes import client, config, watch
from kubernetes.client import V1Pod

from logger import logger
from notifier import get_notifier


class Watcher:
__STARTING_STATUSES = ("ContainerCreating", "PodInitializing")
__NAMESPACE = os.environ["POD_NAMESPACE"]

def __init__(self) -> None:
self.__alerted = set()
self.__notifier = get_notifier()
config.load_incluster_config() # токен ServiceAccount, CA и адрес API
self.__v1 = client.CoreV1Api()

def __get_waiting_reason(self, pod: V1Pod) -> tuple[str, str] | None:
init_container_statuses = pod.status.init_container_statuses or []
container_statuses = pod.status.container_statuses or []
statuses = init_container_statuses + container_statuses
for status in statuses:
state = status.state.waiting
if state and state.reason not in self.__STARTING_STATUSES:
return state.reason, state.message or ""
return None

def __show(self, kind: str, pod: V1Pod) -> None:
name = pod.metadata.name
problem = self.__get_waiting_reason(pod)
logger.info("%s %s %s", kind, name, problem[0] if problem else "")
if kind == "DELETED":
self.__alerted.discard(name)
return
is_not_alerted = name not in self.__alerted
if problem and is_not_alerted and not pod.metadata.deletion_timestamp:
self.__alerted.add(name)
reason, message = problem
notify_message = f"{self.__NAMESPACE}/{name}: {reason}\n{message}"
self.__notifier.notify(notify_message)

def run(self) -> None:
first = True
while True:
pods = self.__v1.list_namespaced_pod(self.__NAMESPACE)
if first:
msg = f"Слежу за подами в {self.__NAMESPACE}"
self.__notifier.notify(msg)
first = False
try:
for event in watch.Watch().stream(
self.__v1.list_namespaced_pod,
self.__NAMESPACE,
resource_version=pods.metadata.resource_version,
timeout_seconds=300,
):
event_type = event["type"]
if event_type in ("ADDED", "MODIFIED", "DELETED"):
self.__show(event_type, event["object"])
except client.ApiException as error:
if error.status != 410:
raise

Файл main.py создаёт Watcher и запускает его:

from watcher import Watcher


def main() -> None:
Watcher().run()


if __name__ == "__main__":
main()

Сервис ntfy.sh не требует регистрации: сообщение уходит обычным POST на ntfy.sh/<тема>
Тема работает как пароль - кто её знает, тот может и писать в неё, и читать, поэтому тему берут случайной и хранят в Secret, а не в коде, и манифест с реальной темой в git не коммитят

apiVersion: v1
kind: Secret
metadata:
name: ntfy
namespace: app
stringData:
topic: "<случайная тема, например app-3f9c1a7b2e44>"

В stringData значение пишется обычным текстом, а в data Kubernetes сам хранит его в base64 - оттуда оно читается обратно одной командой, так что это кодирование, а не шифрование

В практике для скорости все четыре файла приходят в под через один ConfigMap, а main.py запускается в обычном образе python, и библиотеки ставятся при старте
Так образ собирать не нужно, но процессу нужна запись на диск, поэтому readOnlyRootFilesystem из примера выше на таком поде не заработает - в бою зависимости кладут в образ
Поэтому securityContext в практике отрабатывается на бэкенде курса

Проверь себя​

1. Через какие три шага проходит любой запрос к API-серверу?

Аутентификация (кто ты) → авторизация (что тебе можно, в Kubernetes это почти всегда RBAC) → admission control (финальные проверки и возможные изменения объекта)

2. Чем ServiceAccount отличается от обычного пользователя с точки зрения RBAC?

Это просто два разных типа субъекта в subjects у RoleBinding/ClusterRoleBinding - пользователь аутентифицируется как человек (в managed-кластере через IAM), ServiceAccount привязан к поду и живёт как объект внутри самого кластера
У пода, не указавшего свой ServiceAccount явно, будет default из своего namespace, у которого по умолчанию нет прав

3. Чем Role отличается от ClusterRole и когда обязательно нужен именно ClusterRole?

Role ограничена одним namespace, ClusterRole - нет
ClusterRole обязателен, если нужны права на ресурсы без namespace (например ноды) - в остальных случаях его тоже можно привязать RoleBinding'ом к одному namespace вместо ClusterRoleBinding

4. Есть NetworkPolicy с podSelector, выбирающим под app: postgres, и policyTypes: [Ingress]
Что произойдёт с исходящим (egress) трафиком из этого пода?

Ничего не изменится - раз Egress не указан в policyTypes, эта policy на исходящий трафик вообще не влияет, он остаётся полностью разрешённым

5. Вы применили NetworkPolicy на локальном minikube, запущенном без дополнительных флагов, и трафик, который должен был запретиться, всё ещё проходит
Почему так может быть и как это проверить?

NetworkPolicy - это объект API, а правила исполняет CNI-плагин
Если подключённый плагин политики не поддерживает, объект создастся и сохранится, но исполнять его некому - какой CNI подключился на minikube start без флагов, зависит от версии minikube, драйвера и числа нод
Проверяют правило подключением к защищённому сервису из запрещённого пода до применения policy и после: если результат не изменился, нужен CNI с поддержкой NetworkPolicy, например minikube start --cni calico
Прежде чем винить CNI, проверяют kubectl get netpol -A (политика могла попасть не в тот namespace) и метки пода под podSelector

6. Зачем в securityContext нужен capabilities.drop: ["ALL"], если контейнер и так не запущен с privileged: true?

privileged: true - это отдельный, гораздо более широкий флаг (полный доступ к устройствам хоста), а Linux capabilities - это набор более мелких прав ядра, часть которых у контейнера есть даже без privileged
drop: ["ALL"] убирает и их, оставляя только то, что явно возвращено через add

7. Secret хранит значение в base64
Значит ли это, что оно зашифровано?

Нет, base64 - это кодирование, а не шифрование, оно тривиально обратимо
Реальная защита Secret обеспечивается RBAC (кто может его прочитать) и тем, что он не должен попадать в git вместе с остальными манифестами, а не самим фактом base64

8. В сертификате пользователя указано /CN=ivan/O=developers
Как эти два поля используются при выдаче прав через RBAC?

CN становится именем пользователя - на него ссылается subjects[].name при kind: User в RoleBinding/ClusterRoleBinding
O становится именем группы - можно выдать права сразу всей группе через kind: Group, не перечисляя пользователей по одному

9. Бэкенд запускается от пользователя app (в Dockerfile USER app)
Вы добавили runAsNonRoot: true, и под остановился с ошибкой про non-numeric user
Почему и что делать?

kubelet проверяет, что процесс не root, по числовому UID, а имя app из Dockerfile сам в число не превращает, поэтому отказывает
Нужно явно задать числовой runAsUser (узнать UID можно командой id -u в работающем контейнере)

10. Вы открыли в Headlamp контекст пользователя, у которого есть только Role pod-reader
Почему интерфейс показывает поды, но не Secrets и не даёт удалить Deployment?

У Headlamp нет собственных прав - он работает с теми, что есть у выбранного контекста (сертификат или токен)
pod-reader разрешает только читать поды, поэтому остальное закрыто RBAC, а не самим интерфейсом
Если же интерфейс отвечает Access Denied ещё до списка подов, причина может быть в том, что пользователь не может перечислить namespace - тогда доступные namespace указывают в настройках кластера

11. В Role написано apiGroups: [""], resources: ["deployments"], verbs: ["get", "list"]
Почему доступа к Deployment всё равно нет и как найти правильную группу?

Deployment находится в группе apps, а в ядре ("") такого ресурса нет, поэтому правило ничего не разрешает, при этом ошибки при создании Role не будет
Группа - это часть apiVersion до слэша (apps/v1), её же показывает kubectl api-resources
Нужна запись с apiGroups: ["apps"]

12. Role разрешает на secrets только list и не разрешает get
Можно ли с такой Role прочитать значения секретов?

Можно: list возвращает объекты целиком, вместе с данными, так же как watch
Поэтому право на чтение секретов выдают, взвешивая все три verb - get, list и watch, а не только get

13. Что такое signerName и usages в CertificateSigningRequest и почему Subject проверяют перед approve?

signerName называет того, кто подписывает сертификат, и задаёт правила: какие subject, usages и срок жизни ему допустимы (для входа в API-сервер это kubernetes.io/kube-apiserver-client)
usages говорят, для чего сертификат можно использовать, для клиента API нужен client auth
Подписант переносит в сертификат Subject из запроса как есть, поэтому одобрение запроса с O=system:masters выдало бы владельцу ключа права администратора кластера

14. Бэкенд курса с readOnlyRootFilesystem: true остался здоровым без emptyDir на /tmp
Нужно ли его добавлять?

Нет, emptyDir нужен только приложению, которое пишет на диск (nginx, загрузки файлов, кэши библиотек, pip install)
Если бэкенд ничего не пишет, том не нужен, а признак обратного - строка Read-only file system в kubectl logs: тогда emptyDir монтируют именно на тот путь, куда приложение пишет

Практика​

  1. Подними отдельный профиль minikube с Calico (minikube start -p security --cni calico) и разверни в нём стек курса - front, back и postgres - в namespace app
  2. Найди в kubectl api-resources -o wide группу и допустимые verb для pods и deployments, затем опиши манифестом ServiceAccount pod-watcher, Role pod-reader (get/list/watch на pods) и RoleBinding (образец в разделе "Примеры"), примени его и проверь через kubectl auth can-i ... --as=system:serviceaccount:app:pod-watcher, что список подов разрешён, а удаление подов и чтение секретов нет
  3. Создай случайную тему на ntfy.sh, подпишись на неё в браузере и опиши манифестом Secret ntfy с этой темой в stringData (в файл манифест не сохраняй, иначе тема попадёт в git), затем прочитай её обратно через base64 -d - это кодирование, а не шифрование
  4. Запусти pod-watcher из раздела "Примеры": четыре файла положи в один ConfigMap, Deployment запусти в обычном образе python с токеном ServiceAccount
    Создай под с несуществующим образом и убедись, что в ntfy пришло ровно одно сообщение о нём, а в логе pod-watcher видны события этого пода
  5. Выпусти сертификат для пользователя ivan с группой developers через CertificateSigningRequest, перед approve проверь Subject, запиши сертификат в kubeconfig с --embed-certs=true и выдай права pod-reader манифестом RoleBinding на группу developers
    Убедись, что auth whoami показывает обе группы, get pods работает, а удаление Deployment и get secrets запрещены
  6. Открой в Headlamp контексты security и ivan-context и сравни, что видно в каждом
    Если Headlamp установлен в кластере и принимает только токен, опиши манифестом ServiceAccount с тем же RoleBinding и войди по токену из kubectl create token
    Запиши, что именно показал интерфейс там, где права закончились (например логи пода и Secrets)
  7. Закрой базу NetworkPolicy postgres-allow-back и проверь одной и той же командой на Python (socket.create_connection(('postgres-svc', 5432), 3)) из бэкенда и из постороннего пода до политики и после
    У бэкенда должно остаться OK, а у постороннего пода появиться TimeoutError - если ничего не изменилось, проверь по порядку kubectl get netpol -A (namespace политики), наличие calico-node и метки пода
  8. Добавь securityContext в манифесты Deployment курса по шагам: скопируй их в рабочий каталог, правь копии и применяй через kubectl apply
    Сначала runAsNonRoot на фронтенде (CreateContainerConfigError, затем верни исходный манифест и примени его снова), потом на бэкенде (non-numeric user, затем числовой runAsUser), затем readOnlyRootFilesystem, allowPrivilegeEscalation: false и capabilities.drop: ["ALL"], проверь, что бэкенду запись на диск не нужна, а emptyDir на /tmp подключи как приём для приложений, которым она нужна
  9. После занятия удали из kubeconfig контекст и пользователя ivan, файлы ivan.key, ivan.csr, ivan.crt, рабочий каталог с копиями манифестов и весь профиль (minikube delete -p security) - закрытый ключ не должен остаться на диске

Итог / чек-лист​

  • Любой запрос к API-серверу проходит аутентификацию, авторизацию (RBAC) и admission control
  • К API-серверу обращаются пользователи (в managed-кластере - через IAM, см. 06-K8s-cloud-cluster) и ServiceAccount (у каждого пода он есть, по умолчанию - default без прав)
  • RBAC - это пара "что можно" (Role/ClusterRole) и "кому можно" (RoleBinding/ClusterRoleBinding), где Role/RoleBinding работают в рамках одного namespace, а ClusterRole/ClusterRoleBinding - на весь кластер
  • В правиле Role ресурс указывается вместе с группой API (ядро - "", остальные - apps, networking.k8s.io и так далее), и неверная пара ничего не разрешает, при этом ошибки не будет
  • Права в правиле складываются из трёх частей: группа API, ресурс и verb (get, list, watch, create, update, patch, delete, deletecollection), причём подресурсы вроде pods/log выдаются отдельно
  • Секреты читают не только через get: list и watch тоже отдают значения, поэтому права на secrets выдают с особой осторожностью
  • Минимально необходимые права - не выдавать create/delete/доступ к secrets, если сервису это реально не нужно
  • Сервису, который сам ходит в API, достаточно ServiceAccount: Kubernetes кладёт токен в под, а библиотека (например kubernetes для Python) подхватывает его сама
  • Пользователя как ресурса в Kubernetes нет - личность на локальном кластере подтверждает клиентский сертификат (CN = имя, O = группа), выпущенный через CertificateSigningRequest, а права ему или его группе выдаёт та же связка Role/RoleBinding, что и ServiceAccount
  • В CertificateSigningRequest signerName определяет, кто и по каким правилам подписывает, usages - для чего сертификат годится (для входа в API client auth), а Subject из запроса попадает в сертификат как есть, поэтому его проверяют перед approve
  • Интерфейсы вроде Headlamp прав не добавляют: пользователь видит ровно то, что разрешает RBAC его сертификата или токена
  • NetworkPolicy без policyTypes: [Egress] не ограничивает исходящий трафик, а под без единой подходящей policy остаётся полностью открытым
  • NetworkPolicy - это только API-объект, исполняет его CNI-плагин, и не каждый плагин это умеет - поэтому правило проверяют из запрещённого пода до и после, на minikube для надёжности берут --cni calico, а при неудаче смотрят namespace политики, работу CNI и метки пода
  • securityContext на уровне пода и контейнера сужает права процесса внутри контейнера (runAsNonRoot, readOnlyRootFilesystem, allowPrivilegeEscalation: false, capabilities.drop: ["ALL"]), причём часть полей есть только на одном из уровней, а общие поля контейнер переопределяет для себя
  • emptyDir при readOnlyRootFilesystem нужен только приложению, которое пишет на диск, его добавляют по факту ошибки Read-only file system, а не заранее
  • runAsNonRoot не пускает образы, которые работают от root, и образы с пользователем, заданным именем, а не числом - во втором случае нужен числовой runAsUser
  • Secret кодирует значение в base64, а не шифрует - защита обеспечивается RBAC и тем, что секреты не попадают в git, полноценное управление секретами - тема дальше по курсу

Дополнительно​