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 proxy127.0.0.1:9001i127.0.0.1:9002: uzvodne usluge/healthz: obvezna uzvodna krajnja točka za provjeru zdravlja/livezi/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
/healthzprovjerava 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.