惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Engineering at Meta
Engineering at Meta
人人都是产品经理
人人都是产品经理
大猫的无限游戏
大猫的无限游戏
博客园 - 三生石上(FineUI控件)
量子位
腾讯CDC
The Cloudflare Blog
酷 壳 – CoolShell
酷 壳 – CoolShell
云风的 BLOG
云风的 BLOG
Vercel News
Vercel News
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
L
LangChain Blog
aimingoo的专栏
aimingoo的专栏
The Hacker News
The Hacker News
T
The Exploit Database - CXSecurity.com
B
Blog
S
SegmentFault 最新的问题
P
Privacy & Cybersecurity Law Blog
T
Threatpost
博客园 - 聂微东
T
Tailwind CSS Blog
The Last Watchdog
The Last Watchdog
C
Check Point Blog
N
Netflix TechBlog - Medium
D
DataBreaches.Net
爱范儿
爱范儿
IT之家
IT之家
S
Secure Thoughts
M
MIT News - Artificial intelligence
NISL@THU
NISL@THU
C
Cisco Blogs
TaoSecurity Blog
TaoSecurity Blog
有赞技术团队
有赞技术团队
A
Arctic Wolf
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
P
Proofpoint News Feed
Spread Privacy
Spread Privacy
Schneier on Security
Schneier on Security
Simon Willison's Weblog
Simon Willison's Weblog
G
GRAHAM CLULEY
雷峰网
雷峰网
Project Zero
Project Zero
博客园 - Franky
H
Heimdal Security Blog
A
About on SuperTechFans
Security Latest
Security Latest
Webroot Blog
Webroot Blog
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
Hugging Face - Blog
Hugging Face - Blog
H
Hackread – Cybersecurity News, Data Breaches, AI and More

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
Мой мониторинг аптайма сам нагенерил 932 фантомных падения
Ilya519 · 2026-06-26 · via Все публикации подряд на Хабре

10 мин

3

И нет, это не очередная история про утечку файловых дескрипторов. По крайней мере не классическая, когда дескрипторы текут по одному на каждый запрос. Был докеровский дефолт, который никто ни разу не тронул, и оптимизация, которая тихо размножилась в 60 раз.

2 июня, 07:28 UTC. У меня сервис мониторинга аптайма: тысяча с лишним чужих сайтов, которые он проверяет каждую минуту и шлёт владельцу алерт, если что-то упало. И вот в 07:28 он начинает рассылать алерты о том, что упало примерно всё. RustFS лежит. Мониторинг ИБП лежит. Telegram-боты лежат. Всё разом красное. За 25 минут система открыла 932 инцидента.

Сайты были живы. Все до единого.

Я был уверен, что поймал утечку дескрипторов. Оказалось, дело в одной ненастроенной строке конфига и в оптимизации, которая работала наизнанку. Самое обидное тут даже не ложная тревога. Обидно, что сервис, вся работа которого отличать «упало» от «не упало», сам устроил себе ложный обвал на весь дашборд — 932 несуществующих падения за 25 минут.

Сторож

Сторож

TL;DR

  • В 07:28 UTC бэкенд начал ловить OSError: [Errno 24] Too many open files — 4488 раз за 12 часов. Проверки посыпались на всех протоколах сразу, мониторы поехали в ложный DOWN.

  • Soft-лимит nofile в контейнере был 1024 (systemd-дефолт 1024:524288, унаследованный контейнером; в compose.yaml его не выставляли). Hard при этом 524288, поднимать было нечего.

  • Дескрипторы жрала не классическая утечка. 60 воркеров-корутин держали каждый свой httpx-пул, на лидере набегало под 120 пулов и 468 простаивающих keepalive-соединений, причём один и тот же адрес повторялся до 15 раз.

  • Чинил в два захода: сначала поднял лимит до 65536 и пересоздал контейнер, потом сделал один общий пул на процесс вместо 120. И повесил алерт на process_open_fds / process_max_fds, чтобы в следующий раз узнать заранее.

Что вообще за сервис

Без контекста дальше никак. Бэкенд на FastAPI 0.136, крутится под uvicorn 0.41 в Docker на одной VM в Yandex Cloud под Ubuntu 24.04. Внутри живёт фоновый цикл проверок: пул из 60 воркеров (worker_count = 60, дефолт в конфиге) разбирает задачи из Redis-очереди и долбит health-чеки по чужим хостам. HTTP, TCP, ICMP, DNS, SSL, всё подряд. На момент инцидента активных HTTPS-мониторов было 1187.

Важная деталь: это не 60 процессов и не 60 потоков, а 60 asyncio-корутин в одном event loop процесса-лидера. Каждая в цикле берёт задачу из Redis и await-ит health-check. Все их пулы соединений живут в одном адресном пространстве и делят один лимит дескрипторов.

Ещё один нюанс, который выстрелит позже. В проде два uvicorn-процесса, но 60-корутинный пул проверок запускается только на одном из них, на том, кто выиграл выборы лидера через Redis-лок. Второй процесс обслуживает только входящий API. Запомните это, пригодится.

Первая версия, и почему она была неправильной

Лезу в логи. Их заливает вот этим:

OSError: [Errno 24] Too many open files
ICMP check failed: [Errno 24] ... can't spawn subprocess
cannot open /etc/resolv.conf: Too many open files

Первая мысль очевидная: ICMP отвалился, DNS не резолвится, значит проблема в сети или в подсистеме пинга. Полчаса я смотрел не туда.

Потом дошло. ICMP, resolv.conf и subprocess тут жертвы. Когда у процесса кончились дескрипторы, он физически не может ни сокет открыть, ни файл /etc/resolv.conf прочитать, ни subprocess для пинга породить. Errno 24 был один на всех. Лог показывал последствия в порядке живой очереди, а корень был общий: в контейнере кончились file descriptors. Это и есть классический EMFILE, «too many open files». POSIX стандартизирует имя ошибки и её смысл (процессу не дают открыть ещё один дескриптор сверх его лимита RLIMIT_NOFILE), а номер errno 24 это уже деталь Linux-ABI. Кстати, не путать с ENFILE: тот прилетает, когда дескрипторы кончились во всём ядре (/proc/sys/fs/file-max), а у меня упёрся лимит одного процесса.

Где именно кончились

Дальше уже по приборам. Беру PID бэкенда, смотрю лимиты:

PID=$(docker inspect -f '{{.State.Pid}}' pinger-backend)
cat /proc/$PID/limits | grep 'open files'
# Max open files   1024   524288   files

Soft 1024, hard 524288. То есть потолок, в который я упёрся, поднимается одной строчкой, и места до настоящего предела вагон. Просто soft-лимит остался дефолтным, потому что секции ulimits в compose.yaml не было вообще, с самого начала, и никому не мешало.

Уточню сразу, пока не набежали в комментарии: 1024 — не вечный «дефолт Docker». Это пара 1024:524288 из systemd (DefaultLimitNOFILE), которую контейнер унаследовал. На Docker Engine 20.10–28 контейнер нередко получал и 1048576, а жёстко 1024 вернулось дефолтом только в Engine 29 (containerd 2.1.5). Число зависит от версии и хоста, так что проверьте именно своё.

Считаю дескрипторы у воркеров:

ls /proc/<worker-pid>/fd | wc -l

Старый процесс-лидер, проживший 11 часов, сидел ровно на 1024. Молодой, которому было 19 минут, на 52. Растёт со временем, упирается в стенку, начинает сыпаться. Разбивка по типам через ls -l /proc/<pid>/fd дала картину: 592 сокета (из них 41 в CLOSE_WAIT на :443 и :80, потому что апстрим закрыл соединение по своему keepalive-таймауту раньше моего 60-секундного, и httpx дочистит такой сокет лениво, при следующем обращении к пулу) плюс 407 штук /dev/null.

Сразу оговорюсь про эти 407 /dev/null, потому что глаз за них цепляется: это следствие уже наступившего EMFILE, а не причина. На здоровом процессе их три-четыре; распухают они только когда дескрипторы кончились и часть ресурсов не доинициализировалась как надо. Главный потребитель всё-таки сокеты.

И тут вторая ловушка. --limit-max-requests перезапускает весь uvicorn-процесс после N входящих HTTP-запросов. Но фоновый пул из 60 корутин живёт внутри процесса-лидера, а входящий трафик почти весь забирает второй процесс, и счётчик запросов на лидере почти не растёт. Значит процесс с фоновым циклом не перезапускается, и дескрипторы растут монотонно, без сброса, пока не упрутся в 1024.

Казалось бы, вот и ответ: подними лимит и живи. Поднял. Но мне не давал покоя вопрос, на который «подними лимит» не отвечает. А почему, собственно, легитимному процессу нужно под тысячу дескрипторов на ровном месте?

Настоящая причина: оптимизация, размноженная в 60 раз

Вот тут начинается интересное.

Каждый из 60 воркеров в конструкторе создавал свою собственную HealthCheckerFactory. Фабрика по задумке кэширует чекер на каждый протокол, чтобы переиспользовать соединения, нормальная оптимизация. Внутри HTTP-чекера живут два httpx-клиента (один проверяет TLS-сертификаты, второй нет), и у каждого свой пул: max_connections = 200, max_keepalive_connections = 50, keepalive_expiry = 60s.

Теперь арифметика. Фабрика-то на каждый воркер своя. Значит на процессе-лидере, где крутятся все 60 воркеров, живёт как минимум 60 × 2 = 120 независимых httpx-пулов: у каждого воркера свой HTTP-чекер с двумя клиентами, с проверкой TLS и без. Если воркер успел закэшировать и http, и https (это разные ключи в кэше фабрики), пулов на него вдвое больше. Оптимизация, которая должна была переиспользовать соединения, схлопнулась наизнанку: каждый из 60 воркеров независимо держит собственный keepalive к популярным хостам.

Схема

Схема

Считаем сокеты

Чтобы не гадать, замерил живьём:

Процесс

Веб-сокеты

Коннекты к Redis

Всего FD

Лидер (все 60 воркеров)

554

107

815

Второй (только входящий API)

1

6

89

Разница в 10 раз, и вся она в этих пулах. Уточню про числа, чтобы не путать: 815 — это снимок лидера в относительно спокойный момент, а та самая «ровно 1024» из прошлого раздела — снимок на пике, под нагрузкой плато подходило к лимиту вплотную, там и начинался EMFILE.

Дальше разложил установленные веб-соединения на лидере по состоянию через ss:

nsenter -t $PID -n ss -tan | awk '{print $1}' | sort | uniq -c

Из примерно 470 соединений в состоянии ESTAB активными были два. Два. Остальные 468 простаивали в keepalive. То есть 99.6% соединений просто висели и ничего не делали. При этом на 470 коннектов приходилось всего 244 различных IP: один и тот же адрес повторялся до 15 раз, потому что каждый воркер независимо держал собственное соединение к, скажем, github.com.

Плато под 800 дескрипторов оказалось не нагрузкой. Это 120 несшаренных пулов, надышавших гору одинаковых простаивающих keepalive-соединений. Реальная конкурентная работа в этот момент занимала два сокета.

Чтобы баланс сходился: дескрипторы лидера складываются из трёх кусков: около 554 веб-сокетов (из них 468 простаивающий keepalive), 107 коннектов к Redis и остаток на пайпы и /dev/null. Не пытайтесь свести одни keepalive со стенкой 1024, картина набирается из всех трёх.

Тот же веер тремя днями раньше

Кстати, это был не первый звоночек. 30 мая этот же 60-кратный веер высадил пул соединений к PostgreSQL: 60 воркеров разом лезли в базу и выбирали пул подчистую. Прикрыл семафором на 40 одновременных запросов (worker_db_max_concurrency = 40, инициализируется в core/lifespan.py). Болячку нашёл, диагноз поставил, но почему-то решил, что она только про базу. Что ровно тот же веер раздувает и httpx-пулы, и заодно упирается в nofile, мысль я не продлил. Классика: залатал симптом там, где он первый раз стрельнул, и не пошёл смотреть, где он стрельнёт следующим.

Как чинил

В две стадии. Хронология такая:

Время (UTC)

Что произошло

07:28

Первый [Errno 24], проверки посыпались на всех протоколах

07:28–07:53

Открыто 932 инцидента, массовый ложный DOWN

~07:54

up -d с новым лимитом, контейнер пересоздан, дескрипторы освобождены (шторм оборвался на 07:53)

~08:10

915 инцидентов авторезолвнулись по мере прохождения проверок, 17 настоящих остались открытыми

позже

Выкатил общий factory на процесс и алерт на расход FD

Немедленно поднял soft-лимит и пересоздал контейнер:

ulimits:
  nofile:
    soft: 65536
    hard: 65536

Важная деталь, на которой легко обжечься: docker compose restart ulimit-ы не применяет. ulimits это поле OCI runtime-спеки (process.rlimits), и runc выставляет его при СОЗДАНИИ контейнера, а не при старте. Нужен именно up -d с пересозданием, иначе вы рестартнёте процесс под тем же старым лимитом и будете гадать, почему ничего не поменялось.

Кстати, если трогать compose не хочется, есть второй путь: непривилегированный процесс может сам поднять soft-лимит вплоть до hard без root и без CAP_SYS_RESOURCE. Вызвать resource.setrlimit(RLIMIT_NOFILE, (hard, hard)) на старте приложения или ulimit -n в энтрипоинте. Hard у меня уже был 524288, так что запас был и без пересоздания.

По-нормальному убрал саму причину размножения. Теперь WorkerPool создаёт одну HealthCheckerFactory на процесс, поднимает её один раз и прокидывает в каждый воркер параметром. Воркер закрывает фабрику только если создал её сам (флаг _owns_checker_factory, чтобы автономный и тестовый пути не сломались). 120 пулов схлопнулись в одну пару на процесс.

По прикидке 244 хоста, упёртые в max_keepalive_connections = 50, дают вместо 468 простаивающих соединений где-то 50–100, в 5 раз меньше. Сразу честно: это расчётная оценка, а не замер. Общий factory выкатывался отдельно, и графика FD строго после него у меня под рукой нет, поэтому привожу цифру как прогноз, а не как факт. Сверху повесил регрессионный тест, чтобы кто-нибудь снова не развёл фабрику по воркерам.

Что с ложными инцидентами

Схема

Схема

За окно 07:28–07:53 система открыла 932 инцидента. Когда дескрипторы освободились, 915 из них (это 98%) авторезолвнулись сами: чек снова прошёл, инцидент закрылся. Осталось 17 открытых, примерно 2% от шторма, и все 17 оказались настоящими, на реально лежавших мониторах. Стейт-машина инцидентов отработала массовый ложный флип ровно как задумано, руками разгребать переоткрытые инциденты не пришлось.

Схема

Схема

Дальше смешное. Алертов за всё окно ушло около 19. Девятнадцать, на 932 инцидента. Точную раскладку по причинам я не сводил, но главный фактор очевиден: доставка алертов сама сидела на тех же дохлых дескрипторах и физически не могла отправить большую часть. Сверху, вероятно, наложились кулдаун и circuit breaker. То есть пользователей от шторма ложных уведомлений спасла, по сути, та же поломка: побочный эффект отказа, а не моя предусмотрительность.

Тут важно не запутаться в числах, иначе кажется, что они не сходятся. Авторезолв закрыл инциденты как состояние, но не стёр их из истории клиента: в таймлайне монитора остался фантомный провал. Поэтому отдельно, уже по факту, я вычистил из истории 790 закрытых записей с однозначной EMFILE-сигнатурой (too many open files, errno 24, cannot open /etc/resolv.conf) одним транзакционным DELETE. Это подмножество тех 915 авторезолвнутых, а не что-то сверх 932. Оставшиеся в окне ~125 трогать не стал: там вперемешку таймауты и connection failed, которые при тогдашнем базовом фоне в 62 инцидента в час вполне могли быть настоящими. Плюс 17 по-прежнему открытых — тоже настоящие. Лучше недочистить, чем затереть реальное падение из истории клиента.

Что из этого стоит унести с собой

Первое и самое прозаичное. Soft-лимит nofile, который контейнер наследует, зависит от того, как настроены демон и хост, и в моей инсталляции это были дефолтные 1024, цифра из той эпохи, когда тысяча сокетов была экзотикой: она тянется от FD_SETSIZE = 1024 в glibc, select() физически не следит за дескрипторами с номером ≥ 1024, и man select(2) прямо зовёт это «unreasonably low limit for many modern applications». Для любого Python-сервиса, который держит пулы соединений под высокой конкурентностью, это мина. Проблема даже не в аппетите. Лимит низкий, а расход растёт тихо. Проверьте свой прод прямо сейчас, одной командой:

cat /proc/$(docker inspect -f '{{.State.Pid}}' <container>)/limits \
  | grep 'open files'

Если там 1024 и у вас внутри httpx, aiohttp, asyncpg или любой другой пул, вы просто ещё не дорастили нагрузку до стенки.

Второе, куда более коварное. Connection pooling молча умножается, если инстанцировать его на каждый воркер. Один пул на процесс экономит сокеты. 60 пулов на процесс их жгут, и при этом каждый формально «переиспользует соединения». Метрика «соединение переиспользовано» у вас зелёная, а сокетов в системе в 60 раз больше, чем нужно. Прежде чем масштабировать воркеры, посмотрите глазами, что именно у них общее, а что приватное.

Третье. Чините класс бага. У меня тот же 60-кратный веер за три дня до этого положил пул к базе, я прикрыл базу и на том успокоился. httpx-пулы и nofile остались с той же болячкой и подождали своей очереди 72 часа. Когда чините ресурсную проблему, спросите себя, где ещё в системе живёт тот же паттерн.

Четвёртое. Кто мониторит мониторинг. У сервиса, который меряет чужой аптайм, не было алерта на собственный расход дескрипторов. Теперь есть: Prometheus отдаёт process_open_fds и process_max_fds, отношение первого ко второму выше 0.7 — повод проснуться.

Нюанс: под --workers в multiprocess-режиме prometheus_client эти дефолтные метрики не агрегирует, так что пришлось завести свой gauge, который раз в 15 секунд пишет len(/proc/self/fd) и getrlimit(RLIMIT_NOFILE). Порог взят 0.7, а не 0.9, потому что рост у меня монотонный и медленный, на часы, а не спайковый: на 0.7 ещё остаётся запас в несколько часов до стенки, этого хватает на ручную реакцию. Для спайковых нагрузок порог имеет смысл опускать. Дешёвая метрика, которая превратила бы тот инцидент из «25 минут шторма и ручной разбор» в «тикет за неделю до».

Почему это так легко повторить

Самое неприятное даже не баг, а то, насколько он типовой. Секции ulimits в большинстве примеров compose.yaml просто нет — у автора примера до стенки не дорастало, и проблема копируется вместе с конфигом. А worker_count крутят как ручку громкости, и каждый щелчок молча умножает то, что внутри воркера выглядело невинной оптимизацией. Число воркеров касается не только CPU. Это ещё и про то, сколько копий ваших пулов, клиентов и буферов вы только что создали.

Отдельно отрезвляет вот что: сторожевой пёс не уследил за собственной миской. Мы привыкли читать зелёный дашборд как факт, но мониторинг — такой же сервис с пулами, лимитами и слепыми зонами, как всё, за чем он следит. Что это значит для любого, кто держит мониторинг: метрики собственного процесса важны ровно настолько же, насколько метрики того, что он проверяет. Наблюдателю тоже нужен наблюдатель.

Сторож

Сторож

Весь этот разбор был на моём проде, pingzen.dev, уптайм-мониторинг. Никакой морали про «у нас бы такого не случилось» не будет: как видите, случилось, я об этом и пишу.

Вопрос к залу

У кого ловили EMFILE из-за дефолтных лимитов контейнера, а не из-за настоящей утечки? И чем мониторите расход дескрипторов и сокетов в проде, кроме как «упало, пошли смотреть»? Поделитесь в комментариях: на каком отношении open_fds / nofile будили бы вы дежурного? У вас 0.7 — это много или мало?