// Изменено: 2026-03-22 // Builder — запускает kaniko Job в k8s для сборки Docker образа из кода функции. // Почему kaniko, а не docker-in-docker (DinD): // kaniko не требует privileged контейнер, что безопаснее в managed кластере. // kaniko читает контекст сборки из S3 напрямую. // Workflow: FunctionController вызывает Build → EnsureProject (Harbor) → Job запускается → образ пушится в registry. // ImageExists: перед сборкой проверяем наличие образа по тегу в registry. // Если образ с таким тегом (= hash кода) уже существует — сборка пропускается. // Это даёт идемпотентность destroy+apply: если код не менялся, deploy мгновенный. package builder import ( "context" "crypto/sha256" "encoding/json" "fmt" "net/http" "strings" "time" batchv1 "k8s.io/api/batch/v1" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client" ) // Projecter — опциональный интерфейс для реестров с поддержкой проектов (Harbor). // Если nil — вызов EnsureProject пропускается (Docker Hub, другие реестры без проектов). type Projecter interface { EnsureProject(ctx context.Context, name string) error } // Builder — управляет сборкой Docker образов через kaniko Jobs в k8s. type Builder struct { client client.Client builderImage string // образ kaniko registryHost string // куда пушим образ (DockerHub: "naeel"; Harbor: "host") registryProject string // проект/org внутри registry (Harbor: project; DockerHub: пусто) registrySecret string // имя k8s Secret с docker-кредами для kaniko registryInsecure bool // true = in-cluster HTTP registry, без TLS и авторизации s3Endpoint string // откуда kaniko берёт код s3AccessKey string s3SecretKey string s3Bucket string namespace string // namespace где запускаем build Job'ы harborClient Projecter // nil если Harbor не используется } // Config — параметры для создания Builder'а. type Config struct { BuilderImage string RegistryHost string RegistryProject string // пусто = DockerHub-режим (2 уровня); задан = project-режим (3 уровня) RegistrySecret string // имя k8s Secret с .dockerconfigjson для пуша образов RegistryInsecure bool // true = HTTP registry (in-cluster), kaniko получает --insecure S3Endpoint string S3AccessKey string S3SecretKey string S3Bucket string Namespace string HarborClient Projecter // nil — Harbor не используется, EnsureProject пропускается } // New создаёт новый Builder. func New(c client.Client, cfg Config) *Builder { // In-cluster HTTP registry не требует docker credentials — монтировать Secret не нужно. registrySecret := cfg.RegistrySecret if cfg.RegistryInsecure { registrySecret = "" } return &Builder{ client: c, builderImage: cfg.BuilderImage, registryHost: cfg.RegistryHost, registryProject: cfg.RegistryProject, registrySecret: registrySecret, registryInsecure: cfg.RegistryInsecure, s3Endpoint: cfg.S3Endpoint, s3AccessKey: cfg.S3AccessKey, s3SecretKey: cfg.S3SecretKey, s3Bucket: cfg.S3Bucket, namespace: cfg.Namespace, harborClient: cfg.HarborClient, } } // ImageRef возвращает полный путь к образу в registry для данной функции и версии. // Тег = первые 12 символов sha256(s3Key): уникален per build, не меняется при // повторном reconcile с тем же s3Key, не требует хаков с :latest. // // Два режима — переключаются через REGISTRY_PROJECT env без пересборки оператора: // // project-режим (REGISTRY_PROJECT задан): // {host}/{project}/{func}:{tag} // Для: Harbor, GCR, GHCR, любой registry с project-изоляцией // Пример: pearlharbor.host/myproject/chaos-echo:abc123 // // flat-режим (REGISTRY_PROJECT пуст): // {host}/{nsPrefix}-{func}:{tag} // Для: DockerHub (max 2 уровня: user/repo) // nsPrefix = первые 8 символов namespace — tenant-изоляция в имени репо // Пример: naeel/slessffd1-chaos-echo:abc123 func (b *Builder) ImageRef(namespace, funcName, s3Key string) string { h := sha256.Sum256([]byte(s3Key)) tag := fmt.Sprintf("%x", h[:6]) // 12 hex-символов if b.registryProject != "" { // project-режим: Harbor / GCR / GHCR return fmt.Sprintf("%s/%s/%s:%s", b.registryHost, b.registryProject, funcName, tag) } // flat-режим: DockerHub (только 2 уровня) nsPrefix := namespace if len(nsPrefix) > 8 { nsPrefix = nsPrefix[:8] } return fmt.Sprintf("%s/%s-%s:%s", b.registryHost, nsPrefix, funcName, tag) } // ImageExists проверяет наличие образа в Docker Registry v2 по тегу (без pull). // Возвращает (true, nil) если образ есть, (false, nil) если нет, // (false, err) если registry недоступен — контроллер должен requeue, не запускать сборку. // // Два режима: // - insecure (in-cluster registry:2): HTTP, без TLS, без авторизации // - secure (DockerHub): HTTPS, анонимный bearer-token для публичных репо func (b *Builder) ImageExists(ctx context.Context, imageRef string) (bool, error) { colonIdx := strings.LastIndex(imageRef, ":") if colonIdx < 0 { return false, nil } repoFull := imageRef[:colonIdx] tag := imageRef[colonIdx+1:] if b.registryInsecure { // In-cluster HTTP registry — без авторизации и TLS. // repoFull вида "host:port/path" → берём host до первого / slashIdx := strings.Index(repoFull, "/") if slashIdx < 0 { return false, nil } registryHost := repoFull[:slashIdx] repo := repoFull[slashIdx+1:] httpClient := &http.Client{Timeout: 5 * time.Second} manifestURL := fmt.Sprintf("http://%s/v2/%s/manifests/%s", registryHost, repo, tag) req, err := http.NewRequestWithContext(ctx, http.MethodHead, manifestURL, nil) if err != nil { return false, err } req.Header.Set("Accept", "application/vnd.docker.distribution.manifest.v2+json") resp, err := httpClient.Do(req) if err != nil { // Registry недоступен — не притворяемся что образа нет, requeue return false, fmt.Errorf("registry unavailable at %s: %w", registryHost, err) } defer resp.Body.Close() switch resp.StatusCode { case http.StatusOK: return true, nil case http.StatusNotFound: return false, nil default: return false, fmt.Errorf("unexpected registry response: %d", resp.StatusCode) } } // Secure registry (DockerHub или HTTPS). // DockerHub: "naeel/sless-ff-pg" → registry = index.docker.io // Приватный: "harbor.host/proj/func" → registry = harbor.host registryHost := "index.docker.io" repo := repoFull if parts := strings.SplitN(repoFull, "/", 3); len(parts) == 3 { registryHost = parts[0] repo = parts[1] + "/" + parts[2] } tokenURL := fmt.Sprintf("https://auth.docker.io/token?service=registry.docker.io&scope=repository:%s:pull", repo) if registryHost != "index.docker.io" { tokenURL = "" } httpClient := &http.Client{Timeout: 5 * time.Second} var bearerToken string if tokenURL != "" { req, err := http.NewRequestWithContext(ctx, http.MethodGet, tokenURL, nil) if err != nil { return false, nil } resp, err := httpClient.Do(req) if err != nil || resp.StatusCode != http.StatusOK { return false, nil } defer resp.Body.Close() var tkResp struct { Token string `json:"token"` } if err := json.NewDecoder(resp.Body).Decode(&tkResp); err != nil { return false, nil } bearerToken = tkResp.Token } manifestURL := fmt.Sprintf("https://%s/v2/%s/manifests/%s", registryHost, repo, tag) req, err := http.NewRequestWithContext(ctx, http.MethodHead, manifestURL, nil) if err != nil { return false, nil } req.Header.Set("Accept", "application/vnd.docker.distribution.manifest.v2+json") if bearerToken != "" { req.Header.Set("Authorization", "Bearer "+bearerToken) } resp, err := httpClient.Do(req) if err != nil { return false, nil } defer resp.Body.Close() return resp.StatusCode == http.StatusOK, nil } // Build запускает kaniko Job для сборки образа функции. // Возвращает имя Job'а чтобы контроллер мог следить за его статусом. func (b *Builder) Build(ctx context.Context, namespace, funcName, s3Key string) (string, error) { // Идемпотентно создаём Harbor-проект перед push — kaniko сам его не создаёт. // Пропускаем если HarborClient не задан (Docker Hub и другие реестры без проектов). if b.harborClient != nil { if err := b.harborClient.EnsureProject(ctx, namespace); err != nil { return "", fmt.Errorf("harbor ensure project %q: %w", namespace, err) } } imageRef := b.ImageRef(namespace, funcName, s3Key) jobName := fmt.Sprintf("build-%s-%s", funcName, time.Now().Format("20060102150405")) // kaniko читает Dockerfile из context архива в S3 // --context=s3://bucket/key — kaniko поддерживает S3 как источник контекста s3ContextURL := fmt.Sprintf("s3://%s/%s", b.s3Bucket, s3Key) job := &batchv1.Job{ ObjectMeta: metav1.ObjectMeta{ Name: jobName, Namespace: b.namespace, Labels: map[string]string{ "app": "sless-builder", "function-name": funcName, "function-ns": namespace, }, }, Spec: batchv1.JobSpec{ // 2 попытки: при транзиентных сбоях (OOM, network blip) kaniko сможет перезапуститься. // BackoffLimit=0 приводил к ложным "build job failed" при нагрузке. BackoffLimit: int32Ptr(2), Completions: int32Ptr(1), Template: corev1.PodTemplateSpec{ Spec: corev1.PodSpec{ RestartPolicy: corev1.RestartPolicyNever, Containers: []corev1.Container{ { Name: "kaniko", Image: b.builderImage, Args: b.kanikoArgs(s3ContextURL, imageRef), Env: []corev1.EnvVar{ {Name: "AWS_ACCESS_KEY_ID", Value: b.s3AccessKey}, {Name: "AWS_SECRET_ACCESS_KEY", Value: b.s3SecretKey}, // Kaniko AWS SDK требует схему в endpoint (https://) — без неё URL строится без протокола {Name: "S3_ENDPOINT", Value: "https://" + b.s3Endpoint}, // Ceph не поддерживает virtual-hosted style (bucket.endpoint) — только path style {Name: "S3_FORCE_PATH_STYLE", Value: "true"}, {Name: "AWS_REGION", Value: "us-east-1"}, }, // Монтируем docker-креды из k8s Secret — kaniko читает авторизацию из /kaniko/.docker/config.json VolumeMounts: b.dockerConfigMounts(), }, }, Volumes: b.dockerConfigVolumes(), }, }, }, } if err := b.client.Create(ctx, job); err != nil { // Если job с таким именем уже существует (два reconcile в одну секунду) — // возвращаем имя, контроллер будет следить за ним как обычно if errors.IsAlreadyExists(err) { return jobName, nil } return "", fmt.Errorf("create build job: %w", err) } return jobName, nil } // JobStatus проверяет статус build Job'а. // Возвращает: "running", "succeeded", "failed" func (b *Builder) JobStatus(ctx context.Context, jobName string) (string, error) { job := &batchv1.Job{} if err := b.client.Get(ctx, client.ObjectKey{Name: jobName, Namespace: b.namespace}, job); err != nil { if errors.IsNotFound(err) { return "failed", nil } return "", fmt.Errorf("get build job: %w", err) } if job.Status.Succeeded > 0 { return "succeeded", nil } if job.Status.Failed > 0 { return "failed", nil } return "running", nil } // Cleanup удаляет завершённый build Job из k8s. // Вызывается после того как контроллер зафиксировал результат сборки. func (b *Builder) Cleanup(ctx context.Context, jobName string) error { job := &batchv1.Job{} if err := b.client.Get(ctx, client.ObjectKey{Name: jobName, Namespace: b.namespace}, job); err != nil { if errors.IsNotFound(err) { return nil // уже удалён } return fmt.Errorf("get build job for cleanup: %w", err) } propagation := metav1.DeletePropagationForeground return b.client.Delete(ctx, job, &client.DeleteOptions{PropagationPolicy: &propagation}) } // kanikoArgs строит аргументы для kaniko контейнера. // Добавляет --insecure если registry работает по HTTP (in-cluster). func (b *Builder) kanikoArgs(s3ContextURL, imageRef string) []string { args := []string{ "--context=" + s3ContextURL, "--destination=" + imageRef, } if b.registryInsecure { // --insecure: push в HTTP registry без TLS (in-cluster registry:2) args = append(args, "--insecure") } return args } // dockerConfigVolumes возвращает Volume с docker-кредами если RegistrySecret задан. // Ключ .dockerconfigjson монтируется как config.json — стандартное имя для kaniko. func (b *Builder) dockerConfigVolumes() []corev1.Volume { if b.registrySecret == "" { return nil } return []corev1.Volume{ { Name: "docker-config", VolumeSource: corev1.VolumeSource{ Secret: &corev1.SecretVolumeSource{ SecretName: b.registrySecret, Items: []corev1.KeyToPath{ {Key: ".dockerconfigjson", Path: "config.json"}, }, }, }, }, } } // dockerConfigMounts возвращает VolumeMount для docker-кредов если RegistrySecret задан. func (b *Builder) dockerConfigMounts() []corev1.VolumeMount { if b.registrySecret == "" { return nil } return []corev1.VolumeMount{ {Name: "docker-config", MountPath: "/kaniko/.docker"}, } } func int32Ptr(i int32) *int32 { return &i }