Deploying
The image
A Go stage builds the controller static, and the image copies it to
/databases on a distroless base, to run as the non-root user 65532 with
/databases as its entrypoint.
example/kube/databases/Dockerfile
names the exact images and their versions.
Build it from the repository’s root, since the module replaces humus and
kube with their directories:
docker build --file example/kube/databases/Dockerfile --tag databases .
The kustomization
deploy holds the CRD, the namespace, the ServiceAccount, RBAC, the
Deployment, its disruption budget and the settings:
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: databases
resources:
- crd.yaml
- namespace.yaml
- serviceaccount.yaml
- clusterrole.yaml
- clusterrolebinding.yaml
- role.yaml
- rolebinding.yaml
- deployment.yaml
- poddisruptionbudget.yaml
configMapGenerator:
- name: databases
envs:
- databases.env
The settings, in databases.env:
CONTROLLER_NAME=databases-controller
POSTGRES_ADMIN_DSN_FILE=/etc/databases/postgres/dsn
POSTGRES_HOST=postgres.databases.svc
POSTGRES_PORT=5432
PROBES_ADDR=:8443
PROBES_TLS_PEM_FILE=/etc/databases/probes/tls.pem
OTEL_SERVICE_NAME=databases
kubectl kustomize example/kube/databases/deploy
RBAC
The controller runs as the databases ServiceAccount:
apiVersion: v1
kind: ServiceAccount
metadata:
name: databases
namespace: databases
A ClusterRole lets it watch and patch Databases and their status, and
read and apply Secrets, in every namespace:
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: databases-controller
rules:
- apiGroups: [postgres.example.com]
resources: [databases]
verbs: [get, list, watch, patch]
- apiGroups: [postgres.example.com]
resources: [databases/status]
verbs: [patch]
- apiGroups: [""]
resources: [secrets]
verbs: [get, list, watch, create, patch]
A ClusterRoleBinding grants it to the ServiceAccount:
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: databases-controller
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: databases-controller
subjects:
- kind: ServiceAccount
name: databases
namespace: databases
A Role, in its own namespace, lets it hold its Lease and no other. create
can’t be limited to a name, so it’s a rule of its own:
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: databases-controller
namespace: databases
rules:
- apiGroups: [coordination.k8s.io]
resources: [leases]
verbs: [create]
- apiGroups: [coordination.k8s.io]
resources: [leases]
resourceNames: [databases-controller]
verbs: [get, update]
A RoleBinding grants it to the ServiceAccount, in the same way.
The Deployment
Two replicas, which share the Lease, each reading its settings from the ConfigMap, with probes over HTTPS. A rollout starts a new replica before it stops an old one:
apiVersion: apps/v1
kind: Deployment
metadata:
name: databases
namespace: databases
labels:
app.kubernetes.io/name: databases
spec:
replicas: 2
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
selector:
matchLabels:
app.kubernetes.io/name: databases
template:
metadata:
labels:
app.kubernetes.io/name: databases
spec:
serviceAccountName: databases
automountServiceAccountToken: true
terminationGracePeriodSeconds: 30
securityContext:
runAsNonRoot: true
runAsUser: 65532
runAsGroup: 65532
fsGroup: 65532
seccompProfile:
type: RuntimeDefault
containers:
- name: databases
image: databases
envFrom:
- configMapRef:
name: databases
env:
- name: POD_NAMESPACE
valueFrom:
fieldRef:
fieldPath: metadata.namespace
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
ports:
- name: probes
containerPort: 8443
livenessProbe:
httpGet:
path: /health/liveness
port: probes
scheme: HTTPS
readinessProbe:
httpGet:
path: /health/readiness
port: probes
scheme: HTTPS
resources:
requests:
cpu: 100m
memory: 64Mi
limits:
memory: 256Mi
The ServiceAccount’s token is mounted, since the controller calls the API server with it.
The container runs as non-root, on a read-only root filesystem:
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: [ALL]
The admin DSN and the probes’ certificate are files, mounted from Secrets:
volumeMounts:
- name: postgres
mountPath: /etc/databases/postgres
readOnly: true
- name: probes
mountPath: /etc/databases/probes
readOnly: true
volumes:
- name: postgres
secret:
secretName: databases-postgres-admin
defaultMode: 0440
items:
- key: dsn
path: dsn
- name: probes
secret:
secretName: databases-probes
defaultMode: 0440
items:
- key: tls.pem
path: tls.pem
The disruption budget
A drain keeps one replica running:
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: databases
namespace: databases
spec:
minAvailable: 1
selector:
matchLabels:
app.kubernetes.io/name: databases
The grace period
On SIGTERM, each replica finishes the reconciles in flight and releases the
Lease, which the other replica takes. main gives that 20s:
func main() {
humus.Main(app.Init, humus.WithTelemetry(app.BuildTelemetry), humus.WithFaults(app.Faults), humus.WithShutdownGracePeriod(20*time.Second))
}
terminationGracePeriodSeconds covers the grace period, the telemetry’s
shutdown, and a margin, so a rollout never ends in SIGKILL:
terminationGracePeriodSeconds: 30
The kind overlay
deploy/kind adds PostgreSQL and the LGTM stack beside the controller,
exports its telemetry to the Collector, and trusts the cases’ scenarios:
resources:
- ../crd.yaml
- ../namespace.yaml
- ../serviceaccount.yaml
- ../clusterrole.yaml
- ../clusterrolebinding.yaml
- ../role.yaml
- ../rolebinding.yaml
- ../deployment.yaml
- ../poddisruptionbudget.yaml
- postgres.yaml
- otel-collector.yaml
- loki.yaml
- tempo.yaml
- mimir.yaml
- grafana.yaml
configMapGenerator:
- name: databases
envs:
- ../databases.env
literals:
- FAULT_TRUSTED_NAMESPACE_PREFIX=databases-cases-
- OTEL_TRACES_EXPORTER=otlp
- OTEL_METRICS_EXPORTER=otlp
- OTEL_LOGS_EXPORTER=otlp
- OTEL_EXPORTER_OTLP_ENDPOINT=otel-collector:4317
PostgreSQL checks passwords with SCRAM, and takes its admin password from a Secret:
- name: POSTGRES_INITDB_ARGS
value: --auth-host=scram-sha-256
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: postgres
key: password
A patch uses the image up.sh loads:
patches:
- target:
kind: Deployment
name: databases
patch: |-
apiVersion: apps/v1
kind: Deployment
metadata:
name: databases
spec:
template:
spec:
containers:
- name: databases
imagePullPolicy: Never
Running it on kind
up.sh creates a kind cluster if there isn’t one. On each run, it
generates PostgreSQL’s admin password, Grafana’s, and a CA and the probes'
certificate, so nothing secret is kept in the repository. It loads the
image, applies the overlay with the Secrets, and waits for every
Deployment:
docker build --file example/kube/databases/Dockerfile --tag databases .
example/kube/databases/deploy/kind/up.sh
Then run the shared cases against it:
go -C example/kube/databases test -tags functional ./functional/
Applying a Database
deploy/kind/database.yaml is a Database:
apiVersion: postgres.example.com/v1alpha1
kind: Database
metadata:
name: orders
spec:
name: orders
deletionPolicy: Delete
Apply it, and wait for it to be Ready:
kubectl --context kind-kind --namespace default apply --filename example/kube/databases/deploy/kind/database.yaml
kubectl --context kind-kind --namespace default wait database/orders --for=condition=Ready --timeout 2m
Then log in with the credentials in its Secret:
password=$(kubectl --context kind-kind --namespace default get secret orders-postgres -o jsonpath='{.data.password}' | base64 -d)
kubectl --context kind-kind --namespace databases exec deployment/postgres -- \
env PGPASSWORD="$password" psql -h 127.0.0.1 -U orders -d orders -c 'SELECT current_user'
Deleting it drops the database and the role, as its deletionPolicy says:
kubectl --context kind-kind --namespace default delete database orders