Виртуализация памяти · 16 мин
Контент v0.12.0Память в проде: 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, ничего с диска ещё не прочиталось. Ядро только
завело в своём списке областей процесса (VMA) запись: «адреса от X до Y — это файл
такой-то, начиная с такого-то смещения». В таблице страниц для
этого диапазона пока нет ни одной действующей записи.
Первый раз, когда ваш код обращается к адресу внутри отображения, MMU не находит трансляции, и процессор вызывает page fault — тот же механизм, что в главе за пределами физической памяти. Обработчик в ядре находит VMA и видит: «это отображение файла, нужная страница лежит в файле по смещению X». Если страница уже в page cache (о нём ниже), ядро просто заводит PTE на неё (minor fault). Если нет, читает её с диска (major fault), причём обычно не одну, а с запасом вперёд (readahead): последовательное чтение на этом сильно выигрывает. Затем ядро заполняет PTE, и прерванная инструкция повторяется как ни в чём не бывало.
ОТОБРАЖЕНИЕ ФАЙЛА ЧЕРЕЗ mmap (lazy load)
Виртуальное АП процесса Физическая RAM Диск (файл)
┌────────────────────┐ ┌─────────────┐
│ ... │ │ стр.0 ░░░░░ │
│ mmap-регион: │ │ стр.1 ▓▓▓▓▓ │
│ vaddr+0 ──────┐ │ page fault #1 ┌──────────┐ │ стр.2 ▒▒▒▒▒ │
│ vaddr+4К ──┐ └──┼──────────────────►│ кадр 7 │◄────┤ (читается │
│ vaddr+8К │ │ подгрузка │ ▓▓▓▓▓ │ │ по факту │
│ ... ▼ │ └──────────┘ │ обращения) │
│ (ещё не тронуто) │ эти страницы пока НЕ в RAM │ │
└────────────────────┘ └─────────────┘
▲
└ обращение к ещё не загруженной странице → page fault → подкачкаОтсюда два следствия. Первое: mmap гигантского файла почти бесплатен по
времени, вы не платите за чтение того, что не трогали. Второе: первое обращение к
каждой новой странице может быть медленным (поход на диск). Поэтому при mmap
случайный доступ к большому холодному файлу выглядит как «непонятные подвисания»
в обычном коде, где нет ни одного системного вызова. Это major page faults.
В Go mmap доступен через пакет syscall (на Linux и macOS):
package main
import (
"fmt"
"os"
"syscall"
)
func main() {
f, err := os.Open("/etc/hosts")
if err != nil {
panic(err)
}
defer f.Close()
st, err := f.Stat()
if err != nil {
panic(err)
}
// Отобразить весь файл в адресное пространство только для чтения.
data, err := syscall.Mmap(int(f.Fd()), 0, int(st.Size()),
syscall.PROT_READ, syscall.MAP_SHARED)
if err != nil {
panic(err)
}
defer syscall.Munmap(data)
// data — обычный []byte. Первое обращение к нему вызовет page fault,
// и ядро подставит страницу файла из page cache или прочитает её с диска.
n := min(20, len(data))
fmt.Printf("размер %d байт, первые %d байт: %q\n", len(data), n, data[:n])
}На macOS вывод такой (у вас содержимое файла другое):
размер 213 байт, первые 20 байт: "##\n# Host Database\n#"data выглядит как обычный срез, но за ним нет памяти из кучи Go: это окно в
файл. Так работают встраиваемые базы данных вроде BoltDB/bbolt: файл базы
отображается в память, и чтение ключа — это обращение к срезу. Ловушка: если
обратиться к срезу после Munmap или если файл кто-то укоротил, процесс получит
сигнал SIGSEGV или SIGBUS, и рантайм Go завершит его с
fatal error: unexpected fault address. Это не паника, recover не поможет.
Анонимная память
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 и вытесняет страницы в своп вперемешку, а не строго по
очереди.
ПАМЯТЬ КОНЧАЕТСЯ — ПОРЯДОК ДЕЙСТВИЙ ЯДРА
запрос памяти
│
▼
есть свободные кадры? ──да──► выдать
│ нет
▼
выкинуть чистый page cache ──помогло?──► выдать
│ нет
▼
вытеснить страницы в своп ──помогло?──► выдать
│ нет / свопа нет
▼
┌──────────────── OOM KILLER ────────────────┐
│ выбрать процесс с макс. oom_score │
│ → SIGKILL (9). exit code 128+9 = 137 │
└─────────────────────────────────────────────┘Запомните число exit code 137. Это 128 + 9: процесс убит сигналом 9. В Go это
особенно коварно: рантайм не получает управления, так что вы не увидите ни паники,
ни runtime: out of memory, ни стека горутин, процесс просто исчезает.
Частая ошибка — считать, что 137 всегда означает OOM. SIGKILL посылает и
Kubernetes, если контейнер не завершился за отведённое время после SIGTERM, и
человек командой kill -9. Про OOM говорят причина OOMKilled в
kubectl describe pod и строки Out of memory: Killed process ... или
oom-kill в dmesg на ноде.
Контейнеры: cgroup-лимит и «падение по памяти»
Самая частая история с памятью на проде. Внутри контейнера процесс видит всю
память хоста: free и /proc/meminfo показывают ресурсы физической ноды,
например 256 ГБ (а runtime.NumCPU — все ядра, доступные процессу, без учёта
CPU-квоты). Но контейнер ограничен memory-лимитом cgroup, допустим, 512 МБ.
cgroup (control group) — это механизм ядра, который группирует процессы и считает их ресурсы (память, CPU и т.д.) с жёстким лимитом. Память контейнера = анонимная память его процессов плюс относящийся к нему page cache (и ещё память ядра, потраченная на группу). Когда сумма упирается в лимит, ядро сначала пытается освободить память внутри группы: выкидывает её чистый page cache. Если освободить нечего, срабатывает OOM killer внутри этой группы и убивает процесс в контейнере. Хост при этом здоров, RAM на ноде полно.
В cgroup v2 всё это лежит в файлах каталога группы (в контейнере обычно
/sys/fs/cgroup/):
| Файл | Что это |
|---|---|
memory.max |
жёсткий лимит; Kubernetes пишет сюда limits.memory |
memory.current |
сколько группа использует сейчас (вместе с page cache) |
memory.stat |
разбивка: anon (куча, стеки), file (page cache) и другое |
memory.events |
счётчики, в том числе oom_kill |
memory.high |
мягкий порог: выше него ядро тормозит группу и активно отбирает память, но не убивает |
КОНТЕЙНЕР ДУМАЕТ ОДНО, CGROUP РЕШАЕТ ДРУГОЕ
Физическая нода: 256 ГБ RAM
┌──────────────────────────────────────────────────────────┐
│ │
│ ┌─ cgroup контейнера, limit = 512 МБ ────────────────┐ │
│ │ процесс видит free → "256 ГБ свободно!" (врёт) │ │
│ │ ┌────────────────────────────────────────────┐ │ │
│ │ │ RSS процесса + page cache группы │ │ │
│ │ │ [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░] → 512 МБ │ │ │
│ │ └────────────────────────────────────────────┘ │ │
│ │ достигли лимита ──► cgroup OOM ──► SIGKILL(137) │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ на хосте при этом ещё ~255 ГБ свободно — но не важно! │
└──────────────────────────────────────────────────────────┘Практические последствия и грабли:
- «Контейнер падает по памяти, а на ноде памяти полно». Это не баг ноды.
Это лимит cgroup. Kubernetes покажет причину как
OOMKilledи exit code 137. - Go-рантайм не знает про memory-лимит контейнера. Сборщик мусора по умолчанию
ориентируется на
GOGC=100: следующая сборка начнётся, когда куча вырастет вдвое относительно живых данных после прошлой сборки. Если живых данных 300 МБ, куча может дорасти до 600 МБ, и с лимитом 512 МБ контейнер умрёт, хотя мусор можно было собрать раньше. ЛечитсяGOMEMLIMIT(Go 1.19+): мягкий лимит памяти рантайма, при приближении к которому GC запускается чаще. Ставят его с запасом ниже лимита контейнера, на 10–20 %, потому что лимит считает не только кучу Go, но и стеки, память вне кучи и page cache. Для лимита 512 МиБ, например,GOMEMLIMIT=450MiB. Если живых данных больше лимита,GOMEMLIMITне спасёт: GC будет работать почти непрерывно, и всё равно случится OOM. - С CPU-лимитом с Go 1.25 проще. Рантайм сам выставляет
GOMAXPROCSпо CPU-квоте контейнера (подробнее в главе пропорциональное планирование); для старых версий выставляйте его вручную или библиотекойgo.uber.org/automaxprocs. - 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-лимитом контейнера?
Частые ошибки
- Ставить алерты по VIRT. Он почти ничего не говорит о реальном потреблении.
- Пугаться маленького
freeв выводеfree -h. Смотрите наavailable: page cache отдаётся по первому требованию. - Ставить
GOMEMLIMITравным лимиту контейнера. В лимит входит не только куча Go; без запаса OOM всё равно случится. - Считать любой exit code 137 OOM-киллом. Проверяйте
OOMKilledиdmesg.
Проверьте себя
- Процесс сделал
mmapфайла на 10 ГБ и прочитал из него 3 страницы по 4 КБ. Насколько выросли VIRT и RSS? - Сервис читает с диска один и тот же файл на 2 ГБ, на машине 16 ГБ RAM. Почему второй прогон быстрее первого?
- Лимит контейнера 1 ГиБ, у Go-сервиса живых данных около 600 МиБ,
GOGC=100,GOMEMLIMITне задан. Чем это грозит? - Где в контейнере с cgroup v2 посмотреть, сколько раз в нём срабатывал OOM killer?
Показать ответы
Ответы. (1) VIRT на 10 ГБ, RSS примерно на 12 КБ (на деле немного больше из-за
readahead). (2) После первого прогона файл лежит в page cache. (3) Куча может
вырасти до ~1,2 ГиБ до следующей сборки, это выше лимита: OOM. Нужен GOMEMLIMIT
около 900 МиБ. (4) В memory.events, поле oom_kill.
Что спрашивают на собесе
- Чем
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на 10–20 % ниже лимита, чтобы GC работал чаще у потолка;GOMAXPROCSпод CPU-лимит (с Go 1.25 рантайм делает это сам). Объяснить, почемуfreeвнутри контейнера врёт и где смотреть настоящий лимит (memory.max). - Что делает ядро по шагам, когда память кончается? Освобождает чистый page cache → вытесняет страницы в своп → если не помогло, OOM killer выбирает жертву по oom_score (самый жирный) и шлёт SIGKILL.