Netpulse_SasS/docker-compose.yml
byrsapty 08801f631c
All checks were successful
CI / hygiene (push) Successful in 9s
CI / web (push) Successful in 59s
CI / server (push) Successful in 1m13s
CI / agent (push) Successful in 2m54s
Стеля журналів, терплячіша самоперевірка, Етап 13 у плані
ЖУРНАЛИ БЕЗ СТЕЛІ. На запитання «чому не пишемо в /var/log» правильна
відповідь — «бо в контейнері це не той шар»: файл усередині зникає при
перестворенні, невидимий для docker logs і вимагає власної ротації.
Але за питанням стояла справжня вада: драйвер json-file був
налаштований порожньо, тобто НЕ КРУТИВ НІЧОГО. Проксі за добу набрав
27 МБ; до повного диска були місяці, і першим ліг би Postgres.

Тепер 10 МБ × 3 файли на службу — близько 250 МБ на інсталяцію, і
драйвер міняється однією змінною (journald, syslog) для тих, кому
потрібні справжні файли або чужий збирач.

САМОПЕРЕВІРКА ЗОНДА чекала хвилину й одного разу вже дала хибне
червоне: зонд зареєструвався, просто пізніше — перший старт припадає
на найзавантаженішу мить установки. Тепер три хвилини з повідомленням
кожні півхвилини: мовчазна пауза невідрізненна від зависання, а хибне
червоне після успішної установки коштує години пошуку неіснуючої
поломки.

GITSTORE: TestDeleteBranchLocal кличе зовнішній git і не мав перевірки
на його відсутність — падав у golang:1.25-alpine, тобто саме там, де
його проганяють. Сусідній тест таку перевірку має.

ЕТАП 13 у ROADMAP: реєстр образів, релізи, пакети. Записано, чому
порядок саме такий (пакет без реєстру ставив би «зберіть самі») і чого
треба досягти до першого тегу — оновлення з версії на версію не
перевіряв ніхто, а ламається найчастіше саме воно.
2026-08-27 21:52:27 +03:00

389 lines
22 KiB
YAML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Повний стек NetPulse: БД, кеш, міграції, API з інтерфейсом, колектор
# зондів і зворотний проксі з автоматичним TLS.
#
# Швидкий старт:
# cp deploy/.env.example .env # і заповнити секрети
# docker compose up -d
# docker compose run --rm --entrypoint netpulse-user cli \
# -tenant default -login admin -role owner
#
# netpulse-user і netpulse-secret заводять кабінети, людей і секрети —
# тобто роблять рівно те, чого роль під RLS робити не має. Тому для них
# є окрема служба cli з DSN власника: під роллю застосунку вони не
# падають з помилкою, а мовчки нічого не знаходять.
#
# Тільки стенд для розробки (БД і кеш, решта — з go run):
# docker compose up -d db cache
name: netpulse
# ---------------------------------------------------------------------
# DSN трьох ролей
# ---------------------------------------------------------------------
#
# Три ролі, три різні відповіді на «що цій ролі вільно бачити» (0063).
#
# netpulse власник схеми. Ним ходять migrate і cli.
# netpulse_app API і колектор. Без BYPASSRLS — під політиками RLS.
# netpulse_worker фонові такти поверх усіх кабінетів. З BYPASSRLS.
#
# Заповнені NETPULSE_APP_PASSWORD і NETPULSE_WORKER_PASSWORD означають
# «застосунок ходить під RLS». На ЧИСТІЙ базі їх пише установник, а
# netpulse-migrate сам видає ці паролі ролям одразу після накочування
# схеми — тобто нова інсталяція працює під політиками з першої секунди,
# і слова «cutover» клієнт не бачить взагалі.
#
# Порожні означають «як до 0063»: DSN згортається до ролі власника,
# DSN воркера лишається порожнім, store.UseWorkerDSN другого пулу не
# відкриває. Саме на цьому тримається безпека оновлення наявного стенду:
# у його .env цих рядків немає, вирази нижче дають той самий DSN, що й
# раніше, а мігратор на непорожній базі паролів ролям не видає взагалі.
# Перехід для таких інсталяцій — deploy/RLS-EXISTING-INSTALL.md.
#
# Ім'я ролі більше НЕ окрема змінна, і це виправлення, а не спрощення.
# Раніше їх було дві — NETPULSE_APP_USER і NETPULSE_APP_PASSWORD, — і
# будь-яка половина без другої давала DSN, який не встановлюється: нова
# роль зі старим паролем або стара роль з новим. Виглядає це не як
# помилка конфігурації, а як «RLS усе зламав». Тепер прапорець один: є
# пароль — є й роль netpulse_app, немає пароля — немає й ролі.
#
# Вираз читається так. Перша частина додає «netpulse_app:» лише тоді,
# коли пароль заданий. Друга віддає або сам пароль (коли заданий), або
# цілу пару «netpulse:<пароль власника>» (коли ні). Обидві частини
# дивляться на ОДНУ змінну, тому проміжного стану не буває.
x-app-dsn: &app-dsn postgres://${NETPULSE_APP_PASSWORD:+netpulse_app:}${NETPULSE_APP_PASSWORD:-netpulse:$POSTGRES_PASSWORD}@db:5432/netpulse?sslmode=disable
x-worker-dsn: &worker-dsn ${NETPULSE_WORKER_PASSWORD:+postgres://netpulse_worker:${NETPULSE_WORKER_PASSWORD}@db:5432/netpulse?sslmode=disable}
x-owner-dsn: &owner-dsn postgres://netpulse:${POSTGRES_PASSWORD:?потрібен POSTGRES_PASSWORD}@db:5432/netpulse?sslmode=disable
# ---------------------------------------------------------------------
# Журнали
# ---------------------------------------------------------------------
#
# Служби пишуть у stdout, а не у файли всередині контейнера, і це не
# спрощення. Файл у контейнері зникає при перестворенні, невидимий для
# `docker logs`, вимагає власного тому — і ротацію довелось би писати
# самим. Збирає, крутить і віддає назовні драйвер журналів; наша справа
# — задати йому межі.
#
# І саме межі раніше задані НЕ БУЛИ. Типовий json-file без max-size не
# крутить нічого: журнал росте, доки не з'їсть диск. На стенді за добу
# роботи проксі набрав 27 МБ — тобто до біди були не роки, а місяці, і
# першим би ліг Postgres, тобто весь продукт.
#
# 10 МБ × 3 файли на службу — стеля близько 250 МБ на всю інсталяцію.
# Цього вистачає на кілька діб історії навіть на балакучому проксі.
#
# Кому потрібні справжні файли в /var/log або відправка в чужий
# збирач — міняє драйвер однією змінною, не правлячи цей файл:
# NETPULSE_LOG_DRIVER=journald (тоді журнали в journalctl)
# NETPULSE_LOG_DRIVER=syslog (тоді туди, куди налаштований rsyslog)
# Опції нижче json-file-специфічні, тож при зміні драйвера вони
# ігноруються — це нормально й навмисно.
x-logging: &logging
driver: ${NETPULSE_LOG_DRIVER:-json-file}
options:
max-size: ${NETPULSE_LOG_MAX_SIZE:-10m}
max-file: "${NETPULSE_LOG_MAX_FILES:-3}"
x-server-env: &server-env
NETPULSE_DSN: *app-dsn
NETPULSE_DSN_WORKER: *worker-dsn
NETPULSE_DEK: ${NETPULSE_DEK:?потрібен NETPULSE_DEK — див. deploy/README.md}
NETPULSE_LOG_LEVEL: ${NETPULSE_LOG_LEVEL:-info}
TZ: ${TZ:-Europe/Kyiv}
services:
db:
logging: *logging
image: timescale/timescaledb:2.17.2-pg16
environment:
POSTGRES_USER: netpulse
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?потрібен POSTGRES_PASSWORD}
POSTGRES_DB: netpulse
TIMESCALEDB_TELEMETRY: "off"
# Без цього initdb бере кодування з локалі середовища й цілком
# може створити базу як SQL_ASCII. Тексти при цьому виглядають
# цілими, а ламається інше: jsonb із екранованим не-ASCII не
# вставляється, а lower() і сортування працюють як над латиницею.
# Виправити можна лише перестворенням бази, тож задаємо одразу.
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C.UTF-8"
command:
- postgres
- -c
- shared_preload_libraries=timescaledb
- -c
- max_connections=200
- -c
- shared_buffers=${PG_SHARED_BUFFERS:-512MB}
- -c
# Вісім фонових робітників TimescaleDB — розумно на чотирьох
# ядрах і забагато на двох: вони конкурують за ті самі ядра з
# самими запитами, і стиснення чанків починає заважати опитуванню.
# Установник підставляє число за фактичною кількістю ядер.
- timescaledb.max_background_workers=${PG_BG_WORKERS:-8}
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
# -h 127.0.0.1 обов'язкове: без нього pg_isready йде unix-сокетом,
# а споживачі — по TCP.
#
# Різниця видна рівно один раз у житті інсталяції, на ПЕРШОМУ
# запуску. Образ Postgres під час ініціалізації піднімає тимчасовий
# сервер БЕЗ TCP (listen_addresses=''), щоб виконати initdb і
# скрипти. Сокетна перевірка в цю мить каже «готовий», compose
# позначає службу здоровою, migrate стартує — і отримує
# «connection refused», хоча база нібито здорова.
#
# На вже створеній базі цієї фази немає, тому вада не показувалась
# роками: вона чекала не на нас, а на першого клієнта. Знайдена
# пісочницею установника з першої ж спроби — це рівно те, заради
# чого вона й писалась.
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U netpulse -d netpulse"]
interval: 5s
timeout: 5s
retries: 20
restart: unless-stopped
cache:
logging: *logging
image: docker.dragonflydb.io/dragonflydb/dragonfly:v1.25.5
# memlock без обмеження прискорює Dragonfly, але дозволений не всюди:
# у контейнерній віртуалізації (LXC, частина VPS) ядро відмовляє, і
# контейнер не стартує взагалі з «error setting rlimit type 8».
# Швидкість того не варта — обмеження знято.
#
# Стеля пам'яті задається явно, бо без неї Dragonfly бере її з
# доступної на машині — і рахує ту саму пам'ять, яку вже порахував
# PG_SHARED_BUFFERS. На машині з 4 ГБ два незалежні розрахунки
# чверті одного пирога закінчуються тим, що ядро вбиває когось
# третього, найімовірніше API. Установник задає обидві цифри разом.
# Штатна команда образу — `dragonfly --logtostderr`; command її
# ЗАМІНЮЄ, а не доповнює, тому обидва слова тут обов'язкові. Без
# --logtostderr журнал іде у файл усередині контейнера, тобто
# `docker logs` мовчить — і контейнер, що падає в циклі, виглядає
# як контейнер без проблем.
#
# --proactor_threads не косметика, а умова роботи: Dragonfly вимагає
# 256 МБ на КОЖЕН потік вводу-виводу й відмовляється стартувати,
# якщо стеля менша. За замовчуванням потоків стільки ж, скільки
# ядер, тож на 4-ядерній машині стеля 512 МБ дає «потрібен 1 ГіБ,
# виходжу». Два потоки для кешу сесій — з запасом.
command:
- dragonfly
- --logtostderr
- --maxmemory=${DRAGONFLY_MAXMEMORY:-512mb}
- --proactor_threads=${DRAGONFLY_THREADS:-2}
volumes:
- cache-data:/data
restart: unless-stopped
# Міграції окремою службою, а не на старті API.
#
# API запускається в кількох примірниках, і накочування схеми зі старту
# означало б гонку між ними. Тут же — один запуск, який мусить
# завершитись успіхом, перш ніж піднімуться API й колектор.
migrate:
logging: *logging
build: &server-build
context: .
dockerfile: deploy/Dockerfile.server
args:
VERSION: ${NETPULSE_VERSION:-dev}
COMMIT: ${NETPULSE_COMMIT:-none}
image: netpulse/server:${NETPULSE_VERSION:-dev}
entrypoint: ["netpulse-migrate"]
environment:
<<: *server-env
# Схему котить власник, а не netpulse_app: міграції створюють
# об'єкти, правлять самі політики й іноді переливають дані поверх
# кабінетів. Роль під RLS не змогла б жодного з трьох, а 0063 ще й
# перевіряє права netpulse_app — тобто мусить їх бачити.
NETPULSE_DSN: *owner-dsn
# А цими двома мігратор схему не котить — він ними ЗАХОДИТЬ.
#
# По-перше, на чистій базі саме звідси беруться паролі ролей:
# мігратор витягує пароль із самого DSN, тому в базу лягає рівно
# той рядок, який потім надішле pgx. Розійтись вони не можуть за
# побудовою — це той самий рядок і той самий розбирач.
#
# По-друге, перед тим як пустити api й collector, мігратор кожним
# з цих DSN підключається й дивиться, під якою роллю опинився.
# Саме цієї перевірки бракувало 0063: там перевіряли, що не видно
# чужого, а зламався вхід — тобто «чи видно своє».
NETPULSE_DSN_APP: *app-dsn
NETPULSE_DSN_WORKER: *worker-dsn
depends_on:
db:
condition: service_healthy
restart: "no"
# Утиліти командного рядка під роллю власника: netpulse-user (кабінети
# й люди), netpulse-secret (секрети в core.secrets).
#
# Окрема служба, а не `run --rm api`, бо DSN тут інший. Обидві утиліти
# роблять рівно те, чого роль під RLS робити не має: заводять кабінет
# (INSERT у core.tenants під політикою tenant_self неможливий за
# означенням) і додають людину в чужі за контекстом рядки. Під
# netpulse_app вони не падають, а мовчки нічого не знаходять — тобто
# на новій інсталяції перша ж команда «створити адміністратора»
# виглядала б як успіх, а адміністратора не було б.
#
# profiles: службу не піднімає ні `up`, ні `up -d`; `docker compose run`
# вмикає її профіль сам.
#
# docker compose run --rm --entrypoint netpulse-user cli \
# -tenant default -login admin -role owner
cli:
logging: *logging
build: *server-build
image: netpulse/server:${NETPULSE_VERSION:-dev}
profiles: ["cli"]
environment:
<<: *server-env
NETPULSE_DSN: *owner-dsn
NETPULSE_DSN_WORKER: ""
volumes:
- git-data:/var/lib/netpulse
depends_on:
db:
condition: service_healthy
restart: "no"
api:
logging: *logging
build: *server-build
image: netpulse/server:${NETPULSE_VERSION:-dev}
entrypoint: ["netpulse-api"]
command:
- -listen=:8080
- -alert-interval=${NETPULSE_ALERT_INTERVAL:-30s}
environment:
<<: *server-env
NETPULSE_JWT_SECRET: ${NETPULSE_JWT_SECRET:?потрібен NETPULSE_JWT_SECRET — не менше 32 байтів}
# Історія конфігів у Git. Спільний том для API й колектора:
# колектор туди пише під час бекапу, API звідти читає для порівняння
# версій. Різні томи означали б, що половина історії видна одному
# процесу, а половина другому.
volumes:
- git-data:/var/lib/netpulse
depends_on:
migrate:
condition: service_completed_successfully
cache:
condition: service_started
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/healthz"]
interval: 10s
timeout: 3s
retries: 6
start_period: 15s
restart: unless-stopped
# Колектор зондів. TLS знімає Caddy, всередині мережі — h2c, тому
# -insecure. Порт назовні сам не публікує: до нього ходять через
# проксі, який має справжній сертифікат.
collector:
logging: *logging
build: *server-build
image: netpulse/server:${NETPULSE_VERSION:-dev}
entrypoint: ["netpulse-server"]
command:
- -listen=:9443
- -insecure
environment: *server-env
# Історія конфігів у Git. Спільний том для API й колектора:
# колектор туди пише під час бекапу, API звідти читає для порівняння
# версій. Різні томи означали б, що половина історії видна одному
# процесу, а половина другому.
volumes:
- git-data:/var/lib/netpulse
depends_on:
migrate:
condition: service_completed_successfully
restart: unless-stopped
proxy:
logging: *logging
image: caddy:2.8-alpine
environment:
NETPULSE_DOMAIN: ${NETPULSE_DOMAIN:?потрібен NETPULSE_DOMAIN}
# Порожня пошта означає «домену ще немає» — тоді самопідписаний
# сертифікат. Інакше Caddy нескінченно проситиме Let's Encrypt
# видати сертифікат на IP, чого той не робить, і сайт лишиться
# без TLS зовсім.
NETPULSE_TLS: ${ACME_EMAIL:-internal}
volumes:
- ./deploy/Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
- caddy-config:/config
# Порти й адреса прив'язки — змінними, з типовими значеннями, що
# дають рівно попередню поведінку.
#
# Це не гнучкість заради гнучкості. Списки `ports` при накладанні
# compose-файлів ДОДАЮТЬСЯ, а не замінюються: пісочниця, яка
# оголосила б свої 18080/18443, отримала б їх НА ДОДАЧУ до 80 і 443
# бойового проксі — тобто перехопила б робочий трафік. Обійти це
# можна тегом `!override`, але він вимагає compose 2.24.4, а решта
# установника працює на 2.0. Змінна знімає і те, і те.
ports:
- "${NETPULSE_BIND:-0.0.0.0}:${NETPULSE_PORT_HTTP:-80}:80"
- "${NETPULSE_BIND:-0.0.0.0}:${NETPULSE_PORT_HTTPS:-443}:443"
# Окремий порт для зондів: вони говорять gRPC, а не HTTP, і
# ділити з ним 443 означало б розрізняти протоколи за шляхом —
# зайва крихкість там, де порт коштує нічого.
- "${NETPULSE_BIND:-0.0.0.0}:${NETPULSE_PORT_GRPC:-9443}:9443"
depends_on:
- api
- collector
restart: unless-stopped
# Зонд на тому ж хості, що й сервер: базовий моніторинг самої
# інсталяції. Профіль, а не звичайна служба: у типовому розгортанні
# зонди стоять у мережах клієнтів, а не поруч із сервером.
agent:
logging: *logging
build:
context: .
dockerfile: deploy/Dockerfile.agent
args:
VERSION: ${NETPULSE_VERSION:-dev}
COMMIT: ${NETPULSE_COMMIT:-none}
image: netpulse/agent:${NETPULSE_VERSION:-dev}
profiles: ["agent"]
command:
- -server=collector:9443
- -insecure
- -modules=icmp,snmp,topology,ncm,traps
- -traps-listen=${NETPULSE_TRAPS_LISTEN:-:162}
environment:
NETPULSE_ENROLL: ${NETPULSE_ENROLL:-}
NETPULSE_NAME: ${NETPULSE_AGENT_NAME:-локальний зонд}
TZ: ${TZ:-Europe/Kyiv}
# Порт трапів назовні. Прив'язка задається змінною, а не зашита:
# 162/udp без автентифікації приймає будь-кого, хто знає адресу, і
# на машині з публічним інтерфейсом «усі інтерфейси» — це рішення, а
# не типове значення. Порожній NETPULSE_TRAPS_BIND лишає службу
# всередині мережі docker, і зовні вона недосяжна.
ports:
- "${NETPULSE_TRAPS_BIND:-127.0.0.1}:162:162/udp"
cap_add:
- NET_RAW
# 162 нижче 1024, а процес усередині — не root. Без цього зонд
# порт не займе; він про це кричить у журнал, але приймати трапи
# все одно не буде.
- NET_BIND_SERVICE
volumes:
- agent-identity:/var/lib/netpulse
depends_on:
- collector
restart: unless-stopped
volumes:
db-data:
git-data:
cache-data:
caddy-data:
caddy-config:
agent-identity: