From 3eb05bef6d06e929f278231ae83142f680149451 Mon Sep 17 00:00:00 2001 From: Sajeyks Date: Fri, 9 Oct 2026 19:36:30 +0300 Subject: [PATCH] Document the roles and add the Kubernetes guide --- DOCKER.md | 22 +++++---- K8S.md | 138 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 150 insertions(+), 10 deletions(-) create mode 100644 K8S.md diff --git a/DOCKER.md b/DOCKER.md index e00561ac..6a3e92d0 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -7,7 +7,7 @@ Postgres 18, Redis, the web app (Django 6.1, Python 3.12) and a Celery worker in ```sh git clone && cd dynamicweb docker compose up -d --build -docker compose logs -f web # ready when it prints "Starting development server" +docker compose logs -f web # ready when it prints "Listening at" ``` The first start takes a few minutes (image build, migrations, secret key, sample data). @@ -61,25 +61,27 @@ Checklist: CMS; nothing is created for you). Sample data is skipped anyway when it finds page content. - `DEBUG=False` loads the production settings and turns off the `/dev-sites/` switcher, so reach each site by its real domain name. -- The web container runs Django's `runserver`; put a real web server or proxy in front - for real traffic. +- The web container runs gunicorn (static files via WhiteNoise); put a TLS-terminating proxy in + front. Behind one that sets `X-Forwarded-Proto`, set `BEHIND_TLS_PROXY=True`. ## Image and Kubernetes -One image, `dynamicweb:latest`, serves both roles; only the command differs: +One image, `dynamicweb:latest`, serves every role; the `ROLE` variable picks it (compose sets +`ROLE=celery` on the worker): -- web: `sh start-web.sh` (port 8000) -- celery: `celery -A dynamicweb worker -l info` +- `web` (default): migrations, then gunicorn on port 8000 +- `celery`: the Celery worker +- `migrate`: run the migrations and exit -The image's entrypoint (`/entrypoint.sh`) writes the DB settings from the environment and waits -for Postgres before running the command, so in Kubernetes set `args:`, not `command:` -(`command:` replaces the entrypoint). Configuration is the environment variables in -`.env.docker`: use a ConfigMap and a Secret, and set `DJANGO_SECRET_KEY` there. +Don't override `command:` or `args:` in Kubernetes; `ROLE` is all that is needed. +See [K8S.md](K8S.md) for the Kubernetes deployment. ## Configuration - `.env.docker`: committed dev defaults. `.env`: your overrides (git-ignored). - Add a site: add it to `UNGLEICH_SITE_CONFIGS` (a `Site` row is created on start). +- Web server tuning: `GUNICORN_WORKERS` (3), `GUNICORN_TIMEOUT` (120). `RUN_MIGRATIONS=False` + skips the start-up migrations. - Sample data off: `SEED_DUMMY_DATA=False`. Remove it later: `docker compose exec web python manage.py seed_dummy_data --purge`. - Restore a real dump: put a `.sql` or `.sql.gz` file in `db-init/`, then diff --git a/K8S.md b/K8S.md new file mode 100644 index 00000000..714c4959 --- /dev/null +++ b/K8S.md @@ -0,0 +1,138 @@ +# Running dynamicweb on Kubernetes + +One image, `dynamicweb`, runs three roles. Pick the role with the `ROLE` environment variable: + +| `ROLE` | What it does | +|---|---| +| `web` (default) | migrations (unless `RUN_MIGRATIONS=False`), then gunicorn on port 8000 | +| `celery` | the Celery worker | +| `migrate` | runs the migrations and exits (for a Job) | + +Do not set `command:` or `args:` on the containers. The image's entrypoint writes the database +settings from the environment and waits for Postgres, and `ROLE` does the rest. + +> The manifests below follow the image's behaviour but have **not been tested on a cluster**. +> The image itself is tested with `docker compose` (see [DOCKER.md](DOCKER.md)). + +## What you need outside the cluster + +- **PostgreSQL 18** holding the restored database. The app creates nothing but the schema changes + from its migrations; pages, sites and templates come from the database. +- **Redis** (cache and Celery broker), in the cluster or outside. + +## Configuration + +Plain values go in a ConfigMap, passwords and keys in a Secret, both attached with `envFrom`. +The full list is `.env.docker`. The ones that matter: + +| Variable | Notes | +|---|---| +| `DEBUG` | `False`. Loads the production settings. | +| `SEED_DUMMY_DATA` | `False`. | +| `DJANGO_SECRET_KEY` | Secret. Required: with several pods nothing else keeps the key the same. | +| `POSTGRES_HOST/PORT/DB/USER/PASSWORD` | the database | +| `CELERY_BROKER_URL`, `CELERY_RESULT_BACKEND`, `CACHE_URL` | Redis URLs | +| `UNGLEICH_SITE_CONFIGS` | JSON: host to urlconf, one entry per site | +| `BEHIND_TLS_PROXY` | `True` when TLS ends at the ingress (see below) | +| `OPENNEBULA_*`, `LDAP*`, `STRIPE_*`, `EMAIL_*`, `RECAPTCHA_*`, `WEBHOOK_SECRET` | the real integrations | +| `GUNICORN_WORKERS`, `GUNICORN_TIMEOUT` | default 3 and 120 | +| `RUN_MIGRATIONS` | `False` on the web Deployment when a Job runs the migrations | + +## Manifests + +```yaml +apiVersion: batch/v1 +kind: Job +metadata: {name: dynamicweb-migrate} +spec: + backoffLimit: 2 + template: + spec: + restartPolicy: Never + containers: + - name: migrate + image: REGISTRY/dynamicweb:VERSION + env: [{name: ROLE, value: migrate}] + envFrom: [{configMapRef: {name: dynamicweb}}, {secretRef: {name: dynamicweb}}] +--- +apiVersion: apps/v1 +kind: Deployment +metadata: {name: dynamicweb-web} +spec: + replicas: 2 + selector: {matchLabels: {app: dynamicweb-web}} + template: + metadata: {labels: {app: dynamicweb-web}} + spec: + containers: + - name: web + image: REGISTRY/dynamicweb:VERSION + env: [{name: RUN_MIGRATIONS, value: "False"}] + envFrom: [{configMapRef: {name: dynamicweb}}, {secretRef: {name: dynamicweb}}] + ports: [{containerPort: 8000}] + readinessProbe: {tcpSocket: {port: 8000}, periodSeconds: 10} + volumeMounts: [{name: media, mountPath: /usr/src/app/media}] + volumes: + - name: media + persistentVolumeClaim: {claimName: dynamicweb-media} # ReadWriteMany if replicas > 1 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: {name: dynamicweb-celery} +spec: + replicas: 1 + selector: {matchLabels: {app: dynamicweb-celery}} + template: + metadata: {labels: {app: dynamicweb-celery}} + spec: + containers: + - name: celery + image: REGISTRY/dynamicweb:VERSION + env: [{name: ROLE, value: celery}] + envFrom: [{configMapRef: {name: dynamicweb}}, {secretRef: {name: dynamicweb}}] +--- +apiVersion: v1 +kind: Service +metadata: {name: dynamicweb-web} +spec: + selector: {app: dynamicweb-web} + ports: [{port: 80, targetPort: 8000}] +``` + +Add an Ingress that sends every site's hostname (all the domains in `UNGLEICH_SITE_CONFIGS`, with +`www.` variants) to the `dynamicweb-web` Service. + +## What makes it work well + +- **Run the migrations once, as a Job.** Apply the Job and wait for it to finish before rolling out + the web Deployment. With `RUN_MIGRATIONS=False` the web pods start in seconds and several + replicas never migrate at the same time. +- **Keep the `Host` header.** The site is chosen from the request's host, so the ingress must pass + the original host through (the default for most ingress controllers). All hostnames must be in + `UNGLEICH_SITE_CONFIGS`, in the database's `django_site`/alias tables, and in `ALLOWED_HOSTS` + (`dynamicweb/settings/prod.py` lists the production domains; add new ones there). +- **TLS at the ingress:** set `BEHIND_TLS_PROXY=True`. Without it Django thinks requests are plain + http and rejects form posts (admin login, checkout) with a CSRF error. The ingress must set + `X-Forwarded-Proto`; the common ones do. +- **Static files** are collected when the container starts and served by the app (WhiteNoise); nothing + to configure. +- **Uploaded media** (the files managed in the admin) is written to `/usr/src/app/media` and served + by the app. Mount a shared volume there (`ReadWriteMany`), or run one web replica with a normal + volume. Without a volume, uploads vanish when a pod restarts. +- **Readiness** uses the TCP port: gunicorn only starts listening after start-up work is done. An + HTTP probe needs a `Host` header that is in `ALLOWED_HOSTS`, which is why it is not used. +- **Logs** go to stdout: `kubectl logs deploy/dynamicweb-web`, `kubectl logs deploy/dynamicweb-celery`. +- **Start-up needs OpenNebula reachable.** The app reads its VM templates at import, once per + gunicorn worker. If OpenNebula is down the app still starts but logs an error, and VM + creation is unavailable until a restart. +- **Celery**: keep one replica unless the tasks are known to be safe to run in parallel. + +## Checking a deployment + +```sh +kubectl logs job/dynamicweb-migrate # "No migrations to apply" or the list applied +kubectl rollout status deploy/dynamicweb-web +curl -I -H 'Host: ungleich.ch' http:///en-us/ # 200 +``` + +The admin is at `/en-us/admin/login/` on any site's address.