feat(v0.1.58): ImageExists cache hit, timing analysis, in-cluster registry plan

This commit is contained in:
Naeel
2026-03-23 06:22:25 +03:00
parent 7023e0e6fc
commit 9edd43edc5
20 changed files with 596 additions and 515 deletions
+89 -9
View File
@@ -1,5 +1,6 @@
// Создано: 2026-03-20 (function-service-split)
// Изменено: 2026-03-21 (fix: DeleteService возвращает 404 вместо 204 при отсутствующем объекте)
// Изменено: 2026-03-22 (fix: CreateService 409 при пересоздании сервиса через terraform -replace)
// services.go — CRUD handlers для Service CRD (sless_service).
// sless_service = long-running Deployment + URL. Каждый вызов проксируется к поду.
// Namespace берётся из URL: /v1/namespaces/{namespace}/services/{name}
@@ -134,18 +135,69 @@ func (h *Handler) CreateService(w http.ResponseWriter, r *http.Request) {
S3Key: req.S3Key,
},
}
// Пытаемся создать Service CRD в k8s.
// h.K8s.Create обращается к etcd через controller-runtime client.
if err := h.K8s.Create(r.Context(), svc); err != nil {
// IsAlreadyExists = etcd вернул 409 CONFLICT.
// Это нормально при конкурентных запросах или при "replace" через terraform.
// Сценарии:
//
// [A] terraform apply -replace:
// 1. terraform DELETE /services/{name} → API вызывает h.K8s.Delete(svc)
// 2. k8s НЕ удаляет объект немедленно: ставит DeletionTimestamp и ждёт
// пока service_controller.go снимет finalizer sless.kube5s.ru/service-finalizer
// (контроллер сначала сносит Deployment/Service/Ingress)
// 3. terraform сразу POST /services/{name} → h.K8s.Create → IsAlreadyExists
// (объект ещё есть в etcd, просто помечен на удаление)
// → СТАРЫЙ КОД: видел phase=Ready, shouldRecreate=false → возвращал 409 ← БАГ
// → НОВЫЙ КОД: видит DeletionTimestamp → ждёт исчезновения → создаёт
//
// [B] Кеш controller-runtime (split-brain):
// Объект удалён из etcd, но informer-кеш ещё не обновился.
// h.K8s.Create падает с IsAlreadyExists из кеша, хотя в etcd объекта нет.
// → Get возвращает NotFound → пересоздаём.
//
// [C] Сервис в фазе Failed:
// Предыдущий build провалился, terraform не добавил в state.
// Новый apply пытается создать снова → Failed объект удаляем и пересоздаём.
if errors.IsAlreadyExists(err) {
// Повторяем логику function_handler: обрабатываем split-brain кеша.
// Если объект реально есть и не в фазе Failed — возвращаем 409.
// Получаем актуальное состояние объекта напрямую из etcd (не из кеша).
// Нужно чтобы точно определить сценарий A/B/C.
existing := &slessv1alpha1.Service{}
getErr := h.K8s.Get(r.Context(), client.ObjectKey{Name: req.Name, Namespace: ns}, existing)
shouldRecreate := errors.IsNotFound(getErr) ||
(getErr == nil && existing.Status.Phase == slessv1alpha1.ServicePhaseFailed)
if shouldRecreate {
if getErr == nil {
_ = h.K8s.Delete(r.Context(), existing)
// --- Сценарий A: объект помечен на удаление ---
// DeletionTimestamp ≠ zero означает что k8s принял DELETE и ждёт finalizer.
// Finalizer снимает service_controller.go асинхронно (обычно 1-5 сек).
// Мы не можем создать объект пока старый существует — ждём его исчезновения.
if getErr == nil && !existing.DeletionTimestamp.IsZero() {
deleted := false
// Проверяем каждую секунду до 30 итераций (= 30 секунд максимум).
// Обычно finalizer снимается за 1-3 секунды, 30 — запас на перегруженный кластер.
for i := 0; i < 30; i++ {
time.Sleep(1 * time.Second)
checkErr := h.K8s.Get(r.Context(), client.ObjectKey{Name: req.Name, Namespace: ns}, existing)
// IsNotFound = объект полностью исчез из etcd — можно создавать.
if errors.IsNotFound(checkErr) {
deleted = true
break
}
// Любая другая ошибка Get — продолжаем ждать (может быть временный сбой API).
}
// 30 секунд прошло, объект всё ещё не удалён.
// Вероятно контроллер завис или finalizer не снимается.
// Возвращаем 409 с понятным сообщением — клиент (terraform) должен retry.
if !deleted {
writeJSON(w, http.StatusConflict, errResp("service is being deleted, try again later"))
return
}
// Объект исчез. Сбрасываем ResourceVersion чтобы k8s воспринял как новый объект.
// ResourceVersion заполняется при первом Get — при Create должен быть пустым.
svc.ResourceVersion = ""
if createErr := h.K8s.Create(r.Context(), svc); createErr != nil {
writeJSON(w, http.StatusInternalServerError, errResp(createErr.Error()))
@@ -154,11 +206,39 @@ func (h *Handler) CreateService(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusCreated, svcToResponse(svc))
return
}
// --- Сценарии B и C: split-brain кеша или объект в фазе Failed ---
// shouldRecreate=true если:
// - getErr = NotFound (кеш врёт — объекта в etcd нет)
// - объект есть, но в фазе Failed (предыдущий build провалился)
shouldRecreate := errors.IsNotFound(getErr) ||
(getErr == nil && existing.Status.Phase == slessv1alpha1.ServicePhaseFailed)
if shouldRecreate {
// Если объект реально существует (Failed) — удаляем его перед пересозданием.
// Ошибку удаления игнорируем: даже если Delete упал,
// следующий Create либо пройдёт (объект исчез) либо снова упадёт с понятной ошибкой.
if getErr == nil {
_ = h.K8s.Delete(r.Context(), existing)
}
// Сбрасываем ResourceVersion — при split-brain etcd считает объект новым.
svc.ResourceVersion = ""
if createErr := h.K8s.Create(r.Context(), svc); createErr != nil {
writeJSON(w, http.StatusInternalServerError, errResp(createErr.Error()))
return
}
writeJSON(w, http.StatusCreated, svcToResponse(svc))
return
}
// Объект существует, DeletionTimestamp=zero, фаза не Failed.
// Это легитимный конфликт — сервис реально работает, клиент пытается создать дубликат.
writeJSON(w, http.StatusConflict, errResp("service already exists"))
return
}
// k8s возвращает StatusInvalid (422) при нарушении enum-валидации CRD (например, неизвестный runtime)
// — маппим это в 400, а не в 500
// k8s возвращает StatusInvalid (422) при нарушении enum-валидации CRD.
// Например: runtime="java8" которого нет в openapi enum сервисного CRD.
// Маппим в 400 (Bad Request) а не в 500 — это ошибка клиента, не сервера.
if errors.IsInvalid(err) {
writeJSON(w, http.StatusBadRequest, errResp("invalid service spec: "+err.Error()))
return