forked from ungleich-public/dynamicweb
Document the roles and add the Kubernetes guide
This commit is contained in:
parent
9ee1a3bd49
commit
3eb05bef6d
2 changed files with 150 additions and 10 deletions
22
DOCKER.md
22
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 <this repo> && 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
|
||||
|
|
|
|||
138
K8S.md
Normal file
138
K8S.md
Normal file
|
|
@ -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://<ingress-address>/en-us/ # 200
|
||||
```
|
||||
|
||||
The admin is at `/en-us/admin/login/` on any site's address.
|
||||
Loading…
Add table
Add a link
Reference in a new issue