ЖУРНАЛИ БЕЗ СТЕЛІ. На запитання «чому не пишемо в /var/log» правильна відповідь — «бо в контейнері це не той шар»: файл усередині зникає при перестворенні, невидимий для docker logs і вимагає власної ротації. Але за питанням стояла справжня вада: драйвер json-file був налаштований порожньо, тобто НЕ КРУТИВ НІЧОГО. Проксі за добу набрав 27 МБ; до повного диска були місяці, і першим ліг би Postgres. Тепер 10 МБ × 3 файли на службу — близько 250 МБ на інсталяцію, і драйвер міняється однією змінною (journald, syslog) для тих, кому потрібні справжні файли або чужий збирач. САМОПЕРЕВІРКА ЗОНДА чекала хвилину й одного разу вже дала хибне червоне: зонд зареєструвався, просто пізніше — перший старт припадає на найзавантаженішу мить установки. Тепер три хвилини з повідомленням кожні півхвилини: мовчазна пауза невідрізненна від зависання, а хибне червоне після успішної установки коштує години пошуку неіснуючої поломки. GITSTORE: TestDeleteBranchLocal кличе зовнішній git і не мав перевірки на його відсутність — падав у golang:1.25-alpine, тобто саме там, де його проганяють. Сусідній тест таку перевірку має. ЕТАП 13 у ROADMAP: реєстр образів, релізи, пакети. Записано, чому порядок саме такий (пакет без реєстру ставив би «зберіть самі») і чого треба досягти до першого тегу — оновлення з версії на версію не перевіряв ніхто, а ламається найчастіше саме воно.
389 lines
22 KiB
YAML
389 lines
22 KiB
YAML
# Повний стек 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:
|