forked from ungleich-public/ungleich-user
Add Kubernetes manifests
A kustomize base with a Deployment (2 replicas, migrate_locked init container, liveness, readiness and startup probes, uid 10001 with a read-only root filesystem, all capabilities dropped, RuntimeDefault seccomp, no service account token, resource requests and limits), plus a Service, an Ingress for account.ungleich.ch, a PodDisruptionBudget and a ConfigMap with every non-secret setting. The Secret is not part of the base, so credentials stay out of git; secret.example.yaml and the README show how to create it. The README also lists what the cluster must provide and what to confirm before going live (StartTLS on the LDAP servers, HSTS).
This commit is contained in:
parent
e3d359b751
commit
4eeb25a3ea
8 changed files with 262 additions and 0 deletions
66
deploy/k8s/README.md
Normal file
66
deploy/k8s/README.md
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
# Kubernetes deployment
|
||||
|
||||
`base/` holds the manifests for the account webapp: Deployment, Service,
|
||||
Ingress, PodDisruptionBudget and ConfigMap. Apply it with
|
||||
`kubectl apply -k` (kustomize is built into kubectl).
|
||||
|
||||
## What the cluster needs to provide
|
||||
|
||||
- **PostgreSQL.** Sessions, password-reset tokens and pending registrations
|
||||
live in the database, so more than one replica needs PostgreSQL. Point
|
||||
`DATABASE_HOST` at it. The base doesn't ship a database; use the
|
||||
cluster's managed or operator-run PostgreSQL.
|
||||
- **An ingress controller with TLS.** The app expects TLS to terminate at
|
||||
the ingress and trusts `X-Forwarded-Proto` (`TRUST_X_FORWARDED_PROTO=True`).
|
||||
- **Network access** to the LDAP servers, the SMTP relay and the OTP server.
|
||||
|
||||
## Deploying
|
||||
|
||||
1. Build and push the image from the repository root:
|
||||
|
||||
```sh
|
||||
docker build -t registry.example.com/ungleich-user:1.0.0 .
|
||||
docker push registry.example.com/ungleich-user:1.0.0
|
||||
```
|
||||
|
||||
2. Copy `base/` into your environment's overlay (or edit it in place) and set:
|
||||
- `images:` in `kustomization.yaml`: the registry and a pinned tag
|
||||
- `configmap.yaml`: every value marked `REQUIRED` (`env.sample` lists
|
||||
all settings with their defaults)
|
||||
- `ingress.yaml`: the ingress class and the TLS secret or cert-manager
|
||||
annotations
|
||||
|
||||
3. Create the Secret. It isn't part of the base, so credentials never go
|
||||
into git. See `secret.example.yaml`:
|
||||
|
||||
```sh
|
||||
kubectl create secret generic ungleich-user \
|
||||
--from-literal=SECRET_KEY="$(python3 -c 'import secrets; print(secrets.token_urlsafe(50))')" \
|
||||
--from-literal=LDAP_ADMIN_DN='cn=...' \
|
||||
--from-literal=LDAP_ADMIN_PASSWORD='...' \
|
||||
--from-literal=DATABASE_PASSWORD='...' \
|
||||
--from-literal=ADMIN_SEED='...'
|
||||
```
|
||||
|
||||
4. Apply: `kubectl apply -k deploy/k8s/base` (or your overlay).
|
||||
|
||||
## How it behaves
|
||||
|
||||
| Concern | Behaviour |
|
||||
|---|---|
|
||||
| Migrations | An init container runs `manage.py migrate_locked` in each pod. A PostgreSQL advisory lock makes pods that start together wait for the first one instead of migrating at the same time. |
|
||||
| Probes | `/healthz` (liveness) has no dependencies, so an LDAP outage never restarts pods. `/readyz` (readiness) needs the database and at least one LDAP server. Both bypass `ALLOWED_HOSTS`. |
|
||||
| LDAP failover | `LDAPSERVER` is a space-separated list. The first reachable server is used, and a failed server is skipped for 60 seconds. |
|
||||
| Security | Runs as uid 10001 with a read-only root filesystem, all capabilities dropped, no privilege escalation, the RuntimeDefault seccomp profile and no service account token. The image has no sshd. |
|
||||
| Logs | stdout/stderr only. `LOG_LEVEL` sets the level. |
|
||||
| Rollouts | Rolling updates add one new pod before removing an old one (`maxSurge: 1`, `maxUnavailable: 0`). The PodDisruptionBudget keeps at least one pod running during node drains. |
|
||||
|
||||
### Before going live, check with the operators
|
||||
|
||||
- `LDAP_USE_TLS` defaults to `True` (StartTLS) in the ConfigMap. Confirm
|
||||
the production LDAP servers support StartTLS, or use `ldaps://` URIs.
|
||||
- HSTS (`SECURE_HSTS_SECONDS`) is off. Enabling it, especially for
|
||||
subdomains, affects every `*.ungleich.ch` site.
|
||||
- Emails sent before the switch contain activation links in the old
|
||||
format. Those links stop working, and the affected users have to
|
||||
register again.
|
||||
35
deploy/k8s/base/configmap.yaml
Normal file
35
deploy/k8s/base/configmap.yaml
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
# Non-secret settings. See env.sample for every variable and its default.
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: ungleich-user
|
||||
data:
|
||||
ALLOWED_HOSTS: "account.ungleich.ch"
|
||||
CSRF_TRUSTED_ORIGINS: "https://account.ungleich.ch"
|
||||
TRUST_X_FORWARDED_PROTO: "True"
|
||||
DEBUG: "False"
|
||||
LOG_LEVEL: "INFO"
|
||||
|
||||
LDAPSERVER: "ldap://ldap1.ungleich.ch ldap://ldap2.ungleich.ch"
|
||||
LDAP_USE_TLS: "True"
|
||||
LDAPSEARCH: "ou=customers,dc=ungleich,dc=ch ou=users,dc=ungleich,dc=ch"
|
||||
ENTIRE_SEARCH_BASE: "dc=ungleich,dc=ch"
|
||||
LDAP_CUSTOMER_DN: "ou=customers,dc=ungleich,dc=ch"
|
||||
LDAP_CUSTOMER_GROUP_ID: "" # REQUIRED: gidNumber for new customers
|
||||
LDAP_DEFAULT_START_UID: "" # REQUIRED: uidNumbers start above this
|
||||
|
||||
DATABASE_HOST: "" # REQUIRED for >1 replica: PostgreSQL host
|
||||
DATABASE_NAME: "ungleich_user"
|
||||
DATABASE_USER: "ungleich_user"
|
||||
DATABASE_SSLMODE: "prefer"
|
||||
|
||||
EMAIL_FROM_ADDRESS: "" # REQUIRED
|
||||
EMAIL_HOST: "localhost"
|
||||
EMAIL_PORT: "25"
|
||||
EMAIL_USE_TLS: "True"
|
||||
|
||||
OTPSERVER: "" # REQUIRED
|
||||
ADMIN_NAME: ""
|
||||
ADMIN_REALM: ""
|
||||
USER_REALM: ""
|
||||
ALLOWED_REALMS: ""
|
||||
76
deploy/k8s/base/deployment.yaml
Normal file
76
deploy/k8s/base/deployment.yaml
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: ungleich-user
|
||||
spec:
|
||||
replicas: 2
|
||||
revisionHistoryLimit: 5
|
||||
strategy:
|
||||
type: RollingUpdate
|
||||
rollingUpdate:
|
||||
maxSurge: 1
|
||||
maxUnavailable: 0
|
||||
template:
|
||||
spec:
|
||||
automountServiceAccountToken: false
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 10001
|
||||
runAsGroup: 10001
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
# Migrations run before the app starts; migrate_locked serializes
|
||||
# them when several pods start at once.
|
||||
initContainers:
|
||||
- name: migrate
|
||||
image: ungleich-user
|
||||
command: ["python", "manage.py", "migrate_locked"]
|
||||
envFrom: &env
|
||||
- configMapRef:
|
||||
name: ungleich-user
|
||||
- secretRef:
|
||||
name: ungleich-user
|
||||
securityContext: &containerSecurity
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
volumeMounts: &mounts
|
||||
- name: tmp
|
||||
mountPath: /tmp
|
||||
resources:
|
||||
requests: {cpu: 50m, memory: 128Mi}
|
||||
limits: {memory: 256Mi}
|
||||
containers:
|
||||
- name: web
|
||||
image: ungleich-user
|
||||
envFrom: *env
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 8000
|
||||
securityContext: *containerSecurity
|
||||
volumeMounts: *mounts
|
||||
# Liveness has no dependencies, so an LDAP outage never restarts
|
||||
# pods; readiness takes a pod out of the Service when the
|
||||
# database or every LDAP server is unreachable.
|
||||
livenessProbe:
|
||||
httpGet: {path: /healthz, port: http}
|
||||
periodSeconds: 20
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 3
|
||||
readinessProbe:
|
||||
httpGet: {path: /readyz, port: http}
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 10
|
||||
failureThreshold: 3
|
||||
startupProbe:
|
||||
httpGet: {path: /healthz, port: http}
|
||||
periodSeconds: 2
|
||||
failureThreshold: 30
|
||||
resources:
|
||||
requests: {cpu: 100m, memory: 192Mi}
|
||||
limits: {memory: 512Mi}
|
||||
volumes:
|
||||
- name: tmp
|
||||
emptyDir:
|
||||
sizeLimit: 64Mi
|
||||
23
deploy/k8s/base/ingress.yaml
Normal file
23
deploy/k8s/base/ingress.yaml
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# TLS terminates here; the app trusts X-Forwarded-Proto
|
||||
# (TRUST_X_FORWARDED_PROTO in the ConfigMap). Adjust the class, host and
|
||||
# certificate secret to the cluster (e.g. cert-manager annotations).
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: ungleich-user
|
||||
spec:
|
||||
# ingressClassName: nginx
|
||||
tls:
|
||||
- hosts: ["account.ungleich.ch"]
|
||||
secretName: ungleich-user-tls
|
||||
rules:
|
||||
- host: account.ungleich.ch
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: ungleich-user
|
||||
port:
|
||||
name: http
|
||||
25
deploy/k8s/base/kustomization.yaml
Normal file
25
deploy/k8s/base/kustomization.yaml
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# Base manifests for the ungleich account webapp.
|
||||
#
|
||||
# kubectl apply -k deploy/k8s/base # after creating the Secret
|
||||
#
|
||||
# The Secret "ungleich-user" is not part of this base; create it from
|
||||
# secret.example.yaml (or your secret manager) before applying.
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
|
||||
labels:
|
||||
- pairs:
|
||||
app.kubernetes.io/name: ungleich-user
|
||||
includeSelectors: true
|
||||
|
||||
resources:
|
||||
- configmap.yaml
|
||||
- deployment.yaml
|
||||
- service.yaml
|
||||
- ingress.yaml
|
||||
- pdb.yaml
|
||||
|
||||
images:
|
||||
- name: ungleich-user
|
||||
newName: ungleich-user # set to your registry, e.g. registry.example.com/ungleich-user
|
||||
newTag: latest # pin a release tag or digest in production
|
||||
6
deploy/k8s/base/pdb.yaml
Normal file
6
deploy/k8s/base/pdb.yaml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
apiVersion: policy/v1
|
||||
kind: PodDisruptionBudget
|
||||
metadata:
|
||||
name: ungleich-user
|
||||
spec:
|
||||
minAvailable: 1
|
||||
9
deploy/k8s/base/service.yaml
Normal file
9
deploy/k8s/base/service.yaml
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: ungleich-user
|
||||
spec:
|
||||
ports:
|
||||
- name: http
|
||||
port: 80
|
||||
targetPort: http
|
||||
22
deploy/k8s/secret.example.yaml
Normal file
22
deploy/k8s/secret.example.yaml
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# Example only; do not commit real values. Create the Secret with your
|
||||
# secret manager, or fill this in outside git and apply it:
|
||||
#
|
||||
# kubectl create secret generic ungleich-user \
|
||||
# --from-literal=SECRET_KEY="$(python3 -c 'import secrets; print(secrets.token_urlsafe(50))')" \
|
||||
# --from-literal=LDAP_ADMIN_DN='...' \
|
||||
# --from-literal=LDAP_ADMIN_PASSWORD='...' \
|
||||
# --from-literal=DATABASE_PASSWORD='...' \
|
||||
# --from-literal=ADMIN_SEED='...'
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: ungleich-user
|
||||
type: Opaque
|
||||
stringData:
|
||||
SECRET_KEY: ""
|
||||
LDAP_ADMIN_DN: ""
|
||||
LDAP_ADMIN_PASSWORD: ""
|
||||
DATABASE_PASSWORD: ""
|
||||
ADMIN_SEED: ""
|
||||
# EMAIL_HOST_USER: ""
|
||||
# EMAIL_HOST_PASSWORD: ""
|
||||
Loading…
Add table
Add a link
Reference in a new issue