docker-compose.yml
Rama main · réplica de lectura del repositorio original.
services:
# ============================================
# NGINX Load Balancer
# ============================================
nginx:
image: nginx:alpine
ports:
- "80:80"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
extra_hosts:
- "host.docker.internal:host-gateway"
depends_on:
- rda
networks:
- ojuri-network
restart: unless-stopped
healthcheck:
test: ["CMD", "nginx", "-t"]
interval: 30s
timeout: 10s
retries: 3
# ============================================
# Sentinel (operator dashboard)
# ============================================
# Static SPA behind its own nginx. Gated behind a profile so a plain
# `docker compose up` is unchanged: the repo's default `nginx.conf`
# routes `/` to RDA, and adopters fronting Sentinel with their own
# ingress do not want a second copy of it running here. Opt in with
# `--profile sentinel`, and pair it with a config that routes `/` to
# this service — see `deploy/aws/nginx/nginx.conf` for a worked one.
sentinel:
profiles: ["sentinel"]
build:
context: ./frontend
dockerfile: Dockerfile
networks:
- ojuri-network
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost/"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
# ============================================
# RDA Service Replicas (Real-Time Detection Agent)
# ============================================
# Scale with RDA_REPLICAS (default 3). NGINX resolves the `rda` DNS
# name once at startup and balances across every replica IP Docker
# returns — after changing the count, restart nginx so it re-resolves.
rda:
build:
context: .
dockerfile: Dockerfile
environment:
- NODE_ENV=${NODE_ENV:-development}
- PORT=3000
- APP_NAME=ojuri-rda
- REDIS_HOST=redis
- REDIS_PORT=6379
- REDIS_PASSWORD=${REDIS_PASSWORD:-}
# Use the INTERNAL listener (advertised as kafka:29092) for
# container-to-container traffic. Port 9092 is the EXTERNAL
# listener that advertises localhost:9092 for host clients —
# which from inside this container resolves to its own loopback
# and produces the "Consumer crashed → restart" flap.
- KAFKA_BROKERS=kafka:29092
- KAFKA_TOPIC=transactions.completed
- KAFKA_BLOCKED_TOPIC=transactions.blocked
- AUDIT_PIPELINE=${AUDIT_PIPELINE:-queue}
- MODEL_PATH=/app/models/fraud_model.onnx
- FRAUD_THRESHOLD=0.65
- ONNX_SESSION_POOL_SIZE=4
- LOG_LEVEL=info
- DB_CLIENT=pg
- DB_URL=postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-fraud_db}
# Service-health fan-out. Defaults in HealthService point at
# 127.0.0.1 which can't reach sibling containers from inside RDA;
# set the Docker-network URLs explicitly. MLA runs natively on
# the host (not in compose by design), so its URL uses
# host.docker.internal — override with `MLA_HEALTH_URL=` when
# MLA is deliberately disabled.
- PAA_HEALTH_URL=${PAA_HEALTH_URL:-http://paa-1:9090}
- FIA_HEALTH_URL=${FIA_HEALTH_URL:-http://fia:9094}
- MLA_HEALTH_URL=${MLA_HEALTH_URL:-http://host.docker.internal:9095}
- AUTH_JWT_SECRET=${AUTH_JWT_SECRET}
- AUTH_JWT_TTL_SECONDS=${AUTH_JWT_TTL_SECONDS:-28800}
- RDA_REQUIRE_API_KEY=${RDA_REQUIRE_API_KEY:-false}
# Without this the boot-time production guard sees no allowed origin and
# refuses to start, so NODE_ENV=production was unrunnable from this file.
- SENTINEL_CORS_ORIGINS=${SENTINEL_CORS_ORIGINS:-}
- DEMO_CREDENTIALS_URL=${DEMO_CREDENTIALS_URL:-}
- MLA_SERVICE_TOKEN=${MLA_SERVICE_TOKEN:-}
- DB_HOST=postgres
- DB_PORT=5432
- DB_DATABASE=${POSTGRES_DB:-fraud_db}
- DB_USERNAME=${POSTGRES_USER:-postgres}
- DB_PASSWORD=${POSTGRES_PASSWORD:-postgres}
extra_hosts:
# Required on Linux hosts so `host.docker.internal` resolves
# for the MLA health probe. macOS Docker Desktop already
# provides it implicitly.
- "host.docker.internal:host-gateway"
volumes:
- ./models:/app/models:ro
- ./data/training-imports:/app/data/training-imports:ro
- ./data/training-uploads:/app/data/training-uploads
depends_on:
redis:
condition: service_healthy
kafka:
condition: service_healthy
postgres:
condition: service_healthy
db-migrate:
condition: service_completed_successfully
networks:
- ojuri-network
restart: unless-stopped
deploy:
replicas: ${RDA_REPLICAS:-3}
resources:
limits:
memory: 4G
# ============================================
# PAA Service (Pattern Analysis Agent)
# Singleton Kafka consumer.
#
# DO NOT scale this service. The graph + velocity state live in
# process memory; running a second replica under the same Kafka
# consumer group splits the partition assignment, so each replica
# sees only a subset of edges and PageRank / Louvain run on a
# partial graph. A ring whose members hash to different partitions
# becomes invisible. The worker emits an ERROR and a
# `paa_group_members` metric > 1 if a second member joins the
# `pattern-analysis` group.
# ============================================
paa-1:
build:
context: ./paa-service
dockerfile: Dockerfile
environment:
- NODE_ENV=${NODE_ENV:-development}
- APP_NAME=ojuri-paa-1
- METRICS_PORT=9090
- REDIS_HOST=redis
- REDIS_PORT=6379
- REDIS_PASSWORD=${REDIS_PASSWORD:-}
# Use the INTERNAL listener (advertised as kafka:29092) for
# container-to-container traffic. Port 9092 is the EXTERNAL
# listener that advertises localhost:9092 for host clients —
# which from inside this container resolves to its own loopback
# and produces the "Consumer crashed → restart" flap.
- KAFKA_BROKERS=kafka:29092
- KAFKA_TOPIC=transactions.completed
- KAFKA_CONSUMER_GROUP=pattern-analysis
- KAFKA_CLIENT_ID=paa-1
- LOG_LEVEL=info
- DB_CLIENT=pg
- DB_URL=postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-fraud_db}
- GRAPH_UPDATE_INTERVAL=300000
- PAGERANK_DAMPING=0.85
- BATCH_SIZE=100
- MAX_GRAPH_NODES=1000000
ports:
- "9091:9090"
depends_on:
redis:
condition: service_healthy
kafka:
condition: service_healthy
postgres:
condition: service_healthy
db-migrate:
condition: service_completed_successfully
networks:
- ojuri-network
restart: unless-stopped
deploy:
replicas: 1
resources:
limits:
cpus: "2"
memory: 8G
# ============================================
# FIA Service (Fraud Investigation Agent)
# Async LLM-based investigation report generator
# ============================================
fia:
# FIA carries the Phi-3 LLM weights (~7.6 GB on first run) and the
# torch + transformers + accelerate Python stack. Gating it behind
# a Compose profile means `docker compose up` is fast for first-time
# adopters; opt in with `docker compose --profile fia up` once you
# have the disk + bandwidth to download the model.
profiles: ["fia"]
build:
context: ./fia-service
dockerfile: Dockerfile
environment:
# Use the INTERNAL listener (advertised as kafka:29092) for
# container-to-container traffic. Port 9092 is the EXTERNAL
# listener that advertises localhost:9092 for host clients —
# which from inside this container resolves to its own loopback
# and produces the "Consumer crashed → restart" flap.
- KAFKA_BROKERS=kafka:29092
- KAFKA_BLOCKED_TOPIC=transactions.blocked
- KAFKA_CONSUMER_GROUP=ojuri-investigation
- KAFKA_AUTO_OFFSET_RESET=earliest
- POSTGRES_HOST=postgres
- POSTGRES_PORT=5432
- POSTGRES_DB=${POSTGRES_DB:-fraud_db}
- POSTGRES_USER=${POSTGRES_USER:-postgres}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-postgres}
- LLM_MODEL_NAME=${FIA_LLM_MODEL_NAME:-microsoft/Phi-3-mini-4k-instruct}
- LLM_MODEL_PATH=${FIA_LLM_MODEL_PATH:-}
- LLM_DEVICE=${FIA_LLM_DEVICE:-auto}
- LLM_DTYPE=${FIA_LLM_DTYPE:-auto}
- LLM_MAX_NEW_TOKENS=${FIA_LLM_MAX_NEW_TOKENS:-384}
- LLM_TEMPERATURE=${FIA_LLM_TEMPERATURE:-0.2}
- FIA_FALLBACK_ON_LLM_FAILURE=${FIA_FALLBACK_ON_LLM_FAILURE:-true}
# Set to true to skip the Phi-3 load entirely (no 7.6 GB download,
# no 15 GB resident model). Reports are served from the
# deterministic rule-based path. Useful for demos / CI / low-RAM
# hosts. See fia-service/src/llm/phi3_generator.py.
- FIA_DISABLE_LLM=${FIA_DISABLE_LLM:-false}
- PROMPT_TEMPLATE_VERSION=${FIA_PROMPT_TEMPLATE_VERSION:-v1}
- METRICS_PORT=9094
- LOG_LEVEL=${FIA_LOG_LEVEL:-INFO}
# Shared with RDA so the same operator JWT works for both APIs.
# The HTTP server in handler.py verifies HS256 against this secret.
- AUTH_JWT_SECRET=${AUTH_JWT_SECRET}
- AUTH_JWT_ISSUER=${AUTH_JWT_ISSUER:-}
- AUTH_JWT_AUDIENCE=${AUTH_JWT_AUDIENCE:-}
# In Compose the container's loopback is isolated from the host, so
# binding 0.0.0.0 is required to expose port 9094 through the
# published port mapping. Bare-metal deployments should bind to
# 127.0.0.1 (the in-process default) and proxy in front.
- FIA_HTTP_BIND=${FIA_HTTP_BIND:-0.0.0.0}
# Pin HuggingFace cache into the named volume so the ~8 GB Phi-3 download
# survives container recreates.
- HF_HOME=/app/.hf-cache
# Bound torch's CPU thread pool so it doesn't fight RDA/PAA on the host.
- OMP_NUM_THREADS=${FIA_OMP_NUM_THREADS:-2}
ports:
- "9094:9094"
volumes:
- fia-hf-cache:/app/.hf-cache
- ./fia-service/models:/app/models
depends_on:
kafka:
condition: service_healthy
postgres:
condition: service_healthy
networks:
- ojuri-network
restart: unless-stopped
# Standalone `docker compose up` ignores deploy.resources (those
# are Swarm-only). Use top-level mem_limit so the container
# actually gets the headroom. Phi-3-mini in fp32 on CPU needs
# ~15 GB resident; cap at 16 GiB to leave the host breathing room
# but not OOM-kill the load step the way the old 8 GiB cap did.
mem_limit: 16g
mem_reservation: 6g
deploy:
resources:
limits:
cpus: "2"
memory: 16G
reservations:
memory: 6G
# ============================================
# MLA Service (Model Learning Agent)
# ============================================
# Async drift monitor + retraining pipeline. Not on the prediction
# hot path. Gated behind a `mla` profile because adopters often
# prefer to run the trainer on a host with GPU access or specific
# Python deps. Opt in with `docker compose --profile mla up`.
#
# When MLA runs IN compose under this profile, set
# `MLA_HEALTH_URL=http://mla:9095` in `.env` so the RDA replicas
# probe the in-compose service instead of host.docker.internal.
mla:
profiles: ["mla"]
build:
context: ./mla-service
dockerfile: Dockerfile
environment:
- POSTGRES_HOST=postgres
- POSTGRES_PORT=5432
- POSTGRES_DB=${POSTGRES_DB:-fraud_db}
- POSTGRES_USER=${POSTGRES_USER:-postgres}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-postgres}
- KAFKA_BROKERS=kafka:29092
- KAFKA_TOPIC=transactions.completed
- KAFKA_CONSUMER_GROUP=mla-drift-monitor
- METRICS_PORT=9095
- MODEL_OUTPUT_DIR=/app/models
- LOG_LEVEL=${MLA_LOG_LEVEL:-INFO}
- MLA_SERVICE_TOKEN=${MLA_SERVICE_TOKEN:-}
- AUTH_JWT_SECRET=${AUTH_JWT_SECRET}
- DRIFT_PSI_THRESHOLD=${DRIFT_PSI_THRESHOLD:-0.25}
- DRIFT_F1_THRESHOLD=${DRIFT_F1_THRESHOLD:-0.4}
ports:
- "9095:9095"
volumes:
- ./models:/app/models
- mla-data:/app/data
depends_on:
kafka:
condition: service_healthy
postgres:
condition: service_healthy
db-migrate:
condition: service_completed_successfully
networks:
- ojuri-network
restart: unless-stopped
# ============================================
# Redis
# ============================================
redis:
image: redis:7-alpine
command: redis-server --appendonly yes --maxmemory 2gb --maxmemory-policy volatile-lru
ports:
- "6380:6379"
volumes:
- redis-data:/data
networks:
- ojuri-network
restart: unless-stopped
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
# ============================================
# Kafka + Zookeeper
# ============================================
zookeeper:
image: confluentinc/cp-zookeeper:7.5.0
environment:
ZOOKEEPER_CLIENT_PORT: 2181
ZOOKEEPER_TICK_TIME: 2000
volumes:
- zookeeper-data:/var/lib/zookeeper/data
- zookeeper-logs:/var/lib/zookeeper/log
networks:
- ojuri-network
restart: unless-stopped
kafka:
image: confluentinc/cp-kafka:7.5.0
depends_on:
- zookeeper
ports:
- "9092:9092"
- "29092:29092"
environment:
KAFKA_BROKER_ID: 1
KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
KAFKA_LISTENERS: INTERNAL://0.0.0.0:29092,EXTERNAL://0.0.0.0:9092
KAFKA_ADVERTISED_LISTENERS: INTERNAL://kafka:29092,EXTERNAL://localhost:9092
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: INTERNAL:PLAINTEXT,EXTERNAL:PLAINTEXT
KAFKA_INTER_BROKER_LISTENER_NAME: INTERNAL
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
KAFKA_NUM_PARTITIONS: 12
KAFKA_AUTO_CREATE_TOPICS_ENABLE: "true"
KAFKA_LOG_RETENTION_HOURS: 168
KAFKA_LOG_RETENTION_BYTES: 10737418240
volumes:
- kafka-data:/var/lib/kafka/data
networks:
- ojuri-network
restart: unless-stopped
healthcheck:
test: ["CMD", "kafka-topics", "--bootstrap-server", "localhost:9092", "--list"]
interval: 30s
timeout: 10s
retries: 5
# ============================================
# PostgreSQL
# ============================================
postgres:
image: postgres:15-alpine
environment:
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
POSTGRES_DB: ${POSTGRES_DB:-fraud_db}
ports:
- "5433:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
networks:
- ojuri-network
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres}"]
interval: 10s
timeout: 5s
retries: 5
# ============================================
# DB migrate (one-shot)
# ============================================
# Runs Knex migrations + seeds against Postgres, then exits 0.
# RDA replicas depend on it via service_completed_successfully so a
# fresh stack has a fully-migrated schema by the time /v1/predict
# accepts requests. On subsequent `docker compose up`, this re-runs
# and is a no-op ("Already up to date"). The image is the same RDA
# build (it ships the compiled migrations + knexfile); when running
# from prebuilt images, knex reads /app/dist/database/{migrations,seeds}
# via KNEX_MIGRATIONS_DIR / KNEX_SEEDS_DIR.
db-migrate:
build:
context: .
dockerfile: Dockerfile
# The normalize step is guarded on the file existing: this compose file
# is shipped inside @ojuri/cli and is routinely run against an older
# published image via OJURI_VERSION, which has no such file. Requiring
# it outright killed db-migrate, and with it the whole stack.
#
# Invokes the knex CLI directly with node, bypassing the package.json
# `db:migrate` script that wraps it in ts-node + dotenv. The production
# image runs `npm prune --production` which removes ts-node, and Compose
# already injects env vars so dotenv isn't needed. Compiled migrations
# live at /app/dist/database/{migrations,seeds} per KNEX_*_DIR below.
# The seeds include the SUPER_ADMIN bootstrap that prints a one-time
# admin password — surfaced via `docker compose logs db-migrate`.
command:
- sh
- -c
- "if [ -f ./dist/database/normalize-migration-names.cli.js ]; then node ./dist/database/normalize-migration-names.cli.js; fi && node ./node_modules/knex/bin/cli.js --knexfile knexfile.js migrate:latest && node ./node_modules/knex/bin/cli.js --knexfile knexfile.js seed:run"
environment:
- NODE_ENV=${NODE_ENV:-production}
- DB_CLIENT=pg
- DB_HOST=postgres
- DB_PORT=5432
- DB_DATABASE=${POSTGRES_DB:-fraud_db}
- DB_USERNAME=${POSTGRES_USER:-postgres}
- DB_PASSWORD=${POSTGRES_PASSWORD:-postgres}
- KNEX_MIGRATIONS_DIR=/app/dist/database/migrations
- KNEX_SEEDS_DIR=/app/dist/database/seeds
# Demo rules seed INACTIVE by default (their amount thresholds are
# demo props that dominate real traffic). Set true for the demo
# dataset walkthrough. See docs/RULES.md.
- SEED_DEMO_RULES_ACTIVE=${SEED_DEMO_RULES_ACTIVE:-false}
# Unset means the seed migration generates a random password and prints it
# once. Supply one when the deployment needs the credential known ahead of
# time rather than scraped from migration output.
- ADMIN_SEED_PASSWORD=${ADMIN_SEED_PASSWORD:-}
# Seeds a shared, read-mostly `demo` account whose password is meant to be
# published. Only ever set this on a throwaway public sandbox.
- SEED_DEMO_USER=${SEED_DEMO_USER:-false}
- DEMO_USER_PASSWORD=${DEMO_USER_PASSWORD:-}
# The runtime image ships only dist/ + node_modules — models/ is
# mounted, exactly as on the RDA replicas. Without it the
# 00_initial_model_version seed silently skips, leaving the model
# registry empty and the segment-threshold seed unable to run.
# (Restores the mount dropped when #101 merged over #100.)
volumes:
- ./models:/app/models:ro
depends_on:
postgres:
condition: service_healthy
networks:
- ojuri-network
restart: "no"
# ============================================
# Demo traffic seed (one-shot, opt-in)
# ============================================
# Sends ~500 realistic transactions (recurring senders plus embedded
# fraud patterns: money ring, mule fan-out, VPN, structuring) straight
# at the rda service so a fresh stack has a populated dashboard. Opt
# in with `docker compose --profile demo run --rm demo-seed`.
demo-seed:
profiles: ["demo"]
image: node:20-alpine
command: ["node", "/scripts/demo-traffic.mjs"]
environment:
- RDA_URL=http://rda:3000
- DEMO_API_KEY=${DEMO_API_KEY:-}
- DEMO_TX_COUNT=${DEMO_TX_COUNT:-500}
# rda has no compose healthcheck, so the script polls /livez
# itself instead of relying on a service_healthy condition.
- DEMO_WAIT_FOR_RDA_SECONDS=90
volumes:
- ./scripts:/scripts:ro
depends_on:
rda:
condition: service_started
networks:
- ojuri-network
restart: "no"
# ============================================
# Monitoring Stack (Optional)
# ============================================
prometheus:
image: prom/prometheus:v2.47.0
ports:
- "9090:9090"
volumes:
- ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- prometheus-data:/prometheus
command:
- "--config.file=/etc/prometheus/prometheus.yml"
- "--storage.tsdb.path=/prometheus"
- "--web.enable-lifecycle"
networks:
- ojuri-network
restart: unless-stopped
grafana:
image: grafana/grafana:10.1.0
ports:
- "3001:3000"
environment:
- GF_SECURITY_ADMIN_USER=${GRAFANA_USER:-admin}
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD:-admin}
volumes:
- grafana-data:/var/lib/grafana
depends_on:
- prometheus
networks:
- ojuri-network
restart: unless-stopped
# ============================================
# Networks
# ============================================
networks:
ojuri-network:
driver: bridge
# ============================================
# Volumes
# ============================================
volumes:
redis-data:
postgres-data:
kafka-data:
zookeeper-data:
zookeeper-logs:
prometheus-data:
grafana-data:
fia-hf-cache:
mla-data: