# Повний стек 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: