Пишем терабайты видео на диск в Go и не даем ОС сожрать всю память

от автора

Привет, хабарчане!

Это вторая статья про разработку RUSEON-core, Zero-Copy сервера потокового видео для AI-платформ и Edge-видеоинфраструктуры. В первой статье, я рассказал об в принципе причине по которой решили создать свой сервер. Ну и о главной проблеме большинства подобных разработок – «грохочущее стадо» и как получилось выжать 8 Гбит/c на одном ядре. В ней я кстати забыл упомянуть, что помимо простой трансляции производится еще и запись потоков в формате fMP4. Хранится N времени локально, и может улетать (тут как клиенты хотят, зависит от того как долго хранить записи) в S3-хранилище.

Эта статья как раз об одной неочевидной (ну как минимум для меня, возможно для кого-то это вполне рядовая проблема), связанной с хранением данных и ее спецификой во всех ОС. Ну что же приступим.

Выкатили мы первый релиз в прод (100 камер), обрадовали клиентов, начали работать. Прошел где-то час, полетели алерты. Залезаю на сервак, смотрю htop – а там свободно 100 метров ОЗУ. Пу-пу-пу-пу. Нужно внести уточнение что боевой сервер имел 32 гига озу. Ожидаемое поведение было то что процессор отдыхает, сетевуха переваривает трафик, озу 250-300 МБ, диски нагружены не сильно. Так что, когда видишь такие цифры в htop, начинаешь винить себя и свои кривые руки, написавшие это «Г». Но все же решили пойти в Гугл, чатгпт и тому подобное. Благо, ответ нашелся быстро, и посыпать голову пеплом перестали.

Код оказался не вообще не причем, память сожрал сам Линукс. Если когда-нибудь вы писали тонны данных на диск, я думаю вы уже поняли в чем дело. Есть такой «невидимый враг» как страничный кэш (Page Cache). Вот именно он и был корнем этой проблемы.

Как работает страничный кеш и что с ним делать?

Когда ваша функция, которая должна писать данные на диск пишет данные, на самом деле она не пишет их на диск. Она пишет их в ОЗУ. Логика ядра Линукса проста и банальна, и она направленна на ускорение «отзывчивости» системы, всю суть можно объяснить так: «О, только что записали сотню гигабайт данных, наверное, скоро понадобится эти данные прочитать. Оставлю-ка я их в кеше, пользователь будет рад что так быстро смог их прочитать.» И так гигабайт за гигабайтом, пока в сервере не кончится физическая память.

Типичное решение проблемы – пишем скрипт, который раз в час делает echo 3 > /proc/sys/vm/drop_caches. Ну а кто-то просто забивает и позволяет системе убивать случайные процессы через OOM Killer. Но мы же пишем отказоустойчивую штуку. Нам такое не подходит. Задача объяснить ядру ОС, что наши fMP4 сегменты видеоархива — это write-only мусор на небольшое количество времени (т.к если клиент не хочет долго хранить записи, то архив чистится, а если хочет – мы отправляем архив после N времени хранения в S3, а локальную копию тоже чистим). Так что все должно работать по принципу – записал и забыл.

Как приручить ядро Линукса через GO (правильно)

В C/C++ для этого есть системный вызов posix_fadvise. Ты можешь прямо сказать операционке, как именно будешь работать с файлом. В Go из коробки этого нет. Но есть очень хороший пакет golang.org/x/sys/unix который позволяет легко заменить системный вызов. Флаг называется FADV_DONTNEED. Мы буквально говорим ядру: «Мы записали, сбрось на диск и нахрен убери из кеша к едрене фене».

Но тут есть один жестокий нюанс, я сам на нем споткнулся, и долго ломал голову (а стоило всего лишь покурить доку, но тут, как и со сборкой мебели «зачем мне инструкция, я сам все умею»). Ядро Linux не убирает страницы из кэша, если они так называемые грязные (dirty) —это значит, что они еще физически не записаны на блин/банку диска. Если просто дернуть Fadvise, ничего не произойдет.

Сначала нужно сделать жесткий Sync().

// Файл: pkg/storage/localfs/file_linux.gopackage localfsimport ("os""golang.org/x/sys/unix")type FileWrapper struct {*os.File}func (fw *FileWrapper) DropCache() error {// Сначала сбрасываем грязные страницы на диск!if err := fw.File.Sync(); err != nil {return err}// И только потом приказываем ядру их забытьreturn unix.Fadvise(int(fw.File.Fd()), 0, 0, unix.FADV_DONTNEED)}

Как не сломать сборку под Windows

Системные вызовы — это почти всегда большая проблема кроссплатформы. На Винде флага FADV_DONTNEED просто нет (мелкомягкие, привет! Все ли у вас хорошо?). Если не разделить код на разные ОСи, то компилятор просто пошлет куда подальше.

Поэтому был реализован довольно таки элегантный (приглашаю в комменты, насчет этого утверждения) интерфейс. В ядре рекордера теперь есть проверка на то что, умеет ли файловый дескриптор сбрасывать кэш:

// ОПТИМИЗАЦИЯ: Спасаем оперативку от Page Cacheif dropper, ok := file.(registry.CacheDropper); ok {    _ = dropper.DropCache()}

А дальше магия билд тегов Go (спасибо за них). В файле file_linux.go (с тегом //go:build linux) мы теребим unix.Fadvise. А рядом лежит файл file_others.go (с тегом //go:build !linux), где метод DropCache() тупо делает обычный Sync() и возвращает управление. Код кристально чистый, линтеры довольны, сборка работает везде.

Что получилось в итоге то?

Выкатываем фиксы, запускаем те же самые 100 камер.

Открываю дашборд. График потребления памяти выглядит как почти идеальная прямая. Сервер отожрал свои законные 250 метров под кучу Go процесса — и всё. Ура, победа! Больше нет никакого огромного роста Page Cache. Никакие процессы не выгоняются в своп. Сервер честно пишет десятки гигабайтов за час, ОЗУ в покое.

К слову, когда вы делаете бекапы БД, парсите гигантские логи или просто пишите тяжелые файлы — эта фича сэкономит гору невров. Я так же не понимаю, почему про этот флаг почти не говорят и нигде не пишут. Обычно только пишут, как правильно аллоцировать слайсы, а про то что на уровне файловых операций, ваша ОС может свести на ноль все ваши старания — тишина.

Вообщем в опенсорсной части ruseon-core эта логика теперь зашита глубоко в движок записи. Вывод и совет, который хочу дать – не стоит, доверять ядру память бездумно, в надежде на то что ОС условна «идеальна и максимально продуманна». Иногда нужно бить ядро по рукам. Иначе столкнетесь с подобными проблемами, как и я.

Исходники, как всегда, лежат на GitHub: https://github.com/RUSEGAL/ruseon-core

ссылка на оригинал статьи https://habr.com/ru/articles/1068166/