// Изменено: 2026-03-11 // Builder — запускает kaniko Job в k8s для сборки Docker образа из кода функции. // Почему kaniko, а не docker-in-docker (DinD): // kaniko не требует privileged контейнер, что безопаснее в managed кластере. // kaniko читает контекст сборки из S3 напрямую. // Workflow: FunctionController вызывает Build → EnsureProject (Harbor) → Job запускается → образ пушится в registry. package builder import ( "context" "crypto/sha256" "fmt" "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 // куда пушим образ (Harbor: host; DockerHub: user/org) 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 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, 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. // Формат: {registryHost}/{namespace}/{funcName}:{tag} // // Harbor: pearlharbor.registryk8s.services.ngcloud.ru/{ns}/{func}:{tag} // // Чтобы сменить реестр — достаточно изменить REGISTRY_HOST в одном месте (ConfigMap). func (b *Builder) ImageRef(namespace, funcName, s3Key string) string { h := sha256.Sum256([]byte(s3Key)) tag := fmt.Sprintf("%x", h[:6]) // 12 hex-символов return fmt.Sprintf("%s/%s/%s:%s", b.registryHost, namespace, funcName, tag) } // 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, "--cache=true", // кешируем слои для ускорения повторных сборок }, 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 }