Один вечер, три проблемы: почта упала, память кончилась, не тот docker arch

Published: 2026-08-13

Всё крутится на одной маленькой VM: 4GB RAM, k0s, один нод. Вечером mail.antonnovikov.com начал отдавать 503. Починил, но по пути выяснилось, что дело не в почте, а в памяти на всей ноде. А потом ещё сломал маленький сайт при редеплое из-за не той архитектуры процессора. Это пост про весь вечер по порядку, с командами, которые реально запускал.


Проблема 1: mail.antonnovikov.com — 503

Первая команда всегда одна и та же:

bashkubectl get pods -A | grep -v Running

Вывод:

webmail   snappymail-7fd545b5bf-tvhxx   0/1   CrashLoopBackOff   221 (13s ago)   251d

221 рестарт. Под падает давно, не сегодня. Просто сегодня он завис в упавшем состоянии достаточно долго, чтобы APISIX начал отдавать 503 (нет живого backend'а).

bashkubectl describe pod -n webmail snappymail-7fd545b5bf-tvhxx
Last State:  Terminated
  Reason:    OOMKilled
  Exit Code: 137

OOMKilled. Лимит памяти был 256Mi, контейнер медленно растёт (обычная утечка PHP-FPM воркеров) и его убивает где-то раз в день, 251 день подряд. Никто не замечал, потому что под быстро поднимается обратно — просто сегодня момент был неудачный и он провисел дольше обычного.

Фикс: поднять лимит.

yamlresources:
  limits:
    memory: "384Mi"  # было 256Mi
    cpu: "500m"
bashkubectl patch deployment snappymail -n webmail --type='json' \
  -p='[{"op":"replace","path":"/spec/template/spec/containers/0/resources/limits/memory","value":"384Mi"}]'

Почта поднялась. Но это заставило посмотреть не только на под, а на ноду целиком.


Проблема 2: на ноде почти нет свободной памяти

bashssh root@$SERVER_IP free -h
              total   used   free   shared  buff/cache  available
Mem:          3.8Gi   3.4Gi  120Mi  27Mi     553Mi       404Mi
Swap:         2.0Gi   801Mi  1.2Gi

120Mi свободно, 800Mi уже в свопе. Не очень для коробки на 4GB, где крутится весь control-plane k0s плюс ~30 подов (VPN-прокси, мониторинг, почта, пара мелких сайтов).

bashkubectl describe node antonnovikov.com | grep -A5 "Allocated resources"
Resource   Requests          Limits
--------   --------          ------
cpu        1105m (18%)       7250m (120%)
memory     1909048192 (47%)  6240Mi (163%)

163% overcommit по лимитам памяти. На одной ноде это само по себе не катастрофа (переезжать всё равно некуда), но значит: если два-три пода вырастут одновременно — кого-то убьёт OOM.

Что реально урезал

Я не стал просто везде занижать лимиты — на одной ноде это ничего не меняет по факту, реально занятая память есть реально занятая память вне зависимости от того, что написано в limits. Искал настоящую, безопасную экономию.

VictoriaMetrics (vmsingle) — жрал 396Mi.

bashhelm get values vmsingle -n monitoring -a > /tmp/vmsingle-live.yaml
# memory.allowedPercent: 30 -> 20  (потолок кэша, данные не теряются)
helm upgrade vmsingle vm/victoria-metrics-single -n monitoring -f /tmp/vmsingle-live.yaml

Позже ещё поднял глобальный scrape interval 30s -> 60s. Это вдвое снизило скорость записи метрик — меньше CPU, меньше записи на диск, меньше давление на память. Заодно это важно для проблемы 3 ниже.

Sidecar дашбордов в Grafana — контейнер k8s-sidecar, который следит за ConfigMap'ами и живьём перезагружает дашборды. Стоил ~90-130Mi, просто сидел и смотрел. Я не так часто правлю дашборды, поэтому:

yamlsidecar:
  dashboards:
    enabled: false  # было true
bashhelm upgrade grafana grafana/grafana -n monitoring -f platform/monitoring/values/grafana.yaml

Дашборды как работали, так и работают. Если теперь что-то поправлю в ConfigMap — нужен kubectl rollout restart deployment/grafana -n monitoring, чтобы подхватилось. Небольшая цена за -100Mi.

tor-proxy — ротирующий Tor HTTP-прокси, крутился с TOR_INSTANCES=2 (2 tor-демона), 223Mi.

bashkubectl set env deployment/tor-proxy -n tor TOR_INSTANCES=1

Сэкономил ~85Mi. Работает так же, просто меньше параллельных exit-цепочек.

Что НЕ трогал

APISIX, хотя он сам по себе самый прожорливый (~400Mi). В своём же install-скрипте нашёл комментарий из прошлого инцидента:

«Под реальной нагрузкой (blackbox-пробы + реальный трафик) CPU упёрся в лимит 500m и застрял — http и https оба перестали отвечать».

После этого лимиты подняли с 256Mi/500m до 512Mi/1000m. Возвращаться туда не буду. Если когда-то захочу урезать APISIX — нужен второй нод, а не меньший лимит на этом.

Стек алертинга (alertmanager, vmalert, kube-state-metrics, blackbox-exporter) — чуть было не предложил его тоже вырезать, потому что Slack/PagerDuty коннекторы не настроены. Ошибочное предположение: алерты идут напрямую в Telegram, стек реально работает. Вывод: «коннектор X не настроен» не значит «фича не используется». Нужно смотреть в реальный конфиг ресивера, а не только на то, что сам когда-то подключал.


Проблема 3: диск читает ~40-70 МБ/с весь вечер

В Grafana ровный, устойчивый график чтения с диска. Первая мысль — «что-то сканирует много данных». Мысль неверная. Сначала проверил I/O по процессам, но ничего не объясняло 40-70 МБ/с:

bash# снять /proc/<pid>/io read_bytes дважды с разницей 5 секунд, посчитать дельту

Ничего не выделялось. Тогда проверил своп напрямую:

bashvmstat 2 6
procs -----------memory---------- ---swap-- -----io----
 r  b   swpd   free  buff  cache    si   so    bi    bo
 5  0  758556 119432 9736 672540  7836   74  44346  252

si (swap in) и bi (чтение блоков) растут вместе. Вот и ответ: это не один болтливый процесс, это своп-трэшинг. При ~120Mi свободной памяти ядро постоянно подкачивает страницы процессов из свопа туда-обратно, и каждый page-in — это реальное чтение с диска. График ровный, потому что давление на память постоянное, а не разовый всплеск.

То есть проблема 3 и проблема 2 — одна и та же проблема. Освобождение реальной памяти (vmsingle, grafana sidecar, tor-proxy) чинит и график диска тоже, отдельно диск тюнить не нужно было.


Проблема 4: передеплоил сайт, сломал его не той архитектурой

Параллельно со всем этим ещё передеплоил маленький статический сайт (мне-похуй.рф) с обновлённым дизайном. Обычный docker build && push && kubectl apply, делал так сто раз. В этот раз сломалось:

bashkubectl get pods -n default -l app=mne-pohui-site
mne-pohui-site-587fc87586-gmp62   0/1   CrashLoopBackOff
bashkubectl logs -n default -l app=mne-pohui-site
exec /docker-entrypoint.sh: exec format error

exec format error почти всегда значит: не та архитектура процессора. Мой ноутбук — Apple Silicon (arm64), нода k0s — amd64. Деплой-скрипт обычно собирает через docker buildx build --platform linux/amd64, но docker buildx на этой машине вообще не был установлен — сборка молча откатилась на обычный docker build, который собрал под arm64 (родная архитектура ноутбука), запушил это, а amd64-нода такое запустить не смогла.

Заодно по пути нашёл: у локального Docker (colima, не Docker Desktop) не был прописан insecure-registries, поэтому пуш во внутренний registry 91.184.248.13:30500 падал с ошибкой HTTPS ещё до того, как дошло до проблемы с архитектурой.

Починил оба момента:

bash# 1. поставить недостающий плагин buildx
brew install docker-buildx
ln -sf /opt/homebrew/opt/docker-buildx/bin/docker-buildx ~/.docker/cli-plugins/docker-buildx
yaml# 2. ~/.colima/default/colima.yaml
docker:
  insecure-registries:
    - 91.184.248.13:30500
bashcolima stop && colima start

Пересобрал уже честно под --platform linux/amd64, запушил, kubectl rollout restart. Старый под всё это время продолжал отдавать трафик (Kubernetes не убирает старую реплику, пока новая не станет Ready), так что для посетителей реального даунтайма не было — просто застрявший rollout, который нужно было заметить и починить.


Что может пойти не так (если повторяешь у себя)

  • Поднять лимит памяти контейнеру — не значит починить утечку, это значит отсрочить падение. Если snappymail снова упадёт по OOM на 384Mi, утечка никуда не делась — нужно либо периодически рестартовать PHP-FPM воркеры, либо искать реальную утечку.
  • Не режь ресурсы у компонента, про который у тебя же есть задокументированный инцидент про урезание. Читай свои старые комментарии в скриптах перед «оптимизацией». Чуть не полез в APISIX, пока не перечитал свой же install-скрипт.
  • График памяти и график диска могут быть одной и той же причиной. Перед тем как гоняться за конкретным процессом по диску — проверь своп (vmstat, si/so). На ноде с нехваткой памяти это почти всегда своп.
  • exec format error = не та архитектура, а не битый образ. Проверяй, что docker buildx build --platform реально используется, а не тихо пропускается, потому что buildx не установлен.
  • Rolling update защищает больше, чем кажется. Сломанный новый под под Deployment сам по себе не роняет сайт — старая реплика живёт, пока новая не станет Ready. Чинить всё равно надо, но это не аварийная ситуация сама по себе.

Итого

  • Почта 503 → snappymail упал по OOM, поднял лимит 256Mi → 384Mi
  • Реальная причина — нехватка памяти на всей ноде (163% overcommit по лимитам, 120Mi свободно, 800Mi в свопе)
  • Освободил реальную память: потолок кэша vmsingle 30%→20% + scrape interval 30s→60s, sidecar дашбордов grafana выключен (~100Mi), tor-proxy 2→1 демон (~85Mi)
  • APISIX не трогал — прошлый инцидент уже доказал, что урезание его лимитов роняет сайт под нагрузкой
  • Стек алертинга оставил — он подключён к Telegram, «нет коннектора Slack/PagerDuty» было ложным сигналом о неиспользуемости
  • Высокий disk I/O оказался своп-трэшингом из-за нехватки памяти, а не конкретным процессом — та же причина, что и всё остальное
  • Отдельно: сломал и починил редеплой из-за arm64/amd64 — buildx не был установлен, docker build молча собрал не под ту архитектуру