fix: бесконечный цикл сборки — idempotency guard через last-built-s3key аннотацию

Проблема: upload handler сбрасывал phase в Pending ПОСЛЕ того как
контроллер уже выставил Building → бесконечный цикл (94 job'а).

Решение:
- controllers/function_controller.go: startBuild сначала ставит аннотацию
  last-built-s3key = spec.S3Key (idempotency guard), потом status.
  Reconcile стартует сборку только если spec.S3Key != last-built-s3key.
- handler/upload.go: убран Status().Update() — контроллер сам управляет фазой.
- builder.go: IsAlreadyExists при создании job не ошибка (parallel reconcile).
This commit is contained in:
“Naeel”
2026-03-07 09:50:24 +04:00
parent 8bae4ca69c
commit 4115e0f172
3 changed files with 27 additions and 25 deletions
+22 -17
View File
@@ -69,24 +69,23 @@ func (r *FunctionReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c
return ctrl.Result{Requeue: true}, nil return ctrl.Result{Requeue: true}, nil
} }
// Определяем что делать в зависимости от текущей фазы // Есть новый код (s3Key изменился по сравнению с последним запуском сборки)
switch fn.Status.Phase { // и сборка сейчас не идёт — запускаем. Это единственное место где решается "нужна ли сборка".
case "", slessv1alpha1.FunctionPhasePending: // Почему аннотация а не phase: phase может быть Failed/Ready от прошлого кода;
// Новая функция или сброшена в Pending — запускаем сборку образа // новый upload меняет Spec.S3Key → контроллер сам понимает что нужно пересобрать.
builtKey := fn.Annotations["sless.kube5s.ru/last-built-s3key"]
needsBuild := fn.Spec.S3Key != "" && builtKey != fn.Spec.S3Key
if needsBuild && fn.Status.Phase != slessv1alpha1.FunctionPhaseBuilding {
logger.Info("starting build", "function", fn.Name) logger.Info("starting build", "function", fn.Name)
return r.startBuild(ctx, fn) return r.startBuild(ctx, fn)
}
switch fn.Status.Phase {
case slessv1alpha1.FunctionPhaseBuilding: case slessv1alpha1.FunctionPhaseBuilding:
// Сборка уже запущена — проверяем статус Job'а
return r.checkBuild(ctx, fn) return r.checkBuild(ctx, fn)
case slessv1alpha1.FunctionPhaseReady: case slessv1alpha1.FunctionPhaseReady:
// Функция готова — проверяем что Deployment существует и актуален
return r.ensureDeployment(ctx, fn) return r.ensureDeployment(ctx, fn)
case slessv1alpha1.FunctionPhaseFailed:
// Ничего не делаем — пользователь должен исправить spec и обновить объект
return ctrl.Result{}, nil
} }
return ctrl.Result{}, nil return ctrl.Result{}, nil
@@ -94,29 +93,35 @@ func (r *FunctionReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c
const finalizerName = "sless.kube5s.ru/finalizer" const finalizerName = "sless.kube5s.ru/finalizer"
// startBuild переводит функцию в фазу Building и запускает kaniko Job. // startBuild запускает kaniko Job и помечает функцию как Building.
// Критически важно: СНАЧАЛА сохраняем last-built-s3key аннотацию, ПОТОМ status.
// Это предотвращает повторный запуск сборки при параллельных reconcile —
// следующий reconcile увидит last-built-s3key == spec.S3Key и не войдёт в startBuild.
func (r *FunctionReconciler) startBuild(ctx context.Context, fn *slessv1alpha1.Function) (ctrl.Result, error) { func (r *FunctionReconciler) startBuild(ctx context.Context, fn *slessv1alpha1.Function) (ctrl.Result, error) {
jobName, err := r.Builder.Build(ctx, fn.Namespace, fn.Name, fn.Spec.S3Key) jobName, err := r.Builder.Build(ctx, fn.Namespace, fn.Name, fn.Spec.S3Key)
if err != nil { if err != nil {
return r.setFailed(ctx, fn, fmt.Sprintf("failed to start build: %v", err)) return r.setFailed(ctx, fn, fmt.Sprintf("failed to start build: %v", err))
} }
// Сохраняем имя Job'а в аннотации чтобы отслеживать в следующем reconcile // Обновляем аннотации ПЕРВЫМИ — это idempotency guard.
// Как только last-built-s3key == spec.S3Key, дальнейшие reconcile не будут
// вызывать startBuild снова, даже если status ещё не обновился.
if fn.Annotations == nil { if fn.Annotations == nil {
fn.Annotations = map[string]string{} fn.Annotations = map[string]string{}
} }
fn.Annotations["sless.kube5s.ru/build-job"] = jobName fn.Annotations["sless.kube5s.ru/build-job"] = jobName
fn.Annotations["sless.kube5s.ru/last-built-s3key"] = fn.Spec.S3Key
if err := r.Update(ctx, fn); err != nil {
return ctrl.Result{}, fmt.Errorf("update build annotations: %w", err)
}
// Обновляем статус после аннотаций
fn.Status.Phase = slessv1alpha1.FunctionPhaseBuilding fn.Status.Phase = slessv1alpha1.FunctionPhaseBuilding
fn.Status.Message = "Building image: " + jobName fn.Status.Message = "Building image: " + jobName
if err := r.Status().Update(ctx, fn); err != nil { if err := r.Status().Update(ctx, fn); err != nil {
return ctrl.Result{}, fmt.Errorf("update status to building: %w", err) return ctrl.Result{}, fmt.Errorf("update status to building: %w", err)
} }
if err := r.Update(ctx, fn); err != nil {
return ctrl.Result{}, fmt.Errorf("update annotations: %w", err)
}
// Перепроверяем через 10 секунд
return ctrl.Result{RequeueAfter: 10 * time.Second}, nil return ctrl.Result{RequeueAfter: 10 * time.Second}, nil
} }
-8
View File
@@ -175,14 +175,6 @@ func (h *Handler) UploadCode(w http.ResponseWriter, r *http.Request) {
return return
} }
// Сбрасываем phase в Pending чтобы контроллер начал сборку
fn.Status.Phase = slessv1alpha1.FunctionPhasePending
fn.Status.Message = "code uploaded, build queued"
if err := h.K8s.Status().Update(r.Context(), fn); err != nil {
// Не фатально — контроллер подберёт через reconcile
h.Log.Warn("failed to reset function phase", "name", name, "ns", ns, "err", err)
}
writeJSON(w, http.StatusOK, map[string]string{ writeJSON(w, http.StatusOK, map[string]string{
"s3_key": s3Key, "s3_key": s3Key,
"phase": string(slessv1alpha1.FunctionPhasePending), "phase": string(slessv1alpha1.FunctionPhasePending),
+5
View File
@@ -121,6 +121,11 @@ func (b *Builder) Build(ctx context.Context, namespace, funcName, s3Key string)
} }
if err := b.client.Create(ctx, job); err != nil { 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 "", fmt.Errorf("create build job: %w", err)
} }
return jobName, nil return jobName, nil