open atlas
← Все проекты

backend · advanced · 8d

Конкурентный сервис ингеста на Go

Собери конкурентный воркер ингеста/фан-аута на Go — а затем эксплуатируй его: ограничь работу, примени backpressure, сделай вызовы downstream устойчивыми к отказам, выкати в минимальном контейнере и разбери инцидент с утечкой горутин, пока он не съел твою память.

Это капстоун Go-трека: возьми всё из юнитов по конкурентности, рантайму и production-паттернам и доведи один небольшой сервис от и до. Воркер ингеста/фан-аута кажется тривиальным — прими запросы, сделай работу, вызови downstream'ы — но именно на конкурентности Go-сервисы реально умирают: неограниченные горутины, заблокированные каналы, потерянная работа при отмене, ретраи, усиливающие сбой. Ты очертишь SLO, построишь интейк и ограниченный пул, заставишь его сбрасывать нагрузку, а не падать, добавишь таймауты и ретраи, которые не делают хуже, снимешь телеметрию через логи/pprof/метрики, корректно сольёшь работу при завершении, упакуешь в маленький контейнер, задеплоишь с health- и lifecycle-хуками, затем воспроизведёшь и разберёшь реальную утечку горутин.

Результат

Задеплоенный сервис на Go, который принимает HTTP-интейк, раздаёт работу в ограниченный пул воркеров с backpressure, вызывает downstream-сервисы с таймаутами и ретраями, отдаёт структурные логи + pprof + метрики, корректно завершается, работает в минимальном контейнере и поставляется с написанным пост-мортемом инцидента утечки горутин/дедлока.

Этапы

0/8 · 0%
  1. 01Очерти сервис: нагрузка, SLO, бюджет конкурентности

    До любого кода оцени работу. Реши целевую частоту интейка (скажем, 2000 req/s), фан-аут на запрос (каждый интейк порождает N вызовов downstream) и собственную ёмкость downstream'а — их произведение и есть твой бюджет конкурентности, и он конечен. Выпиши SLO (например, p99 приёма < 20 мс, сквозной p99 < 500 мс, нулевой неограниченный рост горутин) и явные non-goals. Ключевое решение: Go-сервис, порождающий по горутине на единицу входящей работы, не имеет верхней границы, поэтому уже сейчас зафиксируй ограниченный пул, размер которого подобран под то, что downstream'ы реально могут поглотить, а не под то, что клиенты могут прислать.

    Критерии готовности
    • У тебя есть числа: целевой QPS интейка, фан-аут на запрос, ёмкость downstream и вытекающие из них размер пула и глубина очереди.
    • Ты выписал 2–3 SLO (включая инвариант ограниченности горутин) и минимум два явных non-goal.
  2. 02Построй HTTP-интейк и ограниченный пул воркеров

    Реализуй handler интейка и фиксированный пул воркеров, питаемый буферизованным каналом. Handler валидирует и кладёт в очередь; фиксированный набор горутин-воркеров (размером с твой бюджет конкурентности, а не случайно с GOMAXPROCS) разгребает канал и делает фан-аут. Каждая горутина принимает context, а рабочий элемент несёт свой дедлайн. Суть — в структуре: одно место порождает воркеров, один канал они читают, так что число горутин — это выбранная тобой константа, а не эмерджентное свойство трафика.

    Критерии готовности
    • Число воркеров — фиксированная настраиваемая константа; под постоянной нагрузкой число горутин (по pprof) остаётся плоским, а не растёт.
    • Каждый воркер и вызов downstream получает context, а пер-item дедлайн течёт от запроса через рабочий элемент.
  3. 03Примени backpressure: сбрасывай нагрузку, а не буферизуй вечно

    Реши, что происходит при заполненной очереди. Неправильный ответ — неограниченный канал, который глотает всё, пока не умрёт память; правильный — ограниченная очередь плюс семафор, где полная очередь означает, что handler интейка быстро возвращает 503 + Retry-After, а не блокируется. Сделай границу явной, а сброс — наблюдаемым. Backpressure — это разница между сервисом, который под перегрузкой деградирует плавно, и тем, который наращивает многосекундный хвост латентности и затем ловит OOM: буферизация — не стратегия ёмкости, она лишь прячет момент, когда ты исчерпал ресурс.

    Критерии готовности
    • Когда очередь насыщена, интейк возвращает 503 + Retry-After в пределах твоего SLO приёма, а не блокируется и не растит память.
    • Нагрузочный тест выше ёмкости пула показывает плоскую память и растущую долю сброса (503), а не взрывающийся хвост латентности.
  4. 04Сделай вызовы downstream устойчивыми: таймауты, ретраи, отмена

    Оберни каждый вызов downstream так, чтобы одна медленная или падающая зависимость не приколола весь пул. Дай каждому вызову пер-попыточный таймаут, выведенный из дедлайна элемента, ретрай только идемпотентных отказов с ограниченным экспоненциальным backoff + jitter, и прекращай ретраи в момент отмены родительского context. Ловушка, которой надо избегать: наивные ретраи усиливают частичный сбой до полного — если твой downstream на 50% ошибок, а каждый клиент ретраит 3×, ты утроил нагрузку на то, что уже падает. Используй errgroup или структурный фан-аут, чтобы упавший сосед отменял остальных, а дедлайн был жёстким потолком, а не пожеланием.

    Критерии готовности
    • Каждый вызов downstream имеет пер-попыточный таймаут и ретраит только идемпотентные отказы с ограниченным backoff + jitter, прекращаясь при отмене context.
    • Тест отказа (внедри зависающий downstream) показывает, что вызов отваливается по таймауту и отменяет соседей, и ни одна горутина не залипает на мёртвой зависимости.
  5. 05Снабди телеметрией: структурные логи, pprof, метрики

    Сделай работающий сервис читаемым снаружи. Снимай структурные логи (log/slog) с request/trace id, который можно скоррелировать через фан-аут, выставь net/http/pprof на приватный порт для живых профилей горутин, кучи и CPU, и публикуй метрики: rate интейка, долю сброса (503), глубину очереди, утилизацию воркеров, error/latency downstream и — ту, что ловит утечку, — живое число горутин. Именно это делает этап инцидента диагностируемым, а не игрой в угадайку; плоская линия горутин на дашборде — твоё раннее предупреждение, что утечка, которую ты позже вызовешь, происходит.

    Критерии готовности
    • Логи структурные с коррелируемым id через фан-аут, а /debug/pprof отдаёт живой профиль горутин на приватном порту.
    • Дашборд показывает rate интейка, долю сброса, глубину очереди, латентность downstream и живое число горутин, привязанные к твоим SLO.
  6. 06Слей при завершении: доделай работу в полёте, не потеряй ничего

    Сделай завершение осознанным. На SIGTERM прекрати принимать новый интейк (server.Shutdown), затем закрой очередь и дай воркерам доделать элементы в полёте в пределах дедлайна слива, отменяя то, что не уложилось. Отказ, которого надо избегать, — это shutdown с потерей работы: процесс выходит в момент SIGTERM от оркестратора, бросая всё в очереди и в полёте. Корректный слив упорядочен — останови интейк, сигнализируй воркерам, подожди с ограниченным таймаутом, форс-отмени отстающих — и именно это делает выкатку или scale-down незаметным событием, а не всплеском потерянных запросов.

    Критерии готовности
    • На SIGTERM сервис прекращает интейк, сливает работу в полёте в пределах ограниченного дедлайна и выходит с кодом 0 без оставшихся горутин.
    • Тест, посылающий SIGTERM в середине нагрузки, показывает, что элементы в полёте завершаются (или чисто отменяются), а не тихо теряются.
  7. 07Упакуй компактно и задеплой с health + lifecycle

    Выкати как минимальный безопасный образ и задеплой с lifecycle-хуками, которые делают предыдущий этап осмысленным. Собери статический бинарь, положи в distroless или scratch базу (единицы МБ, без шелла, non-root, без зашитых секретов) и подключи health деплоя: liveness-проба, ловящая задедлоченный процесс, и readiness-проба, не пускающая трафик, пока пул не поднялся. Сделай terminationGracePeriod оркестратора длиннее твоего дедлайна слива, чтобы SIGTERM → слив → выход успевал до SIGKILL — иначе твой graceful shutdown декоративен.

    Критерии готовности
    • Образ — это non-root статический бинарь на минимальной базе (единицы МБ, без шелла, без зашитых секретов), и пуш собирает и деплоит его.
    • Liveness- и readiness-пробы подключены, а grace period превышает дедлайн слива, так что завершение успевает до SIGKILL.
  8. 08Переживи утечку горутин, затем напиши пост-мортем

    Вызови реальную утечку: вызов downstream, где горутина шлёт в небуферизованный канал, чей получатель сдался по таймауту, так что каждый отменённый запрос навсегда оставляет одну горутину висеть. Под нагрузкой линия живых горутин ползёт вверх, куча растёт вместе с ней, GC работает тяжелее, и в итоге сервис ловит OOM или дедлок. Обнаружь это по своей метрике горутин, сними pprof-профиль горутин, чтобы найти тысячи, припаркованных на одной отправке в канал, почини (дай отправителю выход — select по ctx.Done() или буферизованный слот) и подтверди, что линия выровнялась. Затем напиши пост-мортем: более длинный интервал GC или больший лимит памяти лишь покупают время; корневая причина — горутина без выхода при отмене.

    Критерии готовности
    • Ты воспроизвёл утечку под нагрузкой и зафиксировал растущее число горутин плюс pprof-профиль, указывающий на одну отправку в канал.
    • Ты починил её отменяемой отправкой (select по ctx.Done() или буферизованный слот) и показал, что линия горутин вернулась к плоской.
    • Твой пост-мортем называет триггер, радиус поражения, фикс и одну превенцию, которая не «поднять GOMEMLIMIT» и не «рестарт при OOM».
    Самопроверка

    Вставь корневую причину из пост-мортема и пункт превенции; senior-ревьюер проверяет, что назван механизм утечки (отправка без пути отмены), а не только симптом (рост памяти / OOM).

Рубрика

Джуниор Миддл Сеньор
Структура конкурентности и ограниченность горутин Работа диспетчеризуется через go func() на каждый запрос; число горутин растёт с трафиком, ничего его не ограничивает. Фиксированный пул воркеров разгребает буферизованный канал; число горутин — настраиваемая константа, доказанно плоская по pprof под нагрузкой. Размер пула выведен из реальной ёмкости downstream (а не GOMAXPROCS); каждая горутина владеет context, несёт дедлайн элемента и имеет явный путь выхода при отмене — число горутин на дашборде остаётся плоским при fault injection, отменяющем половину элементов в полёте.
Backpressure и распространение отмены context Очередь неограниченна или handler блокируется бесконечно; полный пул вызывает рост латентности без видимого предела. Полная очередь быстро возвращает 503 + Retry-After; вызовы downstream несут пер-попыточный таймаут и прекращают ретраи при отмене context. errgroup или структурный фан-аут распространяет отмену так, что упавший сосед немедленно освобождает слоты соседей; тест с зависающим downstream показывает, что ни одна горутина не залипает после дедлайна, а память остаётся плоской под нагрузкой, которая раньше роняла OOM.
Наблюдаемость и корректное завершение Логи неструктурированы; сервис завершается немедленно при SIGTERM, теряя работу в очереди и в полёте. Структурные логи несут коррелируемый request id; pprof и RED-метрики открыты; SIGTERM сливает работу в полёте в пределах ограниченного дедлайна до выхода. Метрика числа горутин привязана к SLO; корректный слив упорядочен (стоп интейка → сигнал воркерам → ограниченное ожидание → форс-отмена), а terminationGracePeriod превышает дедлайн слива, так что SIGKILL никогда не является путём завершения при нормальной работе.
Диагностика утечки горутин и глубина пост-мортема Пост-мортем описывает симптомы (память росла, сервис рестартовал), не называя механизм. pprof-профиль горутин указывает на конкретную отправку в канал; фикс применён и показано возвращение числа горутин к плоскому. Пост-мортем называет корневую причину (отправка без пути отмены в канал, чей получатель отвалился по таймауту), количественно оценивает радиус поражения (N горутин зависает на каждый отменённый запрос при P RPS за T минут = M утёкших горутин) и называет превенцию, которая не «поднять GOMEMLIMIT», — например, select по ctx.Done() в каждой отправке в канал.
Эталонный разбор (спойлер)

Почему ограниченный пул: Go-сервис, порождающий горутину на каждый входящий запрос, не имеет потолка. Размер пула должен исходить из реальной ёмкости downstream (по закону Литтла: конкурентность = пропускная способность × задержка), а не из GOMAXPROCS — эти два числа не связаны. Число горутин, растущее с трафиком, приведёт к OOM задолго до того, как планировщик сломается.

Распространение отмены — не опциональное: каждая горутина обязана иметь путь выхода при отмене context. Классическая утечка — отправка в небуферизованный канал, чей получатель уже истёк по таймауту: отправитель зависает навсегда. select{case ch <- v: case <-ctx.Done():} — идиоматичный фикс; errgroup автоматизирует это для фан-аута, чтобы упавший сосед отменял остальных.

Backpressure, а не буферизация: неограниченная очередь — не стратегия ёмкости, она прячет момент исчерпания ёмкости downstream за растущим хвостом памяти. Ограниченная очередь + неблокирующий enqueue, возвращающий 503 + Retry-After, делает лимит видимым клиентам и держит память плоской при насыщении.

Корректный слив должен быть упорядочен и пережить grace-период оркестратора: сначала стоп приёма, затем слив очереди с ограниченным таймаутом, затем форс-отмена отставших. Если terminationGracePeriod ≤ дедлайна слива, оркестратор посылает SIGKILL до завершения слива — каждая выкатка теряет работу в полёте и graceful shutdown декоративен.

Сделай по-сеньорски

  • Добавь адаптивную конкурентность: подбирай размер пула воркеров из наблюдаемой латентности downstream (AIMD или цель по закону Литтла), а не фиксированной константой.
  • Добавь circuit breaker на каждый downstream, чтобы устойчивый отказ размыкал цепь и быстро сбрасывал, а не ретраил в brownout.
  • Затюнь рантайм под нагрузкой: выставь GOMAXPROCS и GOMEMLIMIT под CPU/memory-лимиты контейнера и покажи эффект на GC и хвостовую латентность.
  • Сделай интейк durable: персисти принятую работу в очередь, чтобы краш в полёте реиграл, а не тихо терял, не добавляя латентности пути приёма.

Навыки

concurrency designworker pools and backpressurecontext and cancellationtimeouts and retriesgraceful shutdownstructured loggingpprof profilingcontainerizing Gogoroutine-leak diagnosispost-mortems

Рекомендуемый стек

Gonet/httpcontextlog/slognet/http/pprofa metrics client (Prometheus)a minimal container base (distroless or scratch)a container runtime