ОС · Виртуализация памяти · 17 мин
Память в проде: mmap, page cache, OOM, контейнеры
Зачем эта глава
Все предыдущие главы блока были про механику: как процессор и ОС превращают виртуальные адреса в физические (трансляция, страницы, TLB, многоуровневые таблицы), как работает иллюзия приватной памяти (адресное пространство) и что происходит, когда RAM кончилась (свопинг и page fault).
Теперь соберём всё это в одну картинку — такую, какую вы реально увидите на проде. Это глава-синтез. После неё вы должны понимать четыре вещи, об которые спотыкается каждый бэкендер:
- mmap — почему файл можно «читать как массив в памяти» и откуда там магия.
- page cache — почему сервис «тормозит на холодную и летает на горячую».
- RSS vs VIRT — почему
topпоказывает 12 ГБ виртуальной памяти у процесса, который ест 200 МБ, и это нормально. - OOM killer и cgroup-лимиты — почему ваш контейнер «внезапно умер» без
паники Go, без стектрейса, просто
exit code 137.
Всё это — прямое следствие того, что память виртуальная и ленивая. Поехали.
mmap: отобразить файл прямо в адресное пространство
Обычный способ прочитать файл — системный вызов read: вы просите ядро
«дай мне 4 КБ из файла», ядро копирует эти байты с диска (или из кэша) в ваш
буфер. Хотите следующие 4 КБ — снова read. Каждый вызов — переход в ядро и
копирование.
mmap (memory map) делает иначе. Вы говорите ядру: «возьми этот файл и
сделай вид, что он лежит у меня в памяти начиная с такого-то адреса». Ядро
заводит в вашем адресном пространстве новую область
(VMA — virtual memory area) и говорит: «вот тебе диапазон виртуальных адресов,
он соответствует файлу». После этого вы читаете файл просто разыменовывая
указатель — как обычный байтовый массив. Никаких read.
Аналогия: вместо того чтобы каждый раз бегать к кладовщику за коробкой (read),
вы один раз получаете карту склада. Теперь вы знаете адрес любой коробки и
можете «дотянуться» до неё сами. Но коробку на склад всё равно ещё надо
привезти — и вот тут начинается ленивость.
read(): процесс ──"дай 4КБ"──► ЯДРО ──копирует──► буфер процесса
(системный вызов на каждое чтение, копия данных)
mmap(): один раз настроили отображение, дальше — просто чтение памяти
процесс ──*(ptr + offset)──► (адрес уже "указывает" в файл)Ленивая загрузка через page fault
Когда вы вызвали mmap, ничего с диска ещё не прочиталось. Ядро лишь
записало в таблицу страниц, что этот диапазон адресов
«существует, но не загружен» — страницы помечены как невалидные (present-бит = 0),
но за ними закреплён источник (этот файл, такое-то смещение).
Первый раз, когда ваш код обращается к адресу внутри отображения, MMU видит
невалидную страницу и стреляет page fault — это в точности тот же механизм,
что мы разбирали в главе про свопинг. Обработчик page fault
в ядре смотрит: «а, это mmap-область, нужная страница лежит в файле по смещению
X» — читает с диска ровно одну страницу (4 КБ), кладёт её в физический фрейм,
чинит запись в таблице страниц, и ваш mov повторяется как ни в чём не бывало.
ОТОБРАЖЕНИЕ ФАЙЛА ЧЕРЕЗ mmap (lazy load)
Виртуальное АП процесса Физическая RAM Диск (файл)
┌────────────────────┐ ┌─────────────┐
│ ... │ │ стр.0 ░░░░░ │
│ mmap-регион: │ │ стр.1 ▓▓▓▓▓ │
│ vaddr+0 ──────┐ │ page fault #1 ┌──────────┐ │ стр.2 ▒▒▒▒▒ │
│ vaddr+4К ──┐ └──┼──────────────────►│ фрейм 7 │◄────┤ (читается │
│ vaddr+8К │ │ подгрузка │ ▓▓▓▓▓ │ │ по факту │
│ ... ▼ │ └──────────┘ │ обращения) │
│ (ещё не тронуто) │ эти страницы пока НЕ в RAM │ │
└────────────────────┘ └─────────────┘
▲
└ обращение к ещё не загруженной странице → page fault → подкачкаОтсюда два следствия. Первое: mmap гигантского файла «бесплатен» по времени —
вы не платите за чтение того, что не трогали. Второе: первое обращение к каждой
новой странице — медленное (это поход на диск). Поэтому при mmap случайный доступ
к большому холодному файлу может выглядеть как «непонятные подвисания» — это
page fault'ы.
Анонимная память
mmap умеет отображать не только файл, но и анонимную память — то есть
просто чистые страницы, не связанные ни с каким файлом. Именно так аллокаторы
(включая то, что под капотом у Go-рантайма и у malloc в C) берут крупные куски
памяти у ядра. Анонимные страницы тоже ленивые: ядро обещает диапазон, но
физический фрейм выдаёт только при первом обращении (часто — копией заранее
заготовленной нулевой страницы, zero page). Подробнее про границу куча/стек и про
то, как растёт куча, — в главе API памяти.
| Вид mmap | Что отображает | Чем «подкачивается» при faultе |
|---|---|---|
| File-backed | конкретный файл на диске | страница читается из этого файла |
| Anonymous | ничего (чистая память) | выдаётся нулевой фрейм / из свопа |
Page cache: бесплатное чтение после прогрева
Теперь главное про производительность бэкенда. Когда ОС читает что-то с диска
(через read или через mmap-fault), она не выбрасывает прочитанные страницы
после того, как отдала их вам. Она держит их в свободной RAM — это и есть
page cache. В следующий раз, когда кто угодно (вы, другой процесс, та же БД)
попросит те же байты файла, ядро отдаст их из памяти, не трогая диск.
Аналогия: библиотека, где недавно заказанные книги не уносят обратно в дальнее хранилище, а оставляют на полке у стойки. Первый читатель ждёт, пока книгу принесут из подвала (диск, миллисекунды). Все следующие берут её с полки мгновенно (RAM, наносекунды).
КТО МЕЖДУ ПРОЦЕССАМИ И ДИСКОМ
Процесс A Процесс B Процесс C (БД)
│ │ │
└────────┬───────┴──────────┬───────┘
▼ ▼
┌─────────────────────────────────────┐
│ PAGE CACHE │ ← в "свободной" RAM
│ [файл1:стр3][файл2:стр0][файл1:стр4]│ общий для всех
└─────────────────────────────────────┘
│ промах кэша (cache miss)
▼
┌─────────────────────────────────────┐
│ ДИСК (SSD/HDD) │ ← дорого, миллисекунды
└─────────────────────────────────────┘Это объясняет кучу «магии» на проде:
- Cold start (холодный старт). Только что поднятый под / переехавший на новую ноду сервис первые запросы обслуживает медленно: page cache пуст, индексы и файлы БД читаются с диска. Через минуту-другую «горячие» данные уже в кэше — и latency падает в разы. Это не «JIT прогрелся», это чаще всего именно page cache.
- БД «быстрая после прогрева». PostgreSQL имеет свой буферный пул, но поверх него работает ещё и page cache ОС. Повторное чтение тех же блоков идёт из RAM.
freeпоказывает мало «свободной» памяти — и это хорошо. Linux забивает всю незанятую RAM под page cache, потому что простаивающая память бесполезна. Эти страницы мгновенно отдаются приложению, как только оно попросит (кэш чистых страниц можно просто выкинуть). Поэтому смотреть надо наavailable, а не наfree.
ЧТЕНИЕ ОДНОГО И ТОГО ЖЕ ФАЙЛА ДВАЖДЫ
1-е чтение (cold): read → промах → ДИСК (~ миллисекунды) → отдали + ОСЕЛО в cache
2-е чтение (warm): read → ПОПАЛИ в page cache (~ наносекунды) → отдали
└ диск вообще не трогалиСвязь со свопингом: page cache и свопинг — две стороны управления физической памятью. Под давлением памяти ядро освобождает чистые страницы page cache (их не надо никуда записывать — они уже на диске) и при необходимости вытесняет анонимные/грязные страницы в своп или на диск.
VIRT vs RSS: почему top пугает большими числами
Откройте top или htop — у каждого процесса две колонки про память, и они
обычно сильно различаются.
- VIRT (виртуальный размер) — сколько виртуального адресного пространства процесс себе зарезервировал: вся куча, все стеки потоков, все mmap-регионы, все разделяемые библиотеки. Это обещания, а не реальная RAM. Можно «застолбить» 10 ГБ виртуального адреса и не тронуть из него ни байта — физическая память при этом не расходуется (вспомните ленивость mmap и анонимной памяти).
- RSS (Resident Set Size) — сколько физических страниц процесса реально лежит в RAM прямо сейчас. Вот это и есть «сколько процесс на самом деле ест».
Почему VIRT бывает огромным. Go-рантайм, JVM, аллокаторы любят зарезервировать большой непрерывный виртуальный диапазон заранее (так проще управлять кучей) — но реально коммитят (трогают) лишь малую часть. mmap большого файла тоже раздувает VIRT, хотя в RSS попадут только реально прочитанные страницы.
VIRT — это карта обещаний, RSS — что реально занято в RAM
Виртуальное АП (VIRT = сумма всех областей)
┌───────┬──────────────┬───────────────────────────┬────────┐
│ код │ куча (резерв) │ mmap файла 8 ГБ │ стек │
│ ▓▓▓ │ ▓▓░░░░░░░░░░░ │ ░░░░▓░░░░░▓░░░░░░░░░░░░░░░ │ ▓░░░ │
└───────┴──────────────┴───────────────────────────┴────────┘
▓ = страница реально в RAM (входит в RSS)
░ = обещано, но НЕ тронуто (в RSS НЕ входит, RAM не занимает)
VIRT = вся ширина прямоугольника (например, 12 ГБ)
RSS = сумма только ▓ (например, 200 МБ)Практический вывод: на алерты и лимиты смотрите по RSS (и по working set в cgroup), а не по VIRT. Большой VIRT сам по себе не проблема. А вот один нюанс: у разделяемых библиотек и shared-памяти физические страницы общие между процессами, поэтому простая сумма RSS по всем процессам завысит реальное потребление (одни и те же страницы посчитаются много раз). Для «честной» цифры есть PSS (proportional set size).
| Метрика | Что измеряет | Тратит ли RAM | На что смотреть |
|---|---|---|---|
| VIRT | зарезервированное виртуальное АП (обещания) | нет | почти никогда |
| RSS | физ. страницы процесса в RAM сейчас | да | потребление процесса |
| PSS | RSS, но shared-страницы поделены на едоков | да | сумма по системе честно |
| cgroup usage | RSS + page cache, отнесённые к группе | да | лимиты контейнера, OOM |
OOM killer: когда память кончилась
Что происходит, когда физической памяти и свопа больше нет, а кто-то ещё просит?
Сначала ядро отчаянно освобождает page cache и вытесняет страницы
(свопинг). Если и это не помогает — включается OOM killer
(Out Of Memory killer): ядро выбирает «жертву» и убивает её сигналом SIGKILL
(номер 9). Процесс умирает мгновенно, без шанса что-то сделать напоследок —
никаких defer, никакого graceful shutdown.
Кого убивать? Ядро считает каждому процессу oom_score — грубо «сколько RAM
освободится, если его прибить» (то есть в первую очередь под нож идут самые
жирные по памяти). На счёт можно влиять через oom_score_adj (от -1000
«не трогать» до +1000 «бери первым»).
ПАМЯТЬ КОНЧАЕТСЯ — ПОРЯДОК ДЕЙСТВИЙ ЯДРА
запрос памяти
│
▼
есть свободные фреймы? ──да──► выдать
│ нет
▼
выкинуть чистый page cache ──помогло?──► выдать
│ нет
▼
вытеснить страницы в своп ──помогло?──► выдать
│ нет / свопа нет
▼
┌──────────────── OOM KILLER ────────────────┐
│ выбрать процесс с макс. oom_score │
│ → SIGKILL (9). exit code 128+9 = 137 │
└─────────────────────────────────────────────┘Запомните магическое число: exit code 137. Это 128 + 9 — процесс убит
сигналом 9. Видите 137 — почти наверняка OOM. В Go это особенно коварно: рантайм
не получает управления, так что вы не увидите ни паники, ни runtime: out of memory, ни стека горутин — просто процесс исчезает.
Контейнеры: cgroup-лимит и «падение по памяти»
Самая частая боль на проде в 2020-х. Внутри контейнера процесс видит всю
память хоста. Команда free, runtime.NumCPU, чтение /proc/meminfo — всё
показывает ресурсы физической ноды, например 256 ГБ. Процесс наивно думает:
«места вагон». Но контейнер ограничен cgroup memory limit — допустим, 512 МБ.
cgroup (control group) — это механизм ядра, который группирует процессы и считает их ресурсы (память, CPU и т.д.) с жёстким лимитом. Память контейнера = RSS его процессов плюс относящийся к нему page cache. Как только сумма превышает лимит cgroup — срабатывает cgroup OOM killer внутри этой группы, и убивает процесс в контейнере. Хост при этом совершенно здоров, RAM на ноде полно.
КОНТЕЙНЕР ДУМАЕТ ОДНО, CGROUP РЕШАЕТ ДРУГОЕ
Физическая нода: 256 ГБ RAM
┌──────────────────────────────────────────────────────────┐
│ │
│ ┌─ cgroup контейнера, limit = 512 МБ ────────────────┐ │
│ │ процесс видит free → "256 ГБ свободно!" (врёт) │ │
│ │ ┌────────────────────────────────────────────┐ │ │
│ │ │ RSS процесса + page cache группы │ │ │
│ │ │ [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░] → 512 МБ │ │ │
│ │ └────────────────────────────────────────────┘ │ │
│ │ достигли лимита ──► cgroup OOM ──► SIGKILL(137) │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ на хосте при этом ещё ~255 ГБ свободно — но не важно! │
└──────────────────────────────────────────────────────────┘Практические последствия и грабли:
- «Контейнер падает по памяти, а на ноде памяти полно». Это не баг ноды.
Это лимит cgroup. Kubernetes покажет причину как
OOMKilledи exit code 137. - Go-рантайм исторически не знал про cgroup-лимит. GC ориентировался на
GOGCотносительно текущей кучи и не понимал, что «потолок» близко. Лечится установкойGOMEMLIMIT(мягкий лимит памяти рантайма, появился в Go 1.19) — ставьте его чуть ниже cgroup-лимита, тогда GC будет работать агрессивнее под давлением и не доведёт до OOM. АналогичноGOMAXPROCSстоит выравнивать с CPU-лимитом cgroup. - page cache контейнера тоже считается в лимит. Сервис, активно читающий большие файлы, может упереться в лимит за счёт кэша файлов, даже если сам объектами в куче ест мало. Ядро под давлением сначала пожмёт этот кэш, но если данные нужны постоянно — лимит станет реальным потолком.
- requests vs limits в k8s.
requests— сколько гарантированно резервируется под под (для шедулинга),limits— жёсткий потолок, после которого OOM. Превышение memory limit = смерть, в отличие от CPU limit, где процесс просто троттлят.
Связь с адресным пространством: иллюзия «приватной бесконечной памяти», которую так бережно строит ОС, в контейнере получает второй слой — иллюзию «я один на всей ноде». Обе иллюзии полезны, но обе протекают ровно в момент, когда вы упёрлись в реальный физический потолок (в первом случае — page fault и своп, во втором — cgroup OOM).
Что реально происходит в момент вызова mmap для большого файла?
Сервис обслуживает первые запросы после деплоя медленно, а через минуту — быстро. Самая частая причина на бэкенде?
В top у процесса VIRT = 12 ГБ, RSS = 200 МБ. Что верно?
Контейнер в Kubernetes завершился с exit code 137 при свободной памяти на ноде. Что произошло?
Чему равен exit code процесса, убитого сигналом SIGKILL (9)?
Как правильно подружить Go-сервис с memory-лимитом контейнера?
Что спрашивают на собесе
- Чем
mmapотличается отreadи как там работает ленивая загрузка? Ответ через page fault: mmap лишь настраивает отображение, страницы подгружаются по первому обращению тем же механизмом, что и свопинг. - Почему сервис медленный сразу после деплоя и быстрый через минуту? Холодный page cache: данные читаются с диска, потом оседают в RAM, повторные чтения бесплатны. Назвать cold start именно через page cache, а не только GC/JIT.
topпоказывает VIRT 12 ГБ, RSS 200 МБ — это утечка? Нет. VIRT — это зарезервированное виртуальное АП (обещания, в т.ч. mmap и резерв кучи рантайма), RAM тратит только RSS. Утечку ищут по росту RSS/working set во времени.- Контейнер упал с exit code 137, на ноде памяти полно. Что произошло? cgroup memory limit + OOM killer внутри группы. 137 = 128+9 (SIGKILL). Хост здоров, лимит контейнера превышен.
- Как подружить Go-сервис с memory-лимитом контейнера?
GOMEMLIMITчуть ниже cgroup-лимита, чтобы GC работал агрессивнее у потолка;GOMAXPROCSпод CPU-лимит. Объяснить, почемуfree/NumCPUвнутри контейнера врут. - Что делает ядро по шагам, когда память кончается? Освобождает чистый page cache → вытесняет страницы в своп → если не помогло, OOM killer выбирает жертву по oom_score (самый жирный) и шлёт SIGKILL.