
Это вторая часть серии статей, где мы шаг за шагом строим PaaS на базе Kubernetes без написания кода. Наша цель — выжать максимум из современных технологий и экосистемы Kubernetes, чтобы создать PaaS-решение, которое упростит жизнь разработчикам. Мы хотим, чтобы приложения и сервисы разворачивались быстро, удобно и без глубокого погружения в инфраструктуру. Перейдем сразу к делу.
Что уже сделано
В предыдущей части мы сделали подготовительные шаги:
Добавили Gateway: подключили Envoy Gateway для управления входящим трафиком. Это позволило легко заводить внешний трафик в кластер через Kubernetes Gateway API, без сложных настроек.
Выбрали первый оператор — CloudNativePG: развернули оператор PostgreSQL, который автоматизирует создание и управление кластерами баз данных, обеспечивая высокую доступность и простоту использования.
Добавили Cyclops UI: интегрировали веб-интерфейс Cyclops, чтобы пользователи PaaS могли удобно заводить свои приложения. Через формы в UI они задают параметры, не вникая в YAML или CLI.
Добавили Template для создания PostgreSQL: создали шаблон в Cyclops UI, который позволяет пользователям одним кликом разворачивать экземпляры PostgreSQL, используя CloudNativePG под капотом.
Эти шаги заложили фундамент для нашей PaaS. Пора идти дальше — к централизованному управлению.
Единый IAM для централизованного управления
На нашей платформе будет доступно множество разнообразных приложений, и ручное управление пользователями и доступом быстро превратится в кошмар, поэтому мы добавим единый IAM. Он решает эту проблему, позволяя централизованно управлять пользователями и их правами из одного места — будь то доступ к интерфейсу, сервисам или данным.
На данный момент существует один хорошо зарекомендовавший себя стандарт — OIDC (OpenID Connect). Он построен поверх OAuth 2.0 и широко используется для аутентификации и авторизации. Единственный его минус — многие бесплатные решения предлагают интеграции с OIDC только в платных Enterprise-версиях. Впрочем, нам это пока не помеха: мы можем выбрать инструмент, который поддерживает OIDC из коробки и вписывается в наш бескодовый подход.
Со стандартом определились, теперь перейдем к выбору конкретного решения. При выборе системы авторизации и аутентификации (IAM) для нашей PaaS-платформы я сразу подумал о Keycloak, но он оказался слишком громоздким для моих задач. Его сложная архитектура и код на Java не вписываются в мой стек, где я активно использую Go, — мне не хотелось нагружать проект этим «багажом».
Вдохновившись статьей «Выбираем IAM в 2023, или Что есть кроме Keycloak», где сравниваются IAM-решения на Go, я рассмотрел несколько вариантов. Я стал выбирать из двух:
Casdoor. Этот инструмент меня впечатлил своей простотой. Его можно быстро развернуть и настроить даже без глубоких знаний IAM. Удобный веб-интерфейс, понятная документация и множество готовых интеграций делают его идеальным для моей цели — создать PaaS без лишней разработки.
Zitadel. Хотя Zitadel выглядит стильно и современно, настройка оказалась настоящей проблемой. В документации есть примеры as is, но даже после следования им ничего не работало. Я решил, что, если продукт сразу вызывает трудности, лучше пройти мимо.
В итоге выбор пал на Casdoor. Особенно радует, что для него можно использовать PostgreSQL в качестве базы данных. Это идеально вписывается в нашу архитектуру, ведь у нас уже есть CloudNativePG, развернутый в предыдущей части. Благодаря этому оператору мы можем просто создать новый кластер PostgreSQL для Casdoor, не добавляя новых компонентов и не выходя за рамки бескодового подхода. Теперь наша схема выглядит следующим образом:

Добавление Casdoor в PaaS
Пора добавить Casdoor в нашу платформу через чарт paas-system, в котором нам надо будет реализовать следующую схему:

Сам процесс делится на два шага:
Шаг 1: Базовые компоненты
Одним запуском деплоим три компонента:
Gateway: Разворачиваем Envoy Gateway, который создаёт Deployment и LoadBalancer для Casdoor. Ждём, пока появится внешний IP-адрес.
apiVersion: gateway.networking.k8s.io/v1 kind: Gateway metadata: name: "gateway" spec: gatewayClassName: "eg" listeners: - name: web protocol: HTTP port: "8000"
HTTPRoute и Service. Настраиваем маршрутизацию — HTTPRoute направляет трафик на Service, а Service привязан к подам Casdoor по селектору
apiVersion: v1 kind: Service metadata: name: "iam-service" spec: ports: - name: http port: 8000 targetPort: 8000 selector: labels: paas-system/app: iam --- apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute metadata: name: "casdoor-route" spec: parentRefs: - name: "gateway" # Имя gateway rules: - backendRefs: - group: "" kind: Service name: "iam-service" port: 8000 weight: 1 matches: - path: type: PathPrefix value: /
Postgres Cluster. Используем CloudNativePG для создания кластера PostgreSQL (master и sync replica) с Secret’ом для пароля. CloudNativePG автоматически читает секрет и создает пользователя.
apiVersion: v1 kind: Secret metadata: name: "iam-db" type: kubernetes.io/basic-auth data: username: {{ "admin" | b64enc }} password: {{ "admin" | b64enc }} --- apiVersion: postgresql.cnpg.io/v1 kind: Cluster metadata: name: "iam" spec: instances: 2 bootstrap: initdb: database: "iam" owner: "admin" secret: name: "iam-db" storage: size: "1Gi" storageClass: "csi-ceph-ssd-me1" # Эток SC для vkcloud зоны ME1
Для запуска выполняем команду для helmwave и потом ждем, пока у нас появится IP-адрес.
helmwave up -t system --build --kubedog kubectl get gateway/gateway -n paas-system -o=jsonpath='{.status.addresses[0].value}' -w
Шаг 2: Разворачиваем Casdoor:
После получения IP-адреса LoadBalancer’а обновляем .Values.iam.address в локальном файле values.yaml (он под .gitignore). Добавляем в чарт Secret с конфигурацией Casdoor (OIDC, подключение к базе) и Deployment с селектором paas-system/service: iam. Повторно запускаем деплой — Casdoor запускается и подключается к базе.
apiVersion: v1 kind: Secret metadata: name: iam-config type: Opaque stringData: app.conf: | appname = casdoor httpport = 8000 runmode = dev SessionOn = true copyrequestbody = true driverName = postgres dataSourceName = user=admin password=admin host=iam-rw port=5432 sslmode=disable dbname=iam dbName = ian tableNamePrefix = iam showSql = false redisEndpoint = "" defaultStorageProvider = "" isCloudIntranet = false authState = "casdoor" socks5Proxy = "" verificationCodeTimeout = 10 initScore = 2000 logPostOnly = true origin = "http://<ip>:8000" staticBaseUrl = "http://<ip>:8000" enableGzip = true inactiveTimeoutMinutes = "" --- apiVersion: apps/v1 kind: Deployment metadata: name: iam-casdoor labels: paas-system/app: iam spec: replicas: 1 selector: matchLabels: paas-system/app: iam template: metadata: labels: paas-system/app: iam spec: containers: - name: casdoor-container image: casbin/casdoor:latest imagePullPolicy: Always ports: - containerPort: 8000 volumeMounts: - mountPath: /conf/ name: conf env: - name: RUNNING_IN_DOCKER value: "true" volumes: - name: conf secret: secretName: iam-config
Шаг 3: Проверяем доступ
Заходим по IP-адресу LoadBalancer’а с портом (например, http://<IP>:<port>), попадаем на страницу логина Casdoor. Вводим пароль по умолчанию (обычно admin/123, как указано в документации Casdoor), и после входа оказываемся на главной странице со статистикой и меню управления пользователями и ролями.
Страница логина Casdoor Главная страница Casdoor
Теперь, когда Casdoor работает, мы можем использовать его как OIDC-провайдера для защиты нашего интерфейса. На данный момент Cyclops UI закрыт через basic auth, но с Casdoor мы перейдем на более удобный и безопасный вариант с помощью oauth2-proxy.
Настройка Casdoor
Для начала настраиваем Casdoor. В нем есть понятие организаций, и по умолчанию уже существует организация Built-in, которая используется самим Casdoor для внутренних нужд. Мы же создадим новую организацию под названием PaaS, куда будем добавлять всех пользователей нашей платформы. Из-за особенности Casdoor — невозможности делить пользователей между организациями — мы не будем создавать несколько организаций, а вместо этого используем группы для управления доступом. Это удобно, так как oauth2-proxy поддерживает проверку групп.
В организации PaaS создаем:
группу ops для администраторов PaaS, которые будут иметь полный доступ к платформе;
отдельную группу для каждого тенанта, чтобы управлять доступом пользователей к их приложениям и ресурсам.
Кроме того, для каждого тенанта создаем отдельное приложение в Casdoor с уникальными client_id и client_secret. Это позволяет изолировать доступ и настройки аутентификации для каждого тенанта, обеспечивая дополнительную безопасность и гибкость. Все пользователи добавляются в организацию PaaS, а их роли и права определяются через группы и приложения.
Расписывать процесс добавления пользователей, групп и приложений здесь не будем: интерфейс Casdoor интуитивно понятный, все делается через веб-формы и запутаться сложно.

Защита Cyclops UI с помощью oauth2-proxy
Теперь, когда Casdoor настроен, мы заменяем basic auth в Cyclops UI на аутентификацию через OIDC с помощью oauth2-proxy. Все изменения вносим в наш чарт paas-tenant, работая в namespace paas-tenant-1.
Добавляем ConfigMap. Создаем ConfigMap в namespace paas-tenant-1 с конфигурацией для oauth2-proxy. В нем указываем параметры OIDC: client_id и client_secret приложения из Casdoor (для организации PaaS), URL провайдера (IP-адрес Casdoor), настройки для проверки групп (например, ops или группы тенанта). Также в ConfigMap задаем проксирование запросов к Cyclops UI, указав его адрес и порт. Все прописывается в манифесте чарта.
apiVersion: v1
kind: ConfigMap
metadata:
name: oauth2-proxy-config
data:
oauth2-proxy.cfg: |
provider = "oidc"
provider_display_name = "PaaS"
# Группы должны быть с префиксом организации.
# Первая группа это админы paas, вторая это пользователи tenant
allowed_groups = ["paas/ops", "paas/tenant-1"]
# Публичный address IAM
oidc_issuer_url = "http://<ip>:<iam public port>"
# Параметры приложения
client_id = "<app client id>"
client_secret = "<app client secret>"
# Публичный адрес нашего oauth2-proxy
redirect_url = "http://<ip>:8000/oauth2/callback"
email_domains = "*"
cookie_secret = "your-32-char-secret-key1"
cookie_secure = false # Это позволяет пока отказаться от HTTPs
# Куда будут запроксированы запросы.
upstreams = ["http://cyclops-ui:3000"]
http_address = "0.0.0.0:3000"
scope = "openid profile email"
Добавляем Deployment. Деплоим oauth2-proxy через новый Deployment в namespace paas-tenant-1. Deployment подхватывает ConfigMap с настройками, включая проксирование к Cyclops UI.
apiVersion: apps/v1 kind: Deployment metadata: name: oauth2-proxy labels: app: oauth2-proxy spec: replicas: 1 selector: matchLabels: app: oauth2-proxy template: metadata: labels: app: oauth2-proxy spec: containers: - name: oauth2-proxy image: quay.io/oauth2-proxy/oauth2-proxy:latest args: - --config=/etc/oauth2-proxy/oauth2-proxy.cfg ports: - containerPort: 3000 name: http volumeMounts: - name: config mountPath: /etc/oauth2-proxy volumes: - name: config configMap: name: oauth2-proxy-config
Обновляем Service. В существующем Service (в namespace paas-tenant-1), который направляет трафик на Cyclops UI, меняем селектор, чтобы он указывал на поды oauth2-proxy. Новый селектор, например app: oauth2-proxy, обеспечивает маршрутизацию через oauth2-proxy. Gateway и HTTPRoute остаются без изменений — трафик идет через Envoy Gateway.
apiVersion: v1 kind: Service metadata: name: oauth2-proxy spec: selector: app: oauth2-proxy ports: - name: http port: 3000 targetPort: 3000 protocol: TCP type: ClusterIP
Теперь oauth2-proxy в paas-tenant-1 проверяет аутентификацию через Casdoor и перенаправляет запросы к Cyclops UI, если пользователь авторизован. Пользователь видит страницу логина Casdoor, а после входа попадает в интерфейс Cyclops UI.

Добавление общего ресурса — Grafana
Теперь, когда Cyclops UI защищен через oauth2-proxy и Casdoor, мы добавляем первый общий ресурс для нашей PaaS — Grafana. Это инструмент для визуализации метрик, доступный всем пользователям платформы. Устанавливаем его в namespace paas-system, так как это общий ресурс. Для хранения данных Grafana используем PostgreSQL, благо у нас уже есть CloudNativePG. Авторизацию настроим через Casdoor.
Установка Grafana Operator: Через Helmwave деплоим Grafana Operator в namespace paas-operators.
- name: "grafana"
chart:
name: "oci://ghcr.io/grafana/helm-charts/grafana-operator"
# Версию надо явно указывать, в противном случае не ставится
version: "v5.17.0"
<<: *options-operators
tags: [grafana]
Создаем PostgreSQL для Grafana. Используем CloudNativePG для создания нового кластера PostgreSQL в paas-system. Через Helmwave добавляем манифест с Secret для пароля и конфигурацией кластера. CloudNativePG сам создает пользователя и базу, как мы делали для Casdoor.
apiVersion: v1
kind: Secret
metadata:
name: "grafana-secret"
type: kubernetes.io/basic-auth
data:
username: {{ "grafna" | b64enc }}
password: {{ "password"| b64enc }}
---
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: "grafana-pg"
spec:
instances: 1
bootstrap:
initdb:
database: "grafana"
owner: "grafana"
secret:
name: "grafana-secret"
storage:
size: "1Gi"
storageClass: "csi-ceph-ssd-me1"
Разворачиваем Grafana. В чарте через Helmwave добавляем Custom Resource (CR) для Grafana Operator в paas-system. В CR задаем подключение к PostgreSQL (адрес и credentials из CloudNativePG) и параметры OIDC для авторизации через Casdoor: client_id, client_secret и URL провайдера из приложения Casdoor (для организации paas). Также, чтобы появился доступ извне, нужно добавить HTTPRoute (пока будем использовать тот же Gateway, что и для IAM), а оператор сам создаст сервис. Источники данных вроде Prometheus пока не подключаем — добавим их позже.
# Обновим графана secret
apiVersion: v1
kind: Secret
metadata:
name: "grafana-secret"
type: kubernetes.io/basic-auth
data:
...
admin_password: {{ "admin" | b64enc }}
client_id: {{ "<client id>" | b64enc }}
client_secret: {{ "<client secret>"| b64enc }}
---
apiVersion: grafana.integreatly.org/v1beta1
kind: Grafana
metadata:
name: paas
labels:
dashboards: "paas"
spec:
deployment:
spec:
replicas: 1
template:
spec:
containers:
- name: grafana
env:
- name: GF_DATABASE_USER
valueFrom:
secretKeyRef:
name: "grafana-secret"
key: username
- name: GF_DATABASE_PASSWORD
valueFrom:
secretKeyRef:
name: "grafana-secret"
key: password
- name: GF_SECURITY_ADMIN_PASSWORD
valueFrom:
secretKeyRef:
name: "grafana-secret"
key: admin_password
- name: GF_AUTH_GENERIC_OAUTH_CLIENT_ID
valueFrom:
secretKeyRef:
name: "grafana-secret"
key: client_id
- name: GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: "grafana-secret"
key: client_secret
config:
database:
type: postgres
host: grafana-pg-rw
name: grafana
security:
admin_user: admin
server:
root_url: "http://<public gateway ip>:<iam public port>/grafana"
serve_from_sub_path: "true"
http_port: "3000"
auth:
disable_login_form: "true"
auth.basic:
enabled: "true"
auth.generic_oauth:
enabled: "true"
auth_url: "http://<iam public ip>:<iam public port>/login/oauth/authorize"
api_url: "http://<iam service name>:<iam internal port>/api/userinfo"
token_url: "http://<iam service name>:<iam internal port>/api/login/oauth/access_token"
signout_redirect_url: "http://<iam public ip>:<iam public port>/api/logout"
scopes: "openid profile email groups offline_access"
use_pkce: "true"
use_refresh_token: "true"
role_attribute_path: contains(groups[*], 'paas/ops') && 'GrafanaAdmin' || 'Viewer'
--
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: "grafana-route"
spec:
parentRefs:
- name: "iam-gateway"
rules:
- backendRefs:
- group: ""
kind: Service
name: "paas-service" # сервис с таким именем добавляет оператор
port: "3000"
weight: 1
matches:
- path:
type: PathPrefix
value: /grafana
Интеграция с Casdoor. В CR для Grafana включаем oauth2, указав настройки Casdoor. Пользователи перенаправляются на страницу логина Casdoor, а доступ определяется их группами в организации paas (например, ops или группы тенанта).
После деплоя Grafana доступна через Envoy Gateway (по существующему или новому HTTPRoute). Пользователи логинятся через Casdoor и получают доступ к дашбордам для просмотра метрик платформы.

Подключение PostgreSQL как источников данных в Grafana
С Grafana в namespace paas-system и PostgreSQL для Casdoor и самой Grafana, настроенных через CloudNativePG, мы можем использовать эти базы как источники данных для визуализации метрик. Для этого добавим их в Grafana как Custom Resources (CR) GrafanaDatasource.
Добавление GrafanaDatasource для PostgreSQL. Через Helmwave в чарте paas-tenant создаем два CR GrafanaDatasource в namespace paas-system:
Для базы Casdoor: указываем адрес PostgreSQL кластера Casdoor (из CloudNativePG), имя базы, пользователя и пароль (из Secret’а, созданного для Casdoor). Тип источника данных — PostgreSQL.
Для базы Grafana: аналогично указываем параметры PostgreSQL кластера, используемого самой Grafana (адрес, имя базы, пользователь, пароль из соответствующего Secret’а).
apiVersion: grafana.integreatly.org/v1beta1
kind: GrafanaDatasource
metadata:
name: grafana-postgres
spec:
valuesFrom:
- targetPath: "secureJsonData.password"
valueFrom:
secretKeyRef:
name: "grafana-secret"
key: "password"
instanceSelector:
matchLabels:
dashboards: "paas"
datasource:
name: grafana-postgres
type: postgres
url: "grafana-pg-r:5432"
user: grafana
access: proxy
jsonData:
database: "grafana"
sslmode: 'disable'
maxOpenConns: 10
maxIdleConns: 10
maxIdleConnsAuto: true
connMaxLifetime: 14400
postgresVersion: 1500
timescaledb: false
secureJsonData:
password: "${password}"
---
apiVersion: grafana.integreatly.org/v1beta1
kind: GrafanaDatasource
metadata:
name: iam-postgres
spec:
valuesFrom:
- targetPath: "secureJsonData.password"
valueFrom:
secretKeyRef:
name: "iam-db"
key: "password"
instanceSelector:
matchLabels:
dashboards: "paas"
datasource:
name: iam-postgres
type: postgres
url: "iam-r:5432"
user: "admin"
access: proxy
jsonData:
database: "iam"
sslmode: 'disable'
maxOpenConns: 10
maxIdleConns: 10
maxIdleConnsAuto: true
connMaxLifetime: 14400
postgresVersion: 1500
timescaledb: false
secureJsonData:
password: "${password}"
Автоматическое появление в Grafana. После деплоя CR через Helmwave Grafana Operator подхватывает эти GrafanaDatasource и автоматически добавляет источники данных в Grafana. Они появляются в интерфейсе Grafana, готовые для создания дашбордов.

Теперь пользователи, авторизованные через Casdoor, могут заходить в Grafana и использовать эти источники данных для визуализации метрик, связанных с Casdoor (например, статистика логинов) или самой Grafana (например, внутренние метрики).
Как обычно, весь код можно найти в репозитории.
В следующей части наш PaaS начнет обрастать глазами и ушами, а также мы сделаем несколько улучшений в самих чартах.