Безопасность в 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
Самые частые ресурсы
resources | apiGroups | Где живёт |
|---|---|---|
pods | "" | namespace |
pods/log, pods/exec | "" | namespace (подресурсы пода) |
services | "" | namespace |
configmaps | "" | namespace |
secrets | "" | namespace |
persistentvolumeclaims | "" | namespace |
serviceaccounts | "" | namespace |
events | "" | namespace |
namespaces | "" | весь кластер |
nodes | "" | весь кластер |
persistentvolumes | "" | весь кластер |
deployments, statefulsets, daemonsets | apps | namespace |
jobs, cronjobs | batch | namespace |
ingresses, networkpolicies | networking.k8s.io | namespace |
roles, rolebindings | rbac.authorization.k8s.io | namespace |
clusterroles, clusterrolebindings | rbac.authorization.k8s.io | весь кластер |
certificatesigningrequests | certificates.k8s.io | весь кластер |
storageclasses | storage.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-метода и от того, к чему обращаются - к одному объекту или ко всей коллекции
verbs | HTTP-запрос | Что делает | Пример |
|---|---|---|---|
get | GET одного объекта | прочитать один объект по имени | kubectl get pod <имя> |
list | GET коллекции | получить список объектов | kubectl get pods |
watch | GET с ?watch=true | следить за изменениями | kubectl get pods -w |
create | POST | создать объект | kubectl apply нового объекта |
update | PUT | заменить объект целиком | kubectl replace |
patch | PATCH | изменить часть объекта | kubectl patch |
delete | DELETE одного объекта | удалить объект по имени | kubectl delete pod <имя> |
deletecollection | DELETE коллекции | удалить всю коллекцию одним запросом | запросы клиентов и контроллеров |
Для 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, а не только черезgetcreateнаpodsпозволяет запустить под от имени любого ServiceAccount этого namespace и смонтировать в него любой его Secretpods/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 authexpirationSeconds- желаемый срок жизни в секундах, не меньше 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, privileged | false не даёт получить больше прав, чем были на старте, в том числе через 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- отправка сообщений в ntfywatcher.py- слежение за подами через APImain.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 монтируют именно на тот путь, куда приложение пишет
Практика
- Подними отдельный профиль minikube с Calico (
minikube start -p security --cni calico) и разверни в нём стек курса -front,backиpostgres- в namespaceapp - Найди в
kubectl api-resources -o wideгруппу и допустимые verb дляpodsиdeployments, затем опиши манифестом ServiceAccountpod-watcher, Rolepod-reader(get/list/watchнаpods) и RoleBinding (образец в разделе "Примеры"), примени его и проверь черезkubectl auth can-i ... --as=system:serviceaccount:app:pod-watcher, что список подов разрешён, а удаление подов и чтение секретов нет - Создай случайную тему на ntfy.sh, подпишись на неё в браузере и опиши манифестом Secret
ntfyс этой темой вstringData(в файл манифест не сохраняй, иначе тема попадёт в git), затем прочитай её обратно черезbase64 -d- это кодирование, а не шифрование - Запусти
pod-watcherиз раздела "Примеры": четыре файла положи в один ConfigMap, Deployment запусти в обычном образеpythonс токеном ServiceAccount
Создай под с несуществующим образом и убедись, что в ntfy пришло ровно одно сообщение о нём, а в логеpod-watcherвидны события этого пода - Выпусти сертификат для пользователя
ivanс группойdevelopersчерезCertificateSigningRequest, передapproveпроверь Subject, запиши сертификат в kubeconfig с--embed-certs=trueи выдай праваpod-readerманифестом RoleBinding на группуdevelopers
Убедись, чтоauth whoamiпоказывает обе группы,get podsработает, а удаление Deployment иget secretsзапрещены - Открой в Headlamp контексты
securityиivan-contextи сравни, что видно в каждом
Если Headlamp установлен в кластере и принимает только токен, опиши манифестом ServiceAccount с тем же RoleBinding и войди по токену изkubectl create token
Запиши, что именно показал интерфейс там, где права закончились (например логи пода и Secrets) - Закрой базу NetworkPolicy
postgres-allow-backи проверь одной и той же командой на Python (socket.create_connection(('postgres-svc', 5432), 3)) из бэкенда и из постороннего пода до политики и после
У бэкенда должно остатьсяOK, а у постороннего пода появитьсяTimeoutError- если ничего не изменилось, проверь по порядкуkubectl get netpol -A(namespace политики), наличиеcalico-nodeи метки пода - Добавь
securityContextв манифесты Deployment курса по шагам: скопируй их в рабочий каталог, правь копии и применяй черезkubectl apply
СначалаrunAsNonRootна фронтенде (CreateContainerConfigError, затем верни исходный манифест и примени его снова), потом на бэкенде (non-numeric user, затем числовойrunAsUser), затемreadOnlyRootFilesystem,allowPrivilegeEscalation: falseиcapabilities.drop: ["ALL"], проверь, что бэкенду запись на диск не нужна, аemptyDirна/tmpподключи как приём для приложений, которым она нужна - После занятия удали из 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 - В
CertificateSigningRequestsignerNameопределяет, кто и по каким правилам подписывает,usages- для чего сертификат годится (для входа в APIclient 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, и образы с пользователем, зад анным именем, а не числом - во втором случае нужен числовойrunAsUserSecretкодирует значение в base64, а не шифрует - защита обеспечивается RBAC и тем, что секреты не попадают в git, полноценное управление секретами - тема дальше по курсу
Дополнительно
- Kubernetes Docs: Controlling Access to the Kubernetes API
- Kubernetes Docs: Using RBAC Authorization
- Kubernetes Docs: Issue a Certificate for a Kubernetes API Client Using a CertificateSigningRequest
- Kubernetes Docs: Certificates and Certificate Signing Requests
- Kubernetes Docs: Network Policies
- Kubernetes Docs: Configure a Security Context for a Pod or Container
- Kubernetes Docs: Pod Security Admission
- Kubernetes Docs: Pod Security Standards
- Minikube Docs: Network Policy
- Minikube Issues: Kindnet supports network policies since version v1.3.0
- Headlamp Docs: Installation
- Kubernetes Python Client
- ntfy Docs: Publishing