Patrones de diseño¶
Los patrones de abajo son las ideas que sostienen el código. Cada uno tiene un por qué y un dónde, para que lo vea en el código. Cuando agregue algo, recurra a ellos primero: el código que sigue las formas que ya existen es más fácil de leer para la siguiente persona (incluido usted en el futuro).
Patrones de arquitectura¶
Monolito modular¶
Un solo programa, muchos paquetes con fronteras claras (spec, render, pipeline, controller…). Los paquetes se hablan por interfaces pequeñas, nunca por variables globales compartidas.
- Por qué: un solo Deployment es fácil de correr en un clúster de Pi, hay una sola versión en qué pensar, no hay llamadas de red entre componentes ni transacciones distribuidas. Las fronteras de los paquetes lo mantienen divisible: el trabajador, los controladores y la API podrían volverse Deployments separados cambiando solo
cmd/rendimiento/main.go(vea Escalar). - Dónde:
cmd/rendimiento/main.goconecta todo.
Raíz de composición: inyección de dependencias a mano¶
main.go lee los ajustes, crea cada objeto concreto (almacén, contenedor de GitHub, proveedor de DNS, ejecutor, corredor, plataforma, controladores, servidor de la API) y le pasa a cada uno lo que necesita por campos de estructura.
baseURL := strings.TrimRight(env("BASE_URL", "http://localhost:8080"), "/")
ns := env("NAMESPACE", "rendimiento-system")
dsn := os.Getenv("DATABASE_URL")
if dsn == "" {
return errors.New("DATABASE_URL is required")
}
users := strings.FieldsFunc(os.Getenv("ALLOWED_USERS"), func(r rune) bool { return r == ',' || r == ' ' })
if len(users) == 0 {
return errors.New("ALLOWED_USERS is required (comma-separated GitHub logins)")
}
st, err := store.Open(ctx, dsn)
if err != nil {
return fmt.Errorf("database: %w", err)
}
defer st.Close()
if err := st.Migrate(ctx); err != nil {
return fmt.Errorf("migrate: %w", err)
}
go problemLog.Run(ctx, st, plain)
if requeued, failed, err := st.RequeueOrphans(ctx); err != nil {
return fmt.Errorf("recover interrupted runs: %w", err)
} else if requeued+failed > 0 {
log.Warn("recovered runs interrupted by the last restart", "requeued", requeued, "failed", failed)
}
- Por qué: sin marco de inyección de dependencias, sin magia escondida. Leer
main.gode arriba abajo dice exactamente qué corre y con qué. Las pruebas arman las mismas estructuras con imitaciones.
Ciclo de conciliación: control por nivel¶
Los controladores no reaccionan a lo que pasó; comparan lo que debería ser (la especificación del App o del Addon) con lo que es (el clúster) y actúan para cerrar la diferencia. Cada evento solo significa "vuelve a mirar".
// Reconcile brings one app's objects in line with its App object, or cleans up after it is deleted.
func (r *AppReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
var app v1alpha1.App
if err := r.Get(ctx, req.NamespacedName, &app); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
if !app.DeletionTimestamp.IsZero() {
return ctrl.Result{}, r.finalize(ctx, &app)
}
if controllerutil.AddFinalizer(&app, finalizer) {
if err := r.Update(ctx, &app); err != nil {
return ctrl.Result{}, err
}
}
status := v1alpha1.AppStatus{ObservedGeneration: app.Generation, Release: app.Status.Release}
result, err := r.sync(ctx, &app, &status)
if err != nil {
status.Phase, status.Message = v1alpha1.PhaseError, err.Error()
log.FromContext(ctx).Error(err, "sync failed")
}
app.Status = status
if uerr := r.Status().Update(ctx, &app); uerr != nil {
return ctrl.Result{}, uerr
}
if err != nil && !errors.Is(err, errBlocked) {
return ctrl.Result{}, err // retried with backoff
}
return result, nil
}
- Por qué: se repara solo. Eventos perdidos, caídas, una persona que corre
kubectl delete: la siguiente conciliación lo arregla. También es idempotente: correrlo dos veces no hace daño. - Dónde:
AppReconciler.Reconcile,AddonReconciler.Reconciley, en espíritu, el sincronizador de complementos con git y el ciclo de DNS dinámico ("que así sea", cada cierto tiempo).
Estado deseado declarativo + aplicación del lado del servidor¶
El controlador calcula los objetos deseados completos (render.Render) y se los entrega a Kubernetes con la aplicación del lado del servidor bajo el administrador de campos rendimiento. Kubernetes combina, lleva la cuenta de quién es dueño de cada campo e informa los conflictos.
- Por qué: sin carreras de leer, modificar y escribir, sin comparar a mano; los campos de otros controladores (las réplicas del escalador automático, las anotaciones de cert-manager) se dejan en paz.
Núcleo puro, cáscara imperativa¶
spec, detect, generate, render, pipeline.Plan y addon.Render son puros: entra algo, sale algo, sin clúster y sin red. La cáscara (controladores, ejecutor, manejadores de la API) hace la entrada y salida y los llama.
- Por qué: las partes puras tienen la lógica complicada y se prueban con pruebas unitarias y de referencia rápidas. La cáscara es delgada y se prueba con envtest.
Cola de trabajo en la base de datos (algo así como una bandeja de salida transaccional)¶
Las ejecuciones son filas. Los trabajadores apartan una con SELECT … FOR UPDATE SKIP LOCKED:
// ClaimRun takes the oldest queued run and marks it running. It returns
// ErrNotFound when the queue is empty. Safe across replicas.
func (s *Store) ClaimRun(ctx context.Context) (*Run, error) {
return scanRun(s.pool.QueryRow(ctx, `
UPDATE runs SET status = 'running', started_at = now()
WHERE id = (SELECT id FROM runs WHERE status = 'queued' ORDER BY id FOR UPDATE SKIP LOCKED LIMIT 1)
RETURNING `+runCols))
}
- Por qué: Postgres ya está ahí; no hay Redis ni RabbitMQ que mantener.
SKIP LOCKEDdeja que muchos trabajadores aparten filas distintas sin estorbarse, y una caída deja la fila disponible otra vez (RequeueOrphansal arrancar).
GitOps para la configuración, base de datos para las versiones¶
La configuración de una aplicación vive en su repositorio (rendimiento.yaml), y la de los complementos en p0dxD/gitops. El apuntador de la versión (qué huella de imagen corre) vive en el objeto App y en la tabla releases.
- Por qué: la configuración recibe revisión de código e historial; las versiones no crean el ruido de confirmaciones
[skip ci], y la reversión es instantánea (apuntar a una huella anterior).
Patrones de código¶
Interfaces pequeñas, definidas por quien las usa¶
platform.GitHub, pipeline.Executor, dns.Provider, addon.GitFetcher, generate.Generator. Cada una declara solo los métodos que llama quien la usa. Las implementaciones reales y las imitaciones de las pruebas las cumplen por igual. Vea Go para este código.
Estrategia¶
Una familia de algoritmos intercambiables detrás de una interfaz, elegida al arrancar o en cada llamada:
| Decisión | Estrategias |
|---|---|
| Cómo construir una imagen | Dockerfile o Railpack (build.builder) |
| Qué proveedor de DNS | Cloudflare o Noop |
| Cómo se genera un complemento | paquete de Helm o kustomize/manifiestos simples |
| Qué demonio de BuildKit | el grupo (hash de encuentro) o una sola dirección |
Adaptador¶
github.Holder adapta la API REST de GitHub a platform.GitHub; GitHubFetcher adapta el árbol de un repositorio a fs.FS para que Helm y kustomize lo lean como una carpeta local; el registrador adapta los flujos de registros de Kubernetes a io.Writer + el almacén + SSE.
Contenedor con cambio atómico¶
Las credenciales de la aplicación de GitHub no existen hasta que termina la configuración. github.Holder envuelve un atomic.Pointer para que el servidor en marcha pueda poner una aplicación ya configurada sin reiniciar, y cada quien que lo llama ve o "sin configurar" o una aplicación completa, nunca la mitad de una.
Observador: publicación y suscripción¶
events.Hub reparte los avisos de cambio (se actualizó una ejecución, cambió el estado de una aplicación) a cada navegador conectado con eventos enviados por el servidor. Los productores llaman a Publish y no saben quién escucha.
Opciones sin constructor, con valores predeterminados¶
Las especificaciones y los ajustes son estructuras simples; un método Default() llena los huecos y Validate() revisa el resultado, informando todos los problemas a la vez (errors.Join). Sin cadenas de constructores ni opciones funcionales: el YAML son las opciones.
Método plantilla: las canalizaciones¶
pipeline.Plan convierte una especificación en un grafo dirigido de pasos (clonar → probar → construir, por servicio). El corredor recorre cualquier grafo de la misma manera; solo cambian los pasos. Los tipos nuevos de paso se conectan sin tocar el corredor (vea Recetas).
Hash de encuentro¶
buildkitFor elige un demonio de BuildKit para una imagen sacando el hash de (imagen, demonio) y tomando la calificación más alta:
// buildkitFor picks the pool daemon for key by rendezvous hashing: each key
// has a stable favourite among the ready daemons, and only the keys of a
// daemon that goes away move elsewhere. It returns the address and the
// daemon's name, or the single BuildkitAddr (and "") without a pool.
func (k *KubeExecutor) buildkitFor(ctx context.Context, key string) (string, string) {
if k.BuildkitPool == "" {
return k.BuildkitAddr, ""
}
res := k.Resolver
if res == nil {
res = net.DefaultResolver
}
lctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
_, srvs, err := res.LookupSRV(lctx, "buildkit", "tcp", k.BuildkitPool)
if err != nil || len(srvs) == 0 {
return k.BuildkitAddr, ""
}
var best *net.SRV
var bestScore uint64
for _, s := range srvs {
h := fnv.New64a()
h.Write([]byte(strings.TrimSuffix(s.Target, ".") + "|" + key))
if score := h.Sum64(); best == nil || score > bestScore {
best, bestScore = s, score
}
}
host := strings.TrimSuffix(best.Target, ".")
daemon, _, _ := strings.Cut(host, ".")
return fmt.Sprintf("tcp://%s:%d", host, best.Port), daemon
}
- Por qué: la misma imagen siempre cae en el mismo demonio (caché caliente), y agregar o quitar un demonio solo mueve las imágenes que le tocaban. No hace falta un coordinador.
La propiedad y las etiquetas como índice¶
Todo lo que crea rendimiento lleva las etiquetas app.kubernetes.io/managed-by: rendimiento y rendimiento.ai/app (o rendimiento.ai/addon), y referencias de dueño donde se puede. Podar es "enumerar por etiqueta y borrar lo que no se desea"; adoptar es "tomar el control de los objetos que coinciden pero no tienen nuestras etiquetas, solo después de que una persona esté de acuerdo".
Compuertas de seguridad¶
Los cambios destructivos o sorprendentes se detienen y preguntan: la adopción de objetos existentes, los complementos con manualSync, los tipos de neverDelete (CRD, Namespaces, PVCs, PVs, StorageClasses), la anotación de pausa, los ganchos pre-delete. El controlador devuelve errBlocked y muestra el porqué en el estado, en lugar de adivinar.
Errores centinela y errores envueltos¶
store.ErrNotFound, errBlocked y fmt.Errorf("…: %w", err) por todas partes. Quien llama decide según el tipo de fallo con errors.Is.
Incrustar todo¶
La interfaz, las plantillas y las migraciones se incrustan con //go:embed. La imagen es un solo programa estático; un despliegue nunca puede mezclar un programa nuevo con archivos viejos.
Patrones que no se usan, a propósito¶
| No se usa | Por qué no (todavía) |
|---|---|
| Mapeador de objetos (ORM) | El SQL simple con pgx es más corto, más rápido, y cada consulta está a la vista. |
| Intermediario de mensajes | La cola en Postgres basta a esta escala; vea Escalar. |
| Microservicios | Una persona, un clúster: el costo de operarlos superaría por mucho el beneficio. Las fronteras existen para dividir después. |
Carga de extensiones (paquete plugin, WASM) |
Hoy los puntos de extensión son interfaces compiladas dentro; el contrato de extensiones (contenedores, JSON de entrada y salida) está en la hoja de ruta. |
| Registradores o configuración globales | Todo se pasa explícitamente, así las pruebas pueden correr en paralelo. |