225 lines
9.1 KiB
Go
225 lines
9.1 KiB
Go
// Изменено: 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,
|
||
// --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 }
|