К этому моменту у нас в кластере 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:

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

Применяется одной командой:

bash
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-образы:

bash
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, и разница между ними — частый источник граблей.

  1. Встроенная функция helm() — это, по сути, helm template: Tilt рендерит чарт в YAML локально, без обращения к кластеру. Работает офлайн и быстро, но пропускает Helm-хуки (об этом ниже в 9.5). Подходит для вашего собственного чарта, где хуков нет.
  2. Расширение helm_resource — это настоящий helm install: с хуками, с зависимостями. Рекомендуется для чужих чартов вроде Postgres/Redis/RabbitMQ.
  3. 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:

python
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 ждал готовности базы:

python
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://...:

python
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 поймает их изменения и уйдёт в бесконечную петлю перезапусков:

bash
1helm dependency update ./charts/myapp
gitignore
1# .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.

Проверить, что класс на месте:

bash
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, и не надо вычищать «протухшие» данные руками. Но если вы хотите, чтобы данные пережили пересоздание кластера, нужно смаппить хостовый каталог при создании кластера:

bash
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):

yaml
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.

python
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 для миграций и сидинга) — и есть правильный путь. Как прокинуть в сервис адреса и пароли всех этих зависимостей без хардкода — в следующей главе.

Источники

Хотите production-like зависимости в локальном кластере?
Нужна помощь с запуском Postgres, Redis или RabbitMQ внутри локального кластера Kubernetes через Helm и Tilt? Помогу настроить зависимости, постоянные тома и миграции, повторяющие прод.