Пишем терабайты видео на диск в Go и не даем ОС сожрать всю память
- воскресенье, 9 августа 2026 г. в 00:00:40
Привет, хабарчане!
Это вторая статья про разработку 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, а локальную копию тоже чистим). Так что все должно работать по принципу – записал и забыл.
В C/C++ для этого есть системный вызов posix_fadvise. Ты можешь прямо сказать операционке, как именно будешь работать с файлом. В Go из коробки этого нет. Но есть очень хороший пакет golang.org/x/sys/unix который позволяет легко заменить системный вызов. Флаг называется FADV_DONTNEED. Мы буквально говорим ядру: "Мы записали, сбрось на диск и нахрен убери из кеша к едрене фене".
Но тут есть один жестокий нюанс, я сам на нем споткнулся, и долго ломал голову (а стоило всего лишь покурить доку, но тут, как и со сборкой мебели «зачем мне инструкция, я сам все умею»). Ядро Linux не убирает страницы из кэша, если они так называемые грязные (dirty) —это значит, что они еще физически не записаны на блин/банку диска. Если просто дернуть Fadvise, ничего не произойдет.
Сначала нужно сделать жесткий Sync().
// Файл: pkg/storage/localfs/file_linux.go package localfs import ( "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) }
Системные вызовы — это почти всегда большая проблема кроссплатформы. На Винде флага FADV_DONTNEED просто нет (мелкомягкие, привет! Все ли у вас хорошо?). Если не разделить код на разные ОСи, то компилятор просто пошлет куда подальше.
Поэтому был реализован довольно таки элегантный (приглашаю в комменты, насчет этого утверждения) интерфейс. В ядре рекордера теперь есть проверка на то что, умеет ли файловый дескриптор сбрасывать кэш:
// ОПТИМИЗАЦИЯ: Спасаем оперативку от Page Cache if 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