orchard
mirrorEvery site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.
blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics
1package web
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "io"
8 "log/slog"
9 "net/http"
10 "os"
11 "os/signal"
12 "syscall"
13 "time"
14)
15
16// SetupLogging installs the process-wide structured logger. JSON, because these
17// logs are read by machine before they are read by a person. Source positions
18// are off: they are noise in a request log, and the message and attributes
19// already say where a record came from.
20func SetupLogging() {
21 slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
22 Level: slog.LevelInfo,
23 // UTC, which is not slog's default. Local time in a container
24 // silently differs from the host's.
25 ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
26 if a.Key == slog.TimeKey && len(groups) == 0 {
27 a.Value = slog.TimeValue(a.Value.Time().UTC())
28 }
29 return a
30 },
31 })))
32}
33
34// Serve runs h on addr until SIGINT or SIGTERM, then drains in-flight requests
35// before returning.
36func Serve(addr string, h http.Handler) error {
37 srv := &http.Server{
38 Addr: addr,
39 Handler: h,
40 // No body timeouts here, unlike the copy in the other five sites. This
41 // one serves the git wire, where one request carries a whole packfile,
42 // and Go's ReadTimeout and WriteTimeout bound the entire body and the
43 // entire response, so a 60s write bound cuts a large clone off
44 // mid-stream. ReadHeaderTimeout still closes a connection that dribbles
45 // headers forever, and body size is bounded by the spool cap in wire.go
46 // and by Cloudflare at the edge.
47 ReadHeaderTimeout: 10 * time.Second,
48 ReadTimeout: 0,
49 WriteTimeout: 0,
50 IdleTimeout: 120 * time.Second,
51 }
52
53 stop := make(chan os.Signal, 1)
54 signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
55
56 errs := make(chan error, 1)
57 go func() {
58 slog.Info("listening", slog.String("addr", addr))
59 if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
60 errs <- err
61 }
62 }()
63
64 select {
65 case err := <-errs:
66 return err
67 case sig := <-stop:
68 slog.Info("shutting down", slog.String("signal", sig.String()))
69 }
70
71 // Inside the 30s stop_grace_period the compose file asks for, so a
72 // receive-pack in flight at deploy time finishes writing its pack instead
73 // of being cut off and leaving a stale lock. Ten seconds, the value the
74 // other five sites use, silently defeated that grace period.
75 ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
76 defer cancel()
77 return srv.Shutdown(ctx)
78}
79
80// HealthCheck probes a running server over the loopback and reports whether it
81// answered 200, so that a container can check itself. Two of these images are
82// FROM scratch: no shell, no curl, nothing a HEALTHCHECK can call except the
83// binary. The Alpine images could use wget, but one behaviour everywhere beats
84// two.
85//
86// The timeout is short. Docker treats a timed out check as a failure anyway, so
87// a slow check only delays finding out.
88func HealthCheck(url string, timeout time.Duration) error {
89 client := &http.Client{Timeout: timeout}
90
91 resp, err := client.Get(url)
92 if err != nil {
93 return err
94 }
95 // Drained as well as closed: an undrained body leaks the connection out
96 // of the pool.
97 defer resp.Body.Close()
98 _, _ = io.Copy(io.Discard, resp.Body)
99
100 if resp.StatusCode != http.StatusOK {
101 return fmt.Errorf("%s: %s", url, resp.Status)
102 }
103 return nil
104}