arrstack: run sonarr/radarr/prowlarr active-active on shared Postgres (#383)
## Why
The arrstack `sonarr`/`radarr`/`prowlarr` apps currently run as single-replica upstream LinuxServer images on SQLite (RWO config PVC, `Recreate` strategy) — no HA, no horizontal scale, and a hard one-pod ceiling because SQLite can't be shared. Our `-unkin2` forks move all app state into PostgreSQL and use Postgres advisory locks for cross-replica coordination (migrations, scheduled tasks, command execution), which makes an **active-active multi-replica** deployment safe.
This is a fresh Postgres backend — **no SQLite data migration**. The current single-replica pods are throwaway baselines; the new pods start on an empty per-app database and the fork's provider migrates the schema on first start. `arrproxy` (its own `arrproxy-db`) and `nzbget` are untouched.
## What
New `apps/base/arrstack/postgres/`:
- `cnpg_cluster.yaml` — CNPG `Cluster` `arrstack-postgres` (3 instances, `cephrbd-fast-delete`), throwaway `app` initdb owner, and 3 `managed.roles` (`sonarr`/`radarr`/`prowlarr`, login non-superuser) each with `passwordSecret: <app>-db`.
- `database-{sonarr,radarr,prowlarr}.yaml` — CNPG `Database` `<app>-main` owned by `<app>`, `databaseReclaimPolicy: retain`.
- `cnpg_backup.yaml` — cephrgw `ObjectStoreUser` + `Bucket cnpg-arrstack` + nightly `ScheduledBackup` (mirrors jellyfin).
- `vaultstaticsecret.yaml` — 3 VSO `VaultStaticSecret`s syncing `<app>-db` (keys `username`/`password`) from `kv/kubernetes/namespace/arrstack/default/<app>-db` (already seeded); these are both the CNPG role `passwordSecret` and the Deployment's Postgres creds.
- sync-waves: `<app>-db` Secrets (0) → Cluster (1) → Databases (2).
- added `postgres` to `apps/base/arrstack/kustomization.yaml`.
Per app (`sonarr`/`radarr`/`prowlarr`):
- `deployment.yaml`: image → `docker-internal/<app>:v<base>-unkin2` (sonarr `v5.0.0`, radarr `v6.4.2`, prowlarr `v2.6.2`); `replicas: 3`; `RollingUpdate`. Removed the `apikey-init` initContainer and `PUID`/`PGID`/`TZ`. Runs the binary directly (`command: [/app/<Bin>]`, `args: [-nobrowser, -data=/config, -nosingleinstancecheck]`). Env from the new ConfigMap + `secretKeyRef`: `<App>__Postgres__User`/`__Password` (Secret `<app>-db`) and `<App>__Auth__ApiKey` (existing `<app>-apikey`, key `apitoken`). `/<app>/ping` probes kept. `securityContext` runAsUser/runAsGroup/fsGroup 1000 for CephFS group-write.
- new `configmap.yaml`: non-secret `<App>__…` env (Postgres Host/Port/MainDb, `Log__DbEnabled=false`, `Auth__Method=External`, `Auth__Required=DisabledForLocalAddresses`, `App__InstanceName`, `Server__Port`, `Server__UrlBase=/<app>`, `Update__Mechanism=External`).
- `pvc-config.yaml` → RWX `cephfs-raid5-retain` (shared `/config` for `config.xml`/MediaCover).
- unchanged: `service.yaml`, `gateway.yaml`, `httproute.yaml`, apikey `vaultstaticsecret.yaml` — arrproxy still addresses the Service VIP, so N replicas are transparent.
## Validation
`kustomize build apps/overlays/au-syd1/arrstack` clean; `kubeconform` **64/64 valid**; `yamllint` and the repo pre-commit (incl. no-plain-secrets) pass. Runtime validation (pods Ready, single migrate under advisory lock, scheduled-task/command single-owner, UI via `https://arrstack.unkin.net/<app>/`, 3→1→3 rescale) is post-merge once ArgoCD syncs.
## Decisions / unknowns
- **Fork image UID/fsGroup**: the `-unkin2` images have no `USER` (run as root); pinned `runAsUser`/`runAsGroup`/`fsGroup: 1000` with `fsGroupChangePolicy: OnRootMismatch`. Verify group-write on the shared CephFS `/config` on first deploy.
- **Config storage**: chose RWX `cephfs-raid5-retain` shared `/config` (per spec preference) over per-pod `emptyDir`.
- **Postgres storage/version/resources**: mirrored jellyfin/arrproxy CNPG conventions — `cephrbd-fast-delete` 10Gi, PG17, 3 instances.
Reviewed-on: #383
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
This commit was merged in pull request #383.
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
---
|
||||
# Non-secret env for the -unkin2 fork. The fork reads Servarr config from
|
||||
# Radarr__<Section>__<Key> env (no config.xml edits, no s6/PUID). Postgres wiring
|
||||
# points every replica at the same shared DB (arrstack-postgres-rw / radarr-main);
|
||||
# Auth__Method=External defers UI auth to arrproxy/oauth2-proxy; Server__UrlBase
|
||||
# keeps the /radarr prefix so arrproxy path-routing works; App__InstanceName is
|
||||
# identical across replicas (shared session-cookie name). User/Password/ApiKey
|
||||
# come from Secrets (see deployment.yaml), not here.
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: radarr-env
|
||||
namespace: arrstack
|
||||
data:
|
||||
Radarr__Postgres__Host: arrstack-postgres-rw.arrstack.svc.cluster.local
|
||||
Radarr__Postgres__Port: "5432"
|
||||
Radarr__Postgres__MainDb: radarr-main
|
||||
Radarr__Log__DbEnabled: "false"
|
||||
Radarr__Auth__Method: External
|
||||
Radarr__Auth__Required: DisabledForLocalAddresses
|
||||
Radarr__App__InstanceName: Radarr
|
||||
Radarr__Server__Port: "7878"
|
||||
Radarr__Server__UrlBase: /radarr
|
||||
Radarr__Update__Mechanism: External
|
||||
@@ -5,10 +5,13 @@ metadata:
|
||||
name: radarr
|
||||
namespace: arrstack
|
||||
spec:
|
||||
replicas: 1
|
||||
# Active-active: the -unkin2 fork keeps all state in the shared Postgres
|
||||
# (arrstack-postgres) and coordinates via Postgres advisory locks, so N
|
||||
# replicas run concurrently behind the radarr Service. RollingUpdate is safe —
|
||||
# no SQLite, no RWO lock.
|
||||
replicas: 3
|
||||
strategy:
|
||||
# RWO config PVC + single stateful SQLite DB: never run two pods at once.
|
||||
type: Recreate
|
||||
type: RollingUpdate
|
||||
selector:
|
||||
matchLabels:
|
||||
app: radarr
|
||||
@@ -18,96 +21,49 @@ spec:
|
||||
app: radarr
|
||||
spec:
|
||||
securityContext:
|
||||
# Fork image has no USER (runs as root by default); pin it to a non-root
|
||||
# UID and group-write the shared RWX CephFS /config (MediaCover etc.).
|
||||
# OnRootMismatch avoids a recursive chown of the whole media tree.
|
||||
runAsUser: 1000
|
||||
runAsGroup: 1000
|
||||
fsGroup: 1000
|
||||
fsGroupChangePolicy: OnRootMismatch
|
||||
initContainers:
|
||||
# Enforce the Vault-sourced API key and the reverse-proxy URL base in
|
||||
# /config/config.xml before the app starts. Vault is source of truth
|
||||
# (override bootstrap): the key is minted in Vault, synced by VSO into the
|
||||
# radarr-apikey Secret, and written here. UrlBase=/radarr lets arrproxy
|
||||
# forward arrstack.unkin.net/radarr/... with the prefix preserved (no 307).
|
||||
# Runs as root to fix ownership; touches only <ApiKey>, <UrlBase>, and
|
||||
# <AuthenticationMethod>=External + <AuthenticationRequired>. External makes
|
||||
# the *arr defer UI login to arrproxy/oauth2-proxy (same no-auth request
|
||||
# handler as None, but permits remote access without prompting).
|
||||
- name: apikey-init
|
||||
image: artifactapi.k8s.syd1.au.unkin.net/dockerhub/library/busybox:1.37.0
|
||||
imagePullPolicy: IfNotPresent
|
||||
securityContext:
|
||||
runAsUser: 0
|
||||
env:
|
||||
- name: API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: radarr-apikey
|
||||
key: apitoken
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
set -eu
|
||||
case "$API_KEY" in
|
||||
"" | *[!0-9a-fA-F]*)
|
||||
echo "config-init: API_KEY missing or not hex; refusing" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
URL_BASE=/radarr
|
||||
AUTH_METHOD=External
|
||||
AUTH_REQUIRED=Enabled
|
||||
CFG=/config/config.xml
|
||||
if [ ! -f "$CFG" ]; then
|
||||
printf '<Config>\n <ApiKey>%s</ApiKey>\n <UrlBase>%s</UrlBase>\n <AuthenticationMethod>%s</AuthenticationMethod>\n <AuthenticationRequired>%s</AuthenticationRequired>\n</Config>\n' "$API_KEY" "$URL_BASE" "$AUTH_METHOD" "$AUTH_REQUIRED" > "$CFG"
|
||||
else
|
||||
if grep -q '<ApiKey>' "$CFG"; then
|
||||
sed -i "s|<ApiKey>[^<]*</ApiKey>|<ApiKey>${API_KEY}</ApiKey>|" "$CFG"
|
||||
else
|
||||
sed -i "s|<Config>|<Config>\n <ApiKey>${API_KEY}</ApiKey>|" "$CFG"
|
||||
fi
|
||||
if grep -q '<UrlBase>' "$CFG"; then
|
||||
sed -i "s|<UrlBase>[^<]*</UrlBase>|<UrlBase>${URL_BASE}</UrlBase>|" "$CFG"
|
||||
else
|
||||
sed -i "s|<Config>|<Config>\n <UrlBase>${URL_BASE}</UrlBase>|" "$CFG"
|
||||
fi
|
||||
if grep -q '<AuthenticationMethod>' "$CFG"; then
|
||||
sed -i "s|<AuthenticationMethod>[^<]*</AuthenticationMethod>|<AuthenticationMethod>${AUTH_METHOD}</AuthenticationMethod>|" "$CFG"
|
||||
else
|
||||
sed -i "s|<Config>|<Config>\n <AuthenticationMethod>${AUTH_METHOD}</AuthenticationMethod>|" "$CFG"
|
||||
fi
|
||||
if grep -q '<AuthenticationRequired>' "$CFG"; then
|
||||
sed -i "s|<AuthenticationRequired>[^<]*</AuthenticationRequired>|<AuthenticationRequired>${AUTH_REQUIRED}</AuthenticationRequired>|" "$CFG"
|
||||
else
|
||||
sed -i "s|<Config>|<Config>\n <AuthenticationRequired>${AUTH_REQUIRED}</AuthenticationRequired>|" "$CFG"
|
||||
fi
|
||||
fi
|
||||
chown 1000:1000 "$CFG"
|
||||
chmod 600 "$CFG"
|
||||
echo "config-init: <ApiKey>, <UrlBase>=${URL_BASE}, <AuthenticationMethod>=${AUTH_METHOD}, <AuthenticationRequired>=${AUTH_REQUIRED} enforced from Vault"
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 32Mi
|
||||
limits:
|
||||
cpu: 200m
|
||||
memory: 64Mi
|
||||
volumeMounts:
|
||||
- name: config
|
||||
mountPath: /config
|
||||
containers:
|
||||
- name: radarr
|
||||
image: artifactapi.k8s.syd1.au.unkin.net/dockerhub/linuxserver/radarr:6.3.0
|
||||
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/radarr:v6.4.2-unkin2
|
||||
imagePullPolicy: IfNotPresent
|
||||
command:
|
||||
- /app/Radarr
|
||||
args:
|
||||
- -nobrowser
|
||||
- -data=/config
|
||||
# Required: bypass the single-instance guard so multiple replicas
|
||||
# can share one /config. Cross-replica safety is the Postgres layer,
|
||||
# not a local lock file.
|
||||
- -nosingleinstancecheck
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 7878
|
||||
protocol: TCP
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: radarr-env
|
||||
env:
|
||||
- name: PUID
|
||||
value: "1000"
|
||||
- name: PGID
|
||||
value: "1000"
|
||||
- name: TZ
|
||||
value: Australia/Sydney
|
||||
- name: Radarr__Postgres__User
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: radarr-db
|
||||
key: username
|
||||
- name: Radarr__Postgres__Password
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: radarr-db
|
||||
key: password
|
||||
- name: Radarr__Auth__ApiKey
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: radarr-apikey
|
||||
key: apitoken
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /radarr/ping
|
||||
|
||||
@@ -5,6 +5,7 @@ kind: Kustomization
|
||||
resources:
|
||||
- pvc-config.yaml
|
||||
- vaultstaticsecret.yaml
|
||||
- configmap.yaml
|
||||
- deployment.yaml
|
||||
- service.yaml
|
||||
- gateway.yaml
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
# Radarr config + SQLite DB. RWO on cephrbd (block) — the arr apps' SQLite
|
||||
# does not tolerate CephFS locking. Retain: this is state.
|
||||
# Radarr /config. RWX on CephFS so all replicas share it (the -unkin2 fork keeps
|
||||
# the database in Postgres; /config now holds only config.xml + MediaCover, which
|
||||
# tolerate — and want — shared access). Retain: this is state.
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
@@ -8,9 +9,9 @@ metadata:
|
||||
namespace: arrstack
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
- ReadWriteMany
|
||||
resources:
|
||||
requests:
|
||||
storage: 5Gi
|
||||
storageClassName: cephrbd-fast-retain
|
||||
storageClassName: cephfs-raid5-retain
|
||||
volumeMode: Filesystem
|
||||
|
||||
Reference in New Issue
Block a user