// Package health — HTTP-endpoint /healthz. package health import ( "context" "encoding/json" "fmt" "net" "net/http" "sync" "time" ) // State хранит состояние цикла reconcile. type State struct { maxAge time.Duration now func() time.Time mu sync.Mutex started time.Time lastAttempt time.Time lastSuccess time.Time lastError string } // NewState: сервис считается нездоровым, если цикл не отрабатывал дольше maxAge. func NewState(maxAge time.Duration) *State { s := &State{maxAge: maxAge, now: time.Now} s.started = s.now() return s } // Record фиксирует результат цикла. func (s *State) Record(err error) { s.mu.Lock() defer s.mu.Unlock() s.lastAttempt = s.now() if err == nil { s.lastSuccess = s.lastAttempt s.lastError = "" return } s.lastError = err.Error() } type status struct { Status string `json:"status"` LastAttempt string `json:"lastAttempt,omitempty"` LastSuccess string `json:"lastSuccess,omitempty"` LastError string `json:"lastError,omitempty"` } // Handler отдаёт 200, если цикл жив (попытки идут), иначе 503. // Ошибки внешних систем (Traefik/Selectel) не делают сервис unhealthy — // рестарт контейнера их не исправит; они видны в lastError. func (s *State) Handler() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { s.mu.Lock() ref := s.lastAttempt if ref.IsZero() { ref = s.started } ok := s.now().Sub(ref) <= s.maxAge st := status{Status: "ok", LastError: s.lastError} if !s.lastAttempt.IsZero() { st.LastAttempt = s.lastAttempt.UTC().Format(time.RFC3339) } if !s.lastSuccess.IsZero() { st.LastSuccess = s.lastSuccess.UTC().Format(time.RFC3339) } s.mu.Unlock() code := http.StatusOK if !ok { st.Status, code = "stalled", http.StatusServiceUnavailable } w.Header().Set("Content-Type", "application/json") w.WriteHeader(code) _ = json.NewEncoder(w).Encode(st) }) } // Serve запускает HTTP-сервер и завершает его при отмене ctx. func Serve(ctx context.Context, addr string, h http.Handler) error { mux := http.NewServeMux() mux.Handle("/healthz", h) srv := &http.Server{ Addr: addr, Handler: mux, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 30 * time.Second, } errc := make(chan error, 1) go func() { errc <- srv.ListenAndServe() }() select { case err := <-errc: return err case <-ctx.Done(): sctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() return srv.Shutdown(sctx) } } // Probe выполняет GET /healthz на локальном адресе listen (для флага -healthcheck). func Probe(listen string, timeout time.Duration) error { host, port, err := net.SplitHostPort(listen) if err != nil { return err } if host == "" || host == "0.0.0.0" || host == "::" { host = "127.0.0.1" } c := &http.Client{Timeout: timeout} resp, err := c.Get("http://" + net.JoinHostPort(host, port) + "/healthz") if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("healthz: статус %d", resp.StatusCode) } return nil }