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

Continuous Profiling и eBPF в production

Зачем знать на Middle 3: Production-сервисы редко падают из-за очевидных багов — они деградируют постепенно: utility-функция стала медленнее на 5%, GC начал работать чаще, новая зависимость съела +200 МБ памяти. Без continuous profiling эти проблемы заметит клиент, а не вы. На уровне Senior нужно: разворачивать Pyroscope/Parca, читать flame graph diff, понимать архитектуру eBPF-агентов, ловить регрессии в CI до merge.

  1. Концепция continuous profiling
  2. Глубже / production-практики (Pyroscope, Parca, eBPF, pprof labels)
  3. Gotchas
  4. Real cases
  5. Вопросы (25)
  6. Practice
  7. Источники

Классический подход: разработчик ловит проблему в production, идёт по SSH, делает curl /debug/pprof/profile, скачивает, смотрит в go tool pprof. Проблема: к моменту, когда заметили, инцидент уже шёл часами. И нет baseline — что было «до».

Continuous profiling — это профилирование всех инстансов сервиса непрерывно, 24/7, с низким overhead, с долгосрочным хранением и UI для сравнения.

Ключевые свойства:

  • Production-safe sampling: типично 100 Гц CPU profile = ~1% overhead.
  • Сравнение во времени: сегодня vs вчера, до релиза vs после.
  • Сравнение между инстансами: один pod аномально медленный? Сравните его профиль с соседними.
  • Долгосрочное хранение: 30–90 дней горячих данных, дольше в cold storage.
  • Низкая стоимость: компрессия профилей, дельта-encoding.

Реальные сценарии:

  1. Регрессия после релиза. Деплоили в 14:00, через час latency p99 вырос с 50 мс до 80 мс. Diff-flame graph: новая JSON-библиотека добавила allocation в hot path.
  2. Сезонный leak. Память pod растёт с 200 МБ до 1.5 ГБ за 7 дней. inuse_space diff между snapshots: один из cache TTL не работает.
  3. Costlysearch on Sunday. Один customer запускает огромный отчёт раз в неделю. Профиль показывает 90% CPU в SQL-парсере.
  4. Compare across versions. Развернули PR в canary 5% — профиль canary против stable: hot path функция стала на 20% медленнее.
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Pod A │ │ Pod B │ │ Pod C │ ← target apps
└────┬─────┘ └────┬─────┘ └────┬─────┘
│ │ │
│ pprof │ │
▼ ▼ ▼
┌─────────────────────────────────────┐
│ Profiling Agent (sidecar / DaemonSet)│
│ - scrapes /debug/pprof/profile │
│ - or eBPF kernel sampling │
│ - tags: service, version, env │
└──────────────┬──────────────────────┘
│ push (gzip+protobuf)
┌─────────────────────────────────────┐
│ Profiling Backend (Pyroscope / Parca)│
│ - hot storage: ScyllaDB / TimescaleDB│
│ - cold storage: Parquet on S3 │
│ - dedupe stack traces (symbol cache)│
└──────────────┬──────────────────────┘
┌──────────────┐
│ UI / API │ flame graphs, diff, top
│ Grafana │
└──────────────┘

Push vs pull: Pyroscope исторически был push (агент кидает в сервер), сейчас поддерживает оба. Parca — pull (Prometheus-style scraping).

  • CPU profiling: 100 Гц = 100 samples/sec. Overhead ~0.5–1.5%. Это де-факто стандарт.
  • Memory (alloc) profiling: каждый 512 КБ allocation (runtime.MemProfileRate). Можно регулировать.
  • Block / mutex profiling: по умолчанию выключено, надо включать (overhead значительный).
  • Goroutine profiling: snapshot всех goroutine — overhead зависит от их количества.

Главная фича для production. Позволяет тегировать профилирующие данные:

import (
"context"
"runtime/pprof"
)
func handleRequest(ctx context.Context, req *Request) {
labels := pprof.Labels(
"endpoint", req.Endpoint,
"tenant", req.TenantID,
"method", req.Method,
)
pprof.Do(ctx, labels, func(ctx context.Context) {
// вся работа в этом блоке будет помечена labels
processRequest(ctx, req)
})
}

В UI можно фильтровать flame graph по tenant=acme, endpoint=/api/v2/orders. Это критично для multi-tenant сервисов: один tenant загружает CPU, а вы не знаете кто.

⚠️ Labels не работают для всех типов профилей. CPU profile — да. Heap (memory) labels требуют Go 1.21+ (runtime.SetCPUProfileRate + runtime.SetBlockProfileRate отдельно). Проверять документацию для своей версии Go.

⚠️ Labels добавляют overhead на сами вызовы pprof.Do — несколько сот наносекунд. В horror-сценарии — не оборачивайте каждый микро-вызов.

import (
_ "net/http/pprof" // регистрирует handlers на DefaultServeMux
"net/http"
)
func main() {
// ⚠️ НИКОГДА не вешайте pprof на основной listener!
go func() {
// отдельный listener только для admin/internal
http.ListenAndServe("127.0.0.1:6060", nil)
}()
// основной API на другом порту
http.ListenAndServe(":8080", apiMux)
}

В Kubernetes: pprof endpoint на 127.0.0.1:6060, агент пробрасывает порт изнутри пода. Никогда не экспонируйте 6060 наружу — /debug/pprof/profile?seconds=60 это бесплатный DoS-вектор.

Архитектура:

  • Pyroscope Server: HTTP API, хранение в собственном storage (forked Loki).
  • Pyroscope Agents: Go-агент в коде или sidecar.
  • Grafana plugin: визуализация.

Установка агента в Go-приложение:

import "github.com/grafana/pyroscope-go"
func main() {
_, err := pyroscope.Start(pyroscope.Config{
ApplicationName: "order-service",
ServerAddress: "http://pyroscope:4040",
Logger: pyroscope.StandardLogger,
Tags: map[string]string{
"env": "prod",
"version": Version,
"region": "eu-west-1",
},
ProfileTypes: []pyroscope.ProfileType{
pyroscope.ProfileCPU,
pyroscope.ProfileAllocObjects,
pyroscope.ProfileAllocSpace,
pyroscope.ProfileInuseObjects,
pyroscope.ProfileInuseSpace,
pyroscope.ProfileGoroutines,
pyroscope.ProfileMutexCount,
pyroscope.ProfileMutexDuration,
pyroscope.ProfileBlockCount,
pyroscope.ProfileBlockDuration,
},
})
if err != nil { log.Fatal(err) }
}

Хранение Pyroscope (с версии 1.x): использует BoltDB-подобное хранилище плюс Parquet для долгосрочного. Сжатие через дедупликацию stack traces (одна и та же цепочка функций кодируется ID).

Принципиальное отличие от Pyroscope: Parca-agent работает через eBPF и профилирует любые процессы на хосте, включая те, которые не имеют pprof-инструментации.

┌─────────────────────┐
│ Linux Kernel │
│ ┌──────────────┐ │
│ │ eBPF program │ ←── perf_event_open (99 Hz)
│ │ stack walker │ │
│ └──────┬───────┘ │
└─────────┼───────────┘
│ stacks
┌──────────────┐
│ Parca Agent │ (DaemonSet)
│ - symbolize │
│ - dedupe │
└──────┬───────┘
│ push (gRPC)
┌──────────────┐
│ Parca Server │
└──────────────┘

Stack unwinding для Go: исторически Go не использовал frame pointers, что делало kernel-level unwinding сложным (приходилось парсить .gopclntab или использовать DWARF). С Go 1.20+ frame pointers включены по умолчанию на amd64 и arm64 — это упростило eBPF профилирование.

⚠️ Если бинарь компилируется с -ldflags="-s -w", символы strip-нуты. Parca будет показывать адреса вместо имён функций, если debuginfod недоступен. Решение: хранить debuginfo отдельно, либо НЕ стрипать в проде (Go и так компактен).

  • Закрытый код, но богатый UI и интеграция с APM/logs.
  • Использует pprof под капотом + дополнительные пробы.
  • Тэгирование автоматическое через DD env vars.
  • Цена: ~$2/host/month в нижнем тарифе, но за объём data может быть существенно больше.

Подключение тривиально:

import "gopkg.in/DataDog/dd-trace-go.v1/profiler"
func main() {
err := profiler.Start(
profiler.WithService("order-service"),
profiler.WithEnv("prod"),
profiler.WithVersion(Version),
profiler.WithProfileTypes(
profiler.CPUProfile,
profiler.HeapProfile,
profiler.MutexProfile,
profiler.GoroutineProfile,
),
)
if err != nil { log.Fatal(err) }
defer profiler.Stop()
}
  • Polar Signals: commercial вариант Parca (от автора).
  • New Relic: интегрирован с APM, less mature чем Datadog для Go.
  • Google Cloud Profiler: бесплатный для проектов на GCP, использует свой агент, хранит в Cloud Storage.

Это главная фишка для регрессий.

[BEFORE deploy v2.1.0] [AFTER deploy v2.1.0] [DIFF]
main.handle 50% main.handle 65% +15% 🔴
├─ parseJSON 10% ├─ parseJSON 25% +15% 🔴
├─ dbQuery 20% ├─ dbQuery 20% 0%
└─ render 20% └─ render 20% 0%

В UI: красный = новая версия медленнее, синий = быстрее, серый = без изменений. Вы за 30 секунд видите, что новая parseJSON — bottleneck. Идёте в коммит, который её менял.

Стандартные:

  • cpu, heap, goroutine, block, mutex, threadcreate.

Можно добавлять свои через pprof.NewProfile:

var dbConnProfile = pprof.NewProfile("db_connections")
func acquireConn(ctx context.Context) (*sql.Conn, error) {
conn, err := db.Conn(ctx)
if err != nil { return nil, err }
dbConnProfile.Add(conn, 1) // stack trace где взяли connection
return conn, nil
}
func releaseConn(conn *sql.Conn) {
dbConnProfile.Remove(conn)
conn.Close()
}

Если у вас «pool exhausted» — посмотрите dbConnProfile снимок и увидите, кто держит соединения.

Что такое eBPF (extended Berkeley Packet Filter): это виртуальная машина внутри ядра Linux. Можно загрузить программу, которая срабатывает на kernel events (syscall, hardware interrupt, tracepoint), читает регистры, пишет в BPF map. Это безопаснее, чем kernel module — verifier проверяет программу перед загрузкой.

Профилирование через eBPF:

SEC("perf_event")
int profile_cpu(struct bpf_perf_event_data *ctx) {
u32 pid = bpf_get_current_pid_tgid() >> 32;
if (!is_target_pid(pid)) return 0;
// получить kernel stack
u32 kernel_stack_id = bpf_get_stackid(ctx, &stack_traces, 0);
// получить user stack
u32 user_stack_id = bpf_get_stackid(ctx, &stack_traces, BPF_F_USER_STACK);
struct key_t key = { .pid = pid, .kstack = kernel_stack_id, .ustack = user_stack_id };
increment_counter(&counts, &key);
return 0;
}

User-space агент периодически читает counts map, символизирует stack IDs (через ELF parsing), отправляет в backend.

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

  • Не нужно модифицировать приложение.
  • Работает для любого языка (Go, Rust, Python, Node, Java).
  • Может профилировать ядро вместе с user-space.
  • Низкий overhead (kernel-side).

Ограничения:

  • Только Linux ≥ 4.9 (реально нужно ≥ 5.4 для современного API).
  • Требует CAP_BPF / CAP_SYS_ADMIN (privileged DaemonSet).
  • В Kubernetes managed (GKE, EKS) могут быть ограничения.

Pixie (CNCF, ex-New Relic), Parca-agent могут делать:

  • Goroutine count через чтение runtime.allgs slice (нужны DWARF debug info или адреса из symbol table).
  • GC events через uprobe на runtime.gcStart / runtime.gcMark.
  • Network syscalls per goroutine — связав syscall с current G через TLS register.
  • HTTP server requests — uprobe на net/http.(*ServeMux).ServeHTTP.
Окно терминала
# Пример: Pixie скрипт для top endpoint per service
px run px/http_data
# auto-instruments всё, что слушает HTTP, без рекомпиляции
АспектPyroscopeParca
ПодходPull/push, инструментация в кодеPull, eBPF (whole-system)
StorageCustom (forked Loki + Parquet)FrostDB (Parquet-based)
UIStandalone + Grafana pluginStandalone + Grafana plugin
OwnerGrafana LabsPolar Signals + CNCF Sandbox
MaturityProduction-ready (since 2020)Production, но моложе
СтоимостьSelf-hosted free, Grafana Cloud $$Self-hosted free, Polar Signals $$
Go-friendlyNative pprof, простая интеграцияeBPF — zero code changes

Рекомендация: если у вас Go-моноязычный стек — Pyroscope проще и зрелее. Если многоязычный + нужно профилировать legacy без рекомпиляции — Parca.

Storage volume:

  • Один pod: ~1–5 МБ профилей в час (после дедупликации).
  • 100 pods × 24h × 30d = ~360 ГБ горячих данных.
  • С Parquet + S3 cold tier: $0.023/ГБ ≈ $8/mo за 30 дней.

Но! Datadog/Grafana Cloud берут не за storage, а за host-month. Для 100 хостов это $200–500/mo.

Trade-off: self-hosted Pyroscope дешевле в обслуживании, но требует команду на ops.

.github/workflows/perf.yml
- name: Run benchmark + profile
run: |
go test -cpuprofile=cpu.pprof -bench=. ./...
- name: Compare with main
run: |
go tool pprof -base=main-cpu.pprof -top cpu.pprof > diff.txt
- name: Fail if regression > 10%
run: |
python check_regression.py diff.txt --threshold=0.10

Более продвинуто: benchstat от Google.

Окно терминала
# main branch
go test -bench=. -count=10 ./... > old.txt
# PR branch
go test -bench=. -count=10 ./... > new.txt
benchstat old.txt new.txt
# вывод: BenchmarkX 100ms ± 1% → 115ms ± 2% (+15%, p=0.000)

CI блокирует merge если регрессия statistically significant.

DaemonSet с Parca-agent:

apiVersion: apps/v1
kind: DaemonSet
metadata: { name: parca-agent }
spec:
template:
spec:
hostPID: true
containers:
- name: parca-agent
image: ghcr.io/parca-dev/parca-agent:latest
securityContext:
privileged: true
args:
- --remote-store-address=parca-server.observability:7070
- --node=$(NODE_NAME)
volumeMounts:
- name: sys, mountPath: /sys, readOnly: true
- name: debugfs, mountPath: /sys/kernel/debug

⚠️ privileged: true для eBPF — security review обязателен.

ГдеЧто измеряет
Unit test benchMicrobenchmark одной функции, идеальные условия
Load test (k6)End-to-end под нагрузкой в staging
Continuous prodРеальный трафик, реальные данные, реальные баги

Все три нужны. Production profiling показывает то, что нельзя воспроизвести: настоящие данные клиентов, настоящий network noise, настоящие peak loads.

Block profile записывает stack traces, где goroutine заблокировался на synchronization (channel send/receive, mutex, select, sync.Cond).

runtime.SetBlockProfileRate(1) // every blocking event

Rate 1 означает «каждое событие блокировки длительностью > N ns записывается». Default — выключено (0).

Полезен для:

  • Channel contention (продюсер быстрее consumer-а).
  • Slow downstream calls, где goroutine ждёт ответа.
  • Cond.Wait в кастомных primitive.

Mutex profile записывает stack traces holder-а mutex, когда другая goroutine ждала.

runtime.SetMutexProfileFraction(5) // 1 of 5 contention events

Helps detect:

  • Hot mutex (один lock держится долго).
  • RWMutex с большим количеством writers.
  • Map mutex contention.

⚠️ Не оставляйте включёнными в production без причины. Overhead 5–15%.

⚠️ Pyroscope/Datadog могут собирать эти profiles периодически (e.g., 30 sec each 5 min) для minimal overhead.

Окно терминала
curl -s http://service:6060/debug/pprof/goroutine?debug=2 > goroutines.txt

debug=2 даёт full text dump со stack traces всех goroutine. Если goroutine count внезапно вырос с 100 до 50K — это первый снимок для disgnose:

  • 49K goroutine все в одной строке кода → leak.
  • Все ждут одного mutex → contention.
  • Все на chan receive → producer dead.

В UI Pyroscope: goroutine profile показывает аналогично — flame graph группирует goroutines по stack trace.


⚠️ CPU profiling меняет hot path. SetCPUProfileRate(100) добавляет signal handler. Если ваше приложение делает 1М ops/sec, signal handling становится заметным.

⚠️ Mutex/block profiling DEFAULT-OFF. Их включение через runtime.SetMutexProfileFraction(5) (1 из 5 contention events) даёт overhead 5–15%. Не включайте всем в проде, только когда нужно расследование.

⚠️ Heap profile показывает live memory, не leak rate. inuse_space snapshot между двумя моментами — это diff живых объектов. Если у вас leak медленный, нужно ждать часы.

⚠️ alloc_space даёт total allocations. Если функция делает GC-friendly код (alloc → GC сразу), alloc_space будет огромным, но inuse_space маленьким. Не путать.

⚠️ PGO (Profile-Guided Optimization) с Go 1.21+ — это другая история. Профиль используется компилятором для inline решений. Это не continuous profiling, но source can be the same.

⚠️ Frame pointers и Go 1.20+. До 1.20 на Linux/amd64 frame pointers были выключены для производительности. eBPF профилирование Go было затруднено. В 1.20+ включены по умолчанию (есть -buildmode=pie или GOFLAGS=-ldflags=-fp=true — проверить актуальную форму для своей версии Go).

⚠️ Symbol stripping (-s -w) ломает eBPF профилирование. Если стрипали — нужен debuginfod-сервер с unstripped версией.

⚠️ Контейнеры и process namespace. eBPF profiler видит PID в хост-namespace, не в контейнере. Mapping PID → pod нужен через kube-api или CRI.

⚠️ Pyroscope label cardinality. Если ставите user_id как label с миллионом значений — storage эксплодирует. Используйте только bounded labels.

⚠️ Cold start профилирования. Pyroscope-агент в Go-приложении сам делает alloc при startup. Если у вас sub-second cold start — добавит 50–200 мс.

⚠️ Sampling bias. 100 Гц = 10 мс tick. Функция, которая работает 1 мс, может вообще не попасть в семплы или попасть случайно. Long-tail latency profiling требует block profiling.


Симптом: после обновления github.com/lib/pq на новую версию p99 latency для /api/orders вырос с 80 мс до 130 мс.

Расследование:

  1. Pyroscope flame graph diff между v2.4.0 (старый) и v2.4.1 (новый).
  2. В новой версии pq.QueryContext стала тратить больше CPU на parsing protocol response.
  3. Конкретно: дополнительная allocation per row в (*conn).readPacket.

Fix: pinned lib/pq назад, открыл issue. Через 2 недели вышел fix upstream.

Без continuous profiling: пришлось бы воспроизводить локально, что для 80→130 мс на сложном query почти нереально.

Симптом: pod рестартится по OOM каждые 6 часов. RSS растёт линейно.

Расследование:

  1. Goroutine profile показывает 50 000 goroutine в runtime.gopark (заблокированы на channel).
  2. Stack trace ведёт к pubsub.Subscribe, который создаёт goroutine на каждый запрос, но не cancel-ит при таймауте.
  3. Каждая goroutine держит 4 КБ stack + map entry.

Fix: добавили ctx.Done() в select внутри goroutine.

Симптом: один из shared services периодически даёт CPU 90%, immediately возвращается к 30%. Не было ясно, кто.

Расследование: pprof labels tenant= уже стояли. Pyroscope с фильтром tenant=BIG_CORP показал: их background sync job (XML import 500 МБ) делает CPU spike. Конкретно: regex компилируется на каждой строке.

Fix: precompile regex outside loop. Tenant сообщили о rate limit на background jobs.

Симптом: GC pause p99 = 50 мс на heavy load.

Расследование: heap profile показал, что 70% allocations — это []byte в JSON marshaling. С labels: hottest endpoint — /internal/sync который отдаёт 10 МБ JSON ответ.

Fix: switch to streaming JSON encoder. Allocations упали, GC pause → 5 мс.

Setup: GitHub Actions запускает benchmark suite на каждом PR, сравнивает с main через benchstat.

Срабатывание: PR с рефакторингом cache layer. benchstat сравнение:

BenchmarkCacheGet 450ns ± 2% → 1.2µs ± 5% (+167%, p=0.000)

PR заблокирован автоматически. Автор увидел отчёт, обнаружил, что заменил sync.Map на map+RWMutex в hot path.


  1. Что такое continuous profiling и чем он отличается от ad-hoc pprof?
  2. Какой типичный overhead 100 Hz CPU profiling в Go?
  3. Что такое pprof labels и для чего они в multi-tenant сервисах?
  4. Как правильно exposить /debug/pprof в Kubernetes pod, не открывая security дыру?
  5. В чём разница inuse_space vs alloc_space в heap profile?
  6. Как сравнить heap snapshot до и после ожидаемого leak?
  7. Объясните архитектуру Pyroscope (компоненты, push vs pull).
  8. Чем Parca отличается от Pyroscope в подходе к сбору данных?
  9. Что такое eBPF и почему он подходит для профилирования?
  10. Как eBPF делает stack unwinding для Go-программ? Какие сложности?
  11. Какую роль играют frame pointers (Go 1.20+) в eBPF профилировании?
  12. Что произойдёт с eBPF профилированием, если бинарь скомпилирован с -ldflags=“-s -w”?
  13. Перечислите standard profile types в Go runtime/pprof.
  14. Как создать custom pprof profile для трекинга, например, открытых connections?
  15. Что такое flame graph diff и как читать красные/синие зоны?
  16. Опишите differential profiling в CI: что сравниваем, как блокируем регрессию.
  17. Чем benchstat помогает при benchmark-сравнениях?
  18. Какие label cardinality проблемы могут возникнуть в Pyroscope?
  19. Как Pixie делает auto-instrumentation HTTP requests без модификации кода?
  20. Если pod рестартится по OOM, какой профиль брать первым?
  21. Опишите trade-off self-hosted Pyroscope vs Datadog Profiler.
  22. Какие security implications у DaemonSet с CAP_BPF?
  23. Что такое sampling bias и как он влияет на анализ коротких функций?
  24. Mutex profile в проде включён по умолчанию? Каков overhead включения?
  25. Опишите кейс, когда continuous profiling спас от инцидента / нашёл проблему.

Задача 1: Поднять локально Pyroscope (docker-compose), подключить агента в простой Go-сервис, сгенерировать нагрузку, увидеть flame graph.

Задача 2: Добавить pprof labels в HTTP-handler по endpoint и tenant, отфильтровать профиль по конкретному tenant в UI.

Задача 3: Написать GitHub Actions workflow, который запускает go test -bench=., сохраняет результаты, сравнивает с main через benchstat и fail-ит PR при регрессии > 10%.

Задача 4: Симулировать memory leak (бесконечный slice append без cleanup), снять heap profile через 1 минуту и через 10 минут, использовать pprof -base для diff и найти allocation site.

Задача 5: Включить block + mutex profiling, измерить overhead на realistic workload (например, wrk + сервис с RWMutex contention). Зафиксировать throughput до и после.

Задача 6 (advanced): Поднять Parca с eBPF-агентом, проверить, что профилирует Go-приложение без какой-либо инструментации. Сравнить fidelity с pprof-based профилем.

Задача 7: Реализовать custom profile (pprof.NewProfile("connection_acquire")) для трекинга, кто держит DB connections дольше 1 секунды.


  1. Brendan Gregg, “BPF Performance Tools”, Addison-Wesley, 2019 — основополагающий труд по eBPF profiling.
  2. Brendan Gregg, “Systems Performance: Enterprise and the Cloud”, 2nd ed, 2020.
  3. The Go Blog, “Profile-guided optimization in Go 1.21”, https://go.dev/blog/pgo
  4. Liz Rice, “Learning eBPF”, O’Reilly, 2023.
  5. Pyroscope Documentation, https://grafana.com/docs/pyroscope/
  6. Parca Documentation, https://www.parca.dev/docs/
  7. Polar Signals Blog, “Eyes on production: continuous profiling”, 2022.
  8. Pixie Labs, “Auto-telemetry for Kubernetes”, https://docs.px.dev/
  9. Datadog Continuous Profiler Docs, https://docs.datadoghq.com/profiler/
  10. Frederic Branczyk (CNCF Parca maintainer), KubeCon talks 2022–2024.
  11. Russ Cox, “Profile-guided optimization preview”, Go Blog 2023.
  12. Felix Geisendörfer, “Differential profiling for Go in CI”, FOSDEM 2023.
  13. Go runtime source: src/runtime/pprof for label propagation internals.
  14. Bryan Boreham, “Continuous profiling at Grafana Labs”, PromCon EU 2023.
  15. Tatsuhiro Tsujikawa, “eBPF for Go developers”, GopherCon 2023.