
Всем привет, меня зовут Миша, и я бэкенд‑разработчик в платформе Яндекс Еды. Я уже рассказывал, как мы анализировали наш PHP‑монолит и вынесли из него процессинг заказов, и с тех пор роль этого легаси заметно уменьшилась. Заодно туда стали писать гораздо меньше нового кода, релизы стали реже, и он спокойненько себе работал, не привлекая лишнего внимания.
Так оно бы и продолжалось, но тут случилась повышенная нагрузка и необходимость зарезервировать побольше мощностей для беспроблемной обработки повышенного спроса. Монолит справился на отлично, но самое интересное случилось потом: возвращая выделение ресурсов к прежним значениям, я случайно обратил внимание, что RPS в пиковые вечерние часы как‑то подозрительно совпадает с количеством ядер CPU, выделенных на весь монолит.
Количество выделенных ядер, конечно, ещё ничего не означает, поэтому я полез смотреть реальное потребление процессорного времени, сложив CPU usage по всем подам. С помощью нехитрой арифметики я обнаружил, что 100% загрузки одного ядра приходятся на 2,5 RPS. Какое‑то время я находился в состоянии глубокого изумления, после чего решил, что это никуда не годится, и отправился в увлекательное приключение на 20 минут.
Немного спойлеров: дело оказалось далеко не только в PHP.
Глава 1. Перфорируем PHP

Любая оптимизация начинается с профилирования. И тут меня поджидали две новости: одна хорошая, другая не очень.
Хорошая новость состояла в том, что у нас есть Perforator, который установлен почти на все хост‑машины в дата‑центрах, и с его помощью можно посмотреть, на что уходит процессорное время на подах.
Плохая новость в том, что с его точки зрения вся работа PHP выглядит большей частью как вызов zend_execute_ex.
Тем не менее среди исходного кода инструмента я нашёл директорию с заветным наименованием PHP и связался с коллегами. А у них, как оказалось, был эпик с заманчивым названием «Поддержка PHP в Perforator» в многообещающем статусе in progress.
Выяснилось, что поддержка PHP практически готова, но её ещё не вмержили в основную ветку. Однако была возможность запустить версию Perforator с поддержкой PHP локально, на разработческой Linux‑виртуалке, и даже раскатать такую специально собранную версию на специально выбранной хост‑машине.
Разумеется, я начал с первого варианта, но тут‑то и начались первые сложности. Выглядели они следующим образом:
14:24:17.914 ERROR profiler.BPF machine/bpf.go:406 Kernel verifier rejected the program {"line": 38269, "log": "processed 1000001 insns (limit 1000000) max_states_per_insn 30 total_states 49026 peak_states 3742 mark_read 324"} 14:24:17.914 ERROR profiler profiler/profiler.go:308 Failed to initialize profiler {"error": "failed to initialize eBPF subsystem: failed to setup programs: field PerforatorPerfEvent: program perforator_perf_event: load program: argument list too long: BPF program is too large. Processed 1000001 insn (38269 line(s) omitted)"} failed to initialize profiler: failed to initialize eBPF subsystem: failed to setup programs: field PerforatorPerfEvent: program perforator_perf_event: load program: argument list too long: BPF program is too large. Processed 1000001 insn (38269 line(s) omitted)
Что это вообще означает? Дело в том, что для минимального оверхеда профилировщик разматывает стек целиком в ядре с помощью eBPF‑программы — подробнее об этом можно прочитать в статье про сам Perforator. Ядро же, в свою очередь, накладывает довольно жёсткие ограничения на исполняемые там программы. По сути, они сводятся к проверке двух вещей:
программа правильно обращается с памятью;
программа не зависает.
Оба этих пункта проверяются при помощи статического анализатора, который просто проходится по программе, пытаясь вычислить инварианты для всех возможных состояний.
В выводе видно, что максимальное количество состояний на одну инструкцию — 30. Всего их почти 50 тысяч, и одновременно в памяти верификатора было почти 4 тысячи. И главное: в процессе проверки верификатор уже выполнил миллион инструкций и больше проверять отказывается.
Миллион инструкций здесь — не длина всей программы, но общее количество шагов верификатора: грубо говоря, это произведение числа инструкций на среднее количество состояний на инструкцию.
Один из основных источников работы для верификатора и одновременно умножитель состояний — это циклы. В ядре 5.3 появилась поддержка bounded loops, которая позволяла верификатору «понять» во многих простых случаях, что цикл точно не будет бесконечным, а происходящее внутри будет безопасно на любой итерации.
Однако, во‑первых, eBPF компилируется для специальной виртуальной машины с ограниченным набором регистров (оно потом JIT'ится в ядре, но проверяется именно байт‑код), а во‑вторых, используемые в этой программе функции заинлайнены для снижения накладных расходов выполнения. Из‑за этого даже один неудачный цикл в любой части программы способен сделать код неверифицируемым. Более того, из‑за инлайнинга изменения в одной части программы способны повлиять на раскладку переменных по регистрам в другой её части. Особых способов повлиять на это нет, кроме как переписывать вообще всё прямо на BPF‑ассемблере.
Как это всё можно выяснить? Для начала можно получить вывод верификатора:
sudo bpftool prog load unwinder.release.php.elf /dev/null type perf_event
Он выглядит как‑то так (это самый конец):
from 2885 to 2887: frame3: R0=inv(id=0,smax_value=9223372032559808512,umax_value=18446744069414584320,var_off=(0x0; 0xffffffff00000000),s32_min_value=0,s32_max_value=0,u32_max_value=0) R1=inv(id=0) R2=inv(id=0,umax_value=4294967295,var_off=(0x0; 0xffffffff)) R3=inv(id=0,umin_value=1,umax_value=1024,var_off=(0x0; 0x7ff)) R6=inv(id=44552) R7=inv(id=36583) R8=map_value(id=0,off=3384,ks=4,vs=18440,imm=0) R9=inv44 R10=fp0 fp-8=mmmmmmmm fp-16=mmmmmmmm fp-24=mmmmmmmm fp-32=0??????? fp-40=map_value fp-48=map_value fp-56=map_value fp-64=map_value fp-72=map_value fp-80=map_value fp-88=map_value fp-96=map_value fp-104=inv ; length &= INTERPRETER_SYMBOL_STRING_LENGTH_VERIFIER_MASK; 2887: (57) r2 &= 1023 2888: (7b) *(u64 *)(r10 -8) = r2 ; err = bpf_probe_read_user_str(buf, length, (void*) py_object + config->offsets.py_string_object_offsets.data); 2889: (79) r1 = *(u64 *)(r10 -48) BPF program is too large. Processed 1000001 insn
Что это вообще означает? Строки, которые начинаются с цифр, — это номера команд и, собственно, сами команды BPF‑байт‑кода. Комментарии, которые начинаются с точки с запятой, — это строки исходной программы. Самое интересное — инварианты, которые просчитал BPF‑верификатор. Именно их анализ поможет понять, почему количество проходов верификатора так велико.
И заодно мы сразу получим саму программу целиком в BPF‑кодах:
llvm-objdump -d -S --no-show-raw-insn unwinder.release.php.elf
Флаг S показывает исходные строки на C, а ‑l покажет ещё и соответствующие номера строк в исходном файле.
Для начала найдём, какие строчки повторяются в выводе верификатора чаще всего. Уже не помню, как я сделал это в первый раз, но напишем такой однострочник:
grep -Eo '^[0-9]+:' bpf-verifier-before-1st-fix.txt | sort | uniq -c | sort -r | head
Получаем, что, прежде чем верификатор вылетит, инструкции в диапазоне 2507–2921 анализируются 44 или 45 раз — поскольку верификатор останавливается на инструкции 2889, последняя итерация не завершается.
Упрощаем жизнь верификатору
Здесь я обнаружил, что номера инструкций из верификатора не совсем совпадают со строками из дизассемблированного листинга. Это легко нивелируется сопоставлением самих инструкций и исходных строк кода, и виновник попался — это 44 итерации проверки цикла размотки Python‑стека. На 45-ю уже не хватает лимита проверки инструкций.
Сам цикл выглядит так:
static ALWAYS_INLINE void python_walk_stack( void* py_frame, struct python_state* state ) { if (state == NULL) { return; } for (int i = 0; i < PYTHON_MAX_STACK_DEPTH; i++) { if (py_frame == NULL) { break; } enum python_frame_owner owner = FRAME_OWNED_BY_THREAD; if (!python_read_frame_owner(&owner, py_frame, &state->config)) { break; } if (owner == FRAME_OWNED_BY_CSTACK) { // stub frame in case python is called from C code. // 2 consecutive frames must not be owned by C stack. BPF_TRACE("python: frame owned by c stack"); state->frames[i].symbol_key.linestart = PYTHON_CFRAME_LINENO_ID; state->frames[i].symbol_key.pid = 0; state->frames[i].symbol_key.object_addr = 0; state->frame_count = i + 1; goto move_to_next_frame; } if (!python_process_frame(&state->frames[i], py_frame, state)) { break; } state->frame_count = i + 1; BPF_TRACE("python: Successfully processed frame %d", i); move_to_next_frame: py_frame = python_read_previous_frame(py_frame, &state->config); } BPF_TRACE("python: Collected %d frames", state->frame_count); }
Основной объём инструкций приходится на функцию python_process_frame, которая, собственно, разбирает один фрейм Python‑стека и складывает результат в массив state→frames. При этом если выключить PHP‑часть, то программа укладывается в лимит (пусть и впритык), выводя в конце:
processed 945915 insns (limit 1000000) max_states_per_insn 29 total_states 45288 peak_states 3588 mark_read 324
* Полный вывод лога верификатора занял 1,5 ГБ, и в нём было около 11 млн строк.
Здесь я предположил наугад: если процессинг каждого конкретного стек‑фрейма не будет завязан на переменную i, то верификатору будет проще проверить программу. Таким образом:
for (int i = 0; i < PYTHON_MAX_STACK_DEPTH; i++) { // ... process_frame(&state->frames[i], ...); state->frame_count = i + 1; // ...
превратилось в:
state->frame_count = 0; for (int i = 0; i < PYTHON_MAX_STACK_DEPTH; i++) { u32 cur_frame = state->frame_count; if (cur_frame >= PYTHON_MAX_STACK_DEPTH) break; // ... process_frame(&state->frames[cur_frame], ...); ++state->frame_count; // ... }
То есть как будто вместо одного счётчика цикла появилось два независимых.
И этот фикс помог: проверка всех 128 размотанных итераций цикла и в PHP, и в Python заставила верификатор сделать всего лишь 774 721 шаг проверки. Конечно, в идеале это стоило бы сделать с помощью BPF iterators, но, увы, их поддержка появилась только в ядре 5.18.
Чиним загрузку отладочного вывода
Ещё одно забавное изменение починило загрузку программы с отладочным выводом. Суть проблемы состояла в том, что вызов макроса PHP_TRACE, который раскрывался через несколько макроподстановок в:
static const char __fmt[] = "[perforator] " __FILE__ ":" Q(__LINE__) " " fmt; bpf_trace_printk(__fmt, sizeof(__fmt), ##__VA_ARGS__);
занимал для адреса строки __fmt один из регистров и выталкивал сохранённое в нём значение на стек.
По неудачному стечению обстоятельств именно в этом регистре хранился счётчик цикла вместе с верхней и нижней границами, а для значений на стеке эти инварианты уже не отслеживались. В итоге с точки зрения верификатора цикл становился бесконечным.
Исправление было тривиальным: переместить отладочную печать чуть ниже по коду.
Выслеживаем и выгоняем поедателя процессора
После всех этих исправлений коллеги из команды Perforator залили на одну из хост‑машин, где крутился инстанс PHP‑монолита, специально собранную версию с поддержкой PHP. И я сразу же увидел страшного пожирателя CPU.
Чтобы строить общий граф связей между сервисами, нам нужна полноценная поддержка семантических конвенций OpenTelemetry. Для этого в PHP‑монолит добавили поддержку атрибута http.route следующим образом:
private function getRoutePattern(?Request $request): ?string { $result = $request?->getPathInfo(); if($result == null) { return null; } if ($routeName = $request?->attributes->get('_route')) { // Получаем паттерн роута из коллекции роутов $route = $this->router->getRouteCollection()->get($routeName); if ($route) { $result = $route->getPath(); } } return strtolower($result); }
Код работает прекрасно, поэтому сначала никто не заметил важный нюанс: getRouteCollection не возвращает готовый закешированный список роутов, а каждый раз собирает его заново — проходит по всем YAML‑файлам и читает аннотации в PHP‑файлах.
Такое поведение логично, если вспомнить, что в рантайме Symfony нужны не сами роуты, а уже готовый механизм роутинга — то есть определение, какому обработчику соответствует HTTP path. При сборке контейнера Symfony компилирует роуты в регулярные выражения для матчинга. Из‑за этого исходные строки вроде /api/v1/{order_nr}/info фактически теряются.
После того как прожорливый до памяти виновник был найден, исправление оказалось элементарным: роуты один раз читались и сохранялись на этапе сборки контейнера, а в рантайме брались из кеша.
После исправления потребление CPU в 99-м процентиле снизилось почти на 30%. Заодно улучшились и тайминги: особенно на низких процентилях, где они сократились почти вдвое. Причина высокого потребления была в том, что в конце каждого запроса заново сканировались роуты и это добавляло примерно 100 мс к каждому запросу.


Глава 2. Инфраструктурные полтергейсты

На стороне PHP каких‑то очевидных тормозов больше не было. Но я обратил внимание, что теперь топ потребления CPU выглядит так:
PHP: 20%
HAProxy: 19%
psql: 17%
Причём внутри psql мы видим следующий call stack:

Оказалось, что в Debian psql по историческим причинам запускается через Perl‑обёртку. Она выбирает нужную версию клиента, потому что в системе может быть установлено сразу несколько версий PostgreSQL — такая возможность появилась больше двадцати лет назад.
Но оставался главный вопрос: откуда взялось столько вызовов?
Дело в HAProxy. Опять же по историческим причинам монолит на PHP не использует Postgres‑специфичные connection string, чтобы подключаться к primary‑ноде БД, — это решение вынесено на уровень HAProxy. Он, в свою очередь, тоже не умеет этого делать нативно, может только проверять TCP connectivity. Так что для проверки были написаны специальные скрипты, которые и вызывали psql. Выглядело это примерно так:
backend pgaas_master_rw mode tcp option tcp-check option external-check external-check command /opt/bin/check_pg_master.sh server pgaas database-host:5432 check inter 1s rise 2 fall 3 on-marked-down shutdown-sessions
Проверка запускалась раз в секунду. Нод было около 20: несколько баз, у каждой минимум по три ноды. В итоге psql вызывался очень часто, и один только запуск Perl‑обёртки начал заметно потреблять ресурсы.
Проблему решили довольно просто — через agent‑check в HAProxy. Идея такая: HAProxy передаёт проверку состояния бэкенда отдельному агенту, который умеет делать то, чего сам HAProxy не умеет. В нашем случае агентом стал простой Python‑скрипт. Он подключается ко всем PostgreSQL‑хостам из списка и постоянно выполняет SELECT pg_is_recovery(). Параллельно скрипт слушает запросы от HAProxy на порте 54321: HAProxy присылает нужный хост, а скрипт отвечает up или down.
Строки с описанием бэкенда стали длиннее, но зато работа стала быстрее и стабильнее:
server pgaas database-host:5432 agent-check agent-addr 127.0.0.1 agent-port 54321 agent-send "database-host\n" agent-inter 1s check inter 1s rise 2 fall 3 on-marked-down shutdown-sessions
После выкатки этого изменения на тестинге Perl пропал в профилях, но при этом HAProxy продолжал расходовать довольно много CPU. Посмотрим повнимательнее на флеймграф CPU‑нагрузки:

Видим, что большую часть времени занимает epoll_wait. Для wall‑time‑профиля это выглядело бы нормально: HAProxy действительно большую часть времени ждёт событий. Но здесь мы смотрим именно CPU profile, поэтому интересно не само ожидание, а частые пробуждения и окружающий код.
Особенно выделяется функция с интересным названием load_balance. ИИ‑модель подсказала, что, скорее всего, HAProxy держит слишком много воркеров, поэтому они постоянно вытесняли друг друга. Причина оказалась простой: по умолчанию HAProxy запускает $(nproc) потоков, но в контейнере это число соответствует количеству ядер хоста, а не числу реально выделенных ядер. Нужное количество CPU уже передавалось через переменные окружения, а haproxy.cfg у нас шаблонизируется. Поэтому исправление получилось совсем небольшим.
Буквально накануне публикации аналогичная проблема нашлась в сайдкаре, помогающим управлять рейт‑лимитером на сервисах. Правда, количество тредов было фиксированным, и этот эффект проявлялся только в тех случаях, когда на один под было выделено совсем мало ядер.
Эффект от облегчения этой обвязки оказался очень заметным: абсолютные затраты процессорного времени на балансировку коннектов к БД упали в 40 раз (!), а общее потребление процессорного времени упало приблизительно вдвое.

После этого этапа стало ясно, что держать столько экземпляров приложения нет необходимости. А чтобы понять, сколько их действительно нужно, мы устроили учения.
Глава 3. Кеш наносит ответный удар

Мы регулярно проводим учения, где имитируем недоступность одного дата‑центра. Теми же инструментами можно отключать не весь дата‑центр, а только отдельный сервис. Так часть инстансов сервиса становится недоступной, нагрузка перераспределяется на оставшиеся, и мы можем проверить, сколько ресурсов реально нужно для стабильной работы приложения.
В процессе наблюдения за состоянием сервиса мы заметили, что некоторые поды время от времени начинали заметно подтормаживать: время ответа в 99-м процентиле вырастало до совершенно неприличных 20–30 секунд! Однако этот эпизод длился недолго и проходил сам по себе. Ситуация хоть и не сильно влияла на общую картину — большая часть запросов всё равно обрабатывалась достаточно быстро, — но требовала изменений.
Довольно быстро нашлась метрика, с которой строго коррелировали тормоза, — размер APCu‑кеша. Поскольку PHP создан, чтобы умирать, то данные кешируются не внутри каждого PHP‑FPM‑воркера, а в общей памяти APCu, доступной всем PHP‑FPM‑воркерам одного инстанса.

Предположение о механике проблемы было очевидным: какие‑то механизмы приводят к росту объёма данных в кеше пропорционально RPS.
Довольно быстро виновник был найден. Им оказался Doctrine ORM, а точнее — кеш DQL‑запросов. Дело в том, что для более чистого абстрагирования программист может писать запросы не на конкретном диалекте SQL под определённую базу, а на специальном Doctrine‑диалекте (DQL), который довольно просто транслируется в нативные SQL‑запросы. Эта абстракция, кстати, оказалась очень полезной, когда мы отделяли некоторые кусочки БД‑монолита на MySQL в отдельные небольшие базы на PostgreSQL.
Тем не менее здесь сошлось несколько факторов:
Во‑первых, для этого кеша не был настроен TTL, из‑за чего даже те запросы которые выполнялись редко, навсегда оставались в кеше.
Во‑вторых, нашёлся код, который генерировал много уникальных запросов: вместо того чтобы создавать запрос с параметрами, в него инлайнились конкретные идентификаторы пользователей. Из‑за этого каждый такой запрос отдельно компилировался и тоже оседал в кеше.
Проблема казалась легко решаемой: после добавления TTL для кеша он перестал переполняться, и пилообразные скачки прекратились.

С чувством выполненного долга мы решили, что проблем больше не будет и общие учения с имитацией отключения одного из ДЦ мы переживём без проблем. Но не тут‑то было.
В какой‑то момент мы снова заметили те же симптомы: инстансы начинали тормозить пару минут, а потом всё возвращалось на круги своя. В моменте это решилось развёртыванием дополнительных инстансов, но параллельно мы искали какие‑то новые зацепки.
И такая зацепка нашлась! Кеш одной из динамических конфигураций целиком хранился в APCu: в коде обращаются к нему уж очень часто, и он пишет метрику записи и чтения.

Видно, что до исправления время записи зависело от заполненности кеша: чем сильнее он был забит, тем медленнее шла запись. Но зависимость была не совсем линейной. Сначала рост кеша почти не влиял на тайминги, а потом запись резко начинала замедляться — и становилась всё медленнее до полного заполнения кеша.
Эти графики помогли нам сформулировать несколько дополнительных гипотез. Например, мы предположили, что не хватает количества бакетов в хеш‑таблице APCu: оно статично и задаётся в конфигурации, значение по умолчанию пропорционально размеру выделенной памяти. Но, увы, увеличение их числа ничего не дало.
Следующей гипотезой была высокая фрагментация памяти. К сожалению, эти метрики тогда не снимались (хотя в APCu они в принципе есть), поэтому утверждать на 100%, что проблема была в этом, я не берусь. Но в целом наиболее вероятная реконструкция выглядит следующим образом:
Пока мы планомерно забивали APCu‑кеш неиспользуемыми записями от Doctrine, это маскировало проблему с фрагментацией. Сброс кеша происходил из‑за отсутствия там свободного места нужного размера.
Когда данные DQL‑кеша начали удаляться, в памяти стало больше «дырок». Из‑за этого APCu тратил всё больше времени на поиск свободных мест под новые записи. Под небольшой нагрузкой это не проявлялось — сборщик мусора успевал освобождать достаточно памяти. А чем выше была нагрузка, тем сильнее росла фрагментация, за ней росли тайминги, и в какой‑то момент кеш просто сбрасывался.
Починить получилось дёшево и сердито: увеличили выделенную под APCu память в четыре раза. Конечно, на деле используется не больше четверти — остальное лежит запасом, чтобы фрагментация не превращалась в тормоза. После этого проблема не повторялась уже несколько месяцев.
Глава 4. Динамическая балансировка в мире умирающих процессов

С кешем разобрались — и упёрлись в следующее. Всем инстансам выделено одинаковое количество vCPU, а вот хост‑ноды под ними разной производительности, и даже с учётом коэффициента нормализации производительность между хостами различается. Один и тот же RPS давал на них разный CPU usage. Из‑за этого p99 деградировал раньше, чем мы упирались в реальный потолок кластера: трафик приезжал на слабый инстанс, тот уже захлёбывался, а у мощных ещё оставался запас.
Для фреймворка userver у нас есть внутренняя библиотека, которая решает такую проблему. Она регулярно измеряет CPU usage и RPS, сглаживает значения через экспоненциально взвешенное скользящее среднее, а затем считает, сколько RPS в среднем приходится на одно ядро. Это значение передаётся балансировщику как вес. В результате более мощные инстансы получают больше трафика, а более слабые — меньше. При этом общий RPS до упора в CPU становится выше. Когда мы тестировали эту библиотеку на одном из сервисов, точка деградации сдвинулась примерно на 15% по RPS.
Реализация основывается на том, что сервис на userver — это единый бинарник, который может и сам себе RPS посчитать, и отдельной задачей периодически считывать значения rusage. Проблема в том, что ничего из этого на PHP недоступно. Тем не менее, используя всё тот же APCu в качестве персистентной памяти, можно сэмулировать то же поведение. Разберу по пунктам.
Счётчик RPS. По сути, библиотеке нужен средний RPS за последнюю минуту. Для этого достаточно знать, сколько запросов было за последние 60 секунд. Мы храним эти данные в APCu как атомарные счётчики: ключ — таймстамп, значение — количество запросов за эту секунду. TTL поставили с запасом — 120 секунд, вдвое больше окна. Чтобы посчитать RPS за последнюю минуту, читаем из APCu последние 60 значений и суммируем. Данные лежат в общей памяти: ни сети, ни диска — на горячем пути это стоит примерно ничего.
Расчёт rusage. userver живёт в одном процессе, а PHP‑FPM — это набор отдельных воркеров, которые ещё и перезапускаются. А перезапустился — значит, счётчики процесса начались с нуля. Поэтому CPU приходится считать по каждому воркеру отдельно.
getrusage() отдаёт накопленное процессорное время процесса, так что текущая загрузка — это разница между двумя измерениями. Каждый воркер складывает свой rusage в APCu под своим pid, а при расчёте мы собираем значения всех воркеров и суммируем. Поверх — сглаживание по EWMA, экспоненциальному скользящему среднему: без него цифра скачет от замера к замеру.
Обычная формула , где
— новое значение,
— сглаженное значение,
— вес нового значения) здесь неприменима. PHP обновляет данные неравномерно: воркер может долго не получать запросы или, наоборот, часто вызываться. Поэтому
корректируем по времени:
где — целевой период сглаживания,
— время с прошлого обновления.
Все расчёты выполняются только на ping‑запросах от балансировщика — именно в ответе на них мы отдаём вес. Поэтому APCu не перегружается чтением и обновлением данных, а пользовательские эндпоинты почти не затрагиваются: они только обновляют один счётчик RPS.

Выводы

И всё это прошло практически без инцидентов. Был только период повышенных таймингов из‑за проблем с APCu, но он не привёл к негативным последствиям благодаря корректным ретраям со стороны вызывающих сервисов.
Почему вообще такая заметная оптимизация оказалась возможной? Вынос процессинга из монолита существенно снизил внимание к нему, и по факту никто больше не следил за ним достаточно пристально: «работает — не трогай».
Отсюда вывод: вынести критичный код из монолита в микросервисы — только половина работы. Вторая половина — не забыть про оставшиеся куски системы, которые продолжают есть ресурсы.
Делитесь в комментариях, какие самые нелепые «пожиратели ресурсов» вы находили в старых проектах!

