Виртуализация памяти · 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.

Проверьте себя

  1. Процесс сделал mmap файла на 10 ГБ и прочитал из него 3 страницы по 4 КБ. Насколько выросли VIRT и RSS?
  2. Сервис читает с диска один и тот же файл на 2 ГБ, на машине 16 ГБ RAM. Почему второй прогон быстрее первого?
  3. Лимит контейнера 1 ГиБ, у Go-сервиса живых данных около 600 МиБ, GOGC=100, GOMEMLIMIT не задан. Чем это грозит?
  4. Где в контейнере с 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.

Продолжить