Хост под PostgreSQL тратит на сотнях соединений память, которой не видно в запросах, а потом ядро убивает один процесс сигналом 9, и все сессии рвутся разом. Причины лежат в хосте, а не в SQL: каждый backend держит собственные таблицы страниц для общей памяти (при 4-килобайтных страницах это около 2 МиБ на гигабайт прочитанных буферов), а лимит памяти и OOM killer не отличают «большой запрос» от «всего сервера». Сами буферы разобраны в статье про clock sweep, запись на диск — в статье про checkpoint; здесь измерено то, что лежит под ними, без изменения хоть одной настройки хоста.
Куда уходит память под сотнями backend
Стенд
Контейнер с лимитом 8 ГБ, 1 ГБ shared_buffers, 600 допустимых соединений и таблицей, прогретой в буферы. max_parallel_workers_per_gather = 0 нужен потому, что /dev/shm в Docker по умолчанию 64 МБ, и параллельные запросы в 300 сессиях падают с could not resize shared memory segment ... No space left on device.
docker run -d --name dba-os-level-diagnostics-db-host -e POSTGRES_PASSWORD=pg --memory 8g postgres:18 \
-c shared_buffers=1GB -c huge_pages=off -c max_connections=600 \
-c max_parallel_workers_per_gather=0 -c max_wal_size=4GB
docker exec -i dba-os-level-diagnostics-db-host psql -U postgres -Atq <<'SQL'
create extension pg_prewarm;
create table big as select g as id, repeat('x', 200) as pad from generate_series(1, 3800000) g;
select pg_size_pretty(pg_relation_size('big')), pg_prewarm('big');
SQL
Вывод: 874 MB|111808 — таблица 874 МБ (111 808 страниц) лежит в shared_buffers целиком. Дальше только чтение /proc, /sys и memory.stat своего контейнера: ни sysctl -w, ни --privileged, ни записи в /proc/sys.
Опыт 1: page tables у 300 backend
Скрипт для pgbench держит 300 сессий, а между select 1 и select count(*) from big даёт снять «до» и «после». VmPTE в /proc/<pid>/status — размер таблиц страниц процесса.
printf "select 1;\nselect pg_sleep(15);\nselect count(*) from big;\nselect pg_sleep(40);\n" > /tmp/s.sql
docker cp /tmp/s.sql dba-os-level-diagnostics-db-host:/tmp/s.sql
docker exec dba-os-level-diagnostics-db-host bash -c '
pgbench -U postgres -n -c 300 -j 4 -T 60 -f /tmp/s.sql postgres >/dev/null &
snap() { awk "/^VmPTE/{s+=\$2;n++} END{print n\" процессов postgres, VmPTE: \"s\" kB\"}" $(for p in $(pgrep -x postgres); do echo /proc/$p/status; done)
grep -E "^(anon|pagetables|shmem) " /sys/fs/cgroup/memory.stat; }
sleep 10; snap; sleep 22; snap; wait'
309 процессов postgres, VmPTE: 71584 kB
anon 395296768
pagetables 75075584
shmem 992292864
309 процессов postgres, VmPTE: 657184 kB
anon 2892144640
pagetables 674664448
shmem 992292864
Вывод: сумма VmPTE выросла на 585 600 кБ, то есть на 572 МиБ, или на 1 952 кБ на каждое из 300 соединений; pagetables контейнера выросли на 572 МиБ так же. Общая память (shmem) не изменилась ни на байт: все 300 процессов смотрят на одни и те же 992 МБ, но у каждого свои записи таблицы страниц. Оценка по формуле близка к измерению: 874 МиБ данных при 4 КиБ на страницу и 8 байтах на запись дают 1 748 кБ на процесс против измеренных 1 952 кБ.
Таблицы страниц засчитываются в лимит контейнера. Попытка того же опыта с --memory 4g закончилась client backend (PID 185) was terminated by signal 9: Killed, memory.events показал oom 8 и oom_kill 2, docker inspect — OOMKilled=true. Под лимитом 8 ГБ видно, из чего складывается расход: anon вырос на 2,5 ГБ (около 8 МБ на backend) до 2,9 ГБ, pagetables до 0,67 ГБ, плюс 0,99 ГБ общей памяти; в сумме 4,5 ГБ, больше 4 ГБ.
Что дали бы huge pages (расчёт, не измерение)
С 2-мегабайтными страницами нужна одна запись на страницу вместо 512. Сколько их потребуется, PostgreSQL сообщает до запуска сервера (пустой кластер, shared_buffers = 1GB, 100 соединений по умолчанию):
docker exec -u postgres dba-os-level-diagnostics-db-host bash -c 'initdb -D /tmp/d2 >/dev/null 2>&1
postgres -D /tmp/d2 -c shared_buffers=1GB -C shared_memory_size_in_huge_pages'
543
Вывод: 543 записи по 8 байт, около 4 кБ на процесс, против 1 952 кБ измеренных. Для 300 соединений это порядка 1,2 МиБ вместо 572 МиБ. Число получено вычислением по размеру записи; на своём хосте перемерь VmPTE после включения.
huge_pages = try молча не работает
На хосте HugePages_Total: 0 в /proc/meminfo. Режим try при этом не жалуется:
docker exec -u postgres dba-os-level-diagnostics-db-host bash -c '
O="-c shared_buffers=1GB -c port=5433 -c unix_socket_directories=/tmp"
pg_ctl -D /tmp/d2 -o "$O -c huge_pages=try" -l /tmp/d2.log -w start >/dev/null
psql -h /tmp -p 5433 -Atq -c "show huge_pages" -c "show huge_pages_status" postgres
pg_ctl -D /tmp/d2 -m fast stop >/dev/null
postgres -D /tmp/d2 $O -c huge_pages=on 2>&1 | head -2; rm -rf /tmp/d2 /tmp/d2.log'
try
off
FATAL: could not map anonymous shared memory: Cannot allocate memory
HINT: This error usually means that PostgreSQL's request for a shared memory segment exceeded available memory, swap space, or huge pages. To reduce the request size (currently 1138753536 bytes), reduce PostgreSQL's shared memory usage, perhaps by reducing "shared_buffers" or "max_connections".
Вывод: с try сервер стартует, а huge_pages_status говорит off; с on не стартует. Причина в CreateAnonymousSegment (REL_18_6): если mmap с MAP_HUGETLB не удался, режим try пишет сообщение уровня DEBUG1, а его в логе при обычных настройках нет:
if (huge_pages == HUGE_PAGES_TRY && ptr == MAP_FAILED)
elog(DEBUG1, "mmap(%zu) with MAP_HUGETLB failed, huge pages disabled: %m",
allocsize);
Выделение страниц (vm.nr_hugepages) и права (vm.hugetlb_shm_group, ulimit -l) описаны в документации PostgreSQL; здесь они не менялись и не измерялись.
Опыт 2: cgroup OOM и перезапуск после аварии
Контейнер на 512 МБ без свопа, work_mem 300 МБ и четыре одновременные сортировки. Одна занимает 235 МБ (Sort Method: quicksort Memory: 235514kB в EXPLAIN (ANALYZE) для таблицы t в 1 млн строк, 224 МБ), четыре превышают лимит вдвое. Как читать Sort Method, разобрано в статье про work_mem.
docker run -d --name dba-os-level-diagnostics-db-host-oom -e POSTGRES_PASSWORD=pg --memory 512m --memory-swap 512m \
postgres:18 -c shared_buffers=128MB -c max_parallel_workers_per_gather=0 -c max_connections=50
docker exec dba-os-level-diagnostics-db-host-oom psql -U postgres -Atq -c "create table t as select g as id,
md5(g::text)||md5((g+1)::text)||md5((g+2)::text)||md5((g+3)::text)||md5((g+4)::text)||md5((g+5)::text) as pad
from generate_series(1,1000000) g"
printf "set work_mem='300MB';\nselect * from t order by pad offset 1000000;\n" > /tmp/o.sql
docker cp /tmp/o.sql dba-os-level-diagnostics-db-host-oom:/tmp/o.sql
docker exec dba-os-level-diagnostics-db-host-oom pgbench -U postgres -n -c 4 -t 1 -f /tmp/o.sql postgres
docker logs dba-os-level-diagnostics-db-host-oom 2>&1 | grep -E 'signal|terminating|reinitializ|redo|ready'
docker exec dba-os-level-diagnostics-db-host-oom grep oom /sys/fs/cgroup/memory.events
docker inspect dba-os-level-diagnostics-db-host-oom --format 'OOMKilled={{.State.OOMKilled}} Restarts={{.RestartCount}}'
pgbench: error: client 0 aborted in command 1 (SQL) of script 0; perhaps the backend died while processing
WARNING: terminating connection because of crash of another server process
LOG: client backend (PID 134) was terminated by signal 9: Killed
LOG: terminating any other active server processes
LOG: all server processes terminated; reinitializing
LOG: redo starts at 0/17619C8
LOG: redo done at 0/10CB7A00 system usage: CPU: user: 0.32 s, system: 0.26 s, elapsed: 0.62 s
LOG: database system is ready to accept connections
oom 6
oom_kill 2
OOMKilled=true Restarts=0
Показаны отобранные строки, время в логе убрано. Вывод: убит один backend (PID 134) посреди сортировки, остальные получили terminating connection because of crash of another server process. Контейнер не перезапускался (Restarts=0): postmaster жив, он сам перезапустил дочерние процессы, проиграл около 245 МБ WAL (от 0/17619C8 до 0/10CB7A00) за 0,62 с, и база снова приняла соединения через 0,9 с после убийства. Первое решение принимает HandleChildCrash (REL_18_6): гибель любого дочернего процесса от сигнала объявляет остальным аварию.
LogChildExit(LOG, procname, pid, exitstatus);
ereport(LOG,
(errmsg("terminating any other active server processes")));
Строку all server processes terminated; reinitializing пишет postmaster (postmaster.c, REL_18_6, L3184-L3187), когда погиб последний дочерний процесс; проигрывание WAL после неё описано в статье про crash recovery.
Что меняет vm.overcommit_memory = 2, без изменения хоста
Здесь действует режим по умолчанию (vm.overcommit_memory = 0): нехватка памяти обнаруживается, когда страницу трогают, и тогда приходит OOM killer. Документация PostgreSQL предупреждает, что в этом режиме ядро может убить и сам postmaster: существующие соединения продолжат работать, новые принимаются не будут. Режим 2 не выдаёт памяти больше лимита, и нехватка приходит как отказ выделения, то есть как ошибка внутри одной сессии. Включать режим на общем хосте нельзя, но развилку «ошибка вместо убийства» показывает лимит адресного пространства одного процесса: prlimit на собственный backend пользователя postgres.
cat > /tmp/as.sql <<'SQL'
select pg_backend_pid() as pid \gset
\setenv PID :pid
\! grep VmPeak /proc/$PID/status
\! prlimit --pid $PID --as=420000000
set work_mem='300MB';
select * from t order by pad offset 1000000;
select pg_postmaster_start_time() < now() - interval '1 second' as postmaster_not_restarted;
SQL
docker cp /tmp/as.sql dba-os-level-diagnostics-db-host-oom:/tmp/as.sql
docker exec -u postgres dba-os-level-diagnostics-db-host-oom psql -U postgres -f /tmp/as.sql
VmPeak: 222820 kB
SET
psql:/tmp/as.sql:6: ERROR: out of memory
DETAIL: Failed on request of size 216 in memory context "Caller tuples".
postmaster_not_restarted
--------------------------
t
Вывод: тот же запрос завершился out of memory в одной сессии, postmaster не перезапускался. Это аналогия (предел процесса, а не предел коммита всей системы). Перед включением режима 2 сверь CommitLimit и Committed_AS в /proc/meminfo: на этом хосте первый 41 229 688 кБ, второй 121 977 144 кБ, и по документации ядра в режиме 2 новые выделения получали бы ошибки.
Опыт 3: vmstat и iostat при CHECKPOINT
В контейнер из опыта 1 ставятся пакеты, затем меняются 1,5 млн строк и сбрасываются CHECKPOINT, пока vmstat и iostat пишут по секунде. Устройство nvme1n1 выбрано как то, на которое с загрузки хоста пришлось больше всего записей (iostat -d).
docker exec dba-os-level-diagnostics-db-host bash -c 'apt-get update -qq; apt-get install -y -qq procps sysstat'
docker exec dba-os-level-diagnostics-db-host psql -U postgres -Atq -c "checkpoint"
docker exec dba-os-level-diagnostics-db-host bash -c '
iostat -dx 1 14 nvme1n1 > /tmp/iostat.out & vmstat 1 14 > /tmp/vmstat.out & sleep 2
psql -U postgres -Atq -c "update big set pad = repeat(chr(121), 200) where id <= 1500000" -c "checkpoint"
wait; cat /tmp/vmstat.out /tmp/iostat.out'
Из 14 секунд оставлены первая строка (средние с загрузки хоста) и секунда пика:
procs -----------memory---------- ---swap-- -----io---- -system-- -------cpu-------
r b swpd free buff cache si so bi bo in cs us sy id wa st gu
2 1 8385684 6054424 560 19398736 24 101 3918 8962 23188 32 11 2 86 0 0 0
4 0 8385680 4754936 560 20568140 0 0 4672 694404 37907 107055 6 7 85 1 0 0
Device r/s rkB/s w/s wkB/s w_await wareq-sz f/s f_await aqu-sz %util
nvme1n1 86.48 3870.92 463.46 8861.45 2.57 19.12 18.36 0.87 1.27 4.43
nvme1n1 292.00 4672.00 6644.00 694404.00 0.51 104.52 127.00 0.28 3.44 28.30
Вывод: в секунду пика bo 694 404 кБ/с, wkB/s те же 694 404, средний размер записи 104,5 кБ против 19,1 кБ в среднем с загрузки, f/s 127 в секунду, %util 28,3 %. В логе CHECKPOINT эта же точка: wrote 69584 buffers (53.1%), write=0.583 s, sync=0.060 s, total=0.721 s; 69 584 страницы по 8 КБ — 544 МБ. Механику записи и fsync разбирают статья про fsync и статья про checkpoint.
Оговорка: vmstat и iostat внутри контейнера показывают счётчики хоста целиком: 20 ядер, все процессы, весь своп (swpd 8 385 684 кБ — это своп хоста, а не контейнера). Если на устройстве работает кто-то ещё, по этим счётчикам его записи от записей контейнера не отличить; для этого нужны io.stat cgroup или pidstat -d на хосте.
Что видно только на хосте
Из контейнера без --privileged доступно только чтение. Значения хоста, прочитанные из /sys и /proc/sys:
/sys/kernel/mm/transparent_hugepage/enabled always [madvise] never
/proc/sys/vm/dirty_background_bytes 0
/proc/sys/vm/dirty_background_ratio 10
/proc/sys/vm/dirty_ratio 40
/proc/sys/vm/overcommit_memory 0
/proc/sys/vm/overcommit_ratio 50
/proc/sys/kernel/perf_event_paranoid 2
- THP.
madvise: прозрачные huge pages получают только процессы, которые попросили. Документация PostgreSQL пишет, что THP у части пользователей на части версий Linux снижал производительность, и не рекомендует его; явныеhuge_pagesиз опыта 1 — другой механизм. Режимы ядра: transhuge. vm.dirty_background_bytes. Значение0означает, что действуетdirty_background_ratio = 10: доля от доступной памяти (расчёт для 64 ГБ: порядка 6 ГиБ грязных страниц до начала фонового сброса). Если сброс приходит залпом, документация ядра предлагает задать порог в байтах черезdirty_background_bytes; число подбирай поw_awaitкак в опыте 3. Рекомендуемого значения здесь нет: оно не измерено.perf. Пакет ставится в контейнер, но запуск отказывает:
docker exec -u postgres dba-os-level-diagnostics-db-host bash -c 'perf stat -p $(pgrep -f "postgres: checkpointer") -- sleep 1'
Error:
No permission to enable task-clock event.
Что значение perf_event_paranoid разрешает каждому уровню, перечислено в документации ядра. Рецепт для хоста по вики PostgreSQL: perf top -p <pid backend> на горячем запросе или perf record -g -p <pid> и perf report. Здесь он не запускался.
- Цена вызова часов.
pg_test_timing -d 3в том же контейнере:Per loop time including overhead: 14.20 ns, 98,6 % вызовов быстрее 1 мкс. Это цена одного чтения часов на этом процессоре; на своём хосте измерь её до включенияtrack_io_timingиTIMING. Описание вывода: pg_test_timing.
Что из этого следует
- Соединение стоит памяти, даже когда оно простаивает. 300 соединений без huge pages добавили 572 МиБ таблиц страниц; прежде чем поднимать
max_connections, умножь 2 МиБ на гигабайтshared_buffersна число соединений. tryпроверяй, а не доверяй. После каждого перезапускаSHOW huge_pages_status:offприtryзначит, что у ядра нет страниц, а в логе об этом ничего не будет.- Лимит памяти убивает процесс, а не запрос. В контейнере с
--memoryсуммаwork_memпо сессиям оборвала всех: четыре сортировки по 235 МБ в 512 МБ. При строгом overcommit (расчёт лимита обязателен) та же ситуация даётERROR: out of memoryв одной сессии. - Счётчикам хоста нужен хозяин. Пик записи привязывай к строке
checkpoint completeв логе, а не к одной колонкеiostat.
Чек-лист
SHOW huge_pages_status;иpostgres -D $PGDATA -C shared_memory_size_in_huge_pages(на остановленном кластере) сравни сHugePages_Totalв/proc/meminfo.- Просуммируй
VmPTEпо процессам postgres:awk '/^VmPTE/{s+=$2} END{print s}' /proc/<pid>/status ...и сравни с числом соединений. - В контейнере смотри
memory.events(oom_kill) иdocker inspect --format '{{.State.OOMKilled}}', в логе ищиterminated by signal 9. - Перед включением
vm.overcommit_memory = 2сравниCommitLimitиCommitted_ASв/proc/meminfo. - Убери контейнеры опыта вместе с томами:
docker rm -f -v dba-os-level-diagnostics-db-host dba-os-level-diagnostics-db-host-oom.