OOM Killer — защитный механизм ядра Linux, призванный решать проблемы с нехваткой памяти. При исчерпании доступной памяти он принудительно «убивает» наиболее подходящий по приоритетам процесс, отправляя ему сигнал KILL. Сообщение об этом отображается в /var/log/syslog (Debian/Ubuntu) или /var/log/messages (Centos/Rhel). Иногда OOM Killer может затрагивать важные процессы, нарушая работу проекта. Как исправить это, узнали у Сергея Юдина, инженера Southbridge. Ниже подробный кейс с примерами кода.
Проблема
К нам пришёл клиент с проблемой нехватки памяти на сервере, где много разных сервисов: Nginx, PHP-FPM, Redis и MongoDB.
PHP-FPM, обслуживая запросы, плодил дочерние процессы — столько, сколько ему разрешалось, т.е. был лимитирован. Лимиты на количество дочерних процессов рассчитывались из того, что скрипты php используют определенный размер оперативной памяти. Это возможная ошибка при настройке PHP-FPM, так как разработчики постоянно требуют всё больше и больше оперативной памяти.
В один прекрасный момент прилетела довольно высокая нагрузка, и память оказалась исчерпана. А дальше OOM Killer, как это часто бывает, нашёл самый «плохой», по его мнению, процесс — MongoDB. И убил его.
Запрос клиента — сделать так, чтобы OOM Killer не убивал MongoDB.
Что сделали сначала
Самый «простой» способ решить проблему — посчитать количество нужной оперативной памяти для всех служб и процессов на сервере и, определив нужный размер для PHP-FPM, выставить ему лимиты:
pm.max_children если у вас pm = static
Но так как скрипты достаточно часто меняются, такой расчёт в ближайшем будущем станет ошибочным и приведёт к повторной аварии.
Мы установили приоритет для процесса MongoDB, используя параметры systemd unit:
OOMScoreAdjust=-1000
Прошла неделя и появилась новая проблема — в момент увеличения нагрузки OOM Killer убил Redis для высвобождения оперативной памяти. Мы сообщили об этом клиенту, и он попросил для Redis тоже сделать OOMScoreAdjust=-1000.
Мы всё установили, но спустя ещё неделю пришло уведомление, что произошла авария, и сервер недоступен. Попытались разобраться, в чём причина, но подключиться к серверу не удалось. Других вариантов не было, и мы попросили клиента перезагрузить сервер. После перезагрузки посмотрели по логам и обнаружили, что из-за нехватки оперативной памяти OOM Killer начал искать претендента для убийства. MongoDB, которая занимала 50% оперативной памяти, убить нельзя, потому что она не входит в приоритет. Redis тоже. В итоге OOM Killer начал убивать дочерние процессы PHP FPM, причём выглядело это так: OOM Killer убивал один процесс, и PHP FPM тут же порождал ещё два. Операционная система не могла с этим справиться.
Как правило, в документациях многих приложений рекомендуют отключить swap, что и было сделано на этом проекте. Возможно, если бы swap был, то подключиться к серверу получилось без перезагрузки. Но о swap чуть позже.
Мы предлагали ограничить количество дочерних процессов для PHP-FPM ещё до того, как лимитировали MongoDB. Но клиент не хотел делать это по двум причинам:
Увеличение времени ожидания. Ограничение дочерних процессов PHP FPM решило бы проблему нехватки оперативной памяти, но тогда пришло бы слишком много запросов. Разберём на примере: пришло 200 запросов, а у нас всего 100 процессов, которые могут обрабатывать данные. Первые запросы обработаются быстро, а следующие будут в очереди, из-за чего timeout возрастёт.
Непостоянное количество оперативной памяти, которую могут запрашивать скрипты. Один и тот же скрипт сегодня может попросить 200 Мб, завтра — 500 Мб. А после того, как разработчик внесет в него какие-то изменения, — и 1000 Мб. Этот лимит достаточно сложно вычислить, когда у тебя много процессов.
Как решали проблему дальше
Мы пришли к тому, что все процессы, которые есть на сервере, нужно ограничивать физически. Распределили оперативную память на все службы, оговорили, кто и сколько должен поедать, и для каждой службы установили два лимита. Ещё подключили swap, при этом запретив MongoDB и Redis использовать его.
Почему приняли решение устанавливать два вида лимитов: MongoDB не может жёстко устанавливать количество оперативной памяти, используемой для своей службы. Только устанавливать лимиты для cash size — максимальный размер внутреннего кэша. Поэтому мы установили лимит для движка WiredTider cash size в 50 Гб. И дополнительно установили жёсткий лимит через systemd unit в 80 Гб. Если MongoDB превысит 80 Гб, придёт OOM Killer и точно её убьет.
wiredTiger: engineConfig: cacheSizeGB: 50
Где споткнулись
Выставление лимита storage.wiredTiger.engineConfig.cacheSizeGB не обязывает MongoDB не использовать свободную оперативную память и кэш файловой системы.
Вопрос, как Linux работает с памятью, достаточно сложный. Если интересно, мы можем раскрыть его в следующей статье:) А пока оговоримся примерно так: все данные, которые процессы желают записать на диски, в Linux сначала записываются в оперативную память, а уже потом записываются на диски.
Почему прошлый раз упал сервер:
По всем показателям MongoDB использовала то количество оперативной памяти, которое мы выдали. И по нашим расчётам должно было остаться ещё около 40 Гб оперативной памяти, но по факту их не было — память была закэширована. Сначала MongoDB читала и писала в оперативную память, потом скидывала всё на диск. Но когда произошла авария, MongoDB не успела всё сбросить на диск из своего кэша. И не всегда память, которая используется в Linux для записи на диск, высвобождается моментально — это происходит по мере высвобождения ресурса.
Был отключен swap. Если бы swap работал, система бы очень долго откликалась, но всё же была доступна. Мы смогли бы подключиться в ssh и оперативно что-то сделать. Но для этого нужно было сделать так, чтобы MongoDB не использовала swap.
Мы поняли, что для MongoDB, Redis и PHP-FPM нужно установить «не использовать swap». Чтобы это сделать нужно установить определенное количество оперативной памяти для данных служб в cgroups. Мы задали это в systemd unit для MongoDB, Redis и PHP-FPM. В версиях systemd имеются различия установки тех или иных параметров, мы сделали так:
Мы установили лимит в 20 Гб оперативной памяти для PHP-FPM и настроили systemd.unit так, что если процессы, порожденные PHP-FPM, занимают больше 20 Гб памяти, то PHP-FPM и все его дочерние процессы будут убиты, а после перезапущены. В течение нескольких месяцев PHP-FPM убивался и стартовал. Мы мониторили через Zabbix, когда происходят аварии, почему и сколько секунд недоступен какой-то сайт, делали выводы и сообщали клиенту. Спустя время лимиты для сервисов остались прежними, но позволили понять, в какой момент, что и где идёт не так. Мониторинг уведомлял нас, мы сообщали клиенту, клиент шёл и разбирался, в чём проблема.
Через несколько месяцев проблема была решена — скрипты, которые, пожирали много памяти были устранены и оптимизированы клиентом.
Немного рассуждений напоследок: всё это «костыли»
Всякий раз, когда мы ограничивали MongoDB или Redis, мы говорили клиенту, что так делать не стоит. Нужно искать причину и решать основную проблему, а не лечить последствия.
То, что мы сделали, — это не best practices. Это просто кейс, основанный на работе с определенным клиентом. Теоретически всё можно было бы сделать по-другому. В этом и плюс, и минус Linux — у него много вариантов решения одной проблемы. Как понять, какой из них наиболее правильный? Мне кажется, тут всё субъективно: оцениваешь, исходя из текущих знаний и опыта. На момент решения кейса, у меня было меньше опыта. Сейчас я бы, наверное, поступил по-другому.