infra/docs/architecture/databases.md
Viktor Barzin fc233bd27f docs: comprehensive audit and update of all architecture docs and runbooks [ci skip]
Audited 14 documentation files against live cluster state and Terraform code.

Architecture docs:
- databases.md: MySQL 8.4.4, proxmox-lvm storage (not iSCSI), anti-affinity
  excludes k8s-node1 (GPU), 2Gi/3Gi resources, 7-day rotation (not 24h),
  CNPG 2 instances, PostGIS 16, postgresql.dbaas has endpoints
- overview.md: 1x CPU, ~160GB RAM, all nodes 32GB, proxmox-lvm storage,
  correct Vault paths (secret/ not kv/)
- compute.md: 272GB physical host RAM, ~160GB allocated to VMs
- secrets.md: 7-day rotation, 7 MySQL + 5 PG roles, correct ESO config
- networking.md: MetalLB pool 10.0.20.200-220
- ci-cd.md: 9 GHA projects, travel_blog 5.7GB

Runbooks:
- restore-mysql/postgresql: backup files are .sql.gz (not .sql)
- restore-vault: weekly backup (not daily), auto-unseal sidecar note
- restore-vaultwarden: PVC is proxmox (not iscsi)
- restore-full-cluster: updated node roles, removed trading

Reference docs:
- CLAUDE.md: 7-day rotation, removed trading from PG list
- AGENTS.md: 100+ stacks, proxmox-lvm, platform empty shell
- service-catalog.md: 6 new stacks, 14 stack column updates
2026-04-06 13:21:05 +03:00

11 KiB

Databases

Overview

The cluster provides shared database services (PostgreSQL, MySQL, Redis) for multi-tenant workloads with automated credential rotation via Vault. PostgreSQL uses CloudNativePG (CNPG) with PgBouncer connection pooling, MySQL runs as an InnoDB Cluster with anti-affinity rules for stability, and Redis provides a shared cache layer. SQLite is used for per-app local storage with careful attention to filesystem compatibility.

Architecture Diagram

graph TB
    subgraph Apps
        A1[trading-bot]
        A2[apple-health-data]
        A3[wrongmove]
        A4[claude-memory-mcp]
    end

    subgraph PostgreSQL
        A1 --> PGB[PgBouncer<br/>3 replicas]
        A2 --> PGB
        A4 --> PGB
        PGB --> CNPG_RW[CNPG Primary<br/>pg-cluster-rw.dbaas]
        CNPG_RW --> CNPG_R1[CNPG Replica 1]
    end

    subgraph MySQL
        A3 --> MYC[MySQL InnoDB Cluster<br/>3 instances]
        MYC --> LVM1[Proxmox-LVM Storage]
        MYC -.anti-affinity.-> NODE1[Exclude k8s-node1<br/>GPU node]
    end

    subgraph Redis
        A1 --> RED[Redis<br/>redis.redis.svc.cluster.local]
    end

    subgraph Vault
        V[Vault DB Engine]
        V -.7-day rotation.-> PGB
        V -.7-day rotation.-> MYC
    end

    style CNPG_RW fill:#2088ff
    style PGB fill:#4c9e47
    style MYC fill:#f39c12
    style RED fill:#dc382d

Components

Component Version Location Purpose
PostgreSQL (CNPG) CloudNativePG (PostGIS 16: postgis:16) dbaas namespace Primary/replica cluster, auto-failover
PgBouncer 3 replicas dbaas namespace Connection pooling for PostgreSQL
MySQL InnoDB Cluster 8.4.4 dbaas namespace Multi-master MySQL cluster
Redis Latest redis namespace Shared cache layer
Vault DB Engine - vault namespace Automated credential rotation

Database Endpoints

Service Endpoint Notes
PostgreSQL (primary) pg-cluster-rw.dbaas.svc.cluster.local Always use this via PgBouncer
PgBouncer pgbouncer.dbaas.svc.cluster.local Connection pool (3 replicas)
MySQL mysql.dbaas.svc.cluster.local InnoDB Cluster VIP
Redis redis.redis.svc.cluster.local Shared instance
PostgreSQL (compat) postgresql.dbaas.svc.cluster.local Compatibility service, selects CNPG primary

How It Works

PostgreSQL (CNPG + PgBouncer)

  1. CNPG Cluster: Manages PostgreSQL primary and replicas

    • Primary: pg-cluster-rw.dbaas.svc.cluster.local
    • Auto-failover on primary failure
    • Replicas for read scaling
  2. PgBouncer: Connection pooling layer (3 replicas)

    • Apps connect to PgBouncer, not directly to PostgreSQL
    • Reduces connection overhead
    • Load balances across PgBouncer instances
  3. Credential Rotation: Vault DB engine rotates credentials every 7 days

    • Apps fetch credentials from Vault on startup
    • Vault manages rotation lifecycle

Used by:

  • trading-bot
  • apple-health-data (health)
  • linkwarden
  • affine
  • woodpecker
  • claude-memory-mcp
  • 5 active PG roles

MySQL InnoDB Cluster

  1. Cluster Topology: 3 MySQL instances with auto-recovery

    • Multi-master replication
    • Automatic split-brain resolution
  2. Storage: Proxmox-LVM persistent volumes

    • Thin-provisioned LVM on Proxmox hosts
    • Block-level storage with proper write guarantees
  3. Anti-Affinity: Excludes k8s-node1 (GPU node)

    • Pods scheduled to node2, node3, node4, etc.
    • Keeps database workloads off the GPU-dedicated node
  4. Resource Allocation: 2Gi request / 3Gi limit

    • Right-sized based on VPA recommendations

Used by:

  • wrongmove (realestate-crawler)
  • speedtest
  • codimd
  • nextcloud
  • shlink
  • grafana
  • technitium (DNS query logs via QueryLogsMySqlApp plugin)

Redis

  • Shared instance at redis.redis.svc.cluster.local
  • Used for caching and session storage
  • No persistence (ephemeral)

SQLite (Per-App)

Apps using SQLite:

  • headscale
  • vaultwarden
  • plotting-book
  • holiday-planner
  • priority-pass

Critical: SQLite on NFS is unreliable

  • NFS lacks proper fsync() support
  • Causes database corruption under load
  • Solution: Use Proxmox-LVM volumes for SQLite apps

Vault Database Engine

Rotation Schedule: 7 days (604800s)

PostgreSQL Rotation:

  • health (apple-health-data)
  • linkwarden
  • affine
  • woodpecker
  • claude_memory

MySQL Rotation:

  • speedtest
  • wrongmove
  • codimd
  • nextcloud
  • shlink
  • grafana
  • technitium (password synced to Technitium DNS app via CronJob every 6h)

Excluded from Rotation:

  • authentik (uses PgBouncer, incompatible)
  • crowdsec (Helm-baked credentials)
  • Root users (manual management)

How Rotation Works:

  1. Vault rotates the MySQL user's password (static role, 7-day period)
  2. ExternalSecrets Operator syncs new password to K8s Secret (15-min refresh)
  3. Apps read from K8s Secret via secret_key_ref env vars
  4. Special case: Technitium stores its MySQL connection in internal app config, so a CronJob pushes the rotated password to the Technitium API every 6 hours

Configuration

Terraform Shared Variables

Always use shared variables, never hardcode endpoints:

variable "postgresql_host" {
  default = "pgbouncer.dbaas.svc.cluster.local"
}

variable "mysql_host" {
  default = "mysql.dbaas.svc.cluster.local"
}

variable "redis_host" {
  default = "redis.redis.svc.cluster.local"
}

Vault Paths

PostgreSQL Dynamic Credentials:

database/creds/postgres-<app>-role

MySQL Dynamic Credentials:

database/creds/mysql-<app>-role

Static Credentials (non-rotated):

secret/data/mysql/root
secret/data/postgres/root

Version Pinning

Diun Monitoring Disabled for database images to prevent unwanted version bumps:

  • MySQL: pinned version in Terraform
  • PostgreSQL: pinned CNPG operator version
  • Redis: pinned image tag

Rationale: Database upgrades require careful planning and testing

Example Terraform Stack (PostgreSQL)

resource "vault_database_secret_backend_role" "app" {
  backend             = "database"
  name                = "postgres-myapp-role"
  db_name             = "postgres"
  creation_statements = [
    "CREATE USER \"{{name}}\" WITH PASSWORD '{{password}}' VALID UNTIL '{{expiration}}';",
    "GRANT ALL PRIVILEGES ON DATABASE myapp TO \"{{name}}\";"
  ]
  default_ttl         = 604800  # 7 days
  max_ttl             = 604800
}

resource "kubernetes_secret" "db_creds" {
  metadata {
    name      = "myapp-db"
    namespace = "default"
  }

  data = {
    host     = var.postgresql_host
    database = "myapp"
    # App fetches username/password from Vault at runtime
  }
}

Decisions & Rationale

Why CNPG Instead of Postgres Operator?

Alternatives considered:

  1. Zalando Postgres Operator: Mature but complex
  2. Bitnami PostgreSQL Helm: Simple but manual failover
  3. CNPG (chosen): Kubernetes-native, auto-failover, active development

Benefits:

  • Native Kubernetes CRDs
  • Automatic failover and recovery
  • Active community and updates
  • Better resource efficiency than Zalando

Why PgBouncer for PostgreSQL?

  • Reduces connection overhead (apps create many connections)
  • Load balances across PgBouncer replicas
  • Essential for apps that don't implement connection pooling
  • Required for Vault DB engine compatibility with some apps

Why MySQL InnoDB Cluster?

Alternatives considered:

  1. Single MySQL instance: No HA
  2. Galera Cluster: Complex, split-brain issues
  3. InnoDB Cluster (chosen): Built-in multi-master, auto-recovery

Benefits:

  • Native MySQL HA solution
  • Automatic split-brain resolution
  • Simpler than Galera

Why Block Storage for Databases?

  • NFS lacks proper fsync() support (causes SQLite corruption)
  • Proxmox-LVM provides block-level storage with proper write guarantees
  • Lower latency than NFS for database workloads

Why 7-Day Credential Rotation?

  • Balance between security (shorter is better) and operational overhead
  • 7 days allows ample time to debug issues before next rotation
  • Reduces rotation-related disruptions while maintaining security hygiene

Why Shared Redis (Not Per-App)?

  • Most apps use Redis for ephemeral data (caching, sessions)
  • Over-provisioning Redis wastes memory
  • Shared instance sufficient for current load
  • Can migrate to per-app if needed

Troubleshooting

PostgreSQL: "Too many connections"

Cause: Apps connecting directly to PostgreSQL instead of PgBouncer

Fix:

# Check PgBouncer is running
kubectl get pods -n dbaas | grep pgbouncer

# Verify apps use pgbouncer.dbaas, not pg-cluster-rw
kubectl get configmap <app-config> -o yaml | grep postgres

PostgreSQL: Primary Failover Not Working

Cause: CNPG controller not running or network partition

Fix:

# Check CNPG operator
kubectl get pods -n cnpg-system

# Check cluster status
kubectl get cluster -n dbaas

# Manually trigger failover (last resort)
kubectl cnpg promote pg-cluster-2 -n dbaas

MySQL: Pod Stuck on Excluded Node

Cause: Anti-affinity rule not applied (should exclude k8s-node1)

Fix:

# Check pod affinity rules
kubectl get pod <mysql-pod> -n dbaas -o yaml | grep -A 10 affinity

# Delete pod to reschedule
kubectl delete pod <mysql-pod> -n dbaas

MySQL: Pod Scheduled on GPU Node

Cause: Anti-affinity rule not preventing scheduling on k8s-node1

Fix:

# Check pod affinity rules
kubectl get pod <mysql-pod> -n dbaas -o yaml | grep -A 10 affinity

# Delete pod to reschedule away from node1
kubectl delete pod <mysql-pod> -n dbaas

SQLite: Database Corruption

Cause: SQLite on NFS volume

Fix:

# Check volume type
kubectl get pv | grep <app>

# If NFS, migrate to proxmox-lvm:
# 1. Create proxmox-lvm PVC
# 2. Backup SQLite database
# 3. Restore to proxmox-lvm volume
# 4. Update app to use new volume

Vault Rotation: "User already exists"

Cause: Previous rotation failed to clean up

Fix:

# Connect to database
kubectl exec -it <mysql-pod> -n dbaas -- mysql -u root -p

# List users
SELECT user, host FROM mysql.user WHERE user LIKE 'v-root-%';

# Drop stale users
DROP USER 'v-root-postgres-<hash>'@'%';

# Retry rotation
vault read database/rotate-root/postgres

Redis: Out of Memory

Cause: No eviction policy configured

Fix:

# Connect to Redis
kubectl exec -it redis-0 -n redis -- redis-cli

# Set eviction policy
CONFIG SET maxmemory-policy allkeys-lru

# Persist config
CONFIG REWRITE

App Can't Connect: "Connection refused"

Cause: Service endpoint not reachable or PgBouncer not running

Fix:

# Check service endpoints
kubectl get endpoints pgbouncer -n dbaas
kubectl get endpoints postgresql -n dbaas

# Update app to use pgbouncer
kubectl set env deployment/<app> DB_HOST=pgbouncer.dbaas.svc.cluster.local
  • CI/CD Pipeline — Database credentials in CI/CD
  • Multi-Tenancy — Per-user database provisioning
  • Runbook: ../runbooks/database-failover.md — Manual failover procedures
  • Runbook: ../runbooks/vault-rotation-troubleshooting.md — Debug credential rotation
  • Vault documentation: Database secrets engine
  • CNPG documentation: Cluster configuration