Cómo se despliega la plataforma¶
rendimiento despliega aplicaciones, pero no se despliega a sí mismo. La plataforma se instala con manifiestos simples de Kubernetes en deploy/, aplicados con kubectl apply -k deploy. Dejar la plataforma fuera de su propio control significa que un rendimiento descompuesto siempre se puede arreglar con kubectl.
Sus propios ajustes¶
Los manifiestos de deploy/ traen valores de ejemplo (example.com, registry.example.lan, [email protected]). Los ajustes de un clúster real van en una capa privada, una kustomization en un repositorio privado que se basa en deploy/ y reemplaza lo que es distinto:
# kustomization.yaml en un repositorio privado, junto a una copia de este
resources:
- ../../rendimiento.ai/deploy
images:
- name: registry.example.lan:5000/rendimiento
newName: registry.home.lan:5000/rendimiento # su registro
patches:
- path: configmap.yaml # el ConfigMap de rendimiento con sus ajustes
- target: { kind: Ingress, name: rendimiento }
patch: |-
- { op: replace, path: /spec/rules/0/host, value: rendimiento.your-domain.com }
- { op: replace, path: /spec/tls/0/hosts/0, value: rendimiento.your-domain.com }
Luego apunte el Makefile hacia ella desde un local.mk (ignorado por git) en la raíz del repositorio:
IMAGE := registry.home.lan:5000/rendimiento
DEPLOY_DIR := $(HOME)/private-repo/rendimiento-platform
TEST_EXCLUDE_NODES := my-gpu-node # nodos que las pruebas remotas deben evitar
ITEST_REGISTRY := registry.home.lan:5000
make deploy genera DEPLOY_DIR (deploy/ de forma predeterminada) y lo aplica. Así sus nombres de host, nombres de nodos y correo nunca entran al repositorio público.
Qué se instala¶
flowchart TB
subgraph rs[espacio de nombres rendimiento-system]
dep[Deployment rendimiento<br/>1 réplica, Recreate]
svc[Service rendimiento :80]
ing[Ingress rendimiento.joserod.space<br/>TLS con cert-manager]
db[(StatefulSet rendimiento-db<br/>Postgres 17, 5Gi en Longhorn)]
cm[ConfigMap rendimiento<br/>ajustes]
sa[ServiceAccount rendimiento]
sa2[ServiceAccount rendimiento-addons<br/>sin pods, suplantada]
end
subgraph rb[espacio de nombres rendimiento-builds]
np[NetworkPolicy isolate-builds]
end
crds[CRD apps.rendimiento.ai<br/>addons.rendimiento.ai]
ing --> svc --> dep --> db
dep -. lee .-> cm
| Archivo | Qué contiene |
|---|---|
kustomization.yaml |
La lista de abajo, aplicada junta. |
namespace.yaml |
rendimiento-system (la plataforma) y rendimiento-builds (los pods de integración continua). |
crds/rendimiento.ai_apps.yaml, crds/rendimiento.ai_addons.yaml |
Las dos definiciones de recursos personalizados, generadas desde api/v1alpha1 con make generate. Nunca las edite a mano. |
rbac.yaml |
Lo que puede hacer la plataforma (vea abajo), la identidad rendimiento-addons ligada a cluster-admin y el Role del espacio de nombres de construcción. |
postgres.yaml |
La base de datos de la plataforma: un StatefulSet de una réplica sobre un volumen de Longhorn de 5 Gi. |
rendimiento.yaml |
El ConfigMap de ajustes, el Deployment, su Service y su Ingress. |
networkpolicy.yaml |
El aislamiento de los pods de integración continua: solo DNS, BuildKit e internet público. |
railpack/Dockerfile |
No se aplica: la imagen con la herramienta de Railpack que usan los pods de construcción (make railpack-image). |
deploy/rendimiento.yaml (ajustes, Deployment, Service, Ingress)
apiVersion: v1
kind: ConfigMap
metadata:
name: rendimiento
namespace: rendimiento-system
data:
# Example values: a real cluster's settings live outside this public
# repository (an overlay applied over these manifests; see the book).
BASE_URL: https://rendimiento.example.com
ALLOWED_USERS: your-github-login
GITHUB_APP_NAME: rendimiento
DNS_ZONE: example.com
DNS_PROXIED: "true"
REGISTRY: registry.example.lan:5000
REGISTRY_INSECURE: "true"
BUILDKIT_ADDR: tcp://buildkitd.devops-tools.svc.cluster.local:1234
# One BuildKit per worker (the "buildkit" add-on); each image builds on
# the daemon its name hashes to. BUILDKIT_ADDR is the fallback.
BUILDKIT_POOL: buildkitd-pool.devops-tools.svc.cluster.local
MAX_PARALLEL_STEPS: "4"
# Longest a single test or build step may run. First builds on the Pis
# start with a cold cache (e.g. compiling a Python wheel from source).
STEP_TIMEOUT: 45m
# Nodes that must never run builds: e.g. a node whose kernel cannot
# enforce the build NetworkPolicy, or a GPU node reserved for inference.
BUILD_EXCLUDE_NODES: gpu-node
# One replica with the Recreate strategy never overlaps, so leader
# election only adds a way to crash when the API server is slow.
LEADER_ELECTION: "false"
CLUSTER_ISSUER: letsencrypt-prod
INGRESS_CLASS: nginx
STORAGE_CLASS: longhorn
# Labels added to every app volume claim; these put it in a Longhorn backup job.
# VOLUME_LABELS: recurring-job.longhorn.io/source=enabled,recurring-job-group.longhorn.io/backup-nightly=enabled
# Services without a Dockerfile are built from source with Railpack. The
# CLI image comes from `make railpack-image`; keep both versions in step.
RAILPACK_IMAGE: registry.example.lan:5000/rendimiento-railpack:0.40.0
RAILPACK_FRONTEND: ghcr.io/railwayapp/railpack-frontend:v0.40.0
# How a service with `gpu: 1` gets a GPU (here: an NVIDIA device plugin): its
# runtime class, the host's driver libraries (read-only) and a larger /dev/shm.
GPU_RESOURCE: nvidia.com/gpu
GPU_RUNTIME_CLASS: nvidia
GPU_HOST_PATHS: /usr/lib/aarch64-linux-gnu/nvidia
GPU_ENV: NVIDIA_VISIBLE_DEVICES=all;NVIDIA_DRIVER_CAPABILITIES=all;LD_LIBRARY_PATH=/usr/lib/aarch64-linux-gnu/nvidia:/usr/local/cuda/lib64
GPU_SHARED_MEMORY: 1Gi
# Failed builds, rolled-back releases, outages and recoveries are emailed
# here, through Resend (RESEND_API_KEY in the rendimiento-notify secret).
NOTIFY_EMAIL_TO: [email protected]
NOTIFY_LANG: en # en | es (Mexican Spanish)
NOTIFY_EMAIL_FROM: rendimiento <[email protected]>
# GET /api/public/stats: aggregate numbers for a public page (a portfolio,
# a status page), served only on the internal port 8081 (the Service's
# "stats" port, which the public ingress does not route), so it is
# reachable from inside the cluster but not from the internet.
PUBLIC_STATS: "true"
STATS_LISTEN: ":8081"
PUBLIC_STATS_TZ: America/New_York
# Finished runs' step logs move to S3-compatible storage (gzip; Garage
# here), which deletes them after LOG_RETENTION_DAYS. Credentials: the
# rendimiento-logs secret (a key that can only use this bucket).
LOG_ARCHIVE_ENDPOINT: garage.garage.svc.cluster.local:3900
LOG_ARCHIVE_BUCKET: rendimiento-logs
LOG_RETENTION_DAYS: "365"
# Visitor numbers from Umami (each app's Visits tab), read inside the
# cluster with a view-only user (the rendimiento-umami secret).
# UMAMI_URL: http://umami.umami.svc.cluster.local:3000
# UMAMI_PUBLIC_URL: https://umami.example.com
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: rendimiento
namespace: rendimiento-system
spec:
replicas: 1
strategy: { type: Recreate }
selector:
matchLabels: { app: rendimiento }
template:
metadata:
labels: { app: rendimiento }
spec:
serviceAccountName: rendimiento
containers:
- name: rendimiento
image: registry.example.lan:5000/rendimiento:latest
imagePullPolicy: Always
envFrom:
- configMapRef: { name: rendimiento }
# CLOUDFLARE_API_TOKEN and DNS_TARGET; optional until DNS automation is wanted.
- secretRef: { name: rendimiento-dns, optional: true }
# RESEND_API_KEY for notification emails; optional (no email without it).
- secretRef: { name: rendimiento-notify, optional: true }
# LOG_ARCHIVE_ACCESS_KEY / LOG_ARCHIVE_SECRET_KEY for the log archive.
- secretRef: { name: rendimiento-logs, optional: true }
# UMAMI_USERNAME / UMAMI_PASSWORD (a view-only Umami user) for visitor numbers.
- secretRef: { name: rendimiento-umami, optional: true }
env:
- name: DB_PASSWORD
valueFrom: { secretKeyRef: { name: rendimiento-db, key: password } }
- name: DATABASE_URL
value: postgres://rendimiento:$(DB_PASSWORD)@rendimiento-db:5432/rendimiento?sslmode=disable
- name: SETUP_TOKEN
valueFrom: { secretKeyRef: { name: rendimiento-setup, key: token } }
ports:
- { name: http, containerPort: 8080 }
- { name: metrics, containerPort: 9090 }
- { name: stats, containerPort: 8081 }
readinessProbe:
httpGet: { path: /healthz, port: http }
periodSeconds: 10
livenessProbe:
httpGet: { path: /healthz, port: http }
initialDelaySeconds: 20
periodSeconds: 20
resources:
requests: { cpu: 100m, memory: 128Mi }
limits: { memory: 512Mi }
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 65532
runAsGroup: 65532
capabilities: { drop: [ALL] }
---
apiVersion: v1
kind: Service
metadata:
name: rendimiento
namespace: rendimiento-system
spec:
selector: { app: rendimiento }
ports:
- { name: http, port: 80, targetPort: http }
# In-cluster only: the public stats (the Ingress routes only "http").
- { name: stats, port: 8081, targetPort: stats }
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: rendimiento
namespace: rendimiento-system
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
nginx.ingress.kubernetes.io/ssl-redirect: "true"
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
# Live logs use server-sent events.
nginx.ingress.kubernetes.io/proxy-buffering: "off"
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-body-size: 25m
spec:
ingressClassName: nginx
tls:
- hosts: [rendimiento.example.com]
secretName: rendimiento-tls
rules:
- host: rendimiento.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service: { name: rendimiento, port: { name: http } }
deploy/rbac.yaml
apiVersion: v1
kind: ServiceAccount
metadata:
name: rendimiento
namespace: rendimiento-system
---
# Cluster-wide because each app gets its own namespace. The controller
# refuses namespaces and hostnames it does not own (see checkOwnership).
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: rendimiento
rules:
- apiGroups: [rendimiento.ai]
resources: [apps, apps/status, apps/finalizers, addons, addons/status, addons/finalizers]
verbs: ["*"]
# Add-ons are applied as the rendimiento-addons identity (below); the
# platform itself may only act as it, not hold its rights.
- apiGroups: [""]
resources: [serviceaccounts]
resourceNames: [rendimiento-addons]
verbs: [impersonate]
- apiGroups: [""]
resources: [namespaces, services, persistentvolumeclaims, secrets]
verbs: [get, list, watch, create, update, patch, delete]
- apiGroups: [""]
resources: [pods]
verbs: [get, list, watch]
- apiGroups: [apps]
resources: [deployments]
verbs: [get, list, watch, create, update, patch, delete]
- apiGroups: [networking.k8s.io]
resources: [ingresses]
verbs: [get, list, watch, create, update, patch, delete]
- apiGroups: [batch]
resources: [cronjobs]
verbs: [get, list, watch, create, update, patch, delete]
# Post-deploy tasks run as Jobs in the app's namespace; their logs are kept.
- apiGroups: [batch]
resources: [jobs]
verbs: [get, list, watch, create, delete]
- apiGroups: [""]
resources: [pods/log]
verbs: [get]
- apiGroups: [cert-manager.io]
resources: [certificates]
verbs: [get, list, watch]
- apiGroups: [""]
resources: [events]
verbs: [create, patch]
# Read-only, for the environment page.
- apiGroups: [""]
resources: [nodes]
verbs: [get, list]
- apiGroups: [metrics.k8s.io]
resources: [nodes]
verbs: [get, list]
- apiGroups: [networking.k8s.io]
resources: [ingressclasses, networkpolicies]
verbs: [get, list]
- apiGroups: [storage.k8s.io]
resources: [storageclasses]
verbs: [get, list]
- apiGroups: [apiextensions.k8s.io]
resources: [customresourcedefinitions]
verbs: [get, list]
- apiGroups: [cert-manager.io]
resources: [clusterissuers]
verbs: [get, list]
- apiGroups: [longhorn.io]
resources: [volumes, recurringjobs, backuptargets]
verbs: [get, list]
# Read-only, for the services catalog (who calls what).
- apiGroups: [apps]
resources: [statefulsets]
verbs: [get, list]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: rendimiento
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: rendimiento
subjects:
- kind: ServiceAccount
name: rendimiento
namespace: rendimiento-system
---
# CI pods: create, watch, read logs, clean up.
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: rendimiento-builds
namespace: rendimiento-builds
rules:
- apiGroups: [""]
resources: [pods, secrets]
verbs: [get, list, watch, create, delete]
- apiGroups: [""]
resources: [pods/log]
verbs: [get]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: rendimiento-builds
namespace: rendimiento-builds
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: rendimiento-builds
subjects:
- kind: ServiceAccount
name: rendimiento
namespace: rendimiento-system
---
# Leader election.
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: rendimiento-leader
namespace: rendimiento-system
rules:
- apiGroups: [coordination.k8s.io]
resources: [leases]
verbs: [get, list, watch, create, update, patch]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: rendimiento-leader
namespace: rendimiento-system
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: rendimiento-leader
subjects:
- kind: ServiceAccount
name: rendimiento
namespace: rendimiento-system
---
# Add-ons install cluster software (Helm charts such as Longhorn: CRDs,
# ClusterRoles, DaemonSets), which needs cluster-admin, as ArgoCD has had.
# No pod runs as this account: the platform impersonates it only while
# applying add-ons, so the audit log shows exactly what add-ons changed.
apiVersion: v1
kind: ServiceAccount
metadata:
name: rendimiento-addons
namespace: rendimiento-system
automountServiceAccountToken: false
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: rendimiento-addons
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: cluster-admin
subjects:
- kind: ServiceAccount
name: rendimiento-addons
namespace: rendimiento-system
deploy/networkpolicy.yaml
# CI steps run code from the repos being built (tests, Dockerfile RUN lines).
# Keep them away from everything else: they may reach DNS, the shared
# BuildKit daemon and the public internet (git, package registries), but
# not other apps, databases, the Kubernetes API or the home network.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: isolate-builds
namespace: rendimiento-builds
labels:
app.kubernetes.io/managed-by: rendimiento
spec:
podSelector: {}
policyTypes: [Ingress, Egress]
ingress: [] # nothing connects to build pods
egress:
- to:
- namespaceSelector:
matchLabels: { kubernetes.io/metadata.name: kube-system }
podSelector:
matchLabels: { k8s-app: kube-dns }
ports:
- { protocol: UDP, port: 53 }
- { protocol: TCP, port: 53 }
- to:
- namespaceSelector:
matchLabels: { kubernetes.io/metadata.name: devops-tools }
podSelector:
matchLabels: { app: buildkitd }
ports:
- { protocol: TCP, port: 1234 }
- to:
- ipBlock:
cidr: 0.0.0.0/0
except:
- 10.0.0.0/8 # cluster pods and services (10.42/16, 10.43/16)
- 172.16.0.0/12
- 192.168.0.0/16 # home network, including the router
- 169.254.0.0/16 # link-local / cloud metadata
- 100.64.0.0/10 # carrier-grade NAT
Los permisos, y por qué tienen esta forma¶
La cuenta de servicio propia de la plataforma (rendimiento) puede:
- administrar los recursos
AppyAddon; - administrar espacios de nombres, Deployments, Services, Ingresses, CronJobs, PVCs y Secrets en todo el clúster, porque cada aplicación recibe su propio espacio de nombres. El controlador rechaza los espacios de nombres y nombres de host que no le pertenecen (comprobaciones de propiedad);
- leer nodos, métricas, clases de entrada, clases de almacenamiento, CRD, emisores del clúster y StatefulSets, para las páginas Entorno y Servicios;
- crear y borrar pods y secretos en
rendimiento-builds(integración continua); - suplantar a la cuenta de servicio
rendimiento-addons, y nada más.
rendimiento-addons está ligada a cluster-admin, porque instalar programas como Longhorn significa crear CRD, ClusterRoles y DaemonSets, exactamente lo que necesitaba ArgoCD. Ningún pod corre con ella: solo el controlador de complementos actúa como ella, así la bitácora de auditoría de Kubernetes muestra cada cambio de complemento bajo ese nombre. Vea Modelo de seguridad.
Los secretos que se crean una vez¶
Secret (espacio de nombres rendimiento-system) |
Llaves | Se usa para |
|---|---|---|
rendimiento-db |
password |
La contraseña de Postgres (el Deployment arma DATABASE_URL con ella) |
rendimiento-setup |
token |
El token de un solo uso que protege la página de configuración de la aplicación de GitHub |
rendimiento-dns (opcional) |
CLOUDFLARE_API_TOKEN, DNS_TARGET |
La automatización de DNS. DNS_TARGET=auto sigue la IP pública de la red doméstica. |
rendimiento-github |
lo crea el flujo de configuración | El ID, la llave privada, el secreto de avisos web y el cliente OAuth de la aplicación de GitHub |
kubectl -n rendimiento-system create secret generic rendimiento-db --from-literal=password="$(openssl rand -hex 24)"
kubectl -n rendimiento-system create secret generic rendimiento-setup --from-literal=token="$(openssl rand -hex 16)"
# un token de Cloudflare con Zone:DNS:Edit en sus zonas, escrito sin mostrarlo en pantalla:
read -rs CF && kubectl -n rendimiento-system create secret generic rendimiento-dns \
--from-literal=CLOUDFLARE_API_TOKEN="$CF" --from-literal=DNS_TARGET=auto; unset CF
La aplicación de GitHub¶
rendimiento crea su propia aplicación de GitHub con el flujo de manifiesto, así nadie llena a mano los formularios de GitHub:
sequenceDiagram
actor You as Usted
participant R as rendimiento
participant G as GitHub
You->>R: abre /api/setup/github?token=… (el token de rendimiento-setup)
R->>You: una página que manda el manifiesto de la aplicación a GitHub
You->>G: crea la aplicación (nombre, permisos, URL de avisos web)
G->>R: redirige a /api/setup/github/callback?code=…
R->>G: cambia el código por las credenciales de la aplicación
R->>R: las guarda en el secreto rendimiento-github
You->>G: instala la aplicación en su cuenta (todos o algunos repositorios)
El manifiesto pide estos permisos de repositorio: Contents de lectura y escritura (leer el código, enviar las ramas de incorporación), Pull requests de lectura y escritura (abrir las solicitudes de incorporación), Checks de lectura y escritura (reportar el estado de la integración continua), Metadata de lectura, Issues de lectura y escritura y Commit statuses de lectura (estos dos para el complemento de Renovate). Se suscribe a los eventos de envío (push). El inicio de sesión en el panel usa el OAuth de la misma aplicación, y solo los usuarios de ALLOWED_USERS reciben una sesión.
Cambiar después los permisos de la aplicación
Cámbielos en https://github.com/settings/apps/<app-name>/permissions y luego acepte los permisos nuevos en la instalación (https://github.com/settings/installations → Configure). Mientras no se acepten, las instalaciones conservan los anteriores; la página Complementos avisa cuando a Renovate le falta lo que necesita.
Publicar una versión nueva de rendimiento¶
make test-remote # todas las pruebas, en un nodo trabajador
make image # construye y envía registry.example.lan:5000/rendimiento:latest en el grupo de BuildKit
make deploy # kubectl apply -k deploy (solo hace falta si cambiaron deploy/ o los CRD)
kubectl -n rendimiento-system rollout restart deploy/rendimiento
kubectl -n rendimiento-system rollout status deploy/rendimiento
El Deployment descarga :latest con imagePullPolicy: Always y usa la estrategia Recreate (una réplica, nunca dos a la vez), así que un reinicio tarda unos 20 segundos durante los cuales la interfaz y los avisos web no están disponibles. Las aplicaciones siguen corriendo: no dependen de que la plataforma esté arriba. GitHub reintenta los avisos web que fallan.
Al reiniciar, la plataforma:
- aplica las migraciones de la base de datos (
internal/store/migrations), cada una una sola vez; - vuelve a poner en cola las ejecuciones de integración continua que un proceso anterior dejó corriendo (hasta dos reintentos) y borra los pods de construcción sobrantes;
- cierra las ejecuciones de Renovate que se interrumpieron;
- arranca los controladores, el trabajador de integración continua, el programador de Renovate, la sincronización de complementos desde git y el DNS dinámico.
:latest no tiene historial
Revertir la plataforma significa volver a construir la confirmación anterior. Etiquetar cada construcción con su confirmación está en la hoja de ruta; mientras tanto, git checkout <confirmación buena> && make image es la reversión.
rendimiento se construye a sí mismo¶
El repositorio de rendimiento está incorporado como cualquier aplicación. Su rendimiento.yaml tiene el libro (docs, un servicio) y la imagen de la plataforma (builds: platform), así que cada envío corre:
platform:test:hack/ci-test.sh(generar, vet y las pruebas de Go, con envtest y un Postgres desechable) engolang, con una caché que se conserva;platform:build: el Dockerfile, que además revisa los tipos y las traducciones de la interfaz, publicado como<registro>/rendimiento-ai-platform:<confirmación>.
Las solicitudes de incorporación reciben la misma comprobación, sin versión. En la rama principal, el resumen (digest) de la imagen se guarda con la versión, junto al del libro. Todavía no se despliega solo: publicar sigue siendo los pasos de arriba, que construyen el mismo Dockerfile. El siguiente paso es que una versión actualice la plataforma (con una actualización gradual que conserve la versión anterior hasta que la nueva esté lista, para que una imagen rota no pueda tumbar la plataforma).
Un campo que la plataforma en marcha no conoce
La plataforma lee rendimiento.yaml de forma estricta. Un cambio que le agrega un campo (como builds:) debe publicarse antes de enviar la confirmación que lo usa; si no, la ejecución de ese envío no puede leer su propio spec.
El lado de las construcciones¶
| Qué | De dónde viene |
|---|---|
| Demonios de BuildKit | el complemento buildkit (p0dxD/gitops/buildkit/): un StatefulSet con un demonio por nodo trabajador |
| Imagen de la herramienta de Railpack | make railpack-image desde deploy/railpack/Dockerfile, fijada por suma de verificación |
| Interfaz de entrada de Railpack | BuildKit la descarga de ghcr.io/railwayapp/railpack-frontend en la versión fijada |
| Imágenes de clonado y del cliente de construcción | alpine/git, moby/buildkit (el cliente debe coincidir con la versión del demonio) |