Saltar a contenido

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.go conecta 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.go de 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.Reconcile y, 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 LOCKED deja que muchos trabajadores aparten filas distintas sin estorbarse, y una caída deja la fila disponible otra vez (RequeueOrphans al 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.