Vodiči

Go Reverse Proxy: Circuit Breakers, Load Shedding, and Structured Logging in Practice

Go obrnuti proxy: prekidači strujnog kruga, rasterećenje opterećenja i strukturirano zapisivanje u praksi

Obrnuti proxy obično zakaže mnogo prije nego što prestane prihvaćati veze. Spori uzvodni poslužitelj zauzima svako dostupno mjesto za zahtjev, ponovni pokušaji višestruko povećavaju pritisak, zapisnici postaju niz koji se ne može pretraživati, a tehnički „zdrav” proxy pretvara se u vrlo učinkovitog distributera ispada.

Ovaj vodič izrađuje Go proxy namijenjen produkciji koji se ponaša drukčije. Ograničava istodobnost, izolira neuspješne pozadinske poslužitelje prekidačima strujnog kruga po pozadinskom poslužitelju, provodi aktivne provjere zdravlja i emitira strukturirane zapisnike pristupa. Implementacija koristi samo Go standardnu biblioteku i namjerno izbjegava automatske ponovne pokušaje, čuvajući sigurno ponašanje za neidemponentne zahtjeve.

Preduvjeti i arhitektura

Potrebni su vam Go 1.22 ili noviji te dva HTTP pozadinska poslužitelja dostupna s hosta proxya. Primjer koristi ove adrese:

  • 127.0.0.1:8080: obrnuti proxy
  • 127.0.0.1:9001 i 127.0.0.1:9002: uzvodne usluge
  • /healthz: obvezna uzvodna krajnja točka za provjeru zdravlja
  • /livez i /readyz: krajnje točke životnog ciklusa proxya

Zahtjevi najprije prolaze kroz zapisivanje pristupa i neblokirajuća vrata za istodobnost. Proxy zatim odabire zdrav pozadinski poslužitelj čiji prekidač strujnog kruga dopušta promet. Pogreške prijenosa i uzvodni odgovori 5xx računaju se kao neuspjesi; otkazivanja klijenta ne računaju se.

Postoje dva namjerna kompromisa. Odabir je kružni, a ne svjestan latencije, što čini ponašanje razumljivim pod opterećenjem. Zahtjevi se također pokušavaju samo jednom. Ponovni pokušaj POST-a nakon dvosmislenog mrežnog neuspjeha može duplicirati nuspojavu, stoga ponovni pokušaji pripadaju eksplicitno idemponentnom sloju.

Struktura projekta

edgeproxy/
├── go.mod
├── cmd/
│   ├── edgeproxy/main.go
│   └── testbackend/main.go
└── deploy/
    └── edgeproxy.service

Stvorite direktorije u uređivaču ili pomoću mkdir -p edgeproxy/cmd/{edgeproxy,testbackend} edgeproxy/deploy, a zatim dodajte sljedeću datoteku modula:

module example.com/edgeproxy

go 1.22

Implementirajte proxy

Prekidač strujnog kruga koristi dozvole numerirane generacijama. Kada prekidač promijeni stanje, kasni rezultati iz prethodne generacije zanemaruju se. Time se sprječava da stari zahtjev u tijeku slučajno zatvori noviji strujni krug.

package main

import (
	"context"
	"crypto/rand"
	"encoding/hex"
	"flag"
	"io"
	"log/slog"
	"net"
	"net/http"
	"net/http/httputil"
	"net/url"
	"os"
	"os/signal"
	"strings"
	"sync"
	"sync/atomic"
	"syscall"
	"time"
)

type circuitState uint8

const (
	closed circuitState = iota
	open
	halfOpen
)

type breaker struct {
	mu            sync.Mutex
	state         circuitState
	failures      int
	threshold     int
	cooldown      time.Duration
	openedAt      time.Time
	probeInFlight bool
	generation    uint64
}

func (b *breaker) allow(now time.Time) (uint64, bool) {
	b.mu.Lock()
	defer b.mu.Unlock()

	switch b.state {
	case closed:
		return b.generation, true
	case open:
		if now.Sub(b.openedAt) < b.cooldown {
			return 0, false
		}
		b.state = halfOpen
		b.probeInFlight = true
		b.generation++
		return b.generation, true
	case halfOpen:
		if b.probeInFlight {
			return 0, false
		}
		b.probeInFlight = true
		return b.generation, true
	default:
		return 0, false
	}
}

func (b *breaker) done(ticket uint64, success bool) {
	b.mu.Lock()
	defer b.mu.Unlock()

	if ticket != b.generation {
		return
	}

	switch b.state {
	case closed:
		if success {
			b.failures = 0
			return
		}
		b.failures++
		if b.failures >= b.threshold {
			b.state = open
			b.openedAt = time.Now()
			b.generation++
		}
	case halfOpen:
		b.probeInFlight = false
		b.generation++
		if success {
			b.state = closed
			b.failures = 0
		} else {
			b.state = open
			b.openedAt = time.Now()
		}
	}
}

func (b *breaker) cancel(ticket uint64) {
	b.mu.Lock()
	defer b.mu.Unlock()

	if ticket == b.generation && b.state == halfOpen {
		b.probeInFlight = false
		b.state = open
		b.openedAt = time.Now()
		b.generation++
	}
}

func (b *breaker) ready(now time.Time) bool {
	b.mu.Lock()
	defer b.mu.Unlock()
	return b.state == closed ||
		(b.state == open && now.Sub(b.openedAt) >= b.cooldown)
}

type backend struct {
	url     *url.URL
	healthy atomic.Bool
	breaker breaker
	proxy   *httputil.ReverseProxy
}

type permit struct {
	backend *backend
	ticket  uint64
}

type contextKey uint8

const (
	permitKey contextKey = iota
	metaKey
)

type requestMeta struct {
	backend string
}

type selector struct {
	backends []*backend
	next     atomic.Uint64
}

func (s *selector) choose() (*backend, uint64, bool) {
	n := len(s.backends)
	start := int(s.next.Add(1)-1) % n

	for i := 0; i < n; i++ {
		b := s.backends[(start+i)%n]
		if !b.healthy.Load() {
			continue
		}
		if ticket, ok := b.breaker.allow(time.Now()); ok {
			return b, ticket, true
		}
	}
	return nil, 0, false
}

type responseRecorder struct {
	http.ResponseWriter
	status int
	bytes  int
}

func (w *responseRecorder) WriteHeader(status int) {
	if w.status == 0 {
		w.status = status
		w.ResponseWriter.WriteHeader(status)
	}
}

func (w *responseRecorder) Write(p []byte) (int, error) {
	if w.status == 0 {
		w.WriteHeader(http.StatusOK)
	}
	n, err := w.ResponseWriter.Write(p)
	w.bytes += n
	return n, err
}

func (w *responseRecorder) Unwrap() http.ResponseWriter {
	return w.ResponseWriter
}

func newRequestID() string {
	var value [16]byte
	if _, err := rand.Read(value[:]); err == nil {
		return hex.EncodeToString(value[:])
	}
	return hex.EncodeToString([]byte(time.Now().UTC().Format(time.RFC3339Nano)))
}

func clientAddress(remote string) string {
	host, _, err := net.SplitHostPort(remote)
	if err == nil {
		return host
	}
	return remote
}

func accessLog(log *slog.Logger, next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		started := time.Now()
		id := newRequestID()
		meta := &requestMeta{}
		r = r.WithContext(context.WithValue(r.Context(), metaKey, meta))
		r.Header.Set("X-Request-ID", id)

		rec := &responseRecorder{ResponseWriter: w}
		next.ServeHTTP(rec, r)
		status := rec.status
		if status == 0 {
			status = http.StatusOK
		}

		log.Info("access",
			"request_id", id,
			"method", r.Method,
			quot;path", r.URL.Path,
			"status", status,
			quot;bytes", rec.bytes,
			quot;duration_ms", time.Since(started).Milliseconds(),
			quot;client_ip", clientAddress(r.RemoteAddr),
			quot;backend", meta.backend,
		)
	})
}

func shed(limit int, next http.Handler) http.Handler {
	slots := make(chan struct{}, limit)

	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		select {
		case slots <- struct{}{}:
			defer func() { <-slots }()
			next.ServeHTTP(w, r)
		default:
			w.Header().Set("Retry-After", "1")
			http.Error(w, "proxy capacity exhausted", http.StatusServiceUnavailable)
		}
	})
}

func checkBackend(ctx context.Context, client *http.Client, b *backend) bool {
	u := *b.url
	u.Path = "/healthz"
	u.RawQuery = ""

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
	if err != nil {
		return false
	}
	resp, err := client.Do(req)
	if err != nil {
		return false
	}
	defer resp.Body.Close()
	_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1024))
	return resp.StatusCode >= 200 && resp.StatusCode < 300
}

func healthLoop(
	ctx context.Context,
	log *slog.Logger,
	client *http.Client,
	backends []*backend,
) {
	run := func() {
		for _, b := range backends {
			ok := checkBackend(ctx, client, b)
			previous := b.healthy.Swap(ok)
			if previous != ok {
				log.Info("backend_health_changed",
					quot;backend", b.url.String(),
					quot;healthy", ok,
				)
			}
		}
	}

	run()
	ticker := time.NewTicker(5 * time.Second)
	defer ticker.Stop()

	for {
		select {
		case <-ctx.Done():
			return
		case <-ticker.C:
			run()
		}
	}
}

func main() {
	listen := flag.String("listen", "127.0.0.1:8080", "proxy listen address")
	csv := flag.String(
		quot;backends",
		quot;http://127.0.0.1:9001,http://127.0.0.1:9002",
		quot;comma-separated backend URLs",
	)
	maxInFlight := flag.Int("max-in-flight", 256, "concurrent request limit")
	flag.Parse()

	log := slog.New(slog.NewJSONHandler(os.Stdout, nil))
	if *maxInFlight < 1 {
		log.Error("max-in-flight must be positive")
		os.Exit(2)
	}

	transport := &http.Transport{
		Proxy:                 http.ProxyFromEnvironment,
		DialContext:           (&net.Dialer{Timeout: 2 * time.Second}).DialContext,
		ForceAttemptHTTP2:     true,
		MaxIdleConns:          256,
		MaxIdleConnsPerHost:   64,
		IdleConnTimeout:       60 * time.Second,
		TLSHandshakeTimeout:   3 * time.Second,
		ResponseHeaderTimeout: 5 * time.Second,
		ExpectContinueTimeout: 1 * time.Second,
	}

	var backends []*backend
	for _, raw := range strings.Split(*csv, ",") {
		u, err := url.Parse(strings.TrimSpace(raw))
		if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
			log.Error("invalid backend URL", "value", raw)
			os.Exit(2)
		}

		b := &backend{
			url: u,
			breaker: breaker{
				threshold: 5,
				cooldown:  15 * time.Second,
			},
		}

		b.proxy = &httputil.ReverseProxy{
			Transport: transport,
			Rewrite: func(pr *httputil.ProxyRequest) {
				pr.SetURL(b.url)
				pr.Out.Host = b.url.Host
				pr.SetXForwarded()
				pr.Out.Header.Set("X-Request-ID", pr.In.Header.Get("X-Request-ID"))
			},
			ModifyResponse: func(resp *http.Response) error {
				p := resp.Request.Context().Value(permitKey).(permit)
				p.backend.breaker.done(p.ticket, resp.StatusCode < 500)
				return nil
			},
			ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
				p := r.Context().Value(permitKey).(permit)
				if r.Context().Err() != nil {
					p.backend.breaker.cancel(p.ticket)
					w.WriteHeader(499)
					return
				}
				p.backend.breaker.done(p.ticket, false)
				log.Warn("upstream_error", "backend", b.url.String(), "error", err)
				http.Error(w, "bad gateway", http.StatusBadGateway)
			},
			ErrorLog: slog.NewLogLogger(log.Handler(), slog.LevelError),
		}
		backends = append(backends, b)
	}

	sel := &selector{backends: backends}
	proxyHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		b, ticket, ok := sel.choose()
		if !ok {
			w.Header().Set("Retry-After", "1")
			http.Error(w, "no backend available", http.StatusServiceUnavailable)
			return
		}

		if meta, ok := r.Context().Value(metaKey).(*requestMeta); ok {
			meta.backend = b.url.Host
		}
		ctx := context.WithValue(r.Context(), permitKey, permit{b, ticket})
		b.proxy.ServeHTTP(w, r.WithContext(ctx))
	})

	healthCtx, stopHealth := context.WithCancel(context.Background())
	healthClient := &http.Client{Timeout: time.Second}
	go healthLoop(healthCtx, log, healthClient, backends)

	mux := http.NewServeMux()
	mux.HandleFunc("/livez", func(w http.ResponseWriter, _ *http.Request) {
		w.WriteHeader(http.StatusNoContent)
	})
	mux.HandleFunc("/readyz", func(w http.ResponseWriter, _ *http.Request) {
		for _, b := range backends {
			if b.healthy.Load() && b.breaker.ready(time.Now()) {
				w.WriteHeader(http.StatusNoContent)
				return
			}
		}
		http.Error(w, "no backend ready", http.StatusServiceUnavailable)
	})
	mux.Handle("/", accessLog(log, shed(*maxInFlight, proxyHandler)))

	server := &http.Server{
		Addr:              *listen,
		Handler:           mux,
		ReadHeaderTimeout: 5 * time.Second,
		ReadTimeout:       30 * time.Second,
		WriteTimeout:      30 * time.Second,
		IdleTimeout:       60 * time.Second,
		MaxHeaderBytes:    1 << 20,
	}

	signalCtx, stopSignals := signal.NotifyContext(
		context.Background(), syscall.SIGINT, syscall.SIGTERM,
	)
	defer stopSignals()

	errs := make(chan error, 1)
	go func() {
		log.Info("proxy_started", "listen", *listen)
		errs <- server.ListenAndServe()
	}()

	select {
	case <-signalCtx.Done():
		log.Info("shutdown_started")
	case err := <-errs:
		if err != nil && err != http.ErrServerClosed {
			log.Error("server_failed", "error", err)
		}
	}

	stopHealth()
	shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	if err := server.Shutdown(shutdownCtx); err != nil {
		log.Error("shutdown_incomplete", "error", err)
	}
	transport.CloseIdleConnections()
}

Rezultat aktivne provjere zdravlja i stanje strujnog kruga ostaju odvojeni. Pozadinski poslužitelj može odgovarati na provjere zdravlja dok njegovi aplikacijski zahtjevi ne uspijevaju; prekidač strujnog kruga i dalje ga privremeno uklanja. Nasuprot tomu, neuspjela provjera zdravlja odmah sprječava odabir bez narušavanja povijesti strujnog kruga.

Stvorite kontrolirane testne pozadinske poslužitelje

Ovaj mali poslužitelj pruža zdravu krajnju točku i opcionalan, deterministički odgovor 503. Spremite ga kao cmd/testbackend/main.go.

package main

import (
	"flag"
	"fmt"
	"log"
	"net/http"
	"sync/atomic"
)

func main() {
	listen := flag.String("listen", "127.0.0.1:9001", "listen address")
	name := flag.String("name", "backend-1", "response name")
	failEvery := flag.Uint64("fail-every", 0, "return 503 every N requests")
	flag.Parse()

	var requests atomic.Uint64
	mux := http.NewServeMux()
	mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
		w.WriteHeader(http.StatusNoContent)
	})
	mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) {
		n := requests.Add(1)
		if *failEvery > 0 && n%*failEvery == 0 {
			http.Error(w, "simulated failure", http.StatusServiceUnavailable)
			return
		}
		fmt.Fprintf(w, "%s request=%d\n", *name, n)
	})

	log.Printf("test backend listening on %s", *listen)
	log.Fatal(http.ListenAndServe(*listen, mux))
}

U tri terminala pokrenite:

go run ./cmd/testbackend -listen=127.0.0.1:9001 -name=backend-1
go run ./cmd/testbackend -listen=127.0.0.1:9002 -name=backend-2 -fail-every=3
go run ./cmd/edgeproxy -listen=127.0.0.1:8080 \
  -backends=http://127.0.0.1:9001,http://127.0.0.1:9002 \
  -max-in-flight=64

Provjerite ponašanje pri neuspjehu

Najprije potvrdite krajnje točke životnog ciklusa i kružno usmjeravanje:

curl --fail --silent --show-error -o /dev/null \
  http://127.0.0.1:8080/readyz

for n in 1 2 3 4 5 6 7 8; do
  curl --silent --show-error -i http://127.0.0.1:8080/demo
done

Zaustavite jedan testni pozadinski poslužitelj. Njegovo se stanje zdravlja mijenja nakon sljedeće provjere, dok zahtjevi nastavljaju prolaziti kroz preostali pozadinski poslužitelj. Ponovno ga pokrenite i pričekajte jedan interval provjere zdravlja za ponovno uključivanje.

Da biste isprobali odbacivanje opterećenja, smanjite -max-in-flight i usmjerite proxy na rukovatelj pozadinskog poslužitelja koji namjerno blokira. Prekomjerni zahtjevi trebali bi odmah primiti 503 s Retry-After: 1; ne smiju čekati u neograničenom internom redu.

Prekidač se otvara nakon pet relevantnih neuspjeha u jednoj generaciji. Ostaje otvoren 15 sekundi, dopušta jednu poluotvorenu probu i zatvara se samo ako ta proba uspije. Budući da svaki automatski ponovni pokušaj troši dodatni kapacitet pozadinskog poslužitelja, klijenti bi trebali primjenjivati ograničene ponovne pokušaje s jitterom i pokušavati ponovno samo operacije za koje znaju da su sigurne.

Sigurnost, performanse i vidljivost

Držite proxy na privatnoj adresi kada TLS završava na pouzdanom prednjem poslužitelju. Ako mora izravno prihvaćati javni promet, dodajte TLS, ograničite dopuštene metode i veličine tijela te smjestite administrativne krajnje točke na zaseban zaštićeni slušatelj. Vatrozid hosta trebao bi dopuštati javni port prednjeg poslužitelja, a istodobno ograničavati portove pozadinskih poslužitelja na host proxya ili privatnu mrežu.

ProxyRequest.SetXForwarded je važan: Go uklanja nepouzdana zaglavlja za prosljeđivanje prije pokretanja funkcije prepisivanja, a zatim generira kontrolirane vrijednosti. Nemojte sami kopirati proizvoljna dolazna zaglavlja X-Forwarded-For.

Prijenos zasebno ograničava uspostavljanje veze, TLS pregovaranje i čekanje zaglavlja odgovora. Vremensko ograničenje biranja ne ograničava spor odgovor. Vremenski proračuni čitanja i pisanja poslužitelja iznose 30 sekundi, stoga je ova konfiguracija namijenjena uobičajenom API prometu. Dugotrajni tokovi i velika učitavanja zahtijevaju proračune specifične za rutu umjesto globalnog isključivanja zaštite.

JSON zapisi pristupa uključuju ID zahtjeva, odabrani pozadinski poslužitelj, status, bajtove odgovora, adresu klijenta i trajanje. Promjene zdravlja i neuspjesi prijenosa zasebni su događaji. U većem sustavu izvedite brojače i histograme latencije iz tih događaja ili instrumentirajte iste granice svojim skupom metrika. Izbjegavajte zapisivanje autorizacijskih zaglavlja, kolačića, tajni u upitima ili tijela.

Implementirajte pomoću systemd-a

Izgradite kao neprivilegirani korisnik, pregledajte odredište prije zamjene postojeće binarne datoteke, a zatim instalirajte eksplicitni artefakt:

mkdir -p ./bin
go test ./...
go build -trimpath -o ./bin/edgeproxy ./cmd/edgeproxy
sudo install -m 0755 ./bin/edgeproxy /usr/local/bin/edgeproxy

Spremite ovu jedinicu kao /etc/systemd/system/edgeproxy.service. Stvaranje te datoteke i upravljanje uslugom zahtijevaju ovlasti korijenskog korisnika.

[Unit]
Description=Go edge reverse proxy
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=edgeproxy
Group=edgeproxy
ExecStart=/usr/local/bin/edgeproxy -listen=127.0.0.1:8080 -backends=http://127.0.0.1:9001,http://127.0.0.1:9002 -max-in-flight=256
Restart=on-failure
RestartSec=2
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictSUIDSGID=true
LockPersonality=true
MemoryDenyWriteExecute=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
CapabilityBoundingSet=
AmbientCapabilities=

[Install]
WantedBy=multi-user.target

Stvorite namjenskog korisnika sustava u skladu s lokalnom politikom računa, provjerite jedinicu pomoću systemd-analyze verify /etc/systemd/system/edgeproxy.service, zatim upotrijebite systemctl daemon-reload, systemctl enable --now edgeproxy i journalctl -u edgeproxy. Ako su potrebni uzvodni DNS, otkrivanje usluga ili Unix utičnice, namjerno prilagodite pretpostavke o adresnim obiteljima i mreži.

Uobičajeni produkcijski neuspjesi

  • Svaki pozadinski poslužitelj počinje kao nedostupan: to je namjerno dok prva aktivna provjera zdravlja ne uspije. Neka probe spremnosti toleriraju vrijeme pokretanja.
  • Zdrave krajnje točke prikrivaju neispravne putanje aplikacije: neka /healthz provjerava samo ovisnosti potrebne za posluživanje prometa, a za neuspjehe na putanji zahtjeva oslanjajte se na ishode prekidača.
  • Odgovori 503 pojavljuju se tijekom vrhunaca prometa: utvrdite dolaze li od odbacivanja opterećenja, odabira pozadinskog poslužitelja ili uzvodnog odgovora. Poruka i polje zapisnika pozadinskog poslužitelja razlikuju ih.
  • Strujni odgovori završavaju nakon 30 sekundi: globalno vremensko ograničenje pisanja nije prikladno za WebSockete, događaje koje šalje poslužitelj i neograničena preuzimanja. Takvom prometu dodijelite zaseban poslužitelj ili eksplicitnu politiku.
  • Gašenje prelazi deset sekundi: prihvaćeni zahtjev i dalje je blokiran. Uskladite ograničenja uzvodnog odgovora, vremenska ograničenja poslužitelja, vrijeme pražnjenja prednjeg poslužitelja i systemd proračun za zaustavljanje.

Završni kontrolni popis za provjeru

  • Oba uzvodna URL-a za provjeru zdravlja vraćaju ograničeni odgovor 2xx.
  • Uobičajeni zahtjevi izmjenjuju se među zdravim pozadinskim poslužiteljima.
  • Zaustavljeni pozadinski poslužitelj nestaje bez zaustavljanja proxya.
  • Ponovljeni neuspjesi 5xx ili prijenosa otvaraju strujni krug samo tog pozadinskog poslužitelja.
  • Iscrpljivanje kapaciteta vraća trenutačne odgovore 503.
  • Zapisnici sadrže ID-jeve zahtjeva, trajanja, statuse i identitete pozadinskih poslužitelja bez tajni.
  • SIGTERM prazni prihvaćene zahtjeve unutar proračuna za gašenje.
  • Samo namijenjeni prednji poslužitelj može dosegnuti portove proxya i pozadinskih poslužitelja.

Otporan proxy nije definiran brojem zahtjeva koje može prihvatiti. Definiran je pažnjom s kojom odbija rad, uskošću kojom ograničava neuspjeh i jasnoćom kojom poslije objašnjava svaku odluku. Ta svojstva pretvaraju tanku mrežnu komponentu u pouzdanu produkcijsku granicu.

Portret autora bloga

Mihajlo

Ja sam Mihajlo — programer vođen znatiželjom, disciplinom i stalnom željom da stvorim nešto smisleno. Dijelim uvide, tutorijale i besplatne usluge kako bih pomogao drugima da pojednostave svoj rad i rastu u svijetu softvera i umjetne inteligencije koji se neprestano razvija.