Files
traefik-selectel/internal/health/health.go
T
vasyanskandClaude Sonnet 5 273cd54415
Build / Tests (push) Successful in 13s
Build / Build image (push) Failing after 15s
Build / Notify on failure (push) Failing after 0s
feat: traefik-selectel-dns — A-записи Selectel по роутам Traefik + CI сборка образа
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 21:50:07 +07:00

123 lines
3.4 KiB
Go

// 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
}