Go Servisini Production'a Taşımak: slog, Configuration, Graceful Shutdown, pprof ve Küçük Container
Bir Go servisini production'a hazırlamak için ayarları ortam değişkenlerinden bir struct'a okumak, log/slog ile JSON log yazmak, SIGTERM'i signal.NotifyContext ile yakalayıp server.Shutdown ile kapanmak, health check eklemek ve pprof'u ayrı port'ta açmak gerekir. Bookstore'da request ID'li slog, drain süresi, container-aware GOMAXPROCS ve GOMEMLIMIT ölçümü, -trimpath ve ldflags ile build, 4,72 MB'lık distroless image.
Sürüm notu: Bu yazıdaki kodlar Go 1.27.4 ile derlenip çalıştırılmıştır; module'ün go satırı 1.26.0'dır ve kod Go 1.26.11 ile de derlenip testlerden geçer. log/slog Go 1.21, slog.NewMultiHandler Go 1.26 ile gelmiştir; container-aware GOMAXPROCS Go 1.25'ten, Green Tea GC (garbage collector; kullanılmayan belleği otomatik toplayan bileşen) Go 1.26'dan beri varsayılandır. Container image'ı golang:1.27.4 ve gcr.io/distroless/static-debian13:nonroot ile linux/amd64 için build edildi.
Önce Tahmin Et
Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) Go API'sini 12'deki hâliyle bir container'a koydum. main hâlâ log.Fatal(srv.ListenAndServe()) ile bitiyor; sinyallerle ilgili hiçbir kod yok. docker stop önce SIGTERM (sürece kapanmasını söyleyen sinyal) gönderiyor ve 10 saniye bekliyor, süreç kapanmazsa SIGKILL (yakalanamayan, süreci hemen bitiren sinyal) ile öldürüyor. Bu servis kaç saniyede ve hangi exit code ile kapanır?
$ docker run -d --name goseries-api -p 127.0.0.1:52714:8080 goseries-bookstore-api:12
$ time docker stop goseries-api
goseries-api
real 0.19
$ docker inspect goseries-api --format 'exit={{.State.ExitCode}}'
exit=143
0,19 saniye ve 143, yani 128 + 15: süreç SIGTERM ile öldü. Go runtime'ı SIGTERM için bir handler kurmamışsan varsayılan davranışı uyguluyor ve process'i hemen sonlandırıyor. Log'da kapanışla ilgili tek satır yok; o anda işlenen bir istek varsa cevabı yarıda kalıyor. Bu yazıda servisi production'a hazırlıyoruz: log/slog ile structured logging, configuration'ı ortam değişkenlerinden okumak, graceful shutdown ve health check endpoint'leri, ayrı port'ta net/http/pprof, GOMAXPROCS, GOMEMLIMIT ve GC, son olarak da küçük bir container image'ı.
Configuration: Ortam Değişkenlerinden Bir Struct
09'dan beri listen adresi ve CORS (Cross-Origin Resource Sharing; tarayıcının başka bir origin'e istek atma kuralları) origin'i koda yazılıydı. Container'da server 127.0.0.1'i değil :8080'i dinlemeli; production'da origin localhost:4200 değil. Go'da IConfiguration ya da options pattern'ı gibi hazır bir sistem yok; çoğu servis ayarlarını ortam değişkenlerinden okuyup bir struct'a koyuyor:
// Config, servisin açılışta okuduğu bütün ayarları tutar.
type Config struct {
Addr string // BOOKSTORE_ADDR: public API
DebugAddr string // BOOKSTORE_DEBUG_ADDR: pprof; "off" kapatır
CORSOrigin string // BOOKSTORE_CORS_ORIGIN: Angular uygulaması
DatabaseURL string // BOOKSTORE_DB: boşsa siparişler bellekte tutulur
LogFormat string // BOOKSTORE_LOG_FORMAT: json ya da text
LogLevel slog.Level // BOOKSTORE_LOG_LEVEL: debug, info, warn, error
DrainDelay time.Duration // BOOKSTORE_DRAIN_DELAY: shutdown'dan önce /readyz bu süre boyunca hata döner
ShutdownTimeout time.Duration // BOOKSTORE_SHUTDOWN_TIMEOUT: devam eden isteklere verilen süre
}
// FromEnv, getenv'den bir Config kurar (main'de os.Getenv, testlerde bir map).
// Sadece ilkini değil, bütün geçersiz ayarları birlikte bildirir.
func FromEnv(getenv func(string) string) (Config, error) {
get := func(key, def string) string {
if v := getenv(key); v != "" {
return v
}
return def
}
c := Config{
Addr: get("BOOKSTORE_ADDR", "127.0.0.1:52710"),
DebugAddr: get("BOOKSTORE_DEBUG_ADDR", "127.0.0.1:52711"),
CORSOrigin: get("BOOKSTORE_CORS_ORIGIN", "http://localhost:4200"),
DatabaseURL: getenv("BOOKSTORE_DB"),
LogFormat: get("BOOKSTORE_LOG_FORMAT", "json"),
}
var errs []error
if _, _, err := net.SplitHostPort(c.Addr); err != nil {
errs = append(errs, fmt.Errorf("BOOKSTORE_ADDR: %w", err))
}
if c.DebugAddr == "off" {
c.DebugAddr = ""
} else if _, _, err := net.SplitHostPort(c.DebugAddr); err != nil {
errs = append(errs, fmt.Errorf("BOOKSTORE_DEBUG_ADDR: %w", err))
}
if u, err := url.Parse(c.CORSOrigin); err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.Path != "" {
errs = append(errs, fmt.Errorf("BOOKSTORE_CORS_ORIGIN: %q is not an origin like https://shop.example.com", c.CORSOrigin))
}
if c.LogFormat != "json" && c.LogFormat != "text" {
errs = append(errs, fmt.Errorf("BOOKSTORE_LOG_FORMAT: %q is not json or text", c.LogFormat))
}
if err := c.LogLevel.UnmarshalText([]byte(get("BOOKSTORE_LOG_LEVEL", "info"))); err != nil {
errs = append(errs, fmt.Errorf("BOOKSTORE_LOG_LEVEL: %w", err))
}
if d, err := time.ParseDuration(get("BOOKSTORE_DRAIN_DELAY", "0s")); err != nil || d < 0 {
errs = append(errs, fmt.Errorf("BOOKSTORE_DRAIN_DELAY: %q is not a duration like 5s", getenv("BOOKSTORE_DRAIN_DELAY")))
} else {
c.DrainDelay = d
}
if d, err := time.ParseDuration(get("BOOKSTORE_SHUTDOWN_TIMEOUT", "20s")); err != nil || d <= 0 {
errs = append(errs, fmt.Errorf("BOOKSTORE_SHUTDOWN_TIMEOUT: %q is not a positive duration like 20s", getenv("BOOKSTORE_SHUTDOWN_TIMEOUT")))
} else {
c.ShutdownTimeout = d
}
return c, errors.Join(errs...)
}
FromEnv, os.Getenv'i doğrudan çağırmıyor, bir fonksiyon olarak alıyor. main ona os.Getenv'i veriyor, test ise bir map'ten okuyan küçük bir fonksiyon; 12'deki gibi table-driven test için global ortamı değiştirmek gerekmiyor. Bütün hatalar errors.Join ile toplanıyor, böylece yanlış yazılmış üç ayar üç ayrı deployment denemesi gerektirmiyor:
$ BOOKSTORE_ADDR=8080 BOOKSTORE_LOG_LEVEL=loud BOOKSTORE_CORS_ORIGIN=localhost:4200 ./bookstore-api
bookstore-api: BOOKSTORE_ADDR: address 8080: missing port in address
BOOKSTORE_CORS_ORIGIN: "localhost:4200" is not an origin like https://shop.example.com
BOOKSTORE_LOG_LEVEL: slog: level string "loud": unknown name
$ echo $?
1
Servis hatalı ayarla hiç açılmıyor; Kubernetes bunu bir crash olarak görüyor ve rollout ilerlemiyor. Yarım çalışan bir servisten daha iyi. Bu yazıyı yazarken bunun faydasını da gördüm: makinede başka bir uygulama 52710'u kullanmaya başlamıştı, servis listen tcp 127.0.0.1:52710: bind: address already in use ile hemen çıktı ve sadece BOOKSTORE_ADDR=127.0.0.1:52715 vermem yetti. Ayar sayısı büyürse caarlos0/env gibi struct tag'leriyle okuyan library'ler var; şifre gibi gizli değerler ise ortam değişkenine Kubernetes Secret ya da bir secret manager'dan gelmeli, repoya değil.
log/slog: Structured Logging ve Request ID
09'daki log.Printf satırları insan için okunaklıydı, ama bir log sisteminde "status'u 500 olan istekler" diye arama yapmak için alanların ayrı ayrı olması gerekiyor. Go 1.21'deki log/slog, ILogger ve Serilog'un message template'lerine benzer bir işi yapıyor: her kayıt bir mesaj ve key-value alanlardan oluşuyor, handler bunları JSON ya da key=value metni olarak yazıyor. Logging middleware'i artık şöyle:
// Logging, handler döndükten sonra her istek için bir structured kayıt yazar.
// Request ID'yi NewLogHandler'dan gelen handler ekler.
func Logging(logger *slog.Logger) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w}
next.ServeHTTP(rec, r)
logger.InfoContext(r.Context(), "request",
"method", r.Method,
"path", r.URL.RequestURI(),
"status", rec.status,
"bytes", rec.bytes,
"duration_ms", float64(time.Since(start).Microseconds())/1000,
)
})
}
}
Request ID'yi her log çağrısına elle eklemek yerine handler'ı sarıyorum. slog.Handler dört method'luk bir interface; Handle, context'te request ID varsa onu kayda ekliyor:
// NewLogHandler h'yi sarar; böylece bir isteğin context'iyle yazılan her kayıt
// o isteğin ID'sini taşır.
func NewLogHandler(h slog.Handler) slog.Handler {
return logHandler{h}
}
type logHandler struct{ slog.Handler }
func (h logHandler) Handle(ctx context.Context, r slog.Record) error {
if id := RequestIDFrom(ctx); id != "" {
r.AddAttrs(slog.String("request_id", id))
}
return h.Handler.Handle(ctx, r)
}
// WithAttrs ve WithGroup yeniden sarmalı; embed edilen method'lar
// içteki handler'ı döndürür ve request ID kaybolur.
func (h logHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
return logHandler{h.Handler.WithAttrs(attrs)}
}
func (h logHandler) WithGroup(name string) slog.Handler {
return logHandler{h.Handler.WithGroup(name)}
}
Böylece a.Log.ErrorContext(r.Context(), "place order", "error", err) gibi her çağrı, kendisi bir şey yapmadan request_id alanını taşıyor; 09'da verdiğimiz sözdü. ASP.NET Core'daki logging scope'larının karşılığı bu. WithAttrs ve WithGroup'u yeniden yazmak şart; aşağıdaki tuzaklarda nedenini anlatacağım. Logger run'da kuruluyor ve JSON çıktısı şöyle görünüyor:
func newLogger(cfg config.Config, out io.Writer) *slog.Logger {
opts := &slog.HandlerOptions{Level: cfg.LogLevel}
var h slog.Handler = slog.NewJSONHandler(out, opts)
if cfg.LogFormat == "text" {
h = slog.NewTextHandler(out, opts)
}
return slog.New(httpapi.NewLogHandler(h))
}
{"time":"2026-11-15T05:31:40.561594+03:00","level":"INFO","msg":"starting","version":"1.13.0","go":"go1.27.4","gomaxprocs":4,"memory_limit":9223372036854775807}
{"time":"2026-11-15T05:31:40.5619+03:00","level":"INFO","msg":"listening","addr":"127.0.0.1:52711"}
{"time":"2026-11-15T05:31:40.56191+03:00","level":"INFO","msg":"listening","addr":"127.0.0.1:52710"}
{"time":"2026-11-15T05:31:42.328924+03:00","level":"INFO","msg":"request","method":"GET","path":"/hello","status":200,"bytes":25,"duration_ms":0.026,"request_id":"hello-1"}
{"time":"2026-11-15T05:31:42.341447+03:00","level":"INFO","msg":"request","method":"GET","path":"/api/books/9780000000000","status":404,"bytes":35,"duration_ms":0.024,"request_id":"7VG6OBFAUC52"}
slog.SetDefault(logger)'ın bir yan etkisi var: eski log package'ının çıktısı da artık bu handler'dan geçiyor. http.Server'ın kendi iç hataları için de ErrorLog: slog.NewLogLogger(logger.Handler(), slog.LevelWarn) verdim; TLS (Transport Layer Security; bağlantıyı şifreleyen protokol) handshake hataları gibi mesajlar da JSON olarak çıkıyor. Recover middleware'i artık panic'i stack trace ile birlikte tek bir kayıt olarak yazıyor ve 10'daki Problem Details biçiminde 500 dönüyor; 12'deki gibi bunun da testi var (TestRecoverLogsStack).
Go 1.26'daki slog.NewMultiHandler bir kaydı birden fazla handler'a gönderiyor. labs/13-multihandler her şeyi stdout'a JSON olarak, uyarı ve hataları da ayrıca stderr'e metin olarak yazıyor:
func main() {
noTime := func(_ []string, a slog.Attr) slog.Attr {
if a.Key == slog.TimeKey {
return slog.Attr{} // yazı için çıktıyı kısa tut
}
return a
}
logger := slog.New(slog.NewMultiHandler(
slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ReplaceAttr: noTime}),
slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn, ReplaceAttr: noTime}),
))
logger = logger.With("service", "bookstore-api")
logger.Info("order placed", "order_id", "ORD-1001", "total_kurus", 69980)
logger.Warn("stock low", "isbn", "9780141439686", "stock", 1)
}
--- stdout
{"level":"INFO","msg":"order placed","service":"bookstore-api","order_id":"ORD-1001","total_kurus":69980}
{"level":"WARN","msg":"stock low","service":"bookstore-api","isbn":"9780141439686","stock":1}
--- stderr
level=WARN msg="stock low" service=bookstore-api isbn=9780141439686 stock=1
Log'ların toplanması ve OpenTelemetry ile trace'lere bağlanması Observability serisinin konusu; Go tarafında tek iş, stdout'a JSON yazmak.
Graceful Shutdown ve Health Check'ler
main artık küçük: sinyali bir context'e bağlıyor ve asıl işi run'a bırakıyor. run'ın hata döndürmesi, log.Fatal'ın aksine defer'ların (örneğin db.Close()) çalışmasını sağlıyor:
func main() {
// Context, Ctrl+C'de ve SIGTERM'de iptal edilir; docker stop ve Kubernetes
// süreci öldürmeden önce SIGTERM gönderir.
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := run(ctx, os.Getenv, os.Stdout); err != nil {
fmt.Fprintln(os.Stderr, "bookstore-api:", err)
os.Exit(1)
}
}
errc := make(chan error, len(servers))
for _, s := range servers {
go func() {
logger.Info("listening", "addr", s.Addr)
if err := s.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
errc <- err
}
}()
}
select {
case err := <-errc: // örneğin port zaten kullanımda
return err
case <-ctx.Done():
}
// Graceful shutdown: readiness'ı başarısız yap ve load balancer'a fark etmesi için
// DrainDelay kadar süre ver; sonra yeni bağlantı kabul etmeyi bırak ve devam eden
// isteklerin bitmesini bekle, ama ShutdownTimeout'tan uzun değil.
logger.Info("shutting down", "drain_delay", cfg.DrainDelay.String(), "timeout", cfg.ShutdownTimeout.String())
health.Drain()
time.Sleep(cfg.DrainDelay) // server bu sırada istekleri işlemeye devam eder
shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout)
defer cancel()
var errs []error
for _, s := range servers {
errs = append(errs, s.Shutdown(shutdownCtx))
}
if err := errors.Join(errs...); err != nil {
return fmt.Errorf("shutdown: %w", err)
}
logger.Info("stopped")
return nil
}
signal.NotifyContext, SIGTERM ya da Ctrl+C gelince context'i iptal ediyor; artık sinyal process'i öldürmüyor, sadece ctx.Done()'ı kapatıyor. ListenAndServe, Shutdown çağrıldığı an http.ErrServerClosed ile dönüyor; bu yüzden goroutine bu hatayı hata saymıyor ve run, Shutdown bitene kadar bekliyor. Shutdown listener'ları kapatıyor, boştaki connection'ları kapatıyor ve devam eden istekler bitene ya da context'in süresi dolana kadar bekliyor. WebSocket gibi hijack edilmiş connection'ları beklemiyor; onlar için RegisterOnShutdown var.
Health endpoint'leri iki farklı soruya cevap veriyor. /healthz (liveness) "process ayakta mı?" diye soruyor; cevap vermezse Kubernetes container'ı yeniden başlatıyor. /readyz (readiness) "trafik alabilir mi?" diye soruyor; veritabanına ping atıyor ve shutdown başladıysa 503 dönüyor:
// Ready sadece API gerçekten iş yapabiliyorsa 200 döner.
func (h *Health) Ready(w http.ResponseWriter, r *http.Request) {
if h.draining.Load() {
http.Error(w, "shutting down", http.StatusServiceUnavailable)
return
}
if h.Check != nil {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
if err := h.Check(ctx); err != nil {
slog.WarnContext(ctx, "readiness check failed", "error", err)
http.Error(w, "not ready", http.StatusServiceUnavailable)
return
}
}
w.Write([]byte("ready\n"))
}
Liveness'a veritabanı kontrolü koymamak bilinçli: veritabanı bir dakikalığına gidince bütün pod'ların yeniden başlaması sorunu çözmez, büyütür. DrainDelay ise Kubernetes'teki bir zamanlama sorunu için. Kubernetes pod'a SIGTERM'i gönderirken onu servis endpoint'lerinden de çıkarıyor, ama bu ikisi aynı anda bitmiyor; load balancer birkaç saniye daha eski pod'a istek gönderebiliyor. Servis o arada listener'ı kapatmışsa bu istekler "connection refused" alıyor. Bir 5 saniyelik CPU profili sürerken BOOKSTORE_DRAIN_DELAY=3s ile SIGTERM gönderdim ve /readyz'yi yarım saniyede bir çağırdım (uniq -c ile sayıldı):
SIGTERM 05:36:04.1
profile 200 5.077082s
3 05:36:02.6 200
6 05:36:04.2 503
7 05:36:07.5 000
{"time":"2026-11-15T05:36:04.060953+03:00","level":"INFO","msg":"shutting down","drain_delay":"3s","timeout":"20s"}
{"time":"2026-11-15T05:36:08.13009+03:00","level":"INFO","msg":"stopped"}

Üç saniye boyunca server çalıştı ama /readyz 503 döndü; sonra listener kapandı (000, curl'ün bağlantı kuramadığını gösteriyor) ve Shutdown profil isteğinin bitmesini bekledi; profil 200 ile tamamlandı. Kubernetes'te aynı etki bir preStop hook'uyla da alınabiliyor; bekleme uygulamanın içinde olunca Docker'da ve başka ortamlarda da aynı şekilde çalışıyor. Önce Tahmin Et'teki imajın yeni hâli docker stop'ta şunu yazıyor ve 0 ile çıkıyor:
{"time":"2026-11-15T02:37:02.190430044Z","level":"INFO","msg":"shutting down","drain_delay":"0s","timeout":"20s"}
{"time":"2026-11-15T02:37:02.191183252Z","level":"INFO","msg":"stopped"}
exit=0
pprof: Ayrı Port'ta Profil
net/http/pprof CPU, heap, goroutine ve 08'de gördüğümüz goroutineleak profillerini HTTP üzerinden veriyor. Package import edilince handler'larını http.DefaultServeMux'a kaydediyor; biz hiçbir yerde DefaultServeMux'ı serve etmediğimiz için bu kayıt dışarıya açılmıyor. Handler'ları kendi mux'ımızda, ayrı bir server'da ve sadece 127.0.0.1'de açıyoruz:
// debugMux, net/http/pprof profillerini kendi mux'ında sunar. Package
// onları http.DefaultServeMux'a da kaydeder; onu hiçbir yerde serve etmiyoruz.
func debugMux() *http.ServeMux {
mux := http.NewServeMux()
mux.HandleFunc("GET /debug/pprof/", pprof.Index) // heap, goroutine, goroutineleak, allocs...
mux.HandleFunc("GET /debug/pprof/profile", pprof.Profile)
mux.HandleFunc("GET /debug/pprof/symbol", pprof.Symbol)
mux.HandleFunc("GET /debug/pprof/trace", pprof.Trace)
return mux
}
cmdline bilerek yok: process'in komut satırını döndürüyor ve orada bir şifre olabilir. Public server'da /debug/pprof/ 404 dönüyor; debug server'daki index şunları listeliyor:
$ curl -s http://127.0.0.1:52711/debug/pprof/ | sed -e 's/<[^>]*>//g'
...
3 allocs
0 block
7 goroutine
0 goroutineleak
3 heap
0 mutex
8 threadcreate
...
$ curl -s 'http://127.0.0.1:52711/debug/pprof/goroutineleak?debug=1'
goroutineleak profile: total 0
Container'da debug adresi container'ın kendi 127.0.0.1'i olduğu için dışarıdan erişilemiyor; ihtiyaç olduğunda kubectl port-forward ile açılıyor. Graceful shutdown denemesindeki 5 saniyelik profili, 8 paralel istemcinin POST /api/orders gönderdiği sırada aldım. go tool pprof -top -focus=bookstore/ cpu.pb.gz çıktısından ilgili satırlar (platforma özgü runtime satırlarını çıkardım):
Duration: 5.06s, Total samples = 250ms ( 4.94%)
flat flat% sum% cum cum%
10ms 4.00% 36.00% 20ms 8.00% encoding/json/jsontext.(*decoderState).consumeObject
10ms 4.00% 44.00% 10ms 4.00% log/slog.Value.Kind (inline)
0 0% 44.00% 20ms 8.00% bookstore/internal/httpapi.(*API).placeOrder
0 0% 44.00% 90ms 36.00% bookstore/internal/httpapi.Logging.func1.1
0 0% 44.00% 20ms 8.00% bookstore/internal/httpapi.Recover.func1.1
0 0% 44.00% 20ms 8.00% bookstore/internal/httpapi.decodeJSON
İki şey dikkat çekiyor. Birincisi, Logging'in cum değeri 90 ms; içindeki zincirin geri kalanı (Recover'dan handler'a kadar) 20 ms. Yani 5 saniyelik örnekte yaklaşık 70 ms, her istek için stdout'a senkron bir JSON satırı yazmaya gitmiş; bu kadar küçük bir handler'ın yanında log satırı işin kendisinden pahalı. Yük altında bu oran log seviyesini ve örneklemeyi düşünmek için bir sebep. İkincisi, 10'daki v1 json.Decoder'ı çağırmamıza rağmen stack'te encoding/json/jsontext var; Go 1.27'de v1'in v2 üzerinde çalıştığını profil de gösteriyor.
GOMAXPROCS, GOMEMLIMIT ve Green Tea GC
Startup log'una gomaxprocs ve memory_limit'i bunun için koydum. Container'ı CPU ve bellek sınırıyla çalıştırdım:
$ docker run -d --name goseries-api --cpus=1.5 --memory=256m -e GOMEMLIMIT=200MiB \
-e BOOKSTORE_DEBUG_ADDR=off -p 127.0.0.1:52714:8080 goseries-bookstore-api:13
$ docker logs goseries-api | head -1
{"time":"2026-11-15T02:36:58.174650542Z","level":"INFO","msg":"starting","version":"1.13.0","go":"go1.27.4","gomaxprocs":2,"memory_limit":209715200}
Go 1.25'ten beri runtime GOMAXPROCS'u (aynı anda Go kodu çalıştıran thread sayısı) container'ın CPU limitine göre ayarlıyor: --cpus=1.5 için 2 oldu. Aynı image ile denediğimde --cpus=1 için de 2, --cpus=3.2 için 4 çıktı; değer yukarı yuvarlanıyor ve 2'nin altına inmiyor. Runtime limit değişirse değeri periyodik olarak güncelliyor. Bu davranış Kubernetes'teki CPU request'ine değil limit'ine bakıyor. Daha önce aynı iş için kullanılan uber-go/automaxprocs library'si bu yüzden çoğu durumda artık gerekmiyor. Ortam değişkeni olarak GOMAXPROCS verirsen otomatik ayar devreden çıkıyor.
Bellek için aynı şey yok. memory_limit, GOMEMLIMIT=200MiB verdiğim için 209715200; vermediğim koşularda 9223372036854775807 oldu, yani sınırsız. Runtime container'ın bellek limitini okumuyor. GOMEMLIMIT (Go 1.19) GC'ye "heap bu sınıra yaklaşınca daha sık topla" diyen yumuşak bir sınır; olmadan GC sadece GOGC'ye (varsayılan 100: heap canlı veri kadar büyüyünce topla) bakıyor ve trafik artınca heap container limitini aşıp OOM (Out Of Memory; bellek limitini aşan sürecin kernel tarafından öldürülmesi) ile öldürülebiliyor. Kural olarak container limitinin %80–90'ı civarında bir değer veriyorum; --memory=256m için 200 MiB.
Go 1.26'da varsayılan olan Green Tea GC, küçük object'leri tek tek değil bellekteki sayfalar hâlinde tarayarak GC'nin CPU maliyetini düşürüyor; release notes gerçek programlarda %10–40 arası bir azalma bekliyor. Bunun için kod değiştirmek gerekmiyor. Sorun çıkarsa build sırasında GOEXPERIMENT=nogreenteagc ile kapatılabiliyor. Go 1.26 release notes bu seçeneğin 1.27'de kaldırılmasını bekliyordu; 1.27.4 ile denedim, hâlâ çalışıyor ve go version -m çıktısında build GOEXPERIMENT=nogreenteagc satırı görünüyor.
Build ve Küçük Container Image
Go'nun deploy hikâyesinin en güçlü yanı tek bir statik binary. Aynı komut Windows'ta da Linux binary'si üretiyor:
$ CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o default ./cmd/bookstore-api
$ CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags "-s -w -X main.version=1.13.0" -o small ./cmd/bookstore-api
$ CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags "-s -w -X main.version=1.13.0" -o bookstore-api.exe ./cmd/bookstore-api
13224317 default
9216160 small
9494016 bookstore-api.exe
CGO_ENABLED=0, C library'lerine bağlı olmayan statik bir binary veriyor; scratch ya da distroless gibi içinde C runtime'ı olmayan image'larda çalışması için gerekli. -trimpath, binary'deki dosya yollarından build makinesinin klasörlerini çıkarıyor. -ldflags "-s -w" sembol tablosunu ve DWARF (debugger'ların kullandığı hata ayıklama bilgisi) bölümünü atıyor: 13,2 MB'tan 9,2 MB'a. Panic stack trace'leri yine fonksiyon adlarını ve satır numaralarını gösteriyor, çünkü Go bunları ayrı bir tabloda tutuyor; kaybedilen şey Delve gibi bir debugger ile binary'yi incelemek. -X main.version=1.13.0, main package'ındaki version değişkenini build sırasında dolduruyor. Go ayrıca commit bilgisini kendisi gömüyor:
$ go version -m small
small: go1.27.4
path bookstore/cmd/bookstore-api
build -trimpath=true
build DefaultGODEBUG=tracebacklabels=0,x509sslcertoverrideplatform=0
build CGO_ENABLED=0
build GOARCH=amd64
build GOOS=linux
build vcs=git
build vcs.revision=024bd8e036f92f6dbdb8dd8ff8b5a0f40af5c514
build vcs.modified=true
(Çıktının bir kısmını gösteriyorum.) DefaultGODEBUG satırı 14. yazının konusu: module'ün go satırı 1.26.0 olduğu için 1.27'nin iki yeni davranışı bu binary'de kapalı. Dockerfile iki aşamalı:
# syntax=docker/dockerfile:1
FROM --platform=$BUILDPLATFORM golang:1.27.4 AS build
ARG TARGETOS TARGETARCH VERSION=dev
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \
go build -trimpath -ldflags="-s -w -X main.version=$VERSION" -o /out/bookstore-api ./cmd/bookstore-api
FROM gcr.io/distroless/static-debian13:nonroot
COPY --from=build /out/bookstore-api /bookstore-api
ENV BOOKSTORE_ADDR=:8080
EXPOSE 8080
ENTRYPOINT ["/bookstore-api"]
Go'ya özgü kısım --platform=$BUILDPLATFORM: build aşaması build makinesinin kendi mimarisinde çalışıyor ve hedef platform için cross-compile ediyor. Go cross-compile'ı ek araç gerektirmeden yaptığı için emülasyona hiç ihtiyaç kalmıyor. Çalışma aşamasında shell ve paket yöneticisi yok, kullanıcı root değil (UID 65532). docker buildx build --platform linux/amd64 --build-arg VERSION=1.13.0 -t goseries-bookstore-api:13 . sonrası docker images image'ı 4,72 MB olarak listeliyor. Bu image'ın ayrıntıları (cache katmanları, image tarama, imzalama) Docker serisinde; Kubernetes manifest'leri Kubernetes serisinde.
Üretimde Dikkat
pprof'u public port'ta açma. Bir CPU profili 30 saniye boyunca CPU harcar, heap ve goroutine profilleri kodun iç yapısını ve stack'leri dışarı verir, trace çalışma zamanının ayrıntılı bir dökümünü üretir. Ayrı port, 127.0.0.1 ve gerekirse port-forward yeterli.
GOMEMLIMIT'i container limitine eşit verme. Sınır Go runtime'ının yönettiği her şeyi (heap, goroutine stack'leri) kapsıyor, ama binary'nin kendisini, cgo ile ayrılan belleği ve işletim sisteminin süreç için tuttuğu belleği kapsamıyor. Biraz pay bırak. Sınırı çok düşük verirsen GC sürekli çalışır ve CPU'yu yer; runtime bunun için GC'ye ayrılan CPU'yu yaklaşık %50 ile sınırlıyor.
Health probe'ları da log'a düşüyor: Kubernetes birkaç saniyede bir /readyz çağırınca log'un önemli bir kısmı probe satırları olur. Probe path'lerini Debug seviyesinde yazmak ya da örneklemek mantıklı.
terminationGracePeriodSeconds (varsayılan 30 saniye) DrainDelay + ShutdownTimeout'tan büyük olmalı; yoksa Kubernetes shutdown'ı SIGKILL ile yarıda keser. Bookstore'da 3 + 20 = 23 saniye.
Sık Düşülen Tuzaklar
log.Fatal ile kapanmak. os.Exit çağırdığı için defer'lar çalışmaz; veritabanı pool'u kapanmaz, buffer'daki log'lar yazılmaz. Hatayı run'dan döndür, main'de bir kez çık.
slog.Handler'ı embed edip WithAttrs'ı unutmak. logger.With("service", "x") embed edilmiş handler'ın WithAttrs'ını çağırır ve sarmalanmamış iç handler'ı döndürür; o logger'dan çıkan hiçbir kayıtta request ID olmaz ve hata vermez.
Shutdown'dan sonra ListenAndServe'in dönmesini programın bitişi sanmak. ErrServerClosed hemen döner; main o anda biterse devam eden istekler yine kesilir. Shutdown'ın dönmesini bekle.
Debug server'a WriteTimeout vermek. /debug/pprof/profile varsayılan olarak 30 saniye sürüyor; WriteTimeout bundan kısaysa pprof isteği reddediyor.
Kubernetes CPU limiti olmadan GOMAXPROCS'un otomatik ayarlanmasını beklemek. Sadece request varsa runtime node'un bütün CPU'larını görür.
Küçük Bir Deneme
★☆☆ Servisi BOOKSTORE_LOG_FORMAT=text BOOKSTORE_LOG_LEVEL=debug ile çalıştır ve bir sipariş ver. Aynı kayıt metin hâlinde nasıl görünüyor?
★★☆ Logging'e bir seçenek ekle: /healthz ve /readyz istekleri Debug seviyesinde, diğerleri Info seviyesinde yazılsın. Bunun için bir test yaz.
★★★ Startup log'una debug.ReadBuildInfo() ile vcs.revision ve vcs.modified değerlerini ekle. Sonra labs/08-leakprofile'daki sızdıran fonksiyonu geçici bir endpoint'ten çağır ve /debug/pprof/goroutineleak?debug=1'in sızan goroutine'leri gösterdiğini doğrula.
Servis artık production'a hazır: ayarlarını ortamdan alıyor, JSON log yazıyor, düzgün kapanıyor ve 5 MB'lık bir image'da çalışıyor. Bir sonraki yazıda geriye dönüp Go'nun 1.22'den 1.27'ye nasıl değiştiğine bakıyoruz: döngü değişkeni, ServeMux pattern'leri, iterator'lar, Green Tea GC, generic method'lar, 10'da söz verdiğimiz json/v2'ye geçiş, DefaultGODEBUG satırının anlamı ve go fix ile kodu yeni sürüme taşımak.