---
title: "Память в проде: mmap, page cache, OOM, контейнеры"
description: "Все предыдущие главы блока были про механику: как процессор и ОС превращают виртуальные адреса в физические (трансляция, страницы, TLB, многоуровневые таблицы), как работает…"
canonical: https://graphlms.ru/os/book/memory-cloud
course: "Операционные системы"
updated: 2026-10-12
lang: ru
publisher: GraphLMS
---

# Память в проде: mmap, page cache, OOM, контейнеры

> Источник: https://graphlms.ru/os/book/memory-cloud — курс «Операционные системы», GraphLMS.

## Зачем эта глава

Все предыдущие главы блока были про *механику*: как процессор и ОС превращают
виртуальные адреса в физические ([трансляция](/os/book/address-translation),
[страницы](/os/book/paging), [TLB](/os/book/tlb),
[многоуровневые таблицы](/os/book/smaller-tables)), как работает иллюзия приватной
памяти ([адресное пространство](/os/book/address-space)) и что происходит, когда
RAM кончилась ([свопинг и page fault](/os/book/swapping)).

Теперь соберём всё это в одну картинку — такую, какую вы реально увидите на
проде. Это глава-синтез. После неё вы должны понимать четыре вещи, об которые
спотыкается каждый бэкендер:

- **mmap** — почему файл можно «читать как массив в памяти» и что при этом
  происходит.
- **page cache** — почему сервис «тормозит на холодную и летает на горячую».
- **RSS vs VIRT** — почему `top` показывает 12 ГБ виртуальной памяти у процесса,
  который ест 200 МБ, и это нормально.
- **OOM killer и cgroup-лимиты** — почему ваш контейнер «внезапно умер» без
  паники Go, без стектрейса, просто `exit code 137`.

Всё это — прямое следствие того, что память виртуальная и ленивая.

## mmap: отобразить файл прямо в адресное пространство

Обычный способ прочитать файл — системный вызов `read`: вы просите ядро
«дай мне 4 КБ из файла», ядро копирует эти байты с диска (или из кэша) в ваш
буфер. Хотите следующие 4 КБ — снова `read`. Каждый вызов — переход в ядро и
копирование.

**mmap** (memory map) делает иначе. Вы говорите ядру: «возьми этот файл и
сделай вид, что он лежит у меня в памяти начиная с такого-то адреса». Ядро
заводит в вашем [адресном пространстве](/os/book/address-space) новую область
(VMA — virtual memory area) и говорит: «вот диапазон виртуальных адресов,
он соответствует файлу». После этого вы читаете файл просто разыменовывая
указатель — как обычный байтовый массив. Никаких `read`.

Аналогия: вместо того чтобы каждый раз бегать к кладовщику за коробкой (`read`),
вы один раз получаете *карту склада*. Теперь вы знаете адрес любой коробки и
можете «дотянуться» до неё сами. Но коробку на склад всё равно ещё надо
привезти — и вот тут начинается ленивость.

```text
  read():  процесс ──"дай 4КБ"──► ЯДРО ──копирует──► буфер процесса
           (системный вызов на каждое чтение, копия данных)

  mmap():  один раз настроили отображение, дальше — просто чтение памяти
           процесс ──*(ptr + offset)──► (адрес уже "указывает" в файл)
```

### Ленивая загрузка через page fault

Когда вы вызвали `mmap`, ничего с диска ещё **не прочиталось**. Ядро только
завело в своём списке областей процесса (VMA) запись: «адреса от X до Y — это файл
такой-то, начиная с такого-то смещения». В [таблице страниц](/os/book/paging) для
этого диапазона пока нет ни одной действующей записи.

Первый раз, когда ваш код обращается к адресу внутри отображения, MMU не находит
трансляции, и процессор вызывает **page fault** — тот же механизм, что в главе
[за пределами физической памяти](/os/book/swapping). Обработчик в ядре находит
VMA и видит: «это отображение файла, нужная страница лежит в файле по смещению X».
Если страница уже в page cache (о нём ниже), ядро просто заводит PTE на неё
(minor fault). Если нет, читает её с диска (major fault), причём обычно не одну, а
с запасом вперёд (readahead): последовательное чтение на этом сильно выигрывает.
Затем ядро заполняет PTE, и прерванная инструкция повторяется как ни в чём не
бывало.

```text
ОТОБРАЖЕНИЕ ФАЙЛА ЧЕРЕЗ 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):

```go
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 вывод такой (у вас содержимое файла другое):

```text
размер 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 памяти](/os/book/memory-api).

| Вид mmap | Что отображает | Чем «подкачивается» при faultе |
|----------|----------------|--------------------------------|
| File-backed | конкретный файл на диске | страница читается из этого файла |
| Anonymous | ничего (чистая память) | выдаётся нулевой кадр / из свопа |

## Page cache: бесплатное чтение после прогрева

Теперь главное про производительность бэкенда. Когда ОС читает что-то с диска
(через `read` или через mmap-fault), она **не выбрасывает** прочитанные страницы
после того, как отдала их вам. Она держит их в свободной RAM — это и есть
**page cache**. В следующий раз, когда кто угодно (вы, другой процесс, та же БД)
попросит те же байты файла, ядро отдаст их из памяти, не трогая диск.

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

```text
КТО МЕЖДУ ПРОЦЕССАМИ И ДИСКОМ

  Процесс 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`.

```text
ЧТЕНИЕ ОДНОГО И ТОГО ЖЕ ФАЙЛА ДВАЖДЫ

  1-е чтение (cold):  read → промах → ДИСК (~ миллисекунды) → отдали + ОСЕЛО в cache
  2-е чтение (warm):  read → ПОПАЛИ в page cache (~ наносекунды) → отдали
                            └ диск вообще не трогали
```

Связь со [свопингом](/os/book/swapping): 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 попадут только реально прочитанные страницы.

```text
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 и вытесняет страницы
([свопинг](/os/book/swapping)). Если и это не помогает — включается **OOM killer**
(Out Of Memory killer): ядро выбирает «жертву» и убивает её сигналом `SIGKILL`
(номер 9). Процесс умирает мгновенно, без шанса что-то сделать напоследок —
никаких defer, никакого graceful shutdown.

Кого убивать? Ядро считает каждому процессу **oom_score** — грубо «сколько RAM
освободится, если его прибить» (то есть в первую очередь под удар попадают
самые крупные по памяти). На счёт можно влиять через `oom_score_adj` (от -1000
«не трогать» до +1000 «бери первым»). Схема ниже упрощена: на деле ядро
освобождает page cache и вытесняет страницы в своп вперемешку, а не строго по
очереди.

```text
ПАМЯТЬ КОНЧАЕТСЯ — ПОРЯДОК ДЕЙСТВИЙ ЯДРА

  запрос памяти
       │
       ▼
  есть свободные кадры? ──да──► выдать
       │ нет
       ▼
  выкинуть чистый 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` | мягкий порог: выше него ядро тормозит группу и активно отбирает память, но не убивает |

```text
КОНТЕЙНЕР ДУМАЕТ ОДНО, 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-квоте контейнера (подробнее в главе
  [пропорциональное планирование](/os/book/proportional-scheduling)); для старых
  версий выставляйте его вручную или библиотекой `go.uber.org/automaxprocs`.
- **page cache контейнера тоже считается в лимит.** Сервис, активно читающий
  большие файлы, может упереться в лимит за счёт кэша файлов, даже если сам
  объектами в куче ест мало. Ядро под давлением сначала пожмёт этот кэш, но если
  данные нужны постоянно — лимит станет реальным потолком.
- **requests vs limits в k8s.** `requests` — сколько гарантированно резервируется
  под под (для шедулинга), `limits` — жёсткий потолок, после которого OOM.
  Превышение memory limit = смерть, в отличие от CPU limit, где процесс просто
  троттлят.

Связь с [адресным пространством](/os/book/address-space): иллюзия «приватной
бесконечной памяти», которую так бережно строит ОС, в контейнере получает второй
слой — иллюзию «я один на всей ноде». Обе иллюзии полезны, но обе протекают
ровно в момент, когда вы упёрлись в реальный физический потолок (в первом случае —
page fault и своп, во втором — cgroup OOM).

> Квиз по разделу: см. вопросы в конце документа или на https://graphlms.ru/os/book/memory-cloud

## Частые ошибки

- Ставить алерты по 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.

## Вопросы для самопроверки

**Квиз «Память в проде»**

1. Что реально происходит в момент вызова mmap для большого файла?
   - Создаётся копия файла на диске
   - Файл загружается в swap
   - Ядро лишь настраивает отображение; страницы грузятся по page fault при первом обращении (верно)
   - Весь файл сразу копируется с диска в RAM
   - Пояснение: mmap ленивый: он только заводит VMA и помечает страницы невалидными. Реальное чтение с диска происходит при первом обращении к адресу — через page fault, тем же механизмом, что и свопинг.
2. Сервис обслуживает первые запросы после деплоя медленно, а через минуту — быстро. Самая частая причина на бэкенде?
   - Процессор разгоняется по частоте
   - Холодный page cache: данные читаются с диска, потом оседают в RAM (верно)
   - Сборщик мусора ещё не запускался
   - Сеть прогревается
   - Пояснение: Page cache пуст после старта/переезда. Первые чтения идут на диск (миллисекунды), затем страницы оседают в RAM, и повторные чтения становятся почти бесплатными (наносекунды).
3. В top у процесса VIRT = 12 ГБ, RSS = 200 МБ. Что верно?
   - Большой VIRT всегда означает утечку памяти
   - RAM реально потребляется в объёме RSS, а не VIRT (верно)
   - VIRT — зарезервированное виртуальное АП (обещания), RAM почти не тратит (верно)
   - RSS — это физические страницы процесса в RAM прямо сейчас (верно)
   - Пояснение: VIRT — это обещания (резерв кучи рантайма, mmap, библиотеки), физическую RAM тратит только RSS. Большой VIRT сам по себе не утечка; утечку ищут по росту RSS/working set.
4. Контейнер в Kubernetes завершился с exit code 137 при свободной памяти на ноде. Что произошло?
   - Превышен cgroup memory limit, сработал OOM killer внутри группы (SIGKILL) (верно)
   - CPU-троттлинг убил процесс
   - Ошибка сети
   - Процесс штатно завершился
   - Пояснение: 137 = 128 + 9 (SIGKILL). Внутри контейнера процесс видит память всей ноды, но ограничен cgroup-лимитом. Превышение лимита группы → cgroup OOM killer убивает процесс, хотя хост здоров.
5. Чему равен exit code процесса, убитого сигналом SIGKILL (9)?
   - Ответ: 137
   - Пояснение: Соглашение оболочки: код выхода убитого сигналом процесса = 128 + номер сигнала. Для SIGKILL это 128 + 9 = 137 — характерный признак OOM.
6. Как правильно подружить Go-сервис с memory-лимитом контейнера?
   - Увеличить VIRT процесса
   - Отключить сборщик мусора
   - Полагаться только на free и runtime.NumCPU внутри контейнера
   - Поставить GOMEMLIMIT чуть ниже cgroup-лимита, а GOMAXPROCS под CPU-лимит (верно)
   - Пояснение: free и runtime.NumCPU внутри контейнера могут показывать ресурсы хоста, а не лимиты. GOMEMLIMIT (Go 1.19+) задаёт мягкий потолок памяти: GC начинает работать агрессивнее у лимита и реже доводит до OOM. С GOMAXPROCS то же самое: на Go старше 1.25 его выравнивают по CPU-лимиту вручную (или через automaxprocs), а с 1.25 рантайм учитывает лимит cgroup сам.
