Headless Chrome съел 300 ГБ временными файлами за одну ночь
Утром на сервере, который ночью тихо генерировал скриншоты для сотни сайтов, диск оказался забит под ноль — при том что папка с результатами выросла едва ли на пару гигабайт. Разбираем инцидент по шагам: откуда взялись лишние 300 ГБ, почему их не видно ни в логах, ни в бэкапах, и что сделать, чтобы headless-браузер больше не мог устроить такую подставу.
Содержание
- Что случилось: диск кончился за одну ночь без видимых "полезных" файлов
- Первая гипотеза и что отмели по дороге
- Находка: тысячи каталогов-профилей в /tmp
- Почему headless-браузер вообще плодит временные файлы на каждый запуск
- Корневая причина: очистка не была явной
- Практическое решение: явный временный профиль плюс гарантированная очистка в коде
- Защитный слой: фоновая очистка независимо от основной логики
- Мониторинг роста именно /tmp, а не только процента занятого диска
Что случилось: диск кончился за одну ночь без видимых "полезных" файлов
Сценарий типичный: на VPS крутится джоба, которая раз в сутки, ночью, прогоняет headless Chrome (или Chromium) по списку сайтов — снимает скриншоты, рендерит PDF-отчёты или гоняет автотесты для сотни с лишним страниц. Утром — алерт от мониторинга: диск на 100%, приложение падает с ENOSPC, база данных не может записать WAL, systemd не может даже ротировать журнал.
Первая реакция — посмотреть на df:
df -h /
# Filesystem Size Used Avail Use% Mounted on
# /dev/vda1 80G 80G 0 100% /
Диск был занят процентов на 40 накануне вечером. За ночь система нагенерировала порядка 300 ГБ данных (часть успела удалиться сама, но не вся). Логично предположить, что виноват единственный активный ночью процесс. Но результаты его работы — скриншоты и PDF в целевой папке — занимали от силы пару гигабайт. Пропорция не бьётся: сотня обработанных сайтов не должна давать сотни гигабайт мусора.
Первая гипотеза и что отмели по дороге
Прежде чем копать в сторону браузера, стоит закрыть очевидных подозреваемых — иначе легко потратить час на то, что можно проверить за минуту.
Логи. Первым делом проверили, не разрослись ли логи самого приложения или nginx:
du -sh /var/log/* | sort -rh | head -10
Ничего аномального — обычные несколько сотен мегабайт с ротацией. Если бы дело было в логах без ротации, это отдельная и довольно частая история — она разобрана в статье про логи, которые заняли 300 ГБ и которые никто не читал. Но здесь совпадение объёма (те же условные 300 ГБ) оказалось случайным — причина другая.
Docker. На сервере был докер для части сервисов — проверили слои образов и логи контейнеров:
docker system df
docker ps -a --filter status=exited
Слои не разрастались, брошенных контейнеров с раздутыми json-логами не было. Это тоже частый источник внезапного заполнения диска, но не в этом случае.
Бэкапы. Ночной cron бэкапа баз данных иногда копит старые дампы, если ротация настроена неверно — проверили каталог с дампами, там всё было в пределах нормы.
Когда очевидные варианты отпали, а по времени всё указывало на один-единственный ночной процесс — скрипт со headless-браузером, обрабатывающий сайты один за другим — стало ясно, что смотреть надо туда, где браузер хранит рабочие данные, а не туда, куда он пишет "результат".
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНаходка: тысячи каталогов-профилей в /tmp
Проверка временной директории дала мгновенный ответ:
du -sh /tmp
# 287G /tmp
ls /tmp | wc -l
# 14382
ls /tmp | head -5
# .org.chromium.Chromium.a1B2c3
# .org.chromium.Chromium.d4E5f6
# .org.chromium.Chromium.g7H8i9
# puppeteer_dev_chrome_profile-XXXXXX
# playwright_chromiumdev_profile-XXXXXX
Больше десяти тысяч каталогов, каждый — это отдельный профиль браузера от отдельного запуска: кеш, cookies, LevelDB-базы для localStorage, service worker cache, GPU shader cache и прочие артефакты обычного браузерного профиля. По отдельности каждый весит немного — от пары мегабайт до пары десятков, в зависимости от того, что успела нагрузить и закешировать страница. Но при тысячах запусков за ночь (сайт за сайтом, скриншот за скриншотом) суммарный объём набегает быстро и незаметно, потому что каждый отдельный каталог сам по себе не выглядит подозрительно.
Здесь же выяснилась вторая деталь: часть каталогов имела временные метки за несколько дней и даже недель до инцидента — проблема копилась не одну ночь, просто именно в эту ночь список целей расширили, и накопленный фон "выстрелил" ровно в момент, когда свободного места и так оставалось немного. Характерная ловушка: рост носит накопительный характер, а инцидентом становится момент, когда пик нагрузки совпадает с уже подъеденным запасом. Похожая механика — когда место кончается не сразу, а копится и потом резко проявляется — разобрана в статье про то, что вообще отваливается первым при заполнении диска.
Почему headless-браузер вообще плодит временные файлы на каждый запуск
Headless Chrome и Chromium ведут себя как обычный браузер, только без окна. При старте, если явно не указан постоянный профиль (--user-data-dir), браузер создаёт временный профиль в системной временной директории — это тот же механизм, что и у обычного Chrome при запуске "в режиме гостя" или в анонимном контексте, только сгенерированный путь каждый раз новый. Инструменты автоматизации — Puppeteer, Playwright, Selenium с ChromeDriver — по умолчанию идут тем же путём: если вы не передаёте свой userDataDir, библиотека сама создаёт временный каталог вида puppeteer_dev_chrome_profile-XXXXXX или playwright_chromiumdev_profile-XXXXXX внутри /tmp (или $TMPDIR, если он переопределён) и по идее должна удалить его после завершения работы браузера.
Проблема возникает там, где автоматизация запускает новый экземпляр браузера на каждую отдельную задачу — например, отдельный browser.launch() на каждый сайт в списке, а не один браузер с несколькими вкладками/контекстами на весь прогон. Каждый такой запуск — это новый временный профиль. Если после каждого запуска нет явной и надёжной очистки, а тем более если процесс изредка падает (таймаут на медленном сайте, некорректный SSL-сертификат, зависшая страница с бесконечным JS) до того, как штатный код успевает вызвать browser.close() и удалить профиль — каталог просто остаётся лежать в /tmp навсегда, до перезагрузки сервера или ручной чистки. При сотне сайтов и заметном проценте падений за одну ночь может накопиться заметная доля неудалённых профилей — и так повторяется каждую ночь.
Отдельно стоит сказать про кеш GPU-шейдеров и Service Worker cache внутри профиля — в headless-режиме без реального экрана они часто не нужны вовсе, но браузер всё равно их создаёт, если явно не отключить соответствующие флаги.
Корневая причина: очистка не была явной
Одной фразой: процесс полагался на то, что "браузер сам почистит за собой", вместо того чтобы явно управлять жизненным циклом временного профиля из кода автоматизации. Это сработавшее большую часть времени, но не гарантированное поведение — оно ломается в двух случаях:
- процесс завершается аварийно (краш, kill по таймауту, OOM-killer) до собственной очистки;
- очистка вообще не была написана в коде явно, а разработчик понадеялся, что временный
userDataDirот библиотеки автоматизации "сам себя удалит" при следующем перезапуске ОС — чего при штатной работе сервера (без перезагрузок месяцами) просто не происходит.
Часть дистрибутивов чистит /tmp при перезагрузке (через tmpfs в памяти или systemd-tmpfiles), но на сервере, который не перезагружается неделями, это не спасает — накопление идёт непрерывно между перезагрузками.
Практическое решение: явный временный профиль плюс гарантированная очистка в коде
Первый и главный шаг — перестать полагаться на поведение по умолчанию и явно управлять временным профилем в самом скрипте автоматизации, с гарантией удаления даже при ошибке.
Пример на Node.js с Puppeteer — с явным temp-каталогом и удалением в finally:
const puppeteer = require('puppeteer');
const fs = require('fs/promises');
const os = require('os');
const path = require('path');
async function screenshotSite(url) {
const profileDir = await fs.mkdtemp(path.join(os.tmpdir(), 'chrome-job-'));
let browser;
try {
browser = await puppeteer.launch({
userDataDir: profileDir,
args: [
'--no-sandbox',
'--disable-gpu',
'--disable-dev-shm-usage',
],
});
const page = await browser.newPage();
await page.goto(url, { waitUntil: 'networkidle2', timeout: 30000 });
await page.screenshot({ path: `/data/shots/${Date.now()}.png` });
} finally {
if (browser) await browser.close().catch(() => {});
await fs.rm(profileDir, { recursive: true, force: true }).catch(() => {});
}
}
Ключевые моменты: mkdtemp создаёт гарантированно уникальный каталог, finally выполняет удаление независимо от того, упал ли page.goto по таймауту или нет, а .catch(() => {}) подстраховывает от ситуации, когда сам браузер уже убил процесс и close() бросает исключение — удаление профиля всё равно должно произойти.
То же самое на Python с Playwright:
import shutil, tempfile
from playwright.sync_api import sync_playwright
def screenshot_site(url):
profile_dir = tempfile.mkdtemp(prefix="chrome-job-")
try:
with sync_playwright() as p:
browser = p.chromium.launch_persistent_context(
profile_dir,
args=["--no-sandbox", "--disable-gpu"],
)
page = browser.new_page()
page.goto(url, timeout=30000)
page.screenshot(path=f"/data/shots/{url.split('//')[-1]}.png")
browser.close()
finally:
shutil.rmtree(profile_dir, ignore_errors=True)
Если браузер запускается напрямую из shell-скрипта (без обёртки Puppeteer/Playwright), тот же принцип реализуется через mktemp и trap:
#!/usr/bin/env bash
set -euo pipefail
PROFILE_DIR=$(mktemp -d /tmp/chrome-job-XXXXXX)
trap 'rm -rf "$PROFILE_DIR"' EXIT
chromium --headless --disable-gpu --no-sandbox \
--user-data-dir="$PROFILE_DIR" \
--screenshot="/data/shots/$(date +%s).png" \
"$1"
trap ... EXIT — это ровно тот механизм, который гарантирует удаление каталога вне зависимости от того, как завершится скрипт: штатно, с ошибкой или по сигналу. Именно этого не хватало в исходной версии — очистка либо не была написана вовсе, либо стояла в конце скрипта без trap/finally, и потому пропускалась при любом нештатном завершении.
Защитный слой: фоновая очистка независимо от основной логики
Даже с правильно написанным finally/trap стоит закладываться на то, что где-то очистка всё равно не сработает — новый баг, забытый код в редко используемой ветке, ручной killall во время отладки. Поэтому нужен второй, независимый рубеж — регулярная фоновая проверка /tmp на предмет старых каталогов-профилей, не привязанная к основному процессу.
Простой вариант — cron-задача, которая раз в час удаляет всё, что старше определённого возраста и похоже на профиль браузера:
# /etc/cron.d/cleanup-chrome-tmp
0 * * * * root find /tmp -maxdepth 1 -type d \( -name '.org.chromium.*' -o -name 'puppeteer_*' -o -name 'playwright_*' -o -name 'chrome-job-*' \) -mmin +60 -exec rm -rf {} +
Возраст в -mmin +60 подбирается так, чтобы гарантированно не задеть профиль ещё выполняющегося запуска (для этого важно понимать реальную максимальную длительность одного запуска браузера в вашей задаче и брать запас минимум в 2-3 раза).
Более системный вариант — политика systemd-tmpfiles, которая чистит /tmp по возрасту файлов независимо от cron:
# /etc/tmpfiles.d/chrome-profiles.conf
d /tmp 1777 root root 1d
Строка означает: содержимое /tmp старше суток подлежит удалению при следующем прогоне systemd-tmpfiles --clean (по умолчанию запускается по таймеру systemd-tmpfiles-clean.timer). Это не заменяет очистку в коде — это подстраховка на случай, если код молчит.
Мониторинг роста именно /tmp, а не только процента занятого диска
Общий алерт "диск занят больше 90%" сработал уже постфактум, когда место фактически кончилось. Полезнее иметь отдельный сигнал именно по временной директории — потому что рост /tmp конкретным процессом часто виден за много часов до того, как процент всего диска станет критичным.
Минимальный скрипт-проверка для cron с алертом при быстром росте:
#!/usr/bin/env bash
# check-tmp-growth.sh
STATE_FILE=/var/tmp/.tmp_size_prev
CURRENT=$(du -sm /tmp | cut -f1)
PREV=$(cat "$STATE_FILE" 2>/dev/null || echo 0)
echo "$CURRENT" > "$STATE_FILE"
DELTA=$((CURRENT - PREV))
if [ "$DELTA" -gt 5000 ]; then
echo "ALERT: /tmp вырос на ${DELTA}MB за последний интервал (сейчас ${CURRENT}MB)" | \
mail -s "tmp growth alert" ops@example.com
fi
Порог в 5000 МБ условный — подбирается под реальный профиль нагрузки конкретного сервера. Смысл не в конкретной цифре, а в том, чтобы отслеживать скорость роста именно этой директории отдельным графиком, а не полагаться только на общий процент занятого диска, который реагирует с запозданием. Такой точечный контроль хорошо ложится поверх обычного мониторинга диска на VPS — как дополнительная метрика, а не замена общей.
Стоит также иметь в виду, что тысячи мелких файлов внутри профилей браузера (LevelDB, cache) нагружают не только объём, но и число inode — на файловых системах и тарифах с лимитом inode симптом может проявиться иначе: место формально есть, а файл создать нельзя. Механика разобрана в статье про то, как множество мелких файлов убивает диск.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Почему браузер не удаляет профиль сам при закрытии?
Обычно удаляет — если процесс завершается штатно через browser.close() и библиотека автоматизации создавала профиль сама. Проблема в нештатных завершениях (падение, kill по таймауту, OOM) и в ситуациях, где профиль указан вручную и явной очистки в коде нет вообще.
Достаточно ли просто удалить содержимое /tmp вручную один раз?
Это решает симптом на один раз, но не причину — на следующую ночь при том же коде накопление начнётся заново. Нужно и явное закрытие/удаление профиля в коде, и фоновая подстраховка через cron или tmpfiles, иначе проблема вернётся.
Можно ли просто использовать один и тот же постоянный профиль для всех запусков вместо временного?
Технически можно передать один и тот же userDataDir на все запуски, но тогда параллельные запуски браузера будут конфликтовать за один и тот же профиль (блокировки LevelDB, гонки за cookies), а сам профиль будет расти без ограничения — это чаще создаёт новые проблемы, чем решает старые. Лучше — временный уникальный профиль на запуск с гарантированной очисткой.
Что делать, если процессов, порождающих временные профили, много и они не все под вашим контролем (например сторонние ноды в n8n)?
В таких случаях фоновая очистка /tmp по возрасту становится не подстраховкой, а основной линией защиты — код сторонних модулей вы не контролируете, а cron-очистку и лимит -mmin настраиваете сами.
Поможет ли просто увеличить диск, чтобы проблема не повторялась?
Отложит симптом, но не устранит рост — без явной очистки временные профили будут копиться и на большем диске, только инцидент случится позже. Разумнее сначала исправить причину, а увеличение диска держать как запас на будущий рост нагрузки, а не как компенсацию утечки.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →