Local Kubernetes Dev — Part 9: Dependencies — databases, queues, caches
Как поднять PostgreSQL, Redis и RabbitMQ внутри локального кластера — через Helm, Tilt, постоянные тома и миграции — чтобы локалка осталась структурно той же, что и прод.
К этому моменту у нас в кластере dev крутится сам myapp — наш HTTP API на FastAPI, порт 8080, перезапускаемый через Tilt при каждом сохранении файла (как это устроено — см. главу 8). Но один сервис в вакууме мало что значит: myapp зависит от PostgreSQL, а в реальном проекте к этому почти всегда добавляются кэш (Redis) и брокер сообщений (RabbitMQ). В этой главе разберёмся, как поднять эти зависимости так, чтобы локалка оставалась похожей на прод, а не превращалась в зоопарк из разных способов запуска.
Зависимости как часть кластера, а не docker-compose сбоку
Самый частый соблазн у новичка — поднять приложение в Kubernetes, а базу с Redis запустить рядом обычным docker compose up. Работает? Да. Но вы тут же получаете два параллельных описания инфраструктуры: один мир живёт в compose.yaml, другой — в Helm-чартах и манифестах k8s. Почему compose структурно не равен k8s-манифестам и что именно теряется при переезде — подробно разобрано в главе 2; здесь нам важно лишь следствие для зависимостей. В проде будет только второй мир, а значит, всё, что вы отладили в compose для базы и брокера (адреса сервисов, переменные окружения, порядок запуска), придётся переписывать заново под Kubernetes — и именно там ошибиться.
Идея этой статьи — паритет окружений: локалка должна быть структурно той же, что и прод. Это прямое продолжение принципа из методологии Twelve-Factor App (#10 Dev/prod parity). Если поднять Postgres/Redis/RabbitMQ внутри того же кластера и теми же чартами, что поедут в стейджинг, вы бесплатно получаете единый DNS и service discovery (приложение находит базу по имени сервиса postgres, а не по localhost:5432), общие Secret и ConfigMap, и заодно ловите проблемы манифестов — RBAC, resource limits, readiness/liveness-пробы, network policy — ещё до выката, а не на проде в пятницу вечером.
Будем честны: у compose тоже есть весомые плюсы. Порог входа ниже, итерация быстрее, не нужно знать Helm и k8s, а воспроизводимость достигается одной командой. Значительная часть индустрии вполне осознанно держит compose для локальной разработки, оставляя Kubernetes только для стейджинга и прода. Это нормальный, рабочий выбор.
Эвристика простая. Если ваш сервис поедет в Kubernetes — а вся эта статья именно про подготовку myapp к такому деплою — то поднять зависимости внутри кластера оправдано ради паритета: вы один раз разбираетесь, как оно устроено, и потом не переучиваетесь. Если же в k8s проект не пойдёт, compose рациональнее и спорить тут не о чем.
Установка инфраструктуры через Helm (Postgres, Redis, RabbitMQ)
Stateful-зависимости (те, что хранят данные) почти никогда не пишут руками — берут готовый Helm-чарт. Helm — это пакетный менеджер для Kubernetes: чарт упаковывает набор манифестов с параметрами, которые вы переопределяете под себя. Один helm install — и Postgres со всеми его Service, StatefulSet, Secret и PVC уже в кластере.
Предупреждение про Bitnami, без которого глава была бы вредной
Исторически дефолтом были чарты и образы Bitnami (bitnami/postgresql, bitnami/redis, bitnami/rabbitmq) — их советовали везде. Но в 2025 году правила изменились: 28 августа 2025 начались brownout'ы публичных образов, а с 29 сентября 2025 большинство публичных Bitnami OCI-чартов и образов ушли за коммерческую подписку Broadcom (Bitnami Secure Images). Публично остался лишь ограниченный набор и только с тегом -latest; всё перемещённое лежит в репозитории bitnamilegacy как неподдерживаемый legacy — без security-патчей. То есть советовать bitnami/* вслепую в 2026-м уже нельзя (гайд по миграции с Bitnami, Chainguard).
Что делать на практике:
- Chainguard выпустил 40+ Helm-чартов как drop-in замену Bitnami (включая PostgreSQL, Redis, RabbitMQ и RabbitMQ Cluster Operator) с сохранением привычной конфигурации — это самый прямой путь миграции.
- Можно взять официальные чарты вендоров (например, оператор от RabbitMQ-команды).
- Можно осознанно закрепиться на конкретном legacy-теге Bitnami — но только понимая, что патчей безопасности там больше не будет. Для локалки это меньшее зло, чем для прода, но привычку лучше не закреплять.
Пароли никогда не вшиваем в чарт-значения, попадающие в git. Чарты создают Secret для учётных данных автоматически, а если задаёте пароль сами — делайте это через --set локально или через отдельный values-файл вне репозитория. Подробно про секреты — в главе 10.
Самый надёжный для dev способ поднять PostgreSQL — это даже не Helm, а «сырой» манифест из трёх объектов: Deployment (один под с официальным образом postgres), Service (стабильное DNS-имя postgres внутри namespace) и PersistentVolumeClaim (диск под данные). Ничего не нужно искать в репозиториях чартов, нет зависимости от подписок и legacy-образов — оно просто работает на любом кластере. Сохраните это в postgres.yaml:
1# postgres.yaml — минимальный PostgreSQL для dev (Deployment + Service + PVC)
2apiVersion: v1
3kind: PersistentVolumeClaim
4metadata:
5 name: postgres-data
6 namespace: myapp
7spec:
8 accessModes: ["ReadWriteOnce"]
9 resources:
10 requests:
11 storage: 1Gi
12---
13apiVersion: apps/v1
14kind: Deployment
15metadata:
16 name: postgres
17 namespace: myapp
18spec:
19 replicas: 1
20 selector:
21 matchLabels: { app: postgres }
22 template:
23 metadata:
24 labels: { app: postgres }
25 spec:
26 containers:
27 - name: postgres
28 image: postgres:17 # официальный образ с Docker Hub
29 ports:
30 - containerPort: 5432
31 env:
32 - name: POSTGRES_DB
33 value: myapp
34 - name: POSTGRES_USER
35 value: myapp
36 - name: POSTGRES_PASSWORD
37 value: devpass # для dev; в проде — из Secret (см. главу 10)
38 - name: PGDATA
39 value: /var/lib/postgresql/data/pgdata
40 volumeMounts:
41 - name: data
42 mountPath: /var/lib/postgresql/data
43 readinessProbe:
44 exec:
45 command: ["pg_isready", "-U", "myapp", "-d", "myapp"]
46 initialDelaySeconds: 5
47 periodSeconds: 5
48 volumes:
49 - name: data
50 persistentVolumeClaim:
51 claimName: postgres-data
52---
53apiVersion: v1
54kind: Service
55metadata:
56 name: postgres
57 namespace: myapp
58spec:
59 selector: { app: postgres }
60 ports:
61 - port: 5432
62 targetPort: 5432Применяется одной командой:
1kubectl apply -n myapp -f postgres.yamlПосле этого myapp находит базу по DNS-имени postgres внутри своего namespace (строка подключения вида postgresql://myapp:devpass@postgres:5432/myapp), а данные переживают рестарт пода благодаря PVC (про то, как PVC ведёт себя при пересоздании кластера, — в 9.4). Это базовый путь, который мы и будем использовать дальше в главе.
Альтернатива — Helm-чарт. Если хочется не плодить YAML, а взять готовый пакет с настройками репликации, метриками и бэкапами, ставят PostgreSQL чартом. Учитывая Bitnami brownout (см. выше), команда выглядит так — с явной оговоркой про legacy-образы:
1# ВНИМАНИЕ: с 29.09.2025 публичные bitnami-образы ушли за подписку.
2# Публичный OCI-чарт ещё доступен, но образ по умолчанию надо переключить
3# на legacy-репозиторий (без security-патчей) — для dev это приемлемо.
4helm install postgres \
5 oci://registry-1.docker.io/bitnamicharts/postgresql \
6 --namespace myapp --create-namespace \
7 --set image.repository=bitnamilegacy/postgresql \
8 --set global.security.allowInsecureImages=true \
9 --set auth.username=myapp \
10 --set auth.password=devpass \
11 --set auth.database=myappДля прода лучше уйти на поддерживаемый источник — drop-in чарты Chainguard или оператор CloudNativePG (он же удобен, когда нужны автоматический failover и бэкапы). Для локалки же «сырой» манифест выше остаётся самым простым и предсказуемым выбором.
В реальной работе мы не будем гонять kubectl apply или helm install руками — отдадим это Tilt, чтобы зависимости поднимались и сносились вместе с остальным проектом.
Подключение Helm-чартов к Tilt (helm() в Tiltfile)
У Tilt есть три способа работать с Helm, и разница между ними — частый источник граблей.
- Встроенная функция
helm()— это, по сути,helm template: Tilt рендерит чарт в YAML локально, без обращения к кластеру. Работает офлайн и быстро, но пропускает Helm-хуки (об этом ниже в 9.5). Подходит для вашего собственного чарта, где хуков нет. - Расширение
helm_resource— это настоящийhelm install: с хуками, с зависимостями. Рекомендуется для чужих чартов вроде Postgres/Redis/RabbitMQ. helm_remote— скачивает удалённый чарт и снова прогоняет черезhelm(), то есть тоже без хуков.
Сигнатура встроенной функции — helm(pathToChartDir, name='', namespace='', values=[], set=[], kube_version='', skip_crds=False); она возвращает Blob с YAML, который скармливают в k8s_yaml() (Installing YAML with Helm, Tilt docs; Tiltfile API Reference). Так мы рендерим свой чарт myapp:
1# Tiltfile — наш собственный чарт через helm() (хуков в нём нет)
2k8s_yaml(helm(
3 './charts/myapp',
4 name='myapp',
5 namespace='myapp',
6 values=['./dev-values.yaml'],
7 set=['replicaCount=1'],
8))Для нашего PostgreSQL из раздела выше проще всего скормить тот же postgres.yaml в k8s_yaml() — Tilt сам поднимет и снесёт его вместе с проектом. А порядок старта зададим через resource_deps, чтобы myapp ждал готовности базы:
1# Tiltfile — наша БД из сырого манифеста, заведённая в Tilt
2k8s_yaml('postgres.yaml')
3
4# port_forward, чтобы подключаться к базе из IDE или psql на localhost:5432
5k8s_resource('postgres', port_forwards=['5432:5432'])
6
7# myapp стартует только после того, как поднялся postgres
8k8s_resource('myapp', resource_deps=['postgres'])Параметр resource_deps задаёт порядок: Tilt не запустит myapp, пока под postgres не пройдёт readiness-пробу (pg_isready из манифеста). port_forwards пробрасывает порт на localhost, чтобы вы могли подключиться к базе из IDE или psql.
Если вы пошли по альтернативному пути с Helm-чартом, зависимость заводят через helm_resource из расширений Tilt. Загружаем функции, регистрируем репозиторий через helm_repo и вешаем чарт с resource_deps на этот репозиторий (helm_resource README, tilt-extensions). Для OCI-чарта Bitnami репозиторий регистрировать не нужно — chart указывают прямо как oci://...:
1# Tiltfile — альтернатива: PostgreSQL из Helm-чарта через helm_resource
2load('ext://helm_resource', 'helm_resource')
3
4helm_resource(
5 'postgres',
6 'oci://registry-1.docker.io/bitnamicharts/postgresql',
7 namespace='myapp',
8 flags=[
9 '--set=image.repository=bitnamilegacy/postgresql',
10 '--set=global.security.allowInsecureImages=true',
11 '--set=auth.username=myapp',
12 '--set=auth.password=devpass',
13 '--set=auth.database=myapp',
14 ],
15 port_forwards=['5432:5432'],
16)
17
18k8s_resource('myapp', resource_deps=['postgres'])Под капотом helm_resource делает настоящий helm install (с хуками — в отличие от встроенной helm(), см. ниже в 9.5), поэтому для чужих чартов с инициализацией он и рекомендуется. Если нужно инжектить собранные Tilt-образы в чарт (например, свой воркер), у helm_resource для этого есть параметры image_deps + image_keys. Redis и RabbitMQ заводят ровно так же — отдельным манифестом или своим чартом из поддерживаемого источника.
Один подводный камень для встроенной helm(): если ваш чарт тянет subchart-зависимости, helm dependency update нужно запускать снаружи Tilt, а каталоги charts/ и tmpcharts/ — добавить в .tiltignore, иначе Tilt поймает их изменения и уйдёт в бесконечную петлю перезапусков:
1helm dependency update ./charts/myapp1# .tiltignore
2charts/myapp/charts/
3charts/myapp/tmpcharts/PersistentVolume и данные в локальном кластере
Где физически лежат данные Postgres? В Kubernetes под просит хранилище через PersistentVolumeClaim (PVC) — заявку на диск определённого размера. Кто эту заявку удовлетворяет, определяет StorageClass. В k3d/k3s «из коробки» работает local-path-provisioner: StorageClass называется local-path, provisioner — rancher.io/local-path, режим привязки WaitForFirstConsumer (том создаётся, когда появляется под), а reclaimPolicy — Delete.
Проверить, что класс на месте:
1kubectl get storageclass
2# NAME PROVISIONER RECLAIMPOLICY VOLUMEBINDINGMODE
3# local-path (default) rancher.io/local-path Delete WaitForFirstConsumerА теперь главный сюрприз для новичка: по умолчанию данные эфемерны. local-path-provisioner пишет в /var/lib/rancher/k3s/storage, но этот путь находится внутри файловой системы контейнера ноды k3d. Удалили кластер через k3d cluster delete или пересоздали его — и база начинается с чистого листа. Часто это даже желаемо: каждый старт даёт чистый seed, и не надо вычищать «протухшие» данные руками. Но если вы хотите, чтобы данные пережили пересоздание кластера, нужно смаппить хостовый каталог при создании кластера:
1# Данные БД переживут пересоздание кластера: маппим host-каталог во все ноды
2k3d cluster create dev \
3 --volume $HOME/k3d-storage:/var/lib/rancher/k3s/storage@allСуффикс @all распространяет маппинг на все ноды. Теперь PVC будут писать в $HOME/k3d-storage на вашей машине, и k3d cluster delete данные не тронет. (У kind, альтернативного локального кластера, та же идея реализуется через extraMounts.) Стоит помнить и про node affinity: PVC от local-path привязывается к конкретной ноде по kubernetes.io/hostname, так что под с этим томом всегда будет планироваться туда же.
Миграции и сидинг БД при старте
Поднять пустой Postgres мало — myapp ждёт готовую схему. Есть три паттерна.
Helm-хуки. Чарт может содержать Job (одноразовую задачу), помеченный аннотацией "helm.sh/hook": pre-install,pre-upgrade — Helm выполнит его до установки/апгрейда релиза. Порядок нескольких хуков задаётся helm.sh/hook-weight (по возрастанию), а уборка — helm.sh/hook-delete-policy (Chart Hooks, Helm docs):
1# templates/migrate-job.yaml — миграции как Helm pre-install/pre-upgrade хук
2apiVersion: batch/v1
3kind: Job
4metadata:
5 name: myapp-migrate
6 annotations:
7 "helm.sh/hook": pre-install,pre-upgrade
8 "helm.sh/hook-weight": "-5"
9 "helm.sh/hook-delete-policy": hook-succeeded
10spec:
11 template:
12 spec:
13 restartPolicy: Never
14 containers:
15 - name: migrate
16 image: k3d-registry.localhost:5000/myapp:dev
17 command: ["alembic", "upgrade", "head"]Критичный нюанс именно для Tilt: встроенная helm() пропускает хуки, поэтому такие миграции сработают только если поднимать чарт через helm_resource (настоящий helm install). Это ещё один аргумент в пользу helm_resource для всего, где есть инициализация.
Отдельный Job + initContainer-ожидание (предпочтительный паттерн). Миграции живут в самостоятельном Job, а под приложения через initContainer ждёт, пока база и схема готовы. В Tilt порядок выстраивается через resource_deps: postgres → migrate → seed → myapp.
1# Tiltfile — цепочка инициализации
2k8s_yaml(['migrate-job.yaml', 'seed-job.yaml'])
3k8s_resource('myapp-migrate', resource_deps=['postgres'])
4k8s_resource('myapp-seed', resource_deps=['myapp-migrate'])
5k8s_resource('myapp', resource_deps=['myapp-seed', 'redis', 'rabbitmq'])Сидинг (наполнение тестовыми данными) для dev делается таким же отдельным Job после миграций — так его легко отключить в проде, просто не подключив ресурс.
Миграции прямо в initContainer приложения — антипаттерн
Соблазнительно прописать alembic upgrade head в initContainer самого myapp, но при нескольких репликах каждый под начнёт прогонять миграцию, возникнут гонки, а долгая миграция рискует быть прибита readiness/liveness-пробой, не завершившись. Для локалки с одной репликой это переживаемо, но привычку лучше сразу выработать правильную — отдельный Job.
Когда managed-сервис прода стоит мокать, а когда поднимать как есть
Не всё, что есть в проде, нужно тащить в локалку буквально. Граница проходит по тому, open-source это сервис или проприетарный managed.
OSS-сервисы, которые и так гоняются в проде — Postgres, Redis, RabbitMQ, Kafka, MinIO — поднимаем как есть, прямо в кластере. Это ровно тот же софт, что в проде, паритет достаётся почти бесплатно, и именно этим мы занимались всю главу.
Проприетарные managed-сервисы — AWS S3/SQS/DynamoDB/Lambda, GCP Pub/Sub и подобное — локально воспроизвести «как есть» нельзя, их нет в виде запускаемого образа. Тут два пути: мокать эмулятором или ходить в реальный dev-аккаунт. Для AWS популярен LocalStack — он эмулирует S3, SQS, DynamoDB, Lambda, RDS и десятки других сервисов и умеет работать в том же k8s-кластере (через свой Operator или Helm). Плюсы очевидны: быстрый inner loop, нулевая облачная стоимость, изоляция, работа офлайн, а код часто переносится в реальный AWS без изменений.
Эмулятор не 1:1 с реальным облаком
Семантика IAM, модель консистентности, лимиты и редкие edge-cases у LocalStack и настоящего AWS расходятся. Зелёный локальный прогон — не гарантия, что в облаке всё заработает.
Отсюда рабочая эвристика:
- OSS-зависимости (
myapp→ Postgres/Redis/RabbitMQ) — всегда поднимаем как есть в кластере. - Managed для быстрого цикла, офлайна и простых операций — мокаем эмулятором.
- Критичные интеграции с тонкой семантикой (IAM-политики, специфичные фичи, поведение под нагрузкой перед продом) — проверяем против реального managed dev-аккаунта, а не только против мока.
Для myapp вопрос решается просто
База — это OSS, поэтому весь арсенал из этой главы (Helm-чарт в кластере, том для данных, Job для миграций и сидинга) — и есть правильный путь. Как прокинуть в сервис адреса и пароли всех этих зависимостей без хардкода — в следующей главе.
Источники
- Twelve-Factor App — X. Dev/prod parity
- A practical guide to migrating Helm charts from Bitnami — Chainguard
- Installing YAML with Helm — Tilt docs
- Tiltfile API Reference — Tilt docs
- helm_resource — tilt-extensions README
- Chart Hooks — Helm docs
- K3s features (local-path-provisioner, volume mapping) — k3d docs
- CloudNativePG — PostgreSQL operator for Kubernetes
- Running LocalStack on Kubernetes — LocalStack blog