Selbst betreiben

Kubernetes

DDM in einem Kubernetes-Cluster – eine Datei mit allem, zwei mit Ihren Einstellungen und ein Secret für die Registry.

Für Kubernetes gibt es eine Datei mit allem, was die Installation braucht – ddm.yaml –, und zwei Dateien mit Ihren Einstellungen. Alle drei stehen auf dieser Seite.

Was ddm.yaml anlegt

ObjektBemerkung
postgres (StatefulSet)Mit eigenem Volume. Wer schon eine Postgres-Datenbank betreibt, kann stattdessen sie nutzen – siehe unten.
cms (Deployment)Genau eine Instanz, Aktualisierung mit Recreate: Das CMS spielt die Migrationen ein und führt die geplanten Aufgaben aus.
render-worker (Deployment)Kann auf mehrere Instanzen wachsen.
client (Deployment)Die Leser-App.
ddm-data (PersistentVolumeClaim)Dokumente und Sicherungen, gemeinsam für CMS und Render-Worker – ReadWriteMany, außer die Dokumente liegen in einem S3-Bucket.
ddm (Ingress)Beide Hostnamen, für ingress-nginx mit Uploads bis 1 GB.

1. Den Namespace und das Registry-Secret anlegen

kubectl create namespace ddm
kubectl -n ddm create secret docker-registry ddm-registry \
  --docker-server=registry.gitlab.com \
  --docker-username=<Name des Tokens> --docker-password=<Token>

2. Die Einstellungen anlegen

config.env – Ihre Einstellungen, dieselben wie bei Docker Compose:

config.env
# The installation's settings, made into the ConfigMap `ddm`:
#     kubectl -n ddm create configmap ddm --from-env-file=config.env
# Every setting of the user guide's „Konfiguration" / „Configuration" may be
# added. Secrets go in `secrets.env`.
DDM_CMS_HOST=cms.example.com
DDM_APP_HOST=app.example.com
AUTH_COOKIE_DOMAIN=example.com
PASSKEY_RP_ID=example.com

MAIL_DRIVER=smtp
MAIL_FROM=ddm@example.com
SMTP_HOST=mail.example.com
SMTP_PORT=587
SMTP_USER=ddm

# The documents in an S3-compatible bucket instead of the `ddm-data` volume
# (its keys in `secrets.env`).
# S3_ENDPOINT=https://s3.example.com
# S3_BUCKET=ddm
# S3_REGION=eu-central-1

secrets.env – Passwörter, BETTER_AUTH_SECRET und LICENCE_KEY. Diese Datei gehört in keine Versionsverwaltung – oder nur verschlüsselt, etwa mit SOPS.

secrets.env
# The installation's secrets, made into the Secret `ddm`:
#     kubectl -n ddm create secret generic ddm --from-env-file=secrets.env
# Copy this file to `secrets.env`, fill it in, and keep it out of version
# control. `openssl rand -base64 32` for each. The database's password is kept by
# Postgres on its first start; changing it here later locks the cms out.
POSTGRES_PASSWORD=
BETTER_AUTH_SECRET=
SMTP_PASSWORD=
# The licence we sent you (`ddm1.…`) — needed from the first start; one
# entered later under Settings › Licence replaces it.
LICENCE_KEY=
# S3_ACCESS_KEY_ID=
# S3_SECRET_ACCESS_KEY=

Daraus werden die ConfigMap und das Secret ddm:

kubectl -n ddm create configmap ddm --from-env-file=config.env
kubectl -n ddm create secret generic ddm --from-env-file=secrets.env

3. ddm.yaml anpassen

Speichern Sie die Datei als ddm.yaml und ändern Sie darin zwei Dinge:

  • Die Version: :latest bei cms, render-worker und client durch die Version, die Sie betreiben – zum Beispiel :0.26. Postgres bleibt bei :18.
  • Ihre Hostnamen: cms.example.com und app.example.com im Ingress am Ende der Datei.
ddm.yaml
# DDM on Kubernetes — everything an installation runs, in one file:
#
#     kubectl create namespace ddm
#     kubectl -n ddm create secret docker-registry ddm-registry \
#       --docker-server=registry.gitlab.com --docker-username=<token name> --docker-password=<token>
#     kubectl -n ddm create configmap ddm --from-env-file=config.env
#     kubectl -n ddm create secret generic ddm --from-env-file=secrets.env
#     kubectl -n ddm apply -f ddm.yaml
#
# Before applying, set in this file:
#   - the release, the same for cms, render-worker and client: `:latest`
#     below, e.g. `:0.26` (Postgres stays at its major, 18). The comment
#     behind each of those lines is where Flux writes a new version, when it
#     updates the installation (`ddm-updates.yaml`); without Flux it does nothing;
#   - your two hostnames, for `cms.example.com` and `app.example.com` in the
#     Ingress at the end.
#
# The settings are in the ConfigMap `ddm` and the Secret `ddm`, made from
# `config.env` and `secrets.env`. After changing them, restart the apps:
#     kubectl -n ddm rollout restart deployment
#
# The cms runs once, and is replaced, not rolled: its scheduled tasks and the
# migrations it runs on start are one process's. The documents are on a
# volume the cms and the render worker share (ReadWriteMany), unless the
# configuration names an S3 bucket.

---
# Postgres with the German dictionary DDM searches with, by its major — never
# updated with the rest: a new major needs its data upgraded by hand.
apiVersion: v1
kind: Service
metadata:
  name: postgres
spec:
  selector:
    app.kubernetes.io/name: postgres
  ports:
    - name: postgres
      port: 5432
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: postgres
spec:
  serviceName: postgres
  replicas: 1
  selector:
    matchLabels:
      app.kubernetes.io/name: postgres
  template:
    metadata:
      labels:
        app.kubernetes.io/name: postgres
    spec:
      imagePullSecrets:
        - name: ddm-registry
      # As the image's `postgres` user (70) from the start — no root to drop
      # from — the volume made its group's.
      securityContext:
        runAsNonRoot: true
        runAsUser: 70
        runAsGroup: 70
        fsGroup: 70
        seccompProfile:
          type: RuntimeDefault
      containers:
        - name: postgres
          image: registry.gitlab.com/iq-me/new-ddm-images/postgres:18
          # Postgres' own fixes come under the same tag: a restart brings them (user guide, „Postgres").
          imagePullPolicy: Always
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities:
              drop: [ALL]
          env:
            - name: POSTGRES_USER
              value: ddm
            - name: POSTGRES_DB
              value: ddm
            - name: POSTGRES_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: ddm
                  key: POSTGRES_PASSWORD
          ports:
            - name: postgres
              containerPort: 5432
          readinessProbe:
            exec:
              command: [pg_isready, -U, ddm, -d, ddm]
            periodSeconds: 5
          resources:
            requests:
              cpu: 250m
              memory: 512Mi
            limits:
              memory: 2Gi
          volumeMounts:
            - name: postgres
              mountPath: /var/lib/postgresql
            # Writable besides its data: the socket, and scratch.
            - name: run
              mountPath: /var/run/postgresql
            - name: tmp
              mountPath: /tmp
      volumes:
        - name: run
          emptyDir: {}
        - name: tmp
          emptyDir: {}
  volumeClaimTemplates:
    - metadata:
        name: postgres
      spec:
        accessModes: [ReadWriteOnce]
        resources:
          requests:
            storage: 20Gi
---
# The documents, and the database's dumps taken before each migration (the
# last three): read and written by the cms and the render worker, which may
# run on different nodes. With an S3 bucket configured (`S3_ENDPOINT`) nothing
# is kept here, and its size may be small.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: ddm-data
spec:
  accessModes: [ReadWriteMany]
  resources:
    requests:
      storage: 100Gi
---
# The management app, and the API the reader app, the render worker's
# documents and every integration go through. One replica, replaced rather
# than rolled: it runs the scheduled tasks, and migrates the database to its
# release before it answers.
apiVersion: v1
kind: Service
metadata:
  name: cms
spec:
  selector:
    app.kubernetes.io/name: cms
  ports:
    - name: http
      port: 3000
      targetPort: http
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: cms
spec:
  replicas: 1
  strategy:
    type: Recreate
  selector:
    matchLabels:
      app.kubernetes.io/name: cms
  template:
    metadata:
      labels:
        app.kubernetes.io/name: cms
    spec:
      imagePullSecrets:
        - name: ddm-registry
      securityContext:
        runAsNonRoot: true
        runAsUser: 1000
        runAsGroup: 1000
        fsGroup: 1000
      containers:
        - name: cms
          image: registry.gitlab.com/iq-me/new-ddm-images/cms:latest # {"$imagepolicy": "flux-system:ddm-cms"}
          # Asked anew at every start: a tag that moves (`stable`, `0.26`) brings its newest image.
          imagePullPolicy: Always
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities:
              drop: [ALL]
          envFrom:
            - configMapRef:
                name: ddm
            - secretRef:
                name: ddm
          env:
            # Which version of this file this is: a release that needs a newer
            # one says so to the administrators, rather than failing (user guide, „Updates").
            - name: DDM_DEPLOY_VERSION
              value: "1"
            - name: DATABASE_URL
              value: postgres://ddm:$(POSTGRES_PASSWORD)@postgres:5432/ddm
            - name: BETTER_AUTH_URL
              value: https://$(DDM_CMS_HOST)
            - name: CLIENT_ORIGIN
              value: https://$(DDM_APP_HOST)
            - name: STORAGE_DIR
              value: /data/storage
            - name: BACKUP_DIR
              value: /data/backups
            # Uploads are stored here and drawn by the render worker.
            - name: RENDER_MODE
              value: worker
          ports:
            - name: http
              containerPort: 3000
          # Migrations first, on a new release: up to five minutes.
          startupProbe:
            httpGet:
              path: /api/auth/ok
              port: http
            periodSeconds: 2
            failureThreshold: 150
          readinessProbe:
            httpGet:
              path: /api/auth/ok
              port: http
            periodSeconds: 10
          livenessProbe:
            httpGet:
              path: /api/auth/ok
              port: http
            periodSeconds: 30
            timeoutSeconds: 5
          resources:
            requests:
              cpu: 250m
              memory: 512Mi
            limits:
              memory: 2Gi
          volumeMounts:
            - name: data
              mountPath: /data
            # The one place written besides `/data`: uploads on their way, LibreOffice's profiles.
            - name: tmp
              mountPath: /tmp
      volumes:
        - name: data
          persistentVolumeClaim:
            claimName: ddm-data
        - name: tmp
          emptyDir: {}
---
# Reads the text, draws the thumbnails and converts the Office files of what
# is uploaded, sharing the work through the database: more replicas for more
# imports. Each finishes the documents in hand before it stops — LibreOffice
# may take minutes over a large deck.
apiVersion: apps/v1
kind: Deployment
metadata:
  name: render-worker
spec:
  replicas: 1
  selector:
    matchLabels:
      app.kubernetes.io/name: render-worker
  template:
    metadata:
      labels:
        app.kubernetes.io/name: render-worker
    spec:
      imagePullSecrets:
        - name: ddm-registry
      securityContext:
        runAsNonRoot: true
        runAsUser: 1000
        runAsGroup: 1000
        fsGroup: 1000
      terminationGracePeriodSeconds: 660
      containers:
        - name: render-worker
          image: registry.gitlab.com/iq-me/new-ddm-images/render-worker:latest # {"$imagepolicy": "flux-system:ddm-render-worker"}
          imagePullPolicy: Always
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities:
              drop: [ALL]
          envFrom:
            - configMapRef:
                name: ddm
            - secretRef:
                name: ddm
          env:
            - name: DATABASE_URL
              value: postgres://ddm:$(POSTGRES_PASSWORD)@postgres:5432/ddm
            - name: STORAGE_DIR
              value: /data/storage
            - name: RENDER_LARGE_OFFICE
              value: "on"
          resources:
            requests:
              cpu: 500m
              memory: 1Gi
            limits:
              memory: 3Gi
          volumeMounts:
            - name: data
              mountPath: /data
            # The one place written besides `/data`: uploads on their way, LibreOffice's profiles.
            - name: tmp
              mountPath: /tmp
      volumes:
        - name: data
          persistentVolumeClaim:
            claimName: ddm-data
        - name: tmp
          emptyDir: {}
---
# The reader app: its pages, and everything under /api forwarded to the cms.
apiVersion: v1
kind: Service
metadata:
  name: client
spec:
  selector:
    app.kubernetes.io/name: client
  ports:
    - name: http
      port: 3000
      targetPort: http
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: client
spec:
  replicas: 1
  selector:
    matchLabels:
      app.kubernetes.io/name: client
  template:
    metadata:
      labels:
        app.kubernetes.io/name: client
    spec:
      imagePullSecrets:
        - name: ddm-registry
      securityContext:
        runAsNonRoot: true
        runAsUser: 1000
        runAsGroup: 1000
      containers:
        - name: client
          image: registry.gitlab.com/iq-me/new-ddm-images/client:latest # {"$imagepolicy": "flux-system:ddm-client"}
          imagePullPolicy: Always
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities:
              drop: [ALL]
          envFrom:
            - configMapRef:
                name: ddm
          env:
            - name: NUXT_API_ORIGIN
              value: http://cms:3000
            - name: NUXT_PUBLIC_MGMT_URL
              value: https://$(DDM_CMS_HOST)
            # The reader app on this installation, for the mobile app's „Server".
            - name: NUXT_PUBLIC_SERVER_URL
              value: https://$(DDM_APP_HOST)
            - name: RENDER_MODE
              value: worker
          ports:
            - name: http
              containerPort: 3000
          # Through to the cms: what the reader app forwards answers, too.
          readinessProbe:
            httpGet:
              path: /api/auth/ok
              port: http
            periodSeconds: 10
          resources:
            requests:
              cpu: 100m
              memory: 128Mi
            limits:
              memory: 512Mi
          volumeMounts:
            - name: tmp
              mountPath: /tmp
      volumes:
        - name: tmp
          emptyDir: {}
---
# Both hostnames — replace `cms.example.com` and `app.example.com` with yours —
# with TLS from the Secret `ddm-tls`, or from cert-manager with its
# annotation added here. Uploads are up to 1 GiB and may take a while:
# ingress-nginx's limits raised; another controller needs its own.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: ddm
  annotations:
    nginx.ingress.kubernetes.io/proxy-body-size: 1g
    nginx.ingress.kubernetes.io/proxy-read-timeout: "600"
    nginx.ingress.kubernetes.io/proxy-send-timeout: "600"
    nginx.ingress.kubernetes.io/proxy-request-buffering: "off"
spec:
  tls:
    - hosts: [cms.example.com, app.example.com]
      secretName: ddm-tls
  rules:
    - host: cms.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: cms
                port:
                  name: http
    - host: app.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: client
                port:
                  name: http

4. Anwenden

kubectl -n ddm apply -f ddm.yaml
kubectl -n ddm rollout status deployment/cms

Dann legen Sie wie bei Docker Compose das erste Konto an.

Einstellungen ändern

Nach einer Änderung an config.env oder secrets.env schreiben Sie ConfigMap und Secret neu und starten die Apps neu:

kubectl -n ddm create configmap ddm --from-env-file=config.env --dry-run=client -o yaml | kubectl apply -f -
kubectl -n ddm create secret generic ddm --from-env-file=secrets.env --dry-run=client -o yaml | kubectl apply -f -
kubectl -n ddm rollout restart deployment

Eine eigene Datenbank nutzen

Betreiben Sie Postgres schon selbst, braucht DDM Postgres 18 mit dem deutschen Wörterbuch für die Volltextsuche, das unser Image postgres:18 mitbringt. Dann:

  1. Tragen Sie die Adresse in secrets.env ein: EXTERNAL_DATABASE_URL=postgres://ddm:<Passwort>@db.firma.de:5432/ddm.
  2. Ersetzen Sie in ddm.yaml bei cms und render-worker den Wert von DATABASE_URL durch $(EXTERNAL_DATABASE_URL).
  3. Löschen Sie in ddm.yaml die beiden ersten Abschnitte – den Service und das StatefulSet postgres.

Ohne ReadWriteMany

CMS und Render-Worker teilen sich das Volume ddm-data. Kann Ihr Cluster kein Volume mit ReadWriteMany bereitstellen, legen Sie die Dokumente in einen S3-kompatiblen Bucket – S3_ENDPOINT und S3_BUCKET in config.env, die Schlüssel in secrets.env. Die Sicherungen landen dann im selben Bucket. Das Volume braucht danach nur noch das CMS. Ändern Sie in ddm.yaml:

  1. beim PersistentVolumeClaim ddm-data die accessModes auf [ReadWriteOnce],
  2. beim Deployment render-worker die Einträge data unter volumeMounts und unter volumes – löschen Sie beide; tmp bleibt.