// Изменено: 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 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 для пуша образов 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 { return &Builder{ client: c, builderImage: cfg.BuilderImage, registryHost: cfg.RegistryHost, registryProject: cfg.RegistryProject, registrySecret: cfg.RegistrySecret, 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). // Использует анонимный bearer-token для публичных репо (DockerHub). // Возвращает true если образ с таким тегом уже запушен — сборка не нужна. // // Почему анонимный токен, а не credentials: // // DockerHub выдаёт pull-token без авторизации для публичных репо через // GET /token?service=registry.docker.io&scope=repository:{repo}:pull // Это стандартный Docker Registry v2 auth flow (RFC 7235). func (b *Builder) ImageExists(ctx context.Context, imageRef string) bool { // imageRef вида: "naeel/slessffd1-pg-search:47cab27ada70" // или "host/project/func:tag" — разбираем по последнему ":" colonIdx := strings.LastIndex(imageRef, ":") if colonIdx < 0 { return false } repoFull := imageRef[:colonIdx] tag := imageRef[colonIdx+1:] // Определяем registry host и repo path. // DockerHub: "naeel/sless-ff-pg" → registry = index.docker.io, repo = "naeel/sless-ff-pg" // Приватный: "harbor.host/proj/func" → registry = "harbor.host", repo = "proj/func" registryHost := "index.docker.io" repo := repoFull if parts := strings.SplitN(repoFull, "/", 3); len(parts) == 3 { // host/project/name — кастомный registry registryHost = parts[0] repo = parts[1] + "/" + parts[2] } // Получаем анонимный/публичный bearer-token для pull доступа к репо. // DockerHub: https://auth.docker.io/token?service=registry.docker.io&scope=repository:{repo}:pull // Для приватных registry этот шаг вернёт 401 → ImageExists вернёт false → пойдём строить. tokenURL := fmt.Sprintf("https://auth.docker.io/token?service=registry.docker.io&scope=repository:%s:pull", repo) if registryHost != "index.docker.io" { // Для не-DockerHub registry: пробуем без токена (Harbor с allow anon push) // Если 401 — false, пусть builder разберётся. 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 } resp, err := httpClient.Do(req) if err != nil || resp.StatusCode != http.StatusOK { return false } defer resp.Body.Close() var tkResp struct { Token string `json:"token"` } if err := json.NewDecoder(resp.Body).Decode(&tkResp); err != nil { return false } bearerToken = tkResp.Token } // HEAD /v2/{repo}/manifests/{tag} — проверяем наличие тега без скачивания слоёв. 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 } // Docker Registry v2 требует Accept header для манифестов. 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 } defer resp.Body.Close() return resp.StatusCode == http.StatusOK } // 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: []string{ "--context=" + s3ContextURL, "--destination=" + imageRef, // --no-cache не поддерживается этой версией kaniko; кэш отключён по умолчанию }, 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}) } // 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 }