<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Заметки on Gegemonia</title><link>https://gegemonia.com/posts/</link><description>Recent content in Заметки on Gegemonia</description><generator>Hugo</generator><language>ru-RU</language><lastBuildDate>Mon, 26 Oct 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://gegemonia.com/posts/index.xml" rel="self" type="application/rss+xml"/><item><title>SSH: что реально нужно в конфиге</title><link>https://gegemonia.com/posts/ssh-config/</link><pubDate>Mon, 26 Oct 2026 00:00:00 +0000</pubDate><guid>https://gegemonia.com/posts/ssh-config/</guid><description>Пять строк, которые снимают 90% проблем, и две настройки, которые ломают доступ.</description><content:encoded><![CDATA[<p>Каждый раз, поднимая новый сервер, я правлю конфиг руками. В итоге набор строк устоялся, и он короче, чем кажется по статьям в интернете.</p>
<h2 id="клиентская-часть-sshconfig">Клиентская часть: ~/.ssh/config</h2>
<pre tabindex="0"><code>Host *
  ServerAliveInterval 30
  ServerAliveCountMax 3
  HashKnownHosts yes

Host prod
  HostName 203.0.113.10
  User deploy
  IdentityFile ~/.ssh/id_ed25519_prod
  IdentitiesOnly yes
</code></pre><p><code>ServerAliveInterval</code> — чтобы соединение не рвалось на «простаивающем» NAT. <code>IdentitiesOnly yes</code> — чтобы клиент не перебирал все ключи по очереди: при десяти ключах это и задержка, и отказ аутентификации на серверах с <code>MaxAuthTries 3</code>.</p>
<h2 id="серверная-часть-etcsshsshd_configd99-hardeningconf">Серверная часть: /etc/ssh/sshd_config.d/99-hardening.conf</h2>
<pre tabindex="0"><code>PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin prohibit-password
MaxAuthTries 3
AllowUsers deploy
</code></pre><p>Порядок применения: файлы из <code>sshd_config.d/</code> подключаются директивой <code>Include</code> в начале <code>sshd_config</code>, и для каждого параметра действует <strong>первое</strong> найденное значение. Поэтому дублирующий <code>PermitRootLogin yes</code> ниже по файлу ни на что не влияет — но проверять надо именно <code>sshd -T</code>, а не глазами:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sshd -T <span class="p">|</span> grep -E <span class="s1">&#39;passwordauthentication|permitrootlogin|maxauthtries&#39;</span>
</span></span></code></pre></div><p><code>sshd -T</code> показывает эффективную конфигурацию после всех включений. Это единственный способ убедиться, что вы запретили пароли, а не думаете, что запретили.</p>
<h2 id="что-я-перестал-настраивать">Что я перестал настраивать</h2>
<ul>
<li><strong>Смену порта.</strong> От перебора не спасает (сканируют всё), а в конфигах и документации создаёт мусор. Вход по ключу с <code>MaxAuthTries 3</code> решает ту же задачу честнее.</li>
<li><strong><code>fail2ban</code> при выключенных паролях.</strong> Банить нечего: подобрать ключ перебором нельзя, а логи забиваются шумом ботов, которые всё равно отсекаются на этапе аутентификации.</li>
</ul>
<h2 id="одна-вещь-которую-стоит-сделать-сразу">Одна вещь, которую стоит сделать сразу</h2>
<p>Положить второй ключ (резервный, в другом месте) в <code>authorized_keys</code> до того, как выключите пароли. Звучит банально, но именно потеря единственного ключа — самая частая причина, по которой люди возвращают вход по паролю и через месяц теряют сервер.</p>
]]></content:encoded></item><item><title>Мониторинг, который не зовёт по ночам</title><link>https://gegemonia.com/posts/quiet-monitoring/</link><pubDate>Mon, 12 Oct 2026 00:00:00 +0000</pubDate><guid>https://gegemonia.com/posts/quiet-monitoring/</guid><description>Если алерт не требует действия прямо сейчас, это не алерт, а график.</description><content:encoded><![CDATA[<p>Плохой мониторинг — это когда сообщение приходит на каждый скачок нагрузки, и через неделю его перестают читать. Хороший начинается не с графиков, а с вопроса: что именно для нас «сломано»?</p>
<h2 id="четыре-вопроса-вместо-трёхсот-панелей">Четыре вопроса вместо трёхсот панелей</h2>
<p>Обычно ответов на этот вопрос три-четыре:</p>
<ol>
<li>Пользователь не может войти.</li>
<li>Заказы не создаются.</li>
<li>Выгрузка не уехала получателю.</li>
<li>Резервная копия не снялась.</li>
</ol>
<p>Под каждый ответ — одна проверка, желательно снаружи сервиса, эмулирующая реальный сценарий: авторизация, создание сущности, чтение файла из хранилища, наличие вчерашней копии.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># простейший чёрный ящик: сценарий, а не порт</span>
</span></span><span class="line"><span class="cl">curl -fsS --max-time <span class="m">10</span> -o /dev/null <span class="se">\
</span></span></span><span class="line"><span class="cl">  -H <span class="s2">&#34;Authorization: Bearer </span><span class="nv">$PROBE_TOKEN</span><span class="s2">&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  https://api.example.com/health/deep
</span></span></code></pre></div><p><code>/health/deep</code> отличается от <code>/health</code> тем, что действительно ходит в базу, в кэш и в очередь. Обычный <code>/health</code>, который возвращает <code>{&quot;status&quot;:&quot;ok&quot;}</code> из константы, не проверяет ничего — его отсутствие и присутствие одинаково бесполезны.</p>
<h2 id="что-считать-алертом">Что считать алертом</h2>
<p>Правило, к которому пришёл: <strong>алерт обязан требовать действия в момент получения</strong>. Если дежурный, получив сообщение, идёт смотреть график, чтобы понять, что происходит, — это заметка, а не алерт. Заметки пусть живут на дашборде.</p>
<p>Отсюда три категории:</p>
<ul>
<li><strong>Страница</strong> (звонок): пользователь страдает прямо сейчас, и есть что сделать руками.</li>
<li><strong>Тикет</strong> (утро): деградация, которая станет аварией через дни — место на диске, истекающий сертификат, деградация бэкапа.</li>
<li><strong>График</strong> (молчание): всё остальное, включая нагрузку, задержки и ошибки в пределах нормы.</li>
</ul>
<h2 id="пороги-вместо-средних">Пороги вместо средних</h2>
<p>Каждый алерт должен иметь два параметра: условие и выдержку по времени. «Доля ошибок выше 1% в течение пяти минут» — нормально. «Ошибка была» — нет. Без выдержки вы будете просыпаться от одного ретрая при деплое.</p>
<p>И последнее: раз в квартал полезно посмотреть на список алертов и вычеркнуть те, что за это время не срабатывали или срабатывали вхолостую. Мониторинг гниёт без чистки так же, как код.</p>
]]></content:encoded></item><item><title>Docker: почему я собираю образы не на сервере</title><link>https://gegemonia.com/posts/docker-build/</link><pubDate>Tue, 15 Sep 2026 00:00:00 +0000</pubDate><guid>https://gegemonia.com/posts/docker-build/</guid><description>Сборка на продакшене — это деплой, который сам себя может убить. И три способа этого избежать.</description><content:encoded><![CDATA[<p>Полтора года мы собирали образы на том же сервере, где они и запускались. Работало до момента, когда сборка упёрлась в память: контейнер с <code>mvn package</code> выел 1,5 ГБ, ядро OOM-killer выбрало самый жирный процесс и убило заодно базу. Деплой, который роняет прод, — это не деплой.</p>
<h2 id="что-плохого-в-сборке-на-сервере">Что плохого в сборке на сервере</h2>
<ul>
<li><strong>Память и процессор делятся с рабочими сервисами.</strong> Сборка — самый ресурсоёмкий процесс в жизни сервера.</li>
<li><strong>Тег <code>latest</code> пересобирается непредсказуемо.</strong> «На сервере сейчас какой образ?» — вопрос без ответа.</li>
<li><strong>Нет повторяемости.</strong> Зависимости кэшируются локально, и два запуска на двух машинах дают разные результаты.</li>
</ul>
<h2 id="как-делаю-теперь">Как делаю теперь</h2>
<p>Сборка в CI, на сервер приходит только готовый артефакт по неизменяемому тегу (хэш коммита), и запускается он через <code>docker compose</code> с явным тегом.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">app</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">registry.example.com/app:${TAG:?TAG is required}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">mem_limit</span><span class="p">:</span><span class="w"> </span><span class="l">768m</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">JAVA_TOOL_OPTIONS=-XX:MaxRAMPercentage=70</span><span class="w">
</span></span></span></code></pre></div><p>Три детали:</p>
<ul>
<li><code>${TAG:?TAG is required}</code> — compose откажется стартовать без явного тега. Никаких «взял что было».</li>
<li><code>mem_limit</code> — контейнер не имеет права вытеснить базу. Правильный лимит защищает от того, чтобы одна утечка превратилась в отказ всего сервера.</li>
<li><code>MaxRAMPercentage=70</code> — JVM в контейнере читает лимит и сам держится в нём, вместо того чтобы гадать по памяти хоста.</li>
</ul>
<h2 id="про-кэш-сборки">Про кэш сборки</h2>
<p>Если сборка в CI каждый раз с нуля — это минус десять минут жизни. Кэш слоёв (<code>cache-from</code>/<code>cache-to</code>) решается за одну строку в CI-конфиге, но работает только при правильном порядке инструкций в Dockerfile: сначала зависимости, потом исходники. Копирование всего проекта первой строкой обнуляет кэш при любом изменении.</p>
<h2 id="когда-собирать-локально-на-сервере-всё-же-нормально">Когда собирать локально на сервере всё же нормально</h2>
<p>Единственный случай — одноразовая отладка: когда нужно проверить, что именно ломается на целевой архитектуре. Но результат такой сборки не должен попадать в прод: это инструмент, а не процесс.</p>
]]></content:encoded></item><item><title>Логи: структура важнее объёма</title><link>https://gegemonia.com/posts/structured-logs/</link><pubDate>Thu, 20 Aug 2026 00:00:00 +0000</pubDate><guid>https://gegemonia.com/posts/structured-logs/</guid><description>Одна строка с полями полезнее двадцати строк текста, который никто не читал.</description><content:encoded><![CDATA[<p>Я долго писал логи как художественную прозу: «Получен запрос от пользователя, начинаем обработку, всё ок». Потом в инциденте нужно было ответить на простой вопрос — сколько запросов от одного клиента упало за последние десять минут, — и оказалось, что ответа в логах нет. Есть текст, из которого его надо выковыривать регулярками.</p>
<h2 id="что-менять">Что менять</h2>
<p><strong>Формат.</strong> JSON с фиксированными полями, по строке на событие:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="p">{</span><span class="nt">&#34;ts&#34;</span><span class="p">:</span><span class="s2">&#34;2026-08-20T11:04:21.314Z&#34;</span><span class="p">,</span><span class="nt">&#34;lvl&#34;</span><span class="p">:</span><span class="s2">&#34;error&#34;</span><span class="p">,</span><span class="nt">&#34;msg&#34;</span><span class="p">:</span><span class="s2">&#34;payment failed&#34;</span><span class="p">,</span><span class="nt">&#34;req_id&#34;</span><span class="p">:</span><span class="s2">&#34;a91f&#34;</span><span class="p">,</span><span class="nt">&#34;user_id&#34;</span><span class="p">:</span><span class="mi">4021</span><span class="p">,</span><span class="nt">&#34;amount&#34;</span><span class="p">:</span><span class="mi">1290</span><span class="p">,</span><span class="nt">&#34;err&#34;</span><span class="p">:</span><span class="s2">&#34;timeout&#34;</span><span class="p">,</span><span class="nt">&#34;dur_ms&#34;</span><span class="p">:</span><span class="mi">30114</span><span class="p">}</span>
</span></span></code></pre></div><p><strong>Идентификатор запроса.</strong> Одно поле <code>req_id</code>, которое генерируется на входе и протаскивается через все слои — включая вызовы в другие сервисы. Без него поиск по логам в микросервисах превращается в гадание.</p>
<p><strong>Отсутствие «успешных» строк.</strong> Логируем вход и выход одного запроса (с кодом и длительностью) и все ошибки. Всё промежуточное — не нужно; если нужен трейс, это задача трассировщика, а не логов.</p>
<h2 id="уровни-которые-действительно-работают">Уровни, которые действительно работают</h2>
<ul>
<li><code>error</code> — сломалось, и человек это увидел. Требует реакции.</li>
<li><code>warn</code> — сломалось, но обработали (ретрай удался, кэш промахнулся при деградации).</li>
<li><code>info</code> — значимые изменения состояния: старт, конфиг, деплой. Не «каждый запрос».</li>
</ul>
<p>Если <code>info</code> пишется на каждый запрос, диск кончится раньше, чем вы найдёте ошибку. У нас на 30 ГБ это случилось однажды за выходные.</p>
<h2 id="ротация-и-цена-хранения">Ротация и цена хранения</h2>
<p><code>logrotate</code> с <code>daily</code>, <code>rotate 14</code>, <code>compress</code>. Хранить больше двух недель в горячем виде почти никогда не нужно: инциденты разбираются за час, а не за месяц. Если нужен длинный срок — выгружайте агрегаты, а не сырые строки.</p>
<p>И маленькое правило, которое спасло пару раз: <strong>в логах не должно быть персональных данных и токенов</strong>. Маскирование ставится на уровне логгера, а не на уровне «мы аккуратно пишем». Потому что однажды ночью кто-то обязательно напишет <code>log.info(request.toString())</code>.</p>
]]></content:encoded></item><item><title>WebSocket за обратным прокси: таймауты, на которых всё виснет</title><link>https://gegemonia.com/posts/websocket-proxy/</link><pubDate>Thu, 11 Jun 2026 00:00:00 +0000</pubDate><guid>https://gegemonia.com/posts/websocket-proxy/</guid><description>Соединение установилось, работает и молча умирает через 60 секунд. Почему.</description><content:encoded><![CDATA[<p>Симптом: чат работает, потом через минуту-две сообщения перестают доходить, а клиент об этом не знает. Перезагрузка страницы помогает. В логах — тишина.</p>
<p>Это почти всегда не WebSocket, а обратный прокси, который закрывает «простаивающее» соединение.</p>
<figure>
  <img src="/images/ws-timeouts.svg" alt="Где именно рвётся WebSocket-соединение за nginx" width="720" height="250" loading="lazy">
</figure>
<h2 id="минимальный-правильный-блок">Минимальный правильный блок</h2>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-nginx" data-lang="nginx"><span class="line"><span class="cl"><span class="k">location</span> <span class="s">/ws</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="kn">proxy_pass</span> <span class="s">http://127.0.0.1:8080</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="kn">proxy_http_version</span> <span class="mi">1</span><span class="s">.1</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="kn">proxy_set_header</span> <span class="s">Upgrade</span> <span class="nv">$http_upgrade</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="kn">proxy_set_header</span> <span class="s">Connection</span> <span class="s">&#34;upgrade&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="kn">proxy_set_header</span> <span class="s">Host</span> <span class="nv">$host</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="kn">proxy_set_header</span> <span class="s">X-Real-IP</span> <span class="nv">$remote_addr</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="kn">proxy_read_timeout</span> <span class="s">3600s</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="kn">proxy_send_timeout</span> <span class="s">3600s</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="kn">proxy_buffering</span> <span class="no">off</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>Четыре вещи здесь важны все:</p>
<ul>
<li><code>proxy_http_version 1.1</code> — без него <code>Upgrade</code> не пройдёт, и вы получите бесконечный 101-цикл или тихий отказ.</li>
<li><code>proxy_set_header Upgrade/Connection</code> — собственно переключение протокола.</li>
<li><code>proxy_read_timeout</code> — дефолт 60 секунд. Если сервер не отправляет ничего минуту, nginx прибьёт соединение. Для чата это ровно та минута, через которую «всё сломалось».</li>
<li><code>proxy_buffering off</code> — иначе nginx копит ответы сервера и выдаёт их пачками.</li>
</ul>
<h2 id="почему-keepalive-не-спасает">Почему keepalive не спасает</h2>
<p>Клиенты и серверы часто шлют ping каждые 30 секунд именно чтобы обойти таймаут. Это работает, но создаёт другую проблему: если ping не проходит, вы узнаёте об этом только по отсутствию pong — то есть должны сами считать время. В приложении обязана быть проверка: если последний pong старше двух интервалов, соединение пересоздаётся.</p>
<h2 id="как-проверить-что-дело-в-прокси">Как проверить, что дело в прокси</h2>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">timeout <span class="m">120</span> curl -sN -o /dev/null -w <span class="s1">&#39;%{http_code} %{time_total}\n&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  -H <span class="s1">&#39;Connection: Upgrade&#39;</span> -H <span class="s1">&#39;Upgrade: websocket&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  -H <span class="s1">&#39;Sec-WebSocket-Version: 13&#39;</span> -H <span class="s1">&#39;Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  http://127.0.0.1:8080/ws
</span></span></code></pre></div><p>Если <code>curl</code> висит ровно 60 секунд и выходит — таймаут прокси. Если рвётся сразу — дело в бэкенде.</p>
<p>Ещё один слой, о котором забывают: балансировщики и облачные LB поверх nginx держат свои таймауты, и самый маленький из них и определяет реальное время жизни соединения. Ставить <code>3600s</code> в nginx бессмысленно, если перед ним стоит LB с лимитом 30 секунд.</p>
]]></content:encoded></item><item><title>Утечка памяти в Java-сервисе: как я её искал</title><link>https://gegemonia.com/posts/java-memory-leak/</link><pubDate>Sat, 02 May 2026 00:00:00 +0000</pubDate><guid>https://gegemonia.com/posts/java-memory-leak/</guid><description>Четыре шага от «сервис падает каждые три дня» до конкретной строки кода.</description><content:encoded><![CDATA[<p>Сервис работал ровно три дня, потом падал по <code>OutOfMemoryError</code>. Перезапуск лечил, поэтому три месяца все жили с дежурным, который проверял сервис по утрам. Классика жанра.</p>
<h2 id="шаг-1-понять-кто-ест-память">Шаг 1: понять, кто ест память</h2>
<p>Первое, что я сделал, — включил снятие дампа при падении:</p>
<pre tabindex="0"><code>-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/log/app/
</code></pre><p>Через три дня получил файл на 2,4 ГБ и открыл его в Eclipse MAT. Гистограмма показала: 78% кучи — объекты одного класса, <code>org.postgresql.jdbc.PgArray</code>. То есть не «утечка строк», а накопление массивов из базы.</p>
<h2 id="шаг-2-ошибка-которую-я-делал-сам">Шаг 2: ошибка, которую я делал сам</h2>
<p>Код выглядел безобидно:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-java" data-lang="java"><span class="line"><span class="cl"><span class="kd">public</span><span class="w"> </span><span class="n">List</span><span class="o">&lt;</span><span class="n">Item</span><span class="o">&gt;</span><span class="w"> </span><span class="nf">findAll</span><span class="p">()</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="n">List</span><span class="o">&lt;</span><span class="n">Item</span><span class="o">&gt;</span><span class="w"> </span><span class="n">result</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">new</span><span class="w"> </span><span class="n">ArrayList</span><span class="o">&lt;&gt;</span><span class="p">();</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="n">jdbcTemplate</span><span class="p">.</span><span class="na">query</span><span class="p">(</span><span class="s">&#34;select * from items&#34;</span><span class="p">,</span><span class="w"> </span><span class="n">rs</span><span class="w"> </span><span class="o">-&gt;</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="n">result</span><span class="p">.</span><span class="na">add</span><span class="p">(</span><span class="n">map</span><span class="p">(</span><span class="n">rs</span><span class="p">));</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="p">});</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">return</span><span class="w"> </span><span class="n">result</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">}</span><span class="w">
</span></span></span></code></pre></div><p>Строк было 8 миллионов. Каждый запрос загружал их целиком, а кэш в приложении держал результат «на всякий случай» с TTL 24 часа. При 40 обновлениях в час получалось ровно то, что и должно было получиться.</p>
<h2 id="шаг-3-почему-я-не-увидел-этого-в-метриках">Шаг 3: почему я не увидел этого в метриках</h2>
<p>Потому что метрика была <code>heap_used_bytes</code>, а падала она только в конце. Первые два с половиной дня потребление выглядело как «нормальная пила»: сборщик мусора успевал убирать мусор, и график шёл вдоль верхней границы. Смотря на эту пилу, я и потерял три месяца.</p>
<p>Правильная метрика — не использование, а частота сборок:</p>
<pre tabindex="0"><code>jvm_gc_collection_seconds_count{name=&#34;G1 Young Generation&#34;}
</code></pre><p>Когда молодая сборка начинает ходить в 10 раз чаще обычного, а паузы растут — память утекает, даже если график использования выглядит ровно.</p>
<h2 id="шаг-4-лечение">Шаг 4: лечение</h2>
<ul>
<li>Убрал <code>select *</code> — только нужные колонки.</li>
<li>Заменил загрузку полного списка на постраничную обработку через <code>QueryCursor</code> с <code>fetchSize</code>.</li>
<li>Кэш перевёл на Caffeine с <code>maximumSize</code> и весом по объёму, а не по количеству записей.</li>
</ul>
<p>После этого сервис работал без перезапуска полгода.</p>
<p>Вывод, который стоит записать: <code>OutOfMemoryError</code> почти никогда не случается внезапно. Он приходит ровно в момент, когда кривая, на которую никто не смотрел, пересекла границу.</p>
]]></content:encoded></item><item><title>Nginx: gzip, brotli и миф о скорости</title><link>https://gegemonia.com/posts/nginx-compression/</link><pubDate>Tue, 07 Apr 2026 00:00:00 +0000</pubDate><guid>https://gegemonia.com/posts/nginx-compression/</guid><description>Сжатие почти всегда упирается не в процессор, а в неправильный список типов.</description><content:encoded><![CDATA[<p>Стандартная история: включают <code>gzip on</code>, радуются, потом кто-то замечает, что JSON-ответы API приходят несжатыми. Причина почти всегда в одном: <code>gzip_types</code> по умолчанию содержит только <code>text/html</code>.</p>
<h2 id="что-настроить-в-первую-очередь">Что настроить в первую очередь</h2>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-nginx" data-lang="nginx"><span class="line"><span class="cl"><span class="k">gzip</span> <span class="no">on</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="k">gzip_vary</span> <span class="no">on</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="k">gzip_comp_level</span> <span class="mi">5</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="k">gzip_min_length</span> <span class="mi">512</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="k">gzip_proxied</span> <span class="s">any</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="k">gzip_types</span>
</span></span><span class="line"><span class="cl">    <span class="s">text/css</span>
</span></span><span class="line"><span class="cl">    <span class="s">text/plain</span>
</span></span><span class="line"><span class="cl">    <span class="s">text/xml</span>
</span></span><span class="line"><span class="cl">    <span class="s">application/json</span>
</span></span><span class="line"><span class="cl">    <span class="s">application/javascript</span>
</span></span><span class="line"><span class="cl">    <span class="s">application/xml</span>
</span></span><span class="line"><span class="cl">    <span class="s">image/svg+xml</span><span class="p">;</span>
</span></span></code></pre></div><p><code>gzip_comp_level 5</code> вместо дефолтной единицы — это разница между 12% и 45% экономии для JSON при почти нулевой разнице в процессорном времени. Девятка даёт ещё пару процентов и втрое больше CPU; на маленьком сервере это плохая сделка.</p>
<p><code>gzip_vary on</code> обязателен, если перед nginx стоит кэширующий прокси: без него кэш отдаст сжатый ответ клиенту, который его не понимает.</p>
<h2 id="brotli">Brotli</h2>
<p>Если nginx собран с <code>ngx_brotli</code>, картина такая: brotli выигрывает у gzip на тексте примерно 15–20%, но статику надо сжимать <strong>заранее</strong>, а не на лету.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-nginx" data-lang="nginx"><span class="line"><span class="cl"><span class="k">brotli</span> <span class="no">on</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="k">brotli_comp_level</span> <span class="mi">6</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="k">brotli_static</span> <span class="no">on</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="k">brotli_types</span> <span class="s">text/css</span> <span class="s">application/javascript</span> <span class="s">application/json</span> <span class="s">image/svg+xml</span><span class="p">;</span>
</span></span></code></pre></div><p><code>brotli_static on</code> заставляет nginx искать готовый <code>.br</code> рядом с файлом. Это единственный вариант, где brotli не стоит ничего.</p>
<h2 id="о-чём-забывают">О чём забывают</h2>
<p><strong>Не сжимать уже сжатое.</strong> JPEG, PNG, WOFF2, MP4 — они уже упакованы; попытка их gzip-нуть тратит процессор и иногда увеличивает размер.</p>
<p><strong>Не сжимать ответы, которые отдаёт приложение потоком.</strong> Стриминговые события (SSE, длинные выгрузки) при сжатии буферизуются, и «поток» превращается в «приходит в конце». Лечится <code>gzip off;</code> для конкретного location.</p>
<p><strong>Проверять надо не на глаз.</strong> Одна команда отвечает на все вопросы:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">curl -sI -H <span class="s1">&#39;Accept-Encoding: gzip, br&#39;</span> https://example.com/api/items <span class="se">\
</span></span></span><span class="line"><span class="cl">  <span class="p">|</span> grep -iE <span class="s1">&#39;content-encoding|content-length|vary&#39;</span>
</span></span></code></pre></div><p>Если <code>content-encoding</code> пуст, а <code>content-length</code> равен несжатому размеру — сжатие не включилось, и весь ваш конфиг выше работает вхолостую.</p>
<p>И самое неочевидное: экономия от сжатия заметна на медленном канале и почти не заметна на быстром. Прежде чем оптимизировать, стоит измерить, что вообще является узким местом.</p>
]]></content:encoded></item><item><title>set -euo pipefail и его тихие исключения</title><link>https://gegemonia.com/posts/bash-pipefail/</link><pubDate>Wed, 18 Mar 2026 00:00:00 +0000</pubDate><guid>https://gegemonia.com/posts/bash-pipefail/</guid><description>Три флага, которые все ставят, и четыре места, где они не работают.</description><content:encoded><![CDATA[<p><code>#!/bin/bash</code> плюс <code>set -euo pipefail</code> — стандартная первая строка любого моего скрипта. Но сама по себе она не делает скрипт надёжным, и вот на чём это ломается.</p>
<h2 id="-e-не-срабатывает-в-условиях"><code>-e</code> не срабатывает в условиях</h2>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">set</span> -e
</span></span><span class="line"><span class="cl"><span class="k">if</span> grep -q pattern file.txt<span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;нашли&#34;</span>
</span></span><span class="line"><span class="cl"><span class="k">fi</span>
</span></span></code></pre></div><p>Здесь <code>grep</code> может вернуть 1, и скрипт не упадёт — потому что команда стоит в условии. Это правильно по спецификации, но означает, что «у меня же стоит <code>set -e</code>» ничего не гарантирует для всего, что находится внутри <code>if</code>, <code>while</code>, <code>&amp;&amp;</code> и <code>||</code>.</p>
<h2 id="конвейер-падает-только-по-последней-команде">Конвейер падает только по последней команде</h2>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">set</span> -e
</span></span><span class="line"><span class="cl">gzip -c huge.log <span class="p">|</span> ssh backup <span class="s2">&#34;cat &gt; huge.log.gz&#34;</span>
</span></span></code></pre></div><p>Если <code>gzip</code> умер на середине, а <code>ssh</code> успешно записал обрезанный файл, код возврата конвейера — 0. Именно от этого спасает <code>pipefail</code>, но и он смотрит на код, а не на смысл: оба процесса завершились «успешно», просто данных меньше ожидаемого.</p>
<h2 id="подстановка-команд-в-присваивании">Подстановка команд в присваивании</h2>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">set</span> -e
</span></span><span class="line"><span class="cl"><span class="nv">COUNT</span><span class="o">=</span><span class="k">$(</span>grep -c error app.log<span class="k">)</span>
</span></span></code></pre></div><p><code>grep -c</code> возвращает 1, когда совпадений нет. При <code>set -e</code> скрипт упадёт на пустом логе — при том, что пустой лог это нормальное состояние. Пишу так:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nv">COUNT</span><span class="o">=</span><span class="k">$(</span>grep -c error app.log <span class="o">||</span> <span class="nb">true</span><span class="k">)</span>
</span></span></code></pre></div><h2 id="-u-и-массивы"><code>-u</code> и массивы</h2>
<p>Обращение к пустому массиву как <code>&quot;${arr[@]}&quot;</code> в старых версиях bash падало при <code>-u</code>. В bash 4.4+ это исправлено, но на серверах с bash 4.2 (CentOS 7, да) — по-прежнему грабли. Безопасно: <code>&quot;${arr[@]:-}&quot;</code>.</p>
<h2 id="что-я-добавляю-сверху">Что я добавляю сверху</h2>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">set</span> -Eeuo pipefail
</span></span><span class="line"><span class="cl"><span class="nb">trap</span> <span class="s1">&#39;echo &#34;failed at line $LINENO: $BASH_COMMAND&#34; &gt;&amp;2&#39;</span> ERR
</span></span></code></pre></div><p><code>-E</code> заставляет <code>ERR</code>-ловушку наследоваться функциями, <code>LINENO</code> в сообщении сразу указывает, где именно. Без этого отладка скрипта в три часа ночи превращается в чтение логов через <code>bash -x</code> и догадки.</p>
<p>Ещё одна привычка: любую команду, которая меняет данные, оборачиваю в функцию с проверкой предусловия. Дешевле, чем восстанавливать базу из бэкапа, снятого вчера.</p>
]]></content:encoded></item><item><title>Три метрики Prometheus, которых обычно хватает</title><link>https://gegemonia.com/posts/prometheus-metrics/</link><pubDate>Fri, 27 Feb 2026 00:00:00 +0000</pubDate><guid>https://gegemonia.com/posts/prometheus-metrics/</guid><description>Меньше графиков, больше смысла: насыщение, ошибки и задержка — остальное диагностика.</description><content:encoded><![CDATA[<p>На мониторинг легко подсесть: каждый дашборд хочется дополнить ещё одной панелью, и через полгода на экране триста графиков, на которые никто не смотрит. Между тем почти все реальные аварии описываются тремя вопросами.</p>
<h2 id="1-насыщение--есть-ли-предел-впереди">1. Насыщение — есть ли предел впереди</h2>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-promql" data-lang="promql"><span class="line"><span class="cl"><span class="mi">1</span><span class="w"> </span><span class="o">-</span><span class="w"> </span><span class="k">avg</span><span class="w"> </span><span class="k">by</span><span class="w"> </span><span class="o">(</span><span class="nv">instance</span><span class="o">)</span><span class="w"> </span><span class="o">(</span><span class="kr">rate</span><span class="o">(</span><span class="nv">node_cpu_seconds_total</span><span class="p">{</span><span class="nl">mode</span><span class="o">=</span><span class="p">&#34;</span><span class="s">idle</span><span class="p">&#34;}[</span><span class="s">5m</span><span class="p">]</span><span class="o">))</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">max</span><span class="w"> </span><span class="k">by</span><span class="w"> </span><span class="o">(</span><span class="nv">instance</span><span class="o">)</span><span class="w"> </span><span class="o">(</span><span class="nv">node_filesystem_avail_bytes</span><span class="p">{</span><span class="nl">fstype</span><span class="o">!~</span><span class="p">&#34;</span><span class="s">tmpfs|overlay</span><span class="p">&#34;}</span><span class="w"> </span><span class="o">/</span><span class="w"> </span><span class="nv">node_filesystem_size_bytes</span><span class="o">)</span><span class="w">
</span></span></span></code></pre></div><p>Процессор и диск — не потому, что «так принято», а потому что это единственные ресурсы, которые упираются в физический потолок и не расширяются нажатием кнопки.</p>
<h2 id="2-ошибки--по-факту-а-не-по-коду-ответа">2. Ошибки — по факту, а не по коду ответа</h2>
<p>Код 200 ничего не значит: приложение может возвращать «успех» с пустым списком. Считаю по бизнес-событиям:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-promql" data-lang="promql"><span class="line"><span class="cl"><span class="k">sum</span><span class="o">(</span><span class="kr">rate</span><span class="o">(</span><span class="nv">http_requests_total</span><span class="p">{</span><span class="nl">status</span><span class="o">=~</span><span class="p">&#34;</span><span class="s">5..</span><span class="p">&#34;}[</span><span class="s">5m</span><span class="p">]</span><span class="o">))</span><span class="w"> </span><span class="k">by</span><span class="w"> </span><span class="o">(</span><span class="nv">route</span><span class="o">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="o">/</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">sum</span><span class="o">(</span><span class="kr">rate</span><span class="o">(</span><span class="nv">http_requests_total</span><span class="p">[</span><span class="s">5m</span><span class="p">]</span><span class="o">))</span><span class="w"> </span><span class="k">by</span><span class="w"> </span><span class="o">(</span><span class="nv">route</span><span class="o">)</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="mf">0.01</span><span class="w">
</span></span></span></code></pre></div><p>Алерт на «доля пятисоток выше 1% пять минут подряд». Пять минут — чтобы одиночный всплеск при деплое не будил.</p>
<h2 id="3-задержка--p99-а-не-среднее">3. Задержка — p99, а не среднее</h2>
<p>Среднее по задержке прячет ровно ту часть пользователей, которой плохо.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-promql" data-lang="promql"><span class="line"><span class="cl"><span class="kr">histogram_quantile</span><span class="o">(</span><span class="mf">0.99</span><span class="p">,</span><span class="w"> </span><span class="k">sum</span><span class="w"> </span><span class="k">by</span><span class="w"> </span><span class="o">(</span><span class="nv">le</span><span class="p">,</span><span class="w"> </span><span class="nv">route</span><span class="o">)</span><span class="w"> </span><span class="o">(</span><span class="kr">rate</span><span class="o">(</span><span class="nv">http_request_duration_seconds_bucket</span><span class="p">[</span><span class="s">5m</span><span class="p">]</span><span class="o">)))</span><span class="w">
</span></span></span></code></pre></div><p>Если p99 втрое выше p50 — это не «медленно», это отдельный режим работы, и у него есть причина (пул соединений, лок, холодный кэш).</p>
<h2 id="чего-не-хватает-чаще-всего">Чего не хватает чаще всего</h2>
<p>Не метрик, а <strong>разметки</strong>. <code>route</code> с высоким кодом кардинальности (<code>/user/12345</code>) убивает Prometheus; поэтому в приложении должен быть шаблон пути, а не подставленный id. Это одна строка в middleware, но без неё все три запроса выше бесполезны.</p>
<p>И ещё: алерт обязан требовать действия. Если дежурный, получив сообщение, идёт смотреть график «чтобы понять, что происходит», — это не алерт, это заметка. Заметки пусть живут на дашборде.</p>
]]></content:encoded></item><item><title>Резервные копии, которые проверяют себя сами</title><link>https://gegemonia.com/posts/backups-verify/</link><pubDate>Tue, 03 Feb 2026 00:00:00 +0000</pubDate><guid>https://gegemonia.com/posts/backups-verify/</guid><description>Бэкап без проверки восстановления — это не бэкап, а надежда. Как сделать проверку автоматической.</description><content:encoded><![CDATA[<p>Классическая история: три года бэкапы снимаются, места занимают прилично, а в день аварии выясняется, что последний архив битый с прошлой весны. Никто не заметил, потому что никто не восстанавливал.</p>
<p>Правило, к которому я пришёл: <strong>каждый бэкап должен быть восстановлен автоматически, до того как его сочтут успешным.</strong></p>
<figure>
  <img src="/images/backup-verify.svg" alt="Схема проверки резервной копии: дамп, восстановление, проверки, алерт" width="720" height="210" loading="lazy">
</figure>
<h2 id="схема">Схема</h2>
<ol>
<li>Ночью снимается <code>pg_basebackup</code> или логическая копия.</li>
<li>Сразу после — восстановление в отдельный каталог на той же машине (или на дешёвой резервной).</li>
<li>Запуск PostgreSQL на нестандартном порту, три запроса на проверку.</li>
<li>Если любой шаг упал — алерт, а не тишина.</li>
</ol>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="cp">#!/bin/bash
</span></span></span><span class="line"><span class="cl"><span class="nb">set</span> -euo pipefail
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">DUMP</span><span class="o">=</span>/var/backups/pg/latest.dump
</span></span><span class="line"><span class="cl">pg_dump -Fc -Z6 -d app -f <span class="s2">&#34;</span><span class="nv">$DUMP</span><span class="s2">.new&#34;</span>
</span></span><span class="line"><span class="cl">mv <span class="s2">&#34;</span><span class="nv">$DUMP</span><span class="s2">.new&#34;</span> <span class="s2">&#34;</span><span class="nv">$DUMP</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">TESTDB</span><span class="o">=</span>verify_<span class="k">$(</span>date +%s<span class="k">)</span>
</span></span><span class="line"><span class="cl">createdb <span class="s2">&#34;</span><span class="nv">$TESTDB</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">pg_restore -d <span class="s2">&#34;</span><span class="nv">$TESTDB</span><span class="s2">&#34;</span> --no-owner --exit-on-error <span class="s2">&#34;</span><span class="nv">$DUMP</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">COUNT</span><span class="o">=</span><span class="k">$(</span>psql -tA -d <span class="s2">&#34;</span><span class="nv">$TESTDB</span><span class="s2">&#34;</span> -c <span class="s2">&#34;select count(*) from users&#34;</span><span class="k">)</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="o">[</span> <span class="s2">&#34;</span><span class="nv">$COUNT</span><span class="s2">&#34;</span> -lt <span class="m">1000</span> <span class="o">]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;verify failed: users=</span><span class="nv">$COUNT</span><span class="s2">&#34;</span> &gt;<span class="p">&amp;</span><span class="m">2</span>
</span></span><span class="line"><span class="cl">  <span class="nb">exit</span> <span class="m">1</span>
</span></span><span class="line"><span class="cl"><span class="k">fi</span>
</span></span><span class="line"><span class="cl">dropdb <span class="s2">&#34;</span><span class="nv">$TESTDB</span><span class="s2">&#34;</span>
</span></span></code></pre></div><p><code>--exit-on-error</code> в <code>pg_restore</code> — ключевой флаг. Без него битый дамп восстанавливается «частично», с кучей сообщений об ошибках и кодом возврата 0. Именно так бэкапы и умирают незаметно.</p>
<h2 id="что-проверять-кроме-команда-не-упала">Что проверять, кроме «команда не упала»</h2>
<ul>
<li><strong>Свежесть.</strong> Дата внутри дампа должна быть вчерашней. Файл, который не обновлялся неделю, обычно означает, что упал таймер.</li>
<li><strong>Объём.</strong> Резкое падение размера на 90% — почти всегда признак того, что дамп снялся с пустой базы.</li>
<li><strong>Наличие данных в ключевых таблицах.</strong> Порог <code>≥ 1000 строк</code> ловит случай «восстановили схему без данных».</li>
</ul>
<h2 id="отдельная-боль-права">Отдельная боль: права</h2>
<p>Восстанавливать надо тем же пользователем, который будет читать базу после аварии, иначе половина грантов потеряется. Мы держим <code>pg_dumpall --globals-only</code> отдельным файлом и восстанавливаем его первым — это две строки, которые экономят час ручной работы в самый неподходящий момент.</p>
<p>И последнее: проверка должна идти <strong>на другой машине</strong>. Восстановление на том же диске, который утром отвалится, проверяет только вашу веру в SAN.</p>
]]></content:encoded></item><item><title>systemd-таймеры: что я перестал делать через cron</title><link>https://gegemonia.com/posts/systemd-timers/</link><pubDate>Fri, 09 Jan 2026 00:00:00 +0000</pubDate><guid>https://gegemonia.com/posts/systemd-timers/</guid><description>Логи, повторы после падения и понятное состояние — три причины, по которым таймеры удобнее.</description><content:encoded><![CDATA[<p>К cron у меня нет претензий идеологического характера: он работает и никуда не денется. Но на серверах, где уже есть systemd, таймеры дают три вещи бесплатно.</p>
<h2 id="состояние-видно-без-чтения-логов">Состояние видно без чтения логов</h2>
<p><code>systemctl list-timers</code> показывает, когда задача запускалась и когда запустится в следующий раз. С cron для этого нужно держать в голове <code>crontab -l</code> и верить, что последний запуск вообще был.</p>
<pre tabindex="0"><code>$ systemctl list-timers --all | grep backup
Tue 2026-01-13 03:20:00 MSK  8h left  Sun 2026-01-12 03:20:11 MSK  15h ago  backup.timer  backup.service
</code></pre><h2 id="пропущенный-запуск-не-теряется">Пропущенный запуск не теряется</h2>
<p>Если сервер был выключен в момент запуска, cron молча пропускает задачу. Таймер с <code>Persistent=true</code> догоняет её при загрузке — для ночных бэкапов и ротации это ровно то поведение, которое нужно.</p>
<h2 id="юнит--это-файл-а-не-строка">Юнит — это файл, а не строка</h2>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="cl"><span class="c1"># /etc/systemd/system/backup.service</span>
</span></span><span class="line"><span class="cl"><span class="k">[Unit]</span>
</span></span><span class="line"><span class="cl"><span class="na">Description</span><span class="o">=</span><span class="s">Nightly database backup</span>
</span></span><span class="line"><span class="cl"><span class="na">After</span><span class="o">=</span><span class="s">network-online.target postgresql.service</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">[Service]</span>
</span></span><span class="line"><span class="cl"><span class="na">Type</span><span class="o">=</span><span class="s">oneshot</span>
</span></span><span class="line"><span class="cl"><span class="na">User</span><span class="o">=</span><span class="s">backup</span>
</span></span><span class="line"><span class="cl"><span class="na">ExecStart</span><span class="o">=</span><span class="s">/usr/local/bin/backup.sh</span>
</span></span><span class="line"><span class="cl"><span class="na">Nice</span><span class="o">=</span><span class="s">10</span>
</span></span><span class="line"><span class="cl"><span class="na">IOSchedulingClass</span><span class="o">=</span><span class="s">idle</span>
</span></span><span class="line"><span class="cl"><span class="na">TimeoutStartSec</span><span class="o">=</span><span class="s">2h</span>
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="cl"><span class="c1"># /etc/systemd/system/backup.timer</span>
</span></span><span class="line"><span class="cl"><span class="k">[Unit]</span>
</span></span><span class="line"><span class="cl"><span class="na">Description</span><span class="o">=</span><span class="s">Run backup nightly</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">[Timer]</span>
</span></span><span class="line"><span class="cl"><span class="na">OnCalendar</span><span class="o">=</span><span class="s">*-*-* 03:20:00</span>
</span></span><span class="line"><span class="cl"><span class="na">Persistent</span><span class="o">=</span><span class="s">true</span>
</span></span><span class="line"><span class="cl"><span class="na">RandomizedDelaySec</span><span class="o">=</span><span class="s">20m</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">[Install]</span>
</span></span><span class="line"><span class="cl"><span class="na">WantedBy</span><span class="o">=</span><span class="s">timers.target</span>
</span></span></code></pre></div><p><code>RandomizedDelaySec</code> — недооценённая строка. Двадцать минут разброса спасают от ситуации, когда десять серверов начинают бэкап в одну и ту же секунду и забивают канал.</p>
<h2 id="где-cron-всё-ещё-проще">Где cron всё ещё проще</h2>
<p>Разовые задания на пользователя и всё, что живёт вне системы: <code>@reboot</code>-скрипты, задачи в контейнерах без systemd, машины, где systemd нет вообще. Переписывать их ради единообразия смысла нет.</p>
<p>Ещё одна деталь: таймер не запускает следующий экземпляр, пока работает предыдущий. Для бэкапа это защита от наложения, но если задача может зависнуть — ставьте <code>TimeoutStartSec</code>, иначе таймер тихо перестанет срабатывать навсегда.</p>
]]></content:encoded></item><item><title>Почему VACUUM FULL почти никогда не нужен</title><link>https://gegemonia.com/posts/vacuum-full/</link><pubDate>Sun, 14 Dec 2025 00:00:00 +0000</pubDate><guid>https://gegemonia.com/posts/vacuum-full/</guid><description>Раздувание таблицы лечится настройкой autovacuum, а не блокировкой на весь день.</description><content:encoded><![CDATA[<p>Каждые пару месяцев кто-то приходит с фразой «таблица распухла, надо сделать VACUUM FULL». Почти всегда за этим стоит одно: autovacuum не успевает, потому что ему мешают настройки по умолчанию.</p>
<p><code>VACUUM FULL</code> переписывает таблицу целиком и держит <code>ACCESS EXCLUSIVE</code> на всё время работы. На таблице в 40 ГБ это часы, в которые никто не пишет и не читает. И это лечит симптом, а не причину: через неделю раздувание вернётся.</p>
<h2 id="смотреть-надо-не-на-размер-а-на-мусор">Смотреть надо не на размер, а на мусор</h2>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="n">relname</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">       </span><span class="n">n_live_tup</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">       </span><span class="n">n_dead_tup</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">       </span><span class="n">round</span><span class="p">(</span><span class="n">n_dead_tup</span><span class="p">::</span><span class="nb">numeric</span><span class="w"> </span><span class="o">/</span><span class="w"> </span><span class="n">greatest</span><span class="p">(</span><span class="n">n_live_tup</span><span class="p">,</span><span class="w"> </span><span class="mi">1</span><span class="p">)</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="mi">100</span><span class="p">,</span><span class="w"> </span><span class="mi">2</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">dead_pct</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">       </span><span class="n">last_autovacuum</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">FROM</span><span class="w"> </span><span class="n">pg_stat_user_tables</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">WHERE</span><span class="w"> </span><span class="n">n_dead_tup</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="mi">10000</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">ORDER</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">n_dead_tup</span><span class="w"> </span><span class="k">DESC</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">LIMIT</span><span class="w"> </span><span class="mi">20</span><span class="p">;</span><span class="w">
</span></span></span></code></pre></div><p>Если <code>dead_pct</code> держится выше 20%, а <code>last_autovacuum</code> недельной давности — проблема в том, что вакуум не доходит до таблицы, а не в том, что его мало.</p>
<h2 id="три-настройки-которые-обычно-и-виноваты">Три настройки, которые обычно и виноваты</h2>
<p><strong><code>autovacuum_vacuum_scale_factor</code> = 0.2.</strong> При дефолте вакуум приходит, когда мёртвых строк станет 20% от живых. Для таблицы в 10 миллионов строк это два миллиона — до этого момента место не освобождается. Ставим 0.02 для крупных таблиц.</p>
<p><strong><code>autovacuum_max_workers</code> = 3.</strong> Если в базе двести таблиц, три воркера физически не успевают обойти их все.</p>
<p><strong>Долгие транзакции.</strong> Пока открыта транзакция, которая началась до мусора, вакуум не может его убрать. Одна забытая сессия в <code>idle in transaction</code> держит мусор во всей базе. Проверяется мгновенно:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="n">pid</span><span class="p">,</span><span class="w"> </span><span class="k">state</span><span class="p">,</span><span class="w"> </span><span class="n">now</span><span class="p">()</span><span class="w"> </span><span class="o">-</span><span class="w"> </span><span class="n">xact_start</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">dur</span><span class="p">,</span><span class="w"> </span><span class="n">query</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">FROM</span><span class="w"> </span><span class="n">pg_stat_activity</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">WHERE</span><span class="w"> </span><span class="k">state</span><span class="w"> </span><span class="o">&lt;&gt;</span><span class="w"> </span><span class="s1">&#39;idle&#39;</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="n">now</span><span class="p">()</span><span class="w"> </span><span class="o">-</span><span class="w"> </span><span class="n">xact_start</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="nb">interval</span><span class="w"> </span><span class="s1">&#39;5 minutes&#39;</span><span class="p">;</span><span class="w">
</span></span></span></code></pre></div><h2 id="когда-vacuum-full-всё-таки-оправдан">Когда VACUUM FULL всё-таки оправдан</h2>
<p>Ровно два случая: таблица раздулась один раз и больше не растёт (архив), либо нужен переезд на другой tablespace. Во всех остальных случаях <code>pg_repack</code> делает то же самое без блокировки записи — и это единственный инструмент, который я держу под рукой для такой задачи.</p>
<p>Проверка простая: если вы не можете назвать причину раздувания, <code>VACUUM FULL</code> её не устранит.</p>
]]></content:encoded></item></channel></rss>