Pairs each <TS>.sql.gz with a <TS>.filestore.tar.gz under the same prefix; rotation prunes both together. Backup pod runs on the same node as Odoo (podAffinity) and mounts the filestore PVC read-only — RWO permits multiple pods on the same node, so this is safe. Restore (Tower-side) reads the companion key from S3, scales Odoo to 0, restores DB + filestore, and scales Odoo back up.
171 lines
7.8 KiB
YAML
171 lines
7.8 KiB
YAML
{{- if .Values.backups.enabled -}}
|
|
# Daily dump → S3.
|
|
#
|
|
# Architecture: pg_dump in postgres:alpine, pipe through gzip, then
|
|
# `aws s3 cp -` to push the stream straight to MEGA S4. We use a
|
|
# single multi-stage shell command (no init container) so the dump
|
|
# never lands on the customer-server's local disk — the instance
|
|
# data and the backup destination are deliberately separated.
|
|
#
|
|
# AWS credentials come from a K8s Secret (default `s3-backup-creds`)
|
|
# provisioned out-of-band by Tower's bootstrap. Endpoint + bucket +
|
|
# prefix are committed in this file's values; only the access/secret
|
|
# pair lives in the Secret.
|
|
apiVersion: batch/v1
|
|
kind: CronJob
|
|
metadata:
|
|
name: {{ include "instance.fullname" . }}-backup
|
|
labels:
|
|
{{- include "instance.labels" . | nindent 4 }}
|
|
odoosky.io/role: backup
|
|
spec:
|
|
schedule: {{ .Values.backups.schedule | quote }}
|
|
concurrencyPolicy: Forbid
|
|
successfulJobsHistoryLimit: 5
|
|
failedJobsHistoryLimit: 3
|
|
jobTemplate:
|
|
metadata:
|
|
labels:
|
|
{{- include "instance.labels" . | nindent 8 }}
|
|
odoosky.io/role: backup
|
|
spec:
|
|
backoffLimit: 1
|
|
template:
|
|
metadata:
|
|
labels:
|
|
{{- include "instance.labels" . | nindent 12 }}
|
|
odoosky.io/role: backup
|
|
spec:
|
|
restartPolicy: Never
|
|
# Land on the same node as the running Odoo pod so the backup
|
|
# container can mount the filestore PVC. The PVC is RWO,
|
|
# which K8s reads as "one pod per node" — multiple pods on
|
|
# the SAME node can mount the same volume simultaneously,
|
|
# so this is safe and gives the backup direct read access
|
|
# to /var/lib/odoo without disturbing Odoo.
|
|
affinity:
|
|
podAffinity:
|
|
requiredDuringSchedulingIgnoredDuringExecution:
|
|
- labelSelector:
|
|
matchLabels:
|
|
app.kubernetes.io/instance: {{ .Values.instance.code | quote }}
|
|
odoosky.io/role: odoo
|
|
topologyKey: kubernetes.io/hostname
|
|
volumes:
|
|
- name: filestore
|
|
persistentVolumeClaim:
|
|
claimName: {{ include "instance.fullname" . }}-odoo
|
|
containers:
|
|
- name: pgdump-s3
|
|
# postgres:16-alpine + `apk add aws-cli` — alpine's
|
|
# aws-cli package is ~30 MB and adds ~5 s to the first
|
|
# job run on each node. Subsequent runs reuse the
|
|
# already-installed binary because we keep the same
|
|
# image (containerd's layer cache covers the apk index
|
|
# download). This matches the postgres version of the
|
|
# cluster's actual database container, so pg_dump's
|
|
# client/server protocol always lines up.
|
|
image: "{{ .Values.postgres.image }}:{{ .Values.postgres.tag }}"
|
|
imagePullPolicy: IfNotPresent
|
|
volumeMounts:
|
|
- name: filestore
|
|
mountPath: /var/lib/odoo
|
|
readOnly: true
|
|
env:
|
|
- name: PGHOST
|
|
value: {{ include "instance.fullname" . }}-pg
|
|
- name: PGUSER
|
|
valueFrom:
|
|
secretKeyRef:
|
|
name: {{ include "instance.fullname" . }}-pg
|
|
key: POSTGRES_USER
|
|
- name: PGPASSWORD
|
|
valueFrom:
|
|
secretKeyRef:
|
|
name: {{ include "instance.fullname" . }}-pg
|
|
key: POSTGRES_PASSWORD
|
|
- name: PGDATABASE
|
|
valueFrom:
|
|
secretKeyRef:
|
|
name: {{ include "instance.fullname" . }}-pg
|
|
key: POSTGRES_DB
|
|
- name: AWS_ACCESS_KEY_ID
|
|
valueFrom:
|
|
secretKeyRef:
|
|
name: {{ .Values.backups.credentialsSecret }}
|
|
key: AWS_ACCESS_KEY_ID
|
|
- name: AWS_SECRET_ACCESS_KEY
|
|
valueFrom:
|
|
secretKeyRef:
|
|
name: {{ .Values.backups.credentialsSecret }}
|
|
key: AWS_SECRET_ACCESS_KEY
|
|
- name: S3_ENDPOINT
|
|
value: {{ .Values.backups.s3.endpoint | quote }}
|
|
- name: AWS_DEFAULT_REGION
|
|
value: {{ .Values.backups.s3.region | quote }}
|
|
- name: S3_BUCKET
|
|
value: {{ .Values.backups.s3.bucket | quote }}
|
|
- name: S3_PREFIX
|
|
value: {{ .Values.instance.code | quote }}
|
|
- name: RETAIN
|
|
value: {{ .Values.backups.retain | quote }}
|
|
command:
|
|
- /bin/sh
|
|
- -c
|
|
- |
|
|
# `pipefail` is critical: without it, a failed
|
|
# pg_dump piped into `aws s3 cp` would still produce
|
|
# a 0-exit "successful" job (the cp succeeded
|
|
# uploading empty/garbage data). With pipefail any
|
|
# element of the pipe failing fails the whole thing.
|
|
set -euo pipefail
|
|
TS=$(date -u +%Y%m%dT%H%M%SZ)
|
|
SQL_KEY="${S3_PREFIX}/${TS}.sql.gz"
|
|
FS_KEY="${S3_PREFIX}/${TS}.filestore.tar.gz"
|
|
if ! command -v aws >/dev/null 2>&1; then
|
|
apk add --no-cache aws-cli tar >/dev/null
|
|
fi
|
|
echo ">>> dumping DB to s3://${S3_BUCKET}/${SQL_KEY}"
|
|
pg_dump --format=plain --clean --if-exists --no-owner --no-acl \
|
|
| gzip -9 \
|
|
| aws --endpoint-url "$S3_ENDPOINT" s3 cp - "s3://${S3_BUCKET}/${SQL_KEY}"
|
|
echo ">>> archiving filestore to s3://${S3_BUCKET}/${FS_KEY}"
|
|
# Tar the filestore tree from /var/lib/odoo. If the
|
|
# dir is empty (fresh instance) we still upload an
|
|
# empty tar so the snapshot is paired — restore code
|
|
# treats absent filestore object as "no filestore
|
|
# captured for this snapshot" (older backups).
|
|
if [ -d /var/lib/odoo ] && [ -n "$(ls -A /var/lib/odoo 2>/dev/null)" ]; then
|
|
tar -czf - -C /var/lib/odoo . \
|
|
| aws --endpoint-url "$S3_ENDPOINT" s3 cp - "s3://${S3_BUCKET}/${FS_KEY}"
|
|
else
|
|
echo "(filestore empty; skipping archive)"
|
|
fi
|
|
echo ">>> rotating: keep last $RETAIN snapshots under ${S3_PREFIX}/"
|
|
# Group keys by timestamp prefix (everything before
|
|
# the first dot after the date) and prune the oldest
|
|
# groups. Both .sql.gz and .filestore.tar.gz share
|
|
# the same timestamp prefix, so groups stay paired.
|
|
aws --endpoint-url "$S3_ENDPOINT" s3api list-objects-v2 \
|
|
--bucket "$S3_BUCKET" --prefix "${S3_PREFIX}/" \
|
|
--query 'Contents[].Key' --output text 2>/dev/null \
|
|
| tr '\t' '\n' \
|
|
| grep -E '\.sql\.gz$' \
|
|
| sort -r | tail -n +$((RETAIN + 1)) \
|
|
| while read OLDSQL; do
|
|
[ -n "$OLDSQL" ] || continue
|
|
OLDFS="${OLDSQL%.sql.gz}.filestore.tar.gz"
|
|
echo ">>> deleting: $OLDSQL + $OLDFS (if present)"
|
|
aws --endpoint-url "$S3_ENDPOINT" s3 rm "s3://${S3_BUCKET}/${OLDSQL}" || true
|
|
aws --endpoint-url "$S3_ENDPOINT" s3 rm "s3://${S3_BUCKET}/${OLDFS}" 2>/dev/null || true
|
|
done
|
|
echo ">>> done"
|
|
resources:
|
|
requests:
|
|
cpu: 100m
|
|
memory: 256Mi
|
|
limits:
|
|
cpu: "1"
|
|
memory: 1Gi
|
|
{{- end }}
|