DEV Community

yep
yep

Posted on • Originally published at yepchaos.com

Active/Active Multi-region - Chat application Architecture

In the previous post I covered how I connected two Kubernetes clusters across Mongolia and Germany using Netbird. That was the networking layer — pods can reach each other, DNS works across clusters. Now the interesting part: making the actual application work active/active across both regions.

Active/active means both clusters run independently and serve users, but a user on cluster A can chat with a user on cluster B in real time. No single point of failure, no "primary" region. Either cluster can go down and the other keeps running.

This breaks down into three problems: real-time events, chat history, and application state. Each one needs a different solution.

Part 1: Real-Time Events (NATS Super-Cluster)

For single-cluster WebSocket scaling I already use NATS — covered in an earlier post. The short version: all WebSocket servers publish and subscribe through NATS, so a message from a user on server A reaches a user on server B without those servers knowing about each other.

For multi-region, NATS has a concept called a super-cluster. You deploy independent NATS clusters in each region and connect them together. Messages published in one cluster eventually replicate to the other. "Eventually" here means milliseconds of extra latency — there are more network hops, but I accept that.

Setup is straightforward. Deploy NATS in each cluster using the operator (Helm chart), then configure the super-cluster by pointing each cluster at the other's gateway endpoints. After that, the application doesn't change at all. A backend in Germany subscribes to the same subjects as a backend in Mongolia. A message published in one region fans out to both. The application has no idea it's talking to a distributed system — it just publishes and subscribes like before.

This is the cleanest part of the whole setup. NATS was designed for this, and it shows. Example values.yaml

config:
  cluster:
    enabled: true
    replicas: 3
    merge:
      name: astring-fsn1

  gateway:
    enabled: true
    port: 7522
    merge:
      name: astring-fsn1
      gateways:
        - name: astring-mn
          urls:
            - nats://nats-mn-headless.nats.astring-mn.internal:7522

  monitor:
    enabled: true
    port: 8222
  merge:
    authorization:
      user: << $NATS_USER >>
      password: << $NATS_PASSWORD >>

container:
  env:
    NATS_USER:
      valueFrom:
        secretKeyRef:
          name: nats-auth-secret
          key: username
    NATS_PASSWORD:
      valueFrom:
        secretKeyRef:
          name: nats-auth-secret
          key: password

service:
  ports:
    nats:
      enabled: true
    gateway:
      enabled: true
    monitor:
      enabled: true

promExporter:
  enabled: true
  env:
    NATS_USER:
      valueFrom:
        secretKeyRef:
          name: nats-auth-secret
          key: username
    NATS_PASSWORD:
      valueFrom:
        secretKeyRef:
          name: nats-auth-secret
          key: password

reloader:
  enabled: true

natsBox:
  enabled: true
Enter fullscreen mode Exit fullscreen mode

Part 2: Chat History (Cassandra, Not ScyllaDB)

I was using ScyllaDB. In December 2024 ScyllaDB moved from AGPL to a source-available license — the code is still public on GitHub, but running a cluster beyond a certain size requires a commercial license. ScyllaDB Manager (the tool for automation, repairs, and backups) is limited to 5 nodes on the free version. It's technically "open source" but not really anymore. I switched to Cassandra, which is fully open source under Apache 2.0 and has the same architecture.

For multi-region, Cassandra is actually the best-fit database I've worked with. Cassandra natively understands the concept of datacenters — your two sites aren't two separate clusters, they're two DCs in one logical Cassandra cluster. Replication is configured per-DC. Consistency levels let you decide per-query whether you need a local quorum (fast, single-region) or global quorum (slower but cross-region consistent).

For chat history, I use local quorum for reads and writes. Messages replicate to the other DC asynchronously. A user reading chat history gets it from their local DC — fast. Eventually the other DC catches up. For chat history this is fine — nobody needs sub-millisecond cross-region consistency for reading old messages.

For Kubernetes I use the k8ssandra-operator, which manages Cassandra clusters across multiple Kubernetes clusters. This is where it gets interesting: the operator needs to manage pods in both cluster-mn and cluster-de, which means it needs to reach both clusters. I deploy the k8ssandra-operator on a separate management cluster — a small single-node k3s cluster that reaches both application clusters through Netbird. The operator registers both clusters and treats them as two DCs in one Cassandra deployment.

If the management cluster goes down, the Cassandra cluster keeps running — the operator just can't make configuration changes until it comes back. Acceptable tradeoff. After the register the 2 clusters (use official doc, they have explained better), my cluster.yaml is

apiVersion: k8ssandra.io/v1alpha1
kind: K8ssandraCluster
metadata:
  name: astring
  namespace: k8ssandra-operator
spec:
  cassandra:
    serverVersion: "4.0.10"
    telemetry:
      mcac:
        enabled: false
      prometheus:
        enabled: true
    storageConfig:
      cassandraDataVolumeClaimSpec:
        storageClassName: openebs-hostpath
        accessModes:
          - ReadWriteOnce
        resources:
          requests:
            storage: 1Gi
    config:
      cassandraYaml:
        listen_address: "0.0.0.0"
      jvmOptions:
        heapSize: 512M
    datacenters:
      - metadata:
          name: dc1
        k8sContext: astring-mn
        size: 3
      - metadata:
          name: dc2
        k8sContext: astring-fsn1
        size: 3
  stargate:
    size: 1
    heapSize: 512M
Enter fullscreen mode Exit fullscreen mode

Part 3: Application Database (The Hard Part)

NATS and Cassandra were relatively clean. Postgres is where I spent most of my time.

Postgres stores users, rooms, OTPs, metadatas — all the relational data. The problem: Postgres has one primary at a time. All writes go to the primary, replicas are read-only. In a multi-region setup, if the primary is in Mongolia and a user in Germany does a login, that request either needs to cross the ocean to write (200ms penalty) or I need two primaries that stay in sync.

What I Looked At

TiDB / SurrealDB (TiKV-based)

These are impressive databases but built for low-latency interconnects — single region or multi-AZ with <10ms between nodes. Stretch them across continents and the distributed SQL magic collapses for three specific reasons:

TSO coordination latency. TiDB relies on a Placement Driver (PD) acting as a Timestamp Oracle (TSO) to assign globally ordered timestamps. While timestamp allocation is optimized (batched/pipelined), it still requires coordination with a leader. In a Mongolia–Germany setup, this introduces non-trivial latency before transaction execution, especially under high concurrency.

Raft + 2PC write latency. TiKV uses Raft consensus for replication and Percolator-style two-phase commit for distributed transactions. Writes require quorum acknowledgment, which in cross-region setups means at least one intercontinental round trip. Combined with 2PC coordination, end-to-end write latency can reach hundreds of milliseconds.

Scaling across regions. Adding more regions increases coordination overhead (more replicas, more quorum distance). These systems scale well within a region, but cross-region deployments require careful topology design and acceptance of higher write latency.

CockroachDB

CockroachDB has similar characteristics: Consensus-driven latency. CockroachDB also uses Raft for replication. Cross-region writes require quorum, so latency is bounded by inter-region round trips, similar to TiDB/TiKV.

Operational and licensing considerations. Recent versions have shifted licensing and feature availability. Advanced capabilities like geo-partitioning (which help localize data and reduce cross-region latency) are part of paid tiers. This introduces constraints for setups that require fine-grained data locality control without additional licensing.

YugabyteDB

This one I actually deployed and tested. YugabyteDB is Kubernetes-native, supports active/active replication through their xCluster feature, and the management UI is genuinely good — modern, clear, well-designed. I ran it on both clusters using their operator.

The xCluster setup works: deploy two independent YugabyteDB clusters, configure bidirectional xCluster replication between them. In theory, writes in Mongolia replicate to Germany and vice versa.

The dealbreaker: no DDL replication. Every time I add a table or alter a schema, I have to manually register each new table in the xCluster configuration. There's no automation for this in the open source version — I'd have to go into the dashboard, find the table ID, and add it manually every time. The UI for xCluster management is also rough. YugabyteDB Anywhere (their managed product) handles this properly, but that requires a license.

Here's the values I used — it works fine for single-cluster if you're interested:

Image:
  tag: 2025.2.1.0-b141

storage:
  master:
    count: 3
    size: 2Gi
    storageClass: openebs-hostpath
  tserver:
    count: 3
    size: 2Gi
    storageClass: openebs-hostpath

resource:
  master:
    requests:
      cpu: 0.5
      memory: 0.5Gi
    limits:
      cpu: 1
      memory: 1Gi
  tserver:
    requests:
      cpu: 0.5
      memory: 0.5Gi
    limits:
      cpu: 1
      memory: 1Gi

replicas:
  master: 3
  tserver: 3

partition:
  master: 3
  tserver: 3

domainName: "<zone>.internal"
Enter fullscreen mode Exit fullscreen mode

And creating xCluster replication (run from inside a pod):

yb-admin \
  --master_addresses yb-master-0.yb-masters.yb.svc.astring-fsn1.internal:7100,... \
  setup_universe_replication \
  <replication_id> \
  yb-master-0.yb-masters.yb.svc.astring-mn.internal:7100,... \
  <table_id>
Enter fullscreen mode Exit fullscreen mode

You get the table ID from the dashboard manually. As I said — not practical.

What I Actually Use: PgEdge + Spock

After going through all of that, I ended up with two independent Postgres clusters synchronized using logical replication via Spock — a Postgres extension that enables multi-master replication. PgEdge is a Helm chart built on top of CloudNativePG that packages Spock with a proper Kubernetes operator.

CloudNativePG is excellent — backup, restore, WAL archiving, high availability all work seamlessly. PgEdge adds Spock on top for the cross-cluster sync.

The architecture: two independent Postgres clusters (one per region), each a primary with replicas. Spock creates a logical replication subscription in each direction — cluster-mn subscribes to cluster-de, cluster-de subscribes to cluster-mn. Writes in either region replicate to the other asynchronously.

Helm values for each cluster:

pgEdge:
  appName: astring-cluster
  nodes:
    - name: n1
      hostname: astring-cluster-n1-rw
      clusterSpec:
        instances: 2
        enableSuperuserAccess: true
        postgresql:
          parameters:
            track_commit_timestamp: "on"
            wal_level: "logical"
        plugins:
          - name: barman-cloud.cloudnative-pg.io
            isWALArchiver: true
            parameters:
              barmanObjectName: r2-storage
  clusterSpec:
    storage:
      size: 1Gi
      storageClass: openebs-hostpath
Enter fullscreen mode Exit fullscreen mode

After deploying both clusters, I run an initialization script that sets up the database, roles, and Spock nodes on each cluster:

#!/bin/bash

CONTEXTS=("astring-mn" "astring-fsn1")
DB_NAME="astring_prod"
NAMESPACE="pgedge"
POD_NAME="astring-cluster-n1-1"

for CTX in "${CONTEXTS[@]}"; do
    echo "--- Initializing: $CTX ---"

    POD_IP=$(kubectl get pod "$POD_NAME" --context "$CTX" -n "$NAMESPACE" -o jsonpath='{.status.podIP}')
    SUPER_PASS=$(kubectl get secret "astring-cluster-n1-superuser" --context "$CTX" -n "$NAMESPACE" -o jsonpath='{.data.password}' | base64 --decode)
    APP_PASS=$(kubectl get secret "astring-cluster-n1-app" --context "$CTX" -n "$NAMESPACE" -o jsonpath='{.data.password}' | base64 --decode)

    [[ "$CTX" == *"mn"* ]] && NODE_NAME="region_mn" || NODE_NAME="region_fsn1"
    DSN_HOST="astring-cluster-n1-rw.pgedge.svc.cluster.local"

    export PGPASSWORD=$SUPER_PASS

    psql -h "$POD_IP" -U postgres -d postgres -c "CREATE DATABASE $DB_NAME;" || true
    psql -h "$POD_IP" -U postgres -d "$DB_NAME" -c "
        ALTER ROLE app WITH REPLICATION;
        ALTER DATABASE $DB_NAME OWNER TO app;
        CREATE EXTENSION IF NOT EXISTS spock;
        GRANT USAGE ON SCHEMA spock TO app;
        GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA spock TO app;
        GRANT ALL PRIVILEGES ON ALL FUNCTIONS IN SCHEMA spock TO app;
    "

    psql -h "$POD_IP" -U postgres -d "$DB_NAME" -c "
        SELECT spock.node_create(
            node_name := '$NODE_NAME',
            dsn := 'host=$DSN_HOST port=5432 dbname=$DB_NAME user=app password=$APP_PASS'
        );
    "
done

unset PGPASSWORD
Enter fullscreen mode Exit fullscreen mode

Then sync an initial data dump to make both clusters start from the same state, and set up bidirectional replication:

#!/bin/bash

C1_CTX="astring-mn"
C2_CTX="astring-fsn1"
NS="pgedge"
DB_NAME="production_db"

C1_HOST="postgres-primary.pgedge.astring-mn.internal"
C2_HOST="postgres-primary.pgedge.astring-fsn1.internal"

get_ip() { kubectl get pod "astring-cluster-n1-1" --context "$1" -n "$NS" -o jsonpath='{.status.podIP}'; }
get_pass() { kubectl get secret "$2" --context "$1" -n "$NS" -o jsonpath='{.data.password}' | base64 --decode; }

C1_IP=$(get_ip "$C1_CTX")
C2_IP=$(get_ip "$C2_CTX")
C1_SUP_PASS=$(get_pass "$C1_CTX" "astring-cluster-n1-superuser")
C2_SUP_PASS=$(get_pass "$C2_CTX" "astring-cluster-n1-superuser")
C1_APP_PASS=$(get_pass "$C1_CTX" "astring-cluster-n1-app")
C2_APP_PASS=$(get_pass "$C2_CTX" "astring-cluster-n1-app")

# FSN1 subscribes to MN
export PGPASSWORD=$C2_SUP_PASS
psql -h "$C2_IP" -U postgres -d "$DB_NAME" -c "
SELECT spock.sub_create(
    subscription_name := 'sub_to_region_mn',
    provider_dsn := 'host=$C1_HOST port=5432 dbname=$DB_NAME user=app password=$C1_APP_PASS'
);"

# MN subscribes to FSN1
export PGPASSWORD=$C1_SUP_PASS
psql -h "$C1_IP" -U postgres -d "$DB_NAME" -c "
SELECT spock.sub_create(
    subscription_name := 'sub_to_region_fsn1',
    provider_dsn := 'host=$C2_HOST port=5432 dbname=$DB_NAME user=app password=$C2_APP_PASS'
);"

unset PGPASSWORD
Enter fullscreen mode Exit fullscreen mode

DDL syncs automatically — add a table in one cluster, it appears in the other. No manual table registration like YugabyteDB.

One important detail: with two independent primaries both generating IDs, you need to make sure sequences don't conflict. If both clusters auto-increment from 1, you get duplicate primary keys. Update the sequence ID & increment on both database or use uuid v7, snowflake ID.

Where It Stands

Both clusters run independently behind GSLB. Users are routed to the nearest region, so normal operations stay local — no cross-ocean round trips on the critical path. Within each cluster, data remains strongly consistent. Across regions, I accept eventual consistency and the small window where state may diverge (e.g., a newly created user that hasn’t replicated yet during a failover). (I’ll cover the GSLB setup and routing details separately.)

Real-time messaging flows through a NATS supercluster, chat history is replicated using Apache Cassandra’s multi–data center replication, and application-level state syncs through Spock.

Is this over-engineered for a chat app with 10 users? Yes.

The architecture scales in a straightforward way — adding a new region means deploying another cluster and integrating it into the existing messaging and replication topology, with the usual tradeoffs around replication lag and consistency.

ArgoCD manages all of this across all three clusters — application clusters and management cluster — through ApplicationSets. Maybe I’ll write about this later.

Top comments (0)