Перейти к содержимому

Linker-оптимизации, cold start и connection pool tuning

Зачем знать на Middle 3: Бинарь в Docker 80 МБ vs 30 МБ, cold start 800 мс vs 200 мс, HTTP-клиент с дефолтным MaxIdleConnsPerHost=2 упирается в производительность — всё это разница «продакшен работает / не работает». На уровне Senior: умеешь стрипать debug info с пониманием trade-off, знаешь, куда уходят миллисекунды на старте Lambda, тюнишь pool для каждой зависимости осознанно, не по cargo cult.

  1. Концепция
  2. Глубже / production-практики (linker, cold start, HTTP/gRPC/DB/Redis pools)
  3. Gotchas
  4. Real cases
  5. Вопросы (20)
  6. Practice
  7. Источники

Стандартный Go binary содержит:

  • Code (.text): ваши функции + stdlib + dependencies.
  • Symbols (.symtab): имена функций, переменных — для stack traces.
  • DWARF (.debug_*): для debugger, source mapping.
  • gopclntab: PC → line mapping для panic traces (Go-specific).
  • Type info (.rodata): для reflect, interfaces.
  • Static data: строки, литералы.

Пример: простой HTTP-сервис с chi + pgx = 18 МБ. С -s -w = 12 МБ. Без cgo = 11 МБ. После UPX = 4 МБ.

[t=0] Контейнер shedule-ится на node
[t=ms] Pull image (если не cached)
[t=ms] Start process (kernel exec)
[t=ms] Load binary в memory (linker work)
[t=ms] Initialize runtime (allocator, scheduler, GC)
[t=ms] Sequential init() в каждом package (по importer order)
[t=ms] main() начинает
[t=ms] Read config / env / secrets
[t=ms] Open DB pool, Redis pool — handshake
[t=ms] Register routes, listen on port
[t=ms] Сервис готов
[t=ms] Первый запрос — first request handling

В serverless (AWS Lambda Go runtime, GCP Cloud Functions) — каждый из этих шагов уважительно входит в «cold start» билинг. Для Go типично 100–500 мс cold start, vs Node.js 300–1000, Python 500–2000, Java/JVM 2–10 секунд.

Открытие TCP-соединения — 1 RTT (50+ мс на cross-region). TLS handshake — ещё 1–2 RTT. Если каждый запрос открывает новое соединение — latency взлетает.

Pool держит open соединения, повторно использует. Trade-offs:

  • Слишком маленький pool → contention, queue.
  • Слишком большой → file descriptors, memory, server-side limit.

Окно терминала
go build -ldflags="-s -w" -o app
  • -s: strip symbol table (.symtab, .strtab).
  • -w: strip DWARF debug information.

Эффект: бинарь меньше на 25–35%. Trade-off: panic stack traces всё ещё работают (gopclntab не трогается), но debugger (delve) не сможет ставить breakpoints.

⚠️ Continuous profiling через eBPF (Parca) теряет имена функций — нужен debuginfod сервер с unstripped версией.

Окно терминала
go build -trimpath -o app

Убирает абсолютные пути из binary. Без него panic выдаёт /Users/abylay/go/src/.../file.go:123. С -trimpath — просто myrepo/file.go:123.

Преимущества:

  • Reproducible builds: бинарь не зависит от расположения исходников.
  • Security: не light утечки внутренних путей.
  • Smaller binary: пути занимают KB-MB.

В production CI всегда используйте -trimpath.

Окно терминала
VERSION=$(git describe --tags --always)
COMMIT=$(git rev-parse --short HEAD)
BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)
go build -ldflags="
-X main.Version=$VERSION
-X main.Commit=$COMMIT
-X main.BuildTime=$BUILD_TIME
-X main.GoVersion=$(go version | awk '{print $3}')
" -o app

В коде:

var (
Version = "dev"
Commit = "unknown"
BuildTime = "unknown"
)
func main() {
log.Printf("starting %s (%s) built at %s", Version, Commit, BuildTime)
}

⚠️ -X работает только для var string. Для const — не работает.

Окно терминала
CGO_ENABLED=0 go build -o app

Если ваш код не использует CGO напрямую (нет import "C"), это:

  • Уменьшает бинарь (нет libc, libdl линковки).
  • Делает binary полностью статичным — работает в FROM scratch Docker image.
  • Faster startup (нет dynamic linker).

⚠️ Некоторые stdlib пакеты используют CGO по умолчанию:

  • net — DNS через libc resolver (cgo).
  • os/usergetpwnam через libc.

С CGO_ENABLED=0 Go использует pure-Go fallback. DNS через netgo resolver — может быть медленнее в edge cases.

Альтернатива: go build -tags netgo,osusergo.

Окно терминала
upx --best app

Compresses бинарь в 2–4x. Trade-offs:

  • Startup slower: на старте decompress в memory (~100–500 мс).
  • AV false positives: антивирусы любят отмечать UPX как malware.
  • Не работает на macOS: Apple’s signature verification ругается.

Не рекомендуется для serverless. Подходит для on-premise embedded.

TinyGo — альтернативный компилятор для embedded и WASM. Использует LLVM backend.

Окно терминала
tinygo build -o app -target=wasm
  • Binaries radically smaller (KBs vs MBs).
  • Поддерживает WASM, ESP32, Raspberry Pi, etc.
  • НЕ drop-in replacement: не вся stdlib поддерживается (net/http — частично), нет goroutine scheduler такой же зрелости.

Для микросервисов в Kubernetes — стандартный Go. TinyGo для edge / IoT / WASM-плагинов.

Окно терминала
~/.cache/go-build/ # compiled package artifacts
~/go/pkg/mod/ # downloaded source archives
~/go/pkg/sumdb/ # checksum database

В CI:

- uses: actions/cache@v3
with:
path: |
~/.cache/go-build
~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}

С cached build: повторная сборка 10–50x быстрее.

GOMODCACHE и GOCACHE env vars позволяют управлять расположением.

  1. -ldflags="-s -w" — 25–35% reduction.
  2. -trimpath — minor, но полезно.
  3. CGO_ENABLED=0 — может убрать 1–3 МБ.
  4. Удалить unused imports (особенно large ones типа aws-sdk-go-v2 — модульно подключать только нужные сервисы).
  5. Заменить тяжёлые dependencies (например, использовать valyala/fasthttp вместо net/http — спорно).
  6. go-binsize-viz для анализа: что занимает место.
Окно терминала
go install github.com/knz/go-binsize-viz@latest
go-binsize-viz -o report.html app

Покажет sunburst chart, где gopclntab 4 МБ, lib/pq 1.2 МБ, etc.

Замер:

var startTime = time.Now()
func init() {
log.Printf("init() at %v", time.Since(startTime))
}
func main() {
log.Printf("main() at %v", time.Since(startTime))
// ...
log.Printf("listening at %v", time.Since(startTime))
}

Типичный breakdown для 30 МБ Go binary в Lambda:

[0 ms] exec started
[20 ms] runtime initialized
[40 ms] init() functions начали выполняться
[80 ms] init() закончились (40 packages)
[100 ms] main() начался
[150 ms] AWS SDK client created
[200 ms] DB pool опционально open
[250 ms] готов к запросам

Где может «съесть» секунды:

  • AWS SDK v1 init: 200–500 мс (читает credentials chain, region).
  • gRPC pool с TLS: 100–300 мс на handshake.
  • Vault / Secret Manager fetch: 100 мс — 1 сек.
  • Schema migration check: 100–1000 мс.

1. Smaller binary → faster load.

2. Lazy init:

var (
s3ClientOnce sync.Once
s3Client *s3.Client
)
func getS3Client() *s3.Client {
s3ClientOnce.Do(func() {
s3Client = s3.NewFromConfig(...)
})
return s3Client
}

Не открывайте все clients в main(). Открывайте на первое использование.

3. Reduce import surface:

  • Не импортируйте aws-sdk-go-v2/aws целиком — только service/s3, service/dynamodb, etc.
  • Избегайте init() функций в зависимостях (трудно контролировать).

4. Avoid heavy reflect at init: init() функция, которая парсит большие struct tags через reflect, добавит ms-s.

AWS Lambda Go runtime:

  • Container reused для warm invocations.
  • Cold start = новый container.
  • Provisioned concurrency: pre-warmed containers (не cold start, но платишь за idle).

Lambda Go vs Node.js:

  • Go ~150–250 мс cold start typically.
  • Node.js ~250–500 мс.
  • Java ~1500–3000 мс (без SnapStart).

GCP Cloud Functions / Cloud Run:

  • Cloud Run gen2 cold start ~100–500 мс для Go.
  • Min instances = 1 убирает cold start, но платишь за idle.

⚠️ SnapStart (AWS) для Java делает snapshot JVM state. Для Go это не нужно — Go уже быстро стартует.

Keep-warm pattern: периодически вызываете Lambda (e.g. CloudWatch Events каждые 5 минут) чтобы избежать cold start. Но в 2026 это считается анти-паттерн — лучше provisioned concurrency.

Go 1.21+ поддерживает PGO:

Окно терминала
# 1. Build with profile collection enabled
go build -o app
# 2. Run в production, собрать pprof CPU profile
curl http://app:6060/debug/pprof/profile?seconds=60 > default.pgo
# 3. Поместить в module root, rebuild
mv default.pgo ./
go build -o app # подхватит default.pgo automatically

Эффект: 2–7% improvement на hot path через лучший inlining decisions.

⚠️ PGO не помогает cold start — оптимизирует hot path который runs много раз.

transport := &http.Transport{
MaxIdleConns: 100, // total idle across all hosts
MaxIdleConnsPerHost: 50, // ⚠️ default = 2!
MaxConnsPerHost: 100, // hard cap
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
ResponseHeaderTimeout: 30 * time.Second,
// DialContext customization
DialContext: (&net.Dialer{
Timeout: 10 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
// ForceAttemptHTTP2: true, // default true в новых Go
}
client := &http.Client{
Transport: transport,
Timeout: 30 * time.Second,
}

⚠️ Главный gotcha: MaxIdleConnsPerHost = 2 по умолчанию. Это означает: каждый раз когда вы делаете 3+ concurrent request к одному hostname — открываются новые TCP, после ответа закрываются. На каждый RPS открытие TCP + TLS — это десятки ms.

В production set MaxIdleConnsPerHost ≥ peak concurrent connections to that host.

SettingDefaultSane prod value
MaxIdleConns100100–500
MaxIdleConnsPerHost220–100
MaxConnsPerHost0 (unlimited)100–500
IdleConnTimeout90s90s OK
TLSHandshakeTimeout0 (no timeout)10s
ResponseHeaderTimeout0 (no timeout)30s
client.Timeout0 (no timeout)30s

⚠️ Без client.Timeout ваш сервис может зависнуть навсегда на slow downstream.

transport := &http.Transport{
DisableKeepAlives: true,
}

Когда: behind load balancer, который sticky-route-ит по TCP connection — keep-alive нарушает rebalance. Это редкий случай, но встречается.

⚠️ Без keep-alive каждый запрос — full TCP handshake. На high RPS это catastrophic.

gRPC создаёт один HTTP/2 connection с multiplexing. Один stream = один RPC. HTTP/2 поддерживает 100 concurrent streams по умолчанию.

Для high RPS (>1000 RPS на один backend), нужно несколько connections:

import "google.golang.org/grpc"
// Variant 1: round-robin balancer + DNS resolver
conn, err := grpc.Dial("dns:///service.local:5000",
grpc.WithDefaultServiceConfig(`{"loadBalancingPolicy":"round_robin"}`),
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
// Variant 2: pool of ClientConn manually
type ClientPool struct {
conns []*grpc.ClientConn
next atomic.Uint32
}
func (p *ClientPool) Get() *grpc.ClientConn {
i := p.next.Add(1) % uint32(len(p.conns))
return p.conns[i]
}

⚠️ Single grpc.ClientConn сегодня (с HTTP/2 multiplexing) часто достаточно. Pool нужен только когда: stream limit hit, high RPS, или нужна distribution по backends.

database/sql:

db, err := sql.Open("postgres", connStr)
db.SetMaxOpenConns(25) // total
db.SetMaxIdleConns(25) // == MaxOpenConns для steady-state
db.SetConnMaxLifetime(30 * time.Minute) // recycle для load balancer rebalancing
db.SetConnMaxIdleTime(5 * time.Minute)

Rule of thumb: MaxOpenConns = (CPU cores на DB) × 2. Для Postgres с 4 ядрами — 8 connections per app. Если у вас 10 app instances → 80 connections к DB.

⚠️ Если MaxOpenConns слишком высокий — connection storm после restart. Если слишком низкий — request queue.

⚠️ ConnMaxLifetime = 0 означает «forever». В cloud DB это плохо: load balancer не сможет dropp connections, rebalance не работает. Set 30 мин.

Между app и Postgres ставят pgbouncer для connection multiplexing:

  • Session pooling: одна client connection → одна server connection пока активна. Минимальный overhead, но ограниченный throughput.
  • Transaction pooling: одна client connection → server connection на время транзакции. После commit/rollback — server connection возвращается в pool. Высокий throughput. Limitation: нельзя использовать session features (prepared statements в Postgres < 14, advisory locks, LISTEN/NOTIFY).
  • Statement pooling: одна connection на один statement. Очень редко, almost no Go драйвер с этим работает.

В Postgres 14+ + pgx с protocol_simple или mode: 'session' для prepared statements можно.

import "github.com/redis/go-redis/v9"
rdb := redis.NewClient(&redis.Options{
Addr: "redis:6379",
PoolSize: 50, // default = 10 * NumCPU
MinIdleConns: 10,
MaxIdleConns: 50,
PoolTimeout: 4 * time.Second,
DialTimeout: 5 * time.Second,
ReadTimeout: 3 * time.Second,
WriteTimeout: 3 * time.Second,
})

⚠️ PoolSize default 10 * NumCPU — на 8-core машине = 80. Для small services это перебор; для high-throughput может не хватать.

⚠️ Pipeline / transaction holds connection на всё время. Если pipeline крупный — pool exhaust.

database/sql:

stats := db.Stats()
log.Printf("open=%d, idle=%d, inuse=%d, waitCount=%d, waitDuration=%v",
stats.OpenConnections, stats.Idle, stats.InUse,
stats.WaitCount, stats.WaitDuration)

WaitCount > 0 = ваш pool exhaust. Increase MaxOpenConns или диагностировать slow queries.

go-redis:

stats := rdb.PoolStats()
// Hits, Misses, Timeouts, TotalConns, IdleConns, StaleConns

Экспортируйте в Prometheus, alert когда wait_duration растёт.


⚠️ -ldflags="-s -w" ломает eBPF profiling. Не имена функций — адреса. Решение: debuginfod.

⚠️ -trimpath ломает IDE stack trace navigation. В dev не использовать, только в release builds.

⚠️ CGO_ENABLED=0 меняет DNS resolver. Pure-Go resolver не понимает /etc/nsswitch.conf сложности. Edge cases возможны.

⚠️ UPX не работает на macOS (Apple notarization). Не используйте для macOS dist.

⚠️ init() functions выполняются sequentially в alphabetical order по dependency graph. Тяжёлый init в нижнем пакете тормозит весь startup.

⚠️ Lambda Go runtime в 2026provided.al2 или provided.al2023 (Amazon Linux 2/2023). go1.x runtime deprecated. Build с GOOS=linux GOARCH=arm64 для Graviton (дешевле).

⚠️ MaxIdleConnsPerHost=2 default — главная причина «high latency» в HTTP-клиентах. Чек-лист: всегда override.

⚠️ HTTP/2 over HTTPSForceAttemptHTTP2: true для use HTTP/2, requires TLS.

⚠️ gRPC keepalive on idle. Если connection idle > N минут, server может закрыть. grpc.WithKeepaliveParams(keepalive.ClientParameters{Time: 30 * time.Second}).

⚠️ ConnMaxLifetime = 0 = connections never recycle = load balancer rebalance не работает = stale routing.

⚠️ Transaction pooling в pgbouncer ломает prepared statements в pgx (есть workarounds для PG14+).

⚠️ Connection storm после restart. Если 10 instances одновременно стартуют и open MaxOpenConns=25 each → 250 connections instantly. DB может не справиться. Решение: stagger startup, gradual ramp.

⚠️ PGO profile должен быть representative. Если вы взяли profile только с low traffic — оптимизация для wrong path.


Контекст: микросервис на Go использует AWS SDK v1 целиком (github.com/aws/aws-sdk-go). Бинарь 80 МБ.

Действия:

  1. Migrate to AWS SDK v2 (github.com/aws/aws-sdk-go-v2). Modular imports.
  2. Import только service/s3, service/sqs (не v1 monolith).
  3. -ldflags="-s -w" -trimpath.
  4. CGO_ENABLED=0.

Результат: 25 МБ. Container image (alpine) 35 МБ vs 90 МБ. Pull time ↓ 60%.

Контекст: AWS Lambda на Go, p99 cold start 800 мс.

Анализ: добавили log.Printf на каждом этапе.

  • 100 мс — init() функции.
  • 300 мс — aws.Config creation (credentials chain).
  • 200 мс — DB pool open (1 connection eager).
  • 100 мс — schema migration check.

Действия:

  1. Lazy init AWS clients.
  2. DB pool open в первом request, не в main().
  3. Migration check вынесли в separate deploy step.
  4. Replace heavy YAML parsing с JSON для config.

Результат: cold start 250 мс. Cost экономия ~$300/mo (provisioned concurrency не нужна).

Контекст: payment service делает 5000 RPS к downstream API. p99 latency 2 секунды.

Анализ: dump goroutines — 80% в tls.Handshake. Default MaxIdleConnsPerHost=2 означает: на 5000 RPS, 99% requests открывает новый TCP. TLS handshake → ~30 мс. На concurrent request это 2-секундный queue.

Fix:

transport.MaxIdleConnsPerHost = 100
transport.MaxConnsPerHost = 200
transport.IdleConnTimeout = 90 * time.Second

Результат: p99 200 мс. Throughput удвоился.

Контекст: deploy 20 instances одновременно. Каждый MaxOpenConns=25. DB max_connections=300.

Симптом: 5 минут после deploy — много FATAL: too many connections.

Анализ: 20 × 25 = 500 connections. DB лимит 300.

Fix:

  1. pgbouncer в transaction mode между app и DB. App ↔ pgbouncer (500 connections OK). pgbouncer ↔ DB (50 connections).
  2. MaxOpenConns = 10 per instance (был 25). 20 × 10 = 200.

Результат: stable.

Контекст: high-throughput caching, периодические pool timeout.

Анализ: pool size 30 (NumCPU=3 × 10). При пиках concurrent requests > 30 → timeout.

Fix:

  • PoolSize = 100.
  • MinIdleConns = 20 — warm pool.
  • Pipelining для bulk operations.

Результат: zero pool timeouts.


  1. Что делает -ldflags="-s -w" и какой trade-off?
  2. Зачем -trimpath в production builds?
  3. Как inject version/commit в Go binary через linker?
  4. CGO_ENABLED=0: что меняется в DNS resolution?
  5. Когда UPX-компрессия Go binary оправдана, когда — нет?
  6. TinyGo: для чего и когда не подходит?
  7. Что такое gopclntab и почему его strip не получается стандартным -s?
  8. Опишите этапы cold start Go-приложения.
  9. Где обычно «теряются» миллисекунды на старте Lambda?
  10. Lazy init через sync.Once: пример для AWS SDK client.
  11. PGO в Go 1.21+: что оптимизирует, а что — нет?
  12. MaxIdleConnsPerHost default = 2. Чем это плохо в production?
  13. Опишите все ключевые timeouts в http.Transport и http.Client.
  14. Когда DisableKeepAlives: true оправдан?
  15. gRPC: нужен ли pool ClientConn для high RPS? Когда?
  16. SetConnMaxLifetime = 0 — почему плохо в cloud?
  17. pgbouncer modes: session vs transaction vs statement. Когда какой?
  18. go-redis PoolSize default — какое значение и в чём подвох?
  19. Какие метрики connection pool важно alert-ить?
  20. Опишите кейс connection storm после deploy и его решение.

Задача 1: Собрать Go-сервис в трёх вариантах: default, с -s -w -trimpath, и с CGO_ENABLED=0. Сравнить размеры. Использовать go-binsize-viz для анализа.

Задача 2: Замерить cold start локально — добавить log.Printf в init() и main(). Найти, где «уходит» время.

Задача 3: Развернуть Go-сервис в AWS Lambda с default config и с optimizations (lazy init, smaller binary, ARM64 Graviton). Сравнить p99 cold start.

Задача 4: Написать HTTP-клиент с default transport и с tuned transport (MaxIdleConnsPerHost=50). Замерить throughput через wrk на endpoint, который делает downstream call.

Задача 5: Реализовать httpClient.Stats() (через middleware), экспортировать в Prometheus.

Задача 6: Сэмулировать pool exhaustion: сервис с MaxOpenConns=5, load 50 concurrent requests. Замерить WaitCount, WaitDuration.

Задача 7: Поднять pgbouncer (docker), переключиться в transaction mode, протестировать с prepared statements (должно сломаться без protocol_simple).

Задача 8: Применить PGO к Go-сервису: собрать profile, rebuild, замерить difference.


  1. The Go Blog, “Smaller Go 1.7 binaries”, https://go.dev/blog/go1.7-binary-size
  2. The Go Blog, “Profile-guided optimization in Go 1.21”, https://go.dev/blog/pgo
  3. Filippo Valsorda, “Reducing Go binary size”, 2018.
  4. AWS Lambda Go runtime documentation, https://docs.aws.amazon.com/lambda/latest/dg/lambda-golang.html
  5. Cloudflare blog, “Optimizing Go for HTTP/2 production”, 2019.
  6. Vincent Blanchon, “Tuning Go HTTP client”, 2020.
  7. Marcus Olsson, “Database connection pooling in Go”, 2021.
  8. PgBouncer documentation, https://www.pgbouncer.org/usage.html
  9. go-redis documentation, https://redis.uptrace.dev/
  10. Brad Fitzpatrick, “net/http internals”, GopherCon talks.
  11. The Go runtime source: src/runtime/proc.go for startup sequence.
  12. AWS Lambda Powertools for Go.
  13. Russ Cox, “The Go Linker”, talks 2017–2022.
  14. Damian Gryski, “Go performance recipes”, https://github.com/dgryski/go-perfbook
  15. Bram Gruneir, “Tuning Postgres for cloud”, talks 2022.