<?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/" xmlns:dc="http://purl.org/dc/elements/1.1/"><channel><title>BitPage</title><link>https://bitpage.ru/</link><description>Технический блог о разработке и DevOps</description><generator>Hugo</generator><language>ru-ru</language><copyright>BitPage</copyright><lastBuildDate>Sun, 06 Sep 2026 06:30:00 +0300</lastBuildDate><atom:link href="https://bitpage.ru/index.xml" rel="self" type="application/rss+xml"/><item><title>Место на диске съели индексы, а не данные: раздувание b-tree, которое autovacuum не лечит</title><link>https://bitpage.ru/databases/postgresql-index-bloat-reindex-avg-leaf-density/</link><pubDate>Sun, 06 Sep 2026 06:30:00 +0300</pubDate><dc:creator>BitPage</dc:creator><guid>https://bitpage.ru/databases/postgresql-index-bloat-reindex-avg-leaf-density/</guid><description>Дисковый алерт на базе PostgreSQL чаще означает раздувание индексов, а не таблиц: b-tree не сливает полупустые страницы, и плотность вместо дефолтных 90% падает до 13–15%. Autovacuum тут бессилен by design — помогает только REINDEX, а VACUUM FULL при нехватке места опасен, потому что пишет вторую копию таблицы целиком.</description><content:encoded>&lt;p>&lt;strong>Короткий ответ:&lt;/strong> если PostgreSQL занял диск, сначала разделите тело таблицы и её индексы — &lt;code>pg_relation_size&lt;/code> против &lt;code>pg_indexes_size&lt;/code>. При нагрузке из массовых вставок и удалений раздуваются именно индексы: b-tree освобождает только полностью опустевшие страницы, а полупустые оставляет в дереве навсегда. Плотность падает с дефолтных 90% до 13–15%, то есть индекс занимает вшестеро больше нужного. Autovacuum это не чинит ни при каких настройках — лечит только &lt;code>REINDEX&lt;/code>. А привычный &lt;code>VACUUM FULL&lt;/code> в такой ситуации опасен: он пишет вторую копию таблицы рядом и требует свободного места размером с неё.&lt;/p>
&lt;p>Началось всё с алерта про диск на стенде. Закончилось тем, что «мало места» оказалось не проблемой места, а проблемой скорости: на проде нашёлся индекс с плотностью в районе 15%, по которому идёт основной поток чтения. Каждое обращение к нему разбирает страницы, заполненные на одну шестую.&lt;/p>
&lt;p>Кластер тот же, что в &lt;a href="https://bitpage.ru/databases/postgresql-conflict-with-recovery-patroni-haproxy/">истории про conflict with recovery&lt;/a> и &lt;a href="https://bitpage.ru/databases/patroni-bootstrap-dcs-ansible-config-drift/">разборе bootstrap.dcs в Patroni&lt;/a>: PostgreSQL 15 под Patroni, синхронная реплика, Laravel-приложение сверху. Там же описана и нагрузка, которая всё это создаёт, — крон, раз в четверть часа переписывающий крупную таблицу целиком.&lt;/p>
&lt;h2 id="коротко-tldr">Коротко (TL;DR)&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Диагноз:&lt;/strong> раздуты индексы при здоровом теле таблицы. Проверяется одним запросом — &lt;code>pg_relation_size&lt;/code> (heap) отдельно от &lt;code>pg_indexes_size&lt;/code>.&lt;/li>
&lt;li>&lt;strong>Причина:&lt;/strong> b-tree переиспользует только полностью пустые страницы. Страница, с которой удалили все ключи кроме пары, остаётся в дереве занятой. PostgreSQL не сливает соседние разреженные страницы и не перебалансирует дерево.&lt;/li>
&lt;li>&lt;strong>Мера:&lt;/strong> &lt;code>avg_leaf_density&lt;/code> из &lt;code>pgstatindex&lt;/code>. Ориентир — 90%, дефолтный &lt;code>fillfactor&lt;/code> для b-tree. Всё, что заметно ниже, — раздувание.&lt;/li>
&lt;li>&lt;strong>Лечение:&lt;/strong> &lt;code>REINDEX INDEX CONCURRENTLY&lt;/code> по одному индексу. Пиковый оверхед — размер одного нового индекса, а не всей таблицы.&lt;/li>
&lt;li>&lt;strong>Чего не делать:&lt;/strong> &lt;code>VACUUM FULL&lt;/code> ради индексов. Он пишет новую копию таблицы и не отпускает старую до конца операции — при нехватке места добьёт диск в ноль.&lt;/li>
&lt;li>&lt;strong>Грабля, которая портит данные:&lt;/strong> уникальный индекс с &lt;code>idx_scan = 0&lt;/code> не мёртвый. Проверка уникальности при вставке — не сканирование, счётчик она не трогает.&lt;/li>
&lt;li>&lt;strong>Прогноз выигрыша:&lt;/strong> &lt;code>размер × плотность / 90&lt;/code>. На практике попадает в единицы процентов.&lt;/li>
&lt;/ul>
&lt;h2 id="почему-база-распухла--это-два-разных-диагноза">Почему «база распухла» — это два разных диагноза&lt;/h2>
&lt;p>Общий размер таблицы ничего не говорит о том, чем лечить, потому что тело и индексы болеют по-разному и разными лекарствами. Первым делом я посмотрел на топ таблиц по размеру — и чуть не выбрал не тот инструмент.&lt;/p>
&lt;p>Картина была такая (дальше все абсолютные величины модельные, соотношения — настоящие). Возьмём главную таблицу расчётов на два десятка миллионов строк: суммарно тридцать гигабайт, из них тело — четыре, а индексы — двадцать шесть. И рядом таблицу-кэш: живых строк ноль, тело в десятки мегабайт, индексы — под четыре гигабайта.&lt;/p>
&lt;p>Тело на четыре гигабайта при двух десятках миллионов строк — норма, тут лечить нечего. Вся масса в индексах, соотношение примерно шесть к одному. Разделяющий запрос занимает одну строку:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">8
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-sql" data-lang="sql">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">SELECT&lt;/span> relname,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> pg_size_pretty(pg_total_relation_size(relid)) &lt;span style="color:#ff79c6">AS&lt;/span> total,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> pg_size_pretty(pg_relation_size(relid)) &lt;span style="color:#ff79c6">AS&lt;/span> heap,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> pg_size_pretty(pg_indexes_size(relid)) &lt;span style="color:#ff79c6">AS&lt;/span> idx,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> n_live_tup, n_dead_tup
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">FROM&lt;/span> pg_stat_user_tables
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">ORDER&lt;/span> &lt;span style="color:#ff79c6">BY&lt;/span> pg_total_relation_size(relid) &lt;span style="color:#ff79c6">DESC&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">LIMIT&lt;/span> &lt;span style="color:#bd93f9">10&lt;/span>;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Разница принципиальная. Раздутое тело лечит &lt;code>VACUUM FULL&lt;/code>. Раздутые индексы при здоровом теле он тоже вылечит — попутно, перестроив их, — но заплатить придётся полной блокировкой и второй копией всей таблицы. Документация PostgreSQL про &lt;code>VACUUM FULL&lt;/code> говорит прямо:&lt;/p>
&lt;blockquote>
&lt;p>This method also requires extra disk space, since it writes a new copy of the table and doesn&amp;rsquo;t release the old copy until the operation is complete.&lt;/p>
&lt;/blockquote>
&lt;p>Вот тут и вылезает арифметика, из-за которой очевидный путь закрыт. На модельном стенде диск на 100 ГБ, занято 80, свободно 20. Таблица — 30. Свободного места меньше, чем размер таблицы, а &lt;code>VACUUM FULL&lt;/code> требует положить рядом полную копию. Он не «долго отработает», он упрётся в ноль свободных байт на середине и оставит базу в интересном состоянии.&lt;/p>
&lt;p>&lt;code>REINDEX&lt;/code> идёт по одному индексу: пиковый оверхед — размер одного нового индекса, и место возвращается после каждого шага. Разница между «нужно 30 ГБ разом» и «нужно 4 ГБ на шаг» — это разница между «нельзя» и «можно прямо сейчас».&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>&lt;/th>
&lt;th>&lt;code>VACUUM FULL&lt;/code>&lt;/th>
&lt;th>&lt;code>REINDEX INDEX&lt;/code>&lt;/th>
&lt;th>&lt;code>REINDEX INDEX CONCURRENTLY&lt;/code>&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Что чинит&lt;/td>
&lt;td>тело + все индексы&lt;/td>
&lt;td>один индекс&lt;/td>
&lt;td>один индекс&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Блокировка&lt;/td>
&lt;td>&lt;code>ACCESS EXCLUSIVE&lt;/code>&lt;/td>
&lt;td>&lt;code>ACCESS EXCLUSIVE&lt;/code>&lt;/td>
&lt;td>&lt;code>SHARE UPDATE EXCLUSIVE&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Пиковое доп. место&lt;/td>
&lt;td>размер всей таблицы&lt;/td>
&lt;td>размер одного индекса&lt;/td>
&lt;td>размер одного индекса&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Пишет в WAL&lt;/td>
&lt;td>много&lt;/td>
&lt;td>умеренно&lt;/td>
&lt;td>больше, чем обычный&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Гранулярность отката&lt;/td>
&lt;td>нет, всё или ничего&lt;/td>
&lt;td>по индексу&lt;/td>
&lt;td>по индексу&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="autovacuum-не-сломан-и-это-самое-контринтуитивное">Autovacuum не сломан, и это самое контринтуитивное&lt;/h2>
&lt;p>Место не возвращается не потому, что autovacuum не работает, а потому что он в принципе не делает того, что здесь нужно. Инстинкт подсказывает обратное, и я на него потратил время: полез проверять, не отключён ли автовакуум, нет ли зависшей транзакции, нет ли забытого репликационного слота, удерживающего horizon.&lt;/p>
&lt;p>Всё оказалось в порядке. Автовакуум включён, слотов лишних нет, зависших транзакций нет. А счётчик запусков на горячих таблицах шёл на десятки тысяч. Он работал на износ — и не помогал.&lt;/p>
&lt;p>Дальше стало понятно, почему. Тут работают три независимых механизма, и все три — штатное поведение, а не поломка.&lt;/p>
&lt;p>&lt;strong>Первый: VACUUM освобождает место внутри файла, а не отдаёт его операционной системе.&lt;/strong> Документация формулирует это с единственным исключением:&lt;/p>
&lt;blockquote>
&lt;p>The standard form of VACUUM removes dead row versions in tables and indexes and marks the space available for future reuse. However, it will not return the space to the operating system, except in the special case where one or more pages at the end of a table become entirely free and an exclusive table lock can be easily obtained.&lt;/p>
&lt;/blockquote>
&lt;p>«pages at the end of a table» — ключевое. При хаотичной перезаписи пустые страницы разбросаны по всему файлу, а не собираются в хвосте. Условие не выполняется практически никогда.&lt;/p>
&lt;p>&lt;strong>Второй, и это корень: b-tree не сливает полупустые страницы.&lt;/strong> Раздел «Routine Reindexing» описывает ровно наш случай:&lt;/p>
&lt;blockquote>
&lt;p>B-tree index pages that have become completely empty are reclaimed for re-use. However, there is still a possibility of inefficient use of space: if all but a few index keys on a page have been deleted, the page remains allocated. Therefore, a usage pattern in which most, but not all, keys in each range are eventually deleted will see poor use of space. For such usage patterns, periodic reindexing is recommended.&lt;/p>
&lt;/blockquote>
&lt;p>Обратите внимание на точность формулировки: полностью опустевшие страницы переиспользуются. А вот страница, с которой удалили все ключи кроме пары, остаётся занятой — и такой останется навсегда. PostgreSQL не объединяет соседние разреженные страницы и не перебалансирует дерево. Плотность падает и не восстанавливается. Никакая настройка автовакуума этого не меняет, потому что автовакуум тут вообще ни при чём.&lt;/p>
&lt;p>&lt;strong>Третий — акселератор: HOT-обновления не работают там, где нужнее всего.&lt;/strong> Если &lt;code>UPDATE&lt;/code> не трогает индексируемых колонок и на странице есть место, PostgreSQL обновляет строку, не касаясь индексов. На горячей таблице пересчёта доля HOT округляется до 0.0%: на миллиард обновлений быстрым путём проходят единицы миллионов. Значит каждое обновление дописывало новую запись в каждый индекс таблицы — а их там не один и не два.&lt;/p>
&lt;p>Механизм два объясняет, почему раздувание не рассасывается. Механизм три — почему оно накапливается так быстро.&lt;/p>
&lt;p>Есть и четвёртый фактор, из-за которого часть таблиц не обслуживается вовсе. &lt;code>autovacuum_vacuum_scale_factor&lt;/code> по умолчанию равен 0.2 — документация описывает его как «a fraction of the table size to add to &lt;code>autovacuum_vacuum_threshold&lt;/code>», и уточняет: «The default is 0.2 (20% of table size)». Возьмём таблицу на десять миллионов строк: порог — два миллиона мёртвых строк. Накопилась четверть миллиона. До порога далеко, &lt;code>autovacuum_count&lt;/code> остаётся нулём, и таблица не вакуумировалась ни разу за всё время жизни. Не потому что сломалось, а потому что так настроено по умолчанию.&lt;/p>
&lt;h2 id="как-измерить-раздувание-а-не-гадать-по-размеру">Как измерить раздувание, а не гадать по размеру&lt;/h2>
&lt;p>Мерить надо плотность листовых страниц, и у неё есть точный публичный ориентир. &lt;code>avg_leaf_density&lt;/code> из &lt;code>pgstatindex&lt;/code> показывает, насколько плотно упакованы листья b-tree. Эталон — 90%, и это не эмпирическое наблюдение, а дефолт: документация &lt;code>CREATE INDEX&lt;/code> говорит «B-trees use a default fillfactor of 90». Свежепостроенный индекс набивает листья ровно до этого значения.&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-sql" data-lang="sql">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">CREATE&lt;/span> EXTENSION &lt;span style="color:#ff79c6">IF&lt;/span> &lt;span style="color:#ff79c6">NOT&lt;/span> &lt;span style="color:#ff79c6">EXISTS&lt;/span> pgstattuple;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">SELECT&lt;/span> i.indexrelname,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> pg_size_pretty(pg_relation_size(i.indexrelid)) &lt;span style="color:#ff79c6">AS&lt;/span> sz,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> round(s.avg_leaf_density::&lt;span style="color:#8be9fd;font-style:italic">numeric&lt;/span>, &lt;span style="color:#bd93f9">1&lt;/span>) &lt;span style="color:#ff79c6">AS&lt;/span> density
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">FROM&lt;/span> (&lt;span style="color:#ff79c6">SELECT&lt;/span> indexrelid, indexrelname
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">FROM&lt;/span> pg_stat_user_indexes
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">ORDER&lt;/span> &lt;span style="color:#ff79c6">BY&lt;/span> pg_relation_size(indexrelid) &lt;span style="color:#ff79c6">DESC&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">LIMIT&lt;/span> &lt;span style="color:#bd93f9">20&lt;/span>) i
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">JOIN&lt;/span> pg_class &lt;span style="color:#ff79c6">c&lt;/span> &lt;span style="color:#ff79c6">ON&lt;/span> &lt;span style="color:#ff79c6">c&lt;/span>.oid &lt;span style="color:#ff79c6">=&lt;/span> i.indexrelid
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">JOIN&lt;/span> pg_am am &lt;span style="color:#ff79c6">ON&lt;/span> am.oid &lt;span style="color:#ff79c6">=&lt;/span> &lt;span style="color:#ff79c6">c&lt;/span>.relam &lt;span style="color:#ff79c6">AND&lt;/span> am.amname &lt;span style="color:#ff79c6">=&lt;/span> &lt;span style="color:#f1fa8c">&amp;#39;btree&amp;#39;&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">LATERAL&lt;/span> pgstatindex(i.indexrelid) s
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">ORDER&lt;/span> &lt;span style="color:#ff79c6">BY&lt;/span> s.avg_leaf_density &lt;span style="color:#ff79c6">ASC&lt;/span>;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Зная эталон, выигрыш считается заранее: &lt;code>размер × плотность / 90&lt;/code>. Индекс на гигабайт с плотностью 15% ужмётся примерно до 170 МБ. На стенде я сверил прогноз с фактом — разошлось на единицы процентов, чего для планирования более чем достаточно.&lt;/p>
&lt;p>Два предостережения по замеру. Во-первых, &lt;code>pgstatindex&lt;/code> полностью сканирует индекс, и на лидере под нагрузкой это заметно; лучше снимать на реплике — файлы там те же, а расширение приезжает репликацией. Во-вторых, фильтр по &lt;code>amname = 'btree'&lt;/code> обязателен: для не-b-tree индексов функция даст либо ошибку, либо бессмысленный результат, да и документация честно признаёт, что раздувание в них исследовано плохо.&lt;/p>
&lt;h2 id="уникальный-индекс-с-нулём-обращений--не-мёртвый">Уникальный индекс с нулём обращений — не мёртвый&lt;/h2>
&lt;p>Самая дорогая ошибка в этой истории — та, которую я почти совершил: удалить индексы с &lt;code>idx_scan = 0&lt;/code> как неиспользуемые. На горячей таблице таких набралось больше половины от всех её индексов, окно статистики — больше полугода. Соблазн очевидный.&lt;/p>
&lt;p>Часть из них оказалась уникальными, подпирающими ограничения целостности. Причина расхождения — в определении счётчика. Документация описывает &lt;code>idx_scan&lt;/code> как «Number of index scans initiated on this index»: он считает &lt;strong>сканирования&lt;/strong>. А проверка уникальности при вставке — не сканирование, это отдельный путь внутри вставки в индекс. Счётчик она не трогает никогда.&lt;/p>
&lt;p>Получается индекс, который по всем метрикам мёртв, а фактически единственный не пускает дубликаты в расчёты. Удали его — и данные начнут портиться молча, а заметят это через недели, когда сойдутся не те цифры.&lt;/p>
&lt;p>Поэтому в запросе поиска неиспользуемых индексов фильтр по уникальности обязателен:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-sql" data-lang="sql">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">SELECT&lt;/span> i.relname &lt;span style="color:#ff79c6">AS&lt;/span> tbl, i.indexrelname &lt;span style="color:#ff79c6">AS&lt;/span> idx,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> pg_size_pretty(pg_relation_size(i.indexrelid)) &lt;span style="color:#ff79c6">AS&lt;/span> sz, i.idx_scan
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">FROM&lt;/span> pg_stat_user_indexes i
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">JOIN&lt;/span> pg_index x &lt;span style="color:#ff79c6">ON&lt;/span> x.indexrelid &lt;span style="color:#ff79c6">=&lt;/span> i.indexrelid
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">WHERE&lt;/span> i.idx_scan &lt;span style="color:#ff79c6">=&lt;/span> &lt;span style="color:#bd93f9">0&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">AND&lt;/span> &lt;span style="color:#ff79c6">NOT&lt;/span> x.indisunique &lt;span style="color:#6272a4">-- без этой строки запрос опасен
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4">&lt;/span> &lt;span style="color:#ff79c6">AND&lt;/span> pg_relation_size(i.indexrelid) &lt;span style="color:#ff79c6">&amp;gt;&lt;/span> &lt;span style="color:#bd93f9">5&lt;/span> &lt;span style="color:#ff79c6">*&lt;/span> &lt;span style="color:#bd93f9">1024&lt;/span> &lt;span style="color:#ff79c6">*&lt;/span> &lt;span style="color:#bd93f9">1024&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">ORDER&lt;/span> &lt;span style="color:#ff79c6">BY&lt;/span> pg_relation_size(i.indexrelid) &lt;span style="color:#ff79c6">DESC&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4">-- ноль без окна наблюдения не значит ничего
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4">&lt;/span>&lt;span style="color:#ff79c6">SELECT&lt;/span> stats_reset, now() &lt;span style="color:#ff79c6">-&lt;/span> stats_reset &lt;span style="color:#ff79c6">AS&lt;/span> window
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">FROM&lt;/span> pg_stat_database &lt;span style="color:#ff79c6">WHERE&lt;/span> datname &lt;span style="color:#ff79c6">=&lt;/span> current_database();
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Рядом живёт вторая ловушка того же класса: счётчики &lt;code>pg_stat_*&lt;/code> ведутся &lt;strong>на каждом узле отдельно&lt;/strong>. Реплика считает свои сканирования сама, и если часть чтения уходит на неё, лидер об этом не знает. Я проверил лидер и один standby, взятый из инвентаря, — и молча предположил, что standby единственный. Вывод оказался верным, но обоснован он не был. Список узлов надо брать из самой базы:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-sql" data-lang="sql">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">SELECT&lt;/span> client_addr, &lt;span style="color:#ff79c6">state&lt;/span>, sync_state &lt;span style="color:#ff79c6">FROM&lt;/span> pg_stat_replication;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Сначала перечислить подписчиков запросом, потом обойти каждого — и только после этого говорить «индекс не используется».&lt;/p>
&lt;h2 id="грабли-при-самой-перестройке">Грабли при самой перестройке&lt;/h2>
&lt;p>Перестройка на живом кластере ломается не на SQL, а на обвязке вокруг него — на Patroni, на миграциях приложения и на синхронной реплике. Четыре места, где я споткнулся или мог споткнуться.&lt;/p>
&lt;p>&lt;strong>&lt;code>REINDEX CONCURRENTLY&lt;/code> оставляет мусор при сбое, и суффикс говорит, что делать.&lt;/strong> Если перестройка упала, в базе остаётся невалидный индекс. Документация различает два случая: суффикс &lt;code>_ccnew&lt;/code> — это недостроенный новый индекс, его надо удалить и повторить попытку; суффикс &lt;code>_ccold&lt;/code> — это старый, который не смогли удалить, и тогда перестройка &lt;strong>уже удалась&lt;/strong>, надо просто дропнуть остаток. Путать их дорого: во втором случае «повторю REINDEX» — лишняя работа на часы. К суффиксу может добавляться цифра (&lt;code>_ccnew1&lt;/code>, &lt;code>_ccold2&lt;/code>). Проверка невалидных индексов должна стоять первым шагом любой автоматизации:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-sql" data-lang="sql">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">SELECT&lt;/span> &lt;span style="color:#ff79c6">c&lt;/span>.relname
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">FROM&lt;/span> pg_index i &lt;span style="color:#ff79c6">JOIN&lt;/span> pg_class &lt;span style="color:#ff79c6">c&lt;/span> &lt;span style="color:#ff79c6">ON&lt;/span> &lt;span style="color:#ff79c6">c&lt;/span>.oid &lt;span style="color:#ff79c6">=&lt;/span> i.indexrelid
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">WHERE&lt;/span> &lt;span style="color:#ff79c6">NOT&lt;/span> i.indisvalid;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>&lt;strong>Patroni затрёт &lt;code>ALTER SYSTEM&lt;/code>.&lt;/strong> Параметр применили, &lt;code>SHOW&lt;/code> подтверждает, после переключения значение прежнее. Patroni сам управляет &lt;code>postgresql.conf&lt;/code> и перезаписывает его из своей конфигурации в DCS. Менять надо через &lt;code>patronictl edit-config&lt;/code> — подробнее об этом слое в &lt;a href="https://bitpage.ru/databases/patroni-bootstrap-dcs-ansible-config-drift/">разборе bootstrap.dcs&lt;/a>.&lt;/p>
&lt;p>&lt;strong>Laravel оборачивает миграцию в транзакцию, и только на PostgreSQL.&lt;/strong> &lt;code>DROP INDEX CONCURRENTLY&lt;/code> внутри транзакционного блока запрещён, и миграция падает. В коде миграции этого не видно, потому что решение принимается во фреймворке: в &lt;code>Migration.php&lt;/code> объявлено &lt;code>public $withinTransaction = true&lt;/code>, а мигратор проверяет пару условий сразу:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-php" data-lang="php">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#8be9fd;font-style:italic">$this&lt;/span>&lt;span style="color:#ff79c6">-&amp;gt;&lt;/span>&lt;span style="color:#50fa7b">getSchemaGrammar&lt;/span>(&lt;span style="color:#8be9fd;font-style:italic">$connection&lt;/span>)&lt;span style="color:#ff79c6">-&amp;gt;&lt;/span>&lt;span style="color:#50fa7b">supportsSchemaTransactions&lt;/span>()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">&amp;amp;&amp;amp;&lt;/span> &lt;span style="color:#8be9fd;font-style:italic">$migration&lt;/span>&lt;span style="color:#ff79c6">-&amp;gt;&lt;/span>&lt;span style="color:#50fa7b">withinTransaction&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">?&lt;/span> &lt;span style="color:#8be9fd;font-style:italic">$connection&lt;/span>&lt;span style="color:#ff79c6">-&amp;gt;&lt;/span>&lt;span style="color:#50fa7b">transaction&lt;/span>(&lt;span style="color:#8be9fd;font-style:italic">$callback&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">:&lt;/span> &lt;span style="color:#8be9fd;font-style:italic">$callback&lt;/span>();
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Тонкость в первом условии. &lt;code>PostgresGrammar&lt;/code> объявляет &lt;code>protected $transactions = true&lt;/code>, а &lt;code>MySqlGrammar&lt;/code> этого свойства не переопределяет. То есть одна и та же миграция на MySQL пройдёт, а на PostgreSQL упадёт — и разработчик, который тестировал локально не на той СУБД, узнает об этом на деплое. Лечится одной строкой в классе миграции:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-php" data-lang="php">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">public&lt;/span> &lt;span style="color:#8be9fd;font-style:italic">$withinTransaction&lt;/span> &lt;span style="color:#ff79c6">=&lt;/span> &lt;span style="color:#ff79c6">false&lt;/span>;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>&lt;strong>Синхронная реплика превращает перестройку в задержки приложения.&lt;/strong> &lt;code>REINDEX&lt;/code> выглядит локальной операцией на лидере, но порождает WAL объёмом примерно с сам индекс, а при &lt;code>synchronous_commit = on&lt;/code> каждый коммит ждёт подтверждения от standby. Перестройка многогигабайтного индекса гонит этот объём по сети, и время отклика приложения растёт. Отсюда — по одному индексу, в непиковое окно, с проверкой лага между шагами и с оглядкой на &lt;code>wal_compression&lt;/code>.&lt;/p>
&lt;p>Отдельно про WAL: после серии перестроек каталог &lt;code>pg_wal&lt;/code> разбухнет, и &lt;code>CHECKPOINT&lt;/code> его не ужмёт. Я попробовал — не сработало, и не должно было. Если &lt;code>max_wal_size&lt;/code> выставлен, скажем, в 4 ГБ, каталог упрётся ровно в этот потолок: это не протечка, а штатное поведение. PostgreSQL держит сегменты преаллоцированными и сжимает каталог постепенно, при спаде нагрузки. Гоняться за этими гигабайтами бессмысленно.&lt;/p>
&lt;h2 id="два-одинаковых-индекса-под-разными-именами">Два одинаковых индекса под разными именами&lt;/h2>
&lt;p>Если на таблице два уникальных индекса по одному набору колонок, а обращения идут только к одному — это, скорее всего, дубликат, но проверять надо не по списку колонок. Картина выглядит так: у одного индекса сканирования идут миллиардами, у другого ровно ноль.&lt;/p>
&lt;p>Разный порядок колонок в &lt;code>UNIQUE&lt;/code> сбивает с толку, хотя для уникальности он не значит &lt;strong>ничего&lt;/strong>: &lt;code>UNIQUE (a, b)&lt;/code> и &lt;code>UNIQUE (b, a)&lt;/code> запрещают ровно одни и те же дубликаты. Но и совпадение колонок ещё не делает индексы одинаковыми. Сравнивать надо весь набор свойств:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">7
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-sql" data-lang="sql">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">SELECT&lt;/span> i.relname, x.indisunique, x.indnullsnotdistinct,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> x.indnatts, x.indnkeyatts,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> x.indcollation::&lt;span style="color:#8be9fd;font-style:italic">text&lt;/span>, x.indclass::&lt;span style="color:#8be9fd;font-style:italic">text&lt;/span>, x.indoption::&lt;span style="color:#8be9fd;font-style:italic">text&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> (x.indpred &lt;span style="color:#ff79c6">IS&lt;/span> &lt;span style="color:#ff79c6">NOT&lt;/span> &lt;span style="color:#ff79c6">NULL&lt;/span>) &lt;span style="color:#ff79c6">AS&lt;/span> &lt;span style="color:#ff79c6">partial&lt;/span>,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> (x.indexprs &lt;span style="color:#ff79c6">IS&lt;/span> &lt;span style="color:#ff79c6">NOT&lt;/span> &lt;span style="color:#ff79c6">NULL&lt;/span>) &lt;span style="color:#ff79c6">AS&lt;/span> expr
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">FROM&lt;/span> pg_index x &lt;span style="color:#ff79c6">JOIN&lt;/span> pg_class i &lt;span style="color:#ff79c6">ON&lt;/span> i.oid &lt;span style="color:#ff79c6">=&lt;/span> x.indexrelid
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">WHERE&lt;/span> x.indrelid &lt;span style="color:#ff79c6">=&lt;/span> &lt;span style="color:#f1fa8c">&amp;#39;имя_таблицы&amp;#39;&lt;/span>::regclass;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Что здесь важно и почему: &lt;code>indclass&lt;/code> — классы операторов, &lt;code>indcollation&lt;/code> — сортировки, &lt;code>indoption&lt;/code> — направление и &lt;code>NULLS FIRST/LAST&lt;/code>, &lt;code>indnkeyatts&lt;/code> против &lt;code>indnatts&lt;/code> покажет спрятанный &lt;code>INCLUDE&lt;/code>, &lt;code>indpred&lt;/code> и &lt;code>indexprs&lt;/code> выдадут частичный индекс или индекс по выражению, а &lt;code>indnullsnotdistinct&lt;/code> (появился в PostgreSQL 15) — трактовку &lt;code>NULL&lt;/code> в уникальности. Если совпало всё, индексы действительно взаимозаменяемы — и удалять безопасно любой из двух, включая тот, на который приходятся все сканирования: планировщик прозрачно перейдёт на близнеца. Проверьте заодно &lt;code>attnotnull&lt;/code> на колонках — при &lt;code>NOT NULL&lt;/code> вопрос NULL-семантики снимается совсем.&lt;/p>
&lt;p>И последняя отложенная грабля: &lt;strong>удалённый индекс вернётся с ближайшим деплоем&lt;/strong>, если удаление сделано руками в базе, а объявление осталось в миграции или в схеме для тестов. Перед удалением — поиск имени по репозиторию, само удаление — только миграцией.&lt;/p>
&lt;h2 id="что-сделать-по-шагам">Что сделать, по шагам&lt;/h2>
&lt;p>Порядок такой, чтобы дорогие и необратимые шаги стояли после дешёвых и проверяемых.&lt;/p>
&lt;ol>
&lt;li>Разделите тело и индексы запросом выше. Если раздут heap — это другая болезнь и другое лечение.&lt;/li>
&lt;li>Поставьте &lt;code>pgstattuple&lt;/code> и снимите &lt;code>avg_leaf_density&lt;/code> по крупнейшим b-tree индексам. Замер делайте на реплике.&lt;/li>
&lt;li>Посчитайте выигрыш: &lt;code>размер × плотность / 90&lt;/code>. Так вы поймёте, стоит ли овчинка выделки, ещё до первой блокировки.&lt;/li>
&lt;li>Проверьте окно статистики через &lt;code>stats_reset&lt;/code>. Без него нули в &lt;code>idx_scan&lt;/code> не значат ничего.&lt;/li>
&lt;li>Найдите неиспользуемые индексы &lt;strong>с фильтром &lt;code>AND NOT indisunique&lt;/code>&lt;/strong>. Уникальные оценивайте только по бизнес-смыслу, не по счётчику.&lt;/li>
&lt;li>Обойдите все узлы кластера, перечислив их через &lt;code>pg_stat_replication&lt;/code>, а не по инвентарю.&lt;/li>
&lt;li>Перестраивайте по одному: &lt;code>REINDEX INDEX CONCURRENTLY&lt;/code> на проде, обычный &lt;code>REINDEX&lt;/code> на стендах. Между шагами смотрите лаг реплики.&lt;/li>
&lt;li>Заведите мониторинг плотности, а не только свободного места. К моменту дискового алерта индексы деградируют уже месяцами.&lt;/li>
&lt;/ol>
&lt;p>Пункт 8 стоит последним, а по важности он первый: место — это симптом, который заметил мониторинг, а не болезнь.&lt;/p>
&lt;h2 id="итог">Итог&lt;/h2>
&lt;p>Раздувание индексов — не поломка и не недосмотр администратора, а осознанный компромисс PostgreSQL. Слияние полупустых страниц b-tree стоило бы блокировок и перебалансировок на горячем пути, и его сознательно не делают. Отсюда единственный корректный вывод: перестройку индексов надо планировать, как планируют бэкапы, а не ждать, что автоматика справится сама.&lt;/p>
&lt;p>Практический вывод, который переживёт конкретную версию: когда база «распухла», первое действие — не выбрать инструмент, а разделить диагнозы. Тело и индексы лечатся разным, и &lt;code>VACUUM FULL&lt;/code>, назначенный по общему размеру таблицы, в лучшем случае сделает лишнюю работу, а в худшем добьёт диск, которого и так не хватает.&lt;/p>
&lt;p>И про метрики. &lt;code>idx_scan = 0&lt;/code> на уникальном индексе — хороший пример того, как метрика не знает о смысле. Она честно отвечает на свой вопрос («сколько было сканирований»), а мы читаем в ней ответ на другой («нужен ли этот индекс»). Прежде чем удалять что-то по счётчику, стоит выяснить, что именно счётчик считает — и, главное, чего он не считает.&lt;/p>
&lt;p>Первоисточники: &lt;a href="https://www.postgresql.org/docs/15/routine-reindex.html">Routine Reindexing&lt;/a>, &lt;a href="https://www.postgresql.org/docs/15/routine-vacuuming.html">Routine Vacuuming&lt;/a>, &lt;a href="https://www.postgresql.org/docs/15/sql-reindex.html">REINDEX&lt;/a>, &lt;a href="https://www.postgresql.org/docs/15/sql-vacuum.html">VACUUM&lt;/a>, &lt;a href="https://www.postgresql.org/docs/15/runtime-config-autovacuum.html">параметры autovacuum&lt;/a>, &lt;a href="https://www.postgresql.org/docs/15/sql-createindex.html">CREATE INDEX и fillfactor&lt;/a>, &lt;a href="https://www.postgresql.org/docs/15/pgstattuple.html">pgstattuple&lt;/a>, &lt;a href="https://www.postgresql.org/docs/15/monitoring-stats.html">Monitoring Statistics&lt;/a>, &lt;a href="https://github.com/laravel/framework/blob/11.x/src/Illuminate/Database/Migrations/Migration.php">&lt;code>Migration.php&lt;/code> в laravel/framework&lt;/a>, &lt;a href="https://github.com/laravel/framework/blob/11.x/src/Illuminate/Database/Schema/Grammars/PostgresGrammar.php">&lt;code>PostgresGrammar.php&lt;/code>&lt;/a>.&lt;/p></content:encoded></item><item><title>Сервер не отвечает, но работает: беззнаковое вычитание в ZFS и systemd CrashAction=freeze</title><link>https://bitpage.ru/incidents/zfs-fillpage-underflow-systemd-crashaction-freeze/</link><pubDate>Wed, 02 Sep 2026 16:00:00 +0300</pubDate><dc:creator>BitPage</dc:creator><guid>https://bitpage.ru/incidents/zfs-fillpage-underflow-systemd-crashaction-freeze/</guid><description>Массовые segfault'ы в несвязанных процессах и сервер, который не отвечает ни по одному порту, — это не битая память, а переполнение в zfs_fillpage(): при гонке mmap-чтения с усечением файла io_len = i_size - io_off уходит в минус, и dmu_read() забивает нулями чужую физическую память. Починено в OpenZFS 2.4.4 и 2.3.9 от 21 августа 2026, в 2.2.11 фикса нет. Отказ растягивает systemd: CrashAction по умолчанию freeze, PID 1 замирает навсегда.</description><content:encoded>&lt;p>&lt;strong>Короткий ответ:&lt;/strong> если сервер перестал отвечать по всем портам, а после ресета в журнале обнаружилась лавина segfault&amp;rsquo;ов в несвязанных друг с другом процессах и строка &lt;code>systemd[1]: Freezing execution&lt;/code> — виновата не память, а арифметика в ядре. В OpenZFS до 2.4.4 и 2.3.9 функция &lt;code>zfs_fillpage()&lt;/code> считала длину чтения как &lt;code>io_len = i_size - io_off&lt;/code> в беззнаковых типах: если файл усечь ровно в тот момент, когда подкачивается mmap-страница, разность уходит в минус, превращается в «почти 2⁶⁴», и &lt;code>dmu_read()&lt;/code> забивает нулями физическую память далеко за пределами страницы. Машина при этом остаётся жива — до конца её добивает systemd, у которого &lt;code>CrashAction&lt;/code> по умолчанию равен &lt;code>freeze&lt;/code>.&lt;/p>
&lt;p>Отказ выглядел как выключенный сервер: ни SSH, ни HTTP, ни веб-морда гипервизора. Помог жёсткий ресет через панель хостера. Дальше остался вопрос, который никто не любит: а что это, собственно, было, и повторится ли оно завтра.&lt;/p>
&lt;p>Час расследования дал два ответа вместо одного. Баг в ZFS — это спусковой крючок. Настоящую цену отказу назначил дефолт systemd.&lt;/p>
&lt;h2 id="коротко-tldr">Коротко (TL;DR)&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Причина:&lt;/strong> беззнаковое вычитание в &lt;code>zfs_fillpage()&lt;/code> при гонке mmap-чтения с усечением файла на месте. Ядро затирает нулями чужие страницы физической памяти, жертвами становятся случайные процессы.&lt;/li>
&lt;li>&lt;strong>Где починено:&lt;/strong> OpenZFS 2.4.4 и 2.3.9, оба от 21 августа 2026. В Proxmox — пакет &lt;code>zfsutils-linux&lt;/code> версии 2.4.4-pve1. В 2.2.11, вышедшей в тот же день, фикса нет.&lt;/li>
&lt;li>&lt;strong>Почему отказ длится часами, а не секундами:&lt;/strong> &lt;code>CrashAction=freeze&lt;/code> — дефолт systemd. Упавший PID 1 замирает навсегда, службы никто не перезапускает, &lt;code>systemctl&lt;/code> виснет, sshd со временем умирает следом.&lt;/li>
&lt;li>&lt;strong>Как за минуту отличить от битой памяти:&lt;/strong> счётчики ECC в &lt;code>/sys/devices/system/edac/mc/mc0/&lt;/code> нулевые и в журнале нет ни одного &lt;code>Machine Check&lt;/code> — значит планки ни при чём.&lt;/li>
&lt;li>&lt;strong>Признак в дампе:&lt;/strong> регистр длины &lt;code>memset&lt;/code> — не мусор, а аккуратное отрицательное число, ровно минус другой регистр. Это переполнение при вычислении размера, а не сбойное железо.&lt;/li>
&lt;li>&lt;strong>Смягчение на одну строку:&lt;/strong> &lt;code>CrashAction=reboot&lt;/code> в drop-in — и повторение аварии стоит десяти секунд вместо целой ночи.&lt;/li>
&lt;li>&lt;strong>Побочные грабли:&lt;/strong> &lt;code>restart: on-failure&lt;/code> у Docker переживает жёсткий ресет, но по документации не переживает перезапуск демона.&lt;/li>
&lt;/ul>
&lt;h2 id="как-выглядит-отказ-снаружи">Как выглядит отказ снаружи&lt;/h2>
&lt;p>Снаружи это неотличимо от выключенной машины, и именно поэтому первая версия всегда неверная. Сервер молчит по всем портам, панель хостера показывает питание в норме, техподдержка разводит руками. Логичная гипотеза — «выключился», а дальше ищешь причину выключения: ACPI-событие, watchdog, провал по питанию.&lt;/p>
&lt;p>Гипотеза разваливается о журнал. &lt;code>journalctl --list-boots&lt;/code> показывает предыдущую загрузку, и записи в ней идут вплоть до момента, отстоящего от новой загрузки на полторы-две минуты — ровно на длительность POST серверной платы. Последняя строка перед ресетом была такой:&lt;/p>
&lt;pre tabindex="0">&lt;code>nginx[…]: [alert] …#…: worker process … exited on signal 11
&lt;/code>&lt;/pre>&lt;p>Машина не выключалась. Она работала всё это время и исправно писала в журнал, что ей плохо. Просто снаружи её не было слышно.&lt;/p>
&lt;p>Отсюда же вылезает вторая неприятность: настоящий момент сбоя лежит сильно раньше, чем «время падения» из &lt;code>last&lt;/code>. &lt;code>last -x&lt;/code> покажет &lt;code>crash&lt;/code>, но не покажет, когда именно всё началось. Начало ищется по первому segfault&amp;rsquo;у в лавине — и оно может отстоять от ресета на всю ночь.&lt;/p>
&lt;h2 id="почему-это-не-битая-память-хотя-выглядит-именно-так">Почему это не битая память, хотя выглядит именно так&lt;/h2>
&lt;p>Массовые одновременные segfault&amp;rsquo;ы в несвязанных процессах — классический почерк неисправной RAM, и первый рефлекс здесь неправильный. Планировать даунтайм под memtest86+ не надо, сначала стоит потратить минуту на два счётчика:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>cat /sys/devices/system/edac/mc/mc0/ce_count &lt;span style="color:#6272a4"># исправленные ошибки&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>cat /sys/devices/system/edac/mc/mc0/ue_count &lt;span style="color:#6272a4"># неисправленные&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>journalctl -b -1 -k | grep -i &lt;span style="color:#f1fa8c">&amp;#34;machine check&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Нули во всех трёх местах при ECC-памяти означают, что память как подсистема исправна. Одиночный перевёрнутый бит контроллер поймал бы и записал. А заодно проверьте, что ECC действительно работает, а не просто планки такие:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>dmidecode -t memory | grep -E &lt;span style="color:#f1fa8c">&amp;#34;Error Correction Type|Total Width|Data Width&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>&lt;code>Total Width&lt;/code> на 8 бит больше &lt;code>Data Width&lt;/code> — вот это и есть работающий ECC, дополнительные биты под коррекцию физически присутствуют. Если ширины равны, ECC у вас нет, кто бы что ни писал в спецификации.&lt;/p>
&lt;p>Эта минута экономит несколько часов даунтайма на диагностике не той подсистемы. ECC защищает от перевёрнутых битов, а не от кода, который сам, штатным путём исполнения, пишет нули не туда.&lt;/p>
&lt;h2 id="четыре-ложных-следа-и-все-четыре-через-одну-функцию">Четыре ложных следа, и все четыре через одну функцию&lt;/h2>
&lt;p>Все четыре гипотезы, которые дал поиск по симптому, оказались мимо — и я потратил на них больше времени, чем на разгадку. Выписываю их с причинами отказа: каждая закрывает целое направление, и если у вас похожий стек — эти ветки можно не проходить заново.&lt;/p>
&lt;p>&lt;strong>След 1: гонка Direct IO.&lt;/strong> Первый же осмысленный поиск по стеку вывел на обсуждение реальной гонки в OpenZFS: конкурентная запись с &lt;code>O_DIRECT&lt;/code> может выставить &lt;code>db-&amp;gt;db_data = NULL&lt;/code>, и тогда &lt;code>dmu_read_impl()&lt;/code> падает на разыменовании. Имена функций совпадали с нашим стеком буквально. Проверка тюнабла подлила масла в огонь — Direct IO включён:&lt;/p>
&lt;pre tabindex="0">&lt;code># cat /sys/module/zfs/parameters/zfs_dio_enabled
1
&lt;/code>&lt;/pre>&lt;p>Я успел назвать это главной гипотезой вслух. Развалилось об регистры: у нас не разыменование нуля, а &lt;code>memset&lt;/code> с осмысленным адресом назначения и абсурдной длиной. Механизм принципиально другой.&lt;/p>
&lt;p>&lt;strong>След 2: повреждение SLUB на шифрованном ZFS.&lt;/strong> Нашёлся свежий &lt;a href="https://github.com/openzfs/zfs/issues/18427">issue #18427&lt;/a> с симптомом слово в слово нашим — массовые segfault&amp;rsquo;ы в userspace, воспроизводится на версиях с 2.2.3 по 2.4.1. Отверг одной командой:&lt;/p>
&lt;pre tabindex="0">&lt;code># zfs get -r encryption rpool
NAME PROPERTY VALUE SOURCE
rpool encryption off default
&lt;/code>&lt;/pre>&lt;p>&lt;strong>След 3: уже исправленная регрессия в &lt;code>dmu_read_impl&lt;/code>.&lt;/strong> &lt;a href="https://github.com/openzfs/zfs/issues/17886">Issue #17886&lt;/a> — Oops в &lt;strong>той же функции&lt;/strong> и тоже с затиранием памяти по кривой длине, закрыт через &lt;a href="https://github.com/openzfs/zfs/pull/17915">PR #17915&lt;/a>. Выглядело как «баг известен, фикс есть, обновляйтесь». Прочитал сам PR — он чинит потерянные скобки в макросе &lt;code>BRT_RANGESIZE_TO_NBLOCKS()&lt;/code>, деление вместо умножения. Автор пишет прямо: «this could cause small memory corruptions for vdevs bigger than 64TB+». Порог отсекает сразу: до 64 ТБ на vdev типовой пул под веб-нагрузку не дотягивает на порядок. Да и стек в том баге идёт через &lt;code>brt_load()&lt;/code> при импорте пула, а не через mmap под нагрузкой. Общее место падения, разные баги.&lt;/p>
&lt;p>&lt;strong>След 4: «сервер был выключен несколько часов».&lt;/strong> Про него выше — разбился о непрерывный журнал предыдущей загрузки.&lt;/p>
&lt;p>Общий вывод неприятный: четыре следа из четырёх шли через &lt;code>dmu_read_impl&lt;/code>. Функция популярная, «повреждение памяти» — слишком общий симптом, и поиск по симптому в такой ситуации выдаёт бесконечную ленту чужих несчастий. Развязку дал не поиск, а арифметика.&lt;/p>
&lt;h2 id="развязка-длина-memset--это-ровно-минус-другой-регистр">Развязка: длина &lt;code>memset&lt;/code> — это ровно минус другой регистр&lt;/h2>
&lt;p>Разгадка целиком лежала в дампе Oops, надо было просто посчитать. Вот он, сокращённо до значимого:&lt;/p>
&lt;pre tabindex="0">&lt;code>BUG: unable to handle page fault for address: ffff8983e8400000
#PF: supervisor write access in kernel mode
#PF: error_code(0x0003) - permissions violation
Comm: php-fpm
RIP: 0010:memset+0xb/0x20
RAX: 0000000000020000 RSI: 0000000000000000 RDI: ffff8983e8400000
RDX: ffffffffff7a4000 R15: 000000000085c000
Call Trace:
? dmu_read_impl+0x21e/0x230 [zfs]
dmu_read [zfs]
zfs_fillpage [zfs]
zfs_getpage [zfs]
zpl_read_folio [zfs]
filemap_read_folio
filemap_fault
__do_fault
handle_mm_fault
&lt;/code>&lt;/pre>&lt;p>Смотрим на аргументы &lt;code>memset&lt;/code>. В x86-64 SysV это &lt;code>RDI&lt;/code> — адрес, &lt;code>RSI&lt;/code> — чем заполнять, &lt;code>RDX&lt;/code> — сколько байт.&lt;/p>
&lt;p>&lt;code>RSI = 0&lt;/code>, заполняем нулями. &lt;code>RDX = 0xffffffffff7a4000&lt;/code> — на первый взгляд мусор. А теперь &lt;code>R15 = 0x85c000 = 8 765 440&lt;/code>. И вот здесь надо остановиться: &lt;code>0xffffffffff7a4000&lt;/code> — это ровно &lt;code>−8 765 440&lt;/code> в дополнительном коде. Не случайное значение, не затёртый регистр. &lt;strong>Аккуратное отрицательное число.&lt;/strong>&lt;/p>
&lt;p>Отрицательная длина не берётся из воздуха. Она означает, что где-то вычли большее из меньшего в беззнаковой арифметике. Виноват не тот, кто испортил память, а тот, кто неправильно посчитал размер.&lt;/p>
&lt;p>Заодно &lt;code>RAX = 0x20000&lt;/code> — это 128 КиБ, дефолтный &lt;code>recordsize&lt;/code> ZFS. Всё сходится: обычное чтение записи ZFS, у которого поехала длина.&lt;/p>
&lt;p>С этой гипотезой поиск перестал быть поиском по симптому и стал поиском по имени функции вместе с точной версией. Нужный issue нашёлся первым же результатом.&lt;/p>
&lt;h2 id="что-сломано-в-zfs-инвариант-который-не-доживает-до-релизной-сборки">Что сломано в ZFS: инвариант, который не доживает до релизной сборки&lt;/h2>
&lt;p>Виновата пара строк в &lt;code>zfs_fillpage()&lt;/code>, а прикрывал их &lt;code>ASSERT&lt;/code>, которого в релизных сборках нет вовсе. Вот код &lt;a href="https://github.com/openzfs/zfs/blob/zfs-2.4.3/module/os/linux/zfs/zfs_vnops_os.c">из ветки 2.4.3&lt;/a>:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">8
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-c" data-lang="c">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#8be9fd">loff_t&lt;/span> i_size &lt;span style="color:#ff79c6">=&lt;/span> &lt;span style="color:#50fa7b">i_size_read&lt;/span>(ip);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#8be9fd">u_offset_t&lt;/span> io_off &lt;span style="color:#ff79c6">=&lt;/span> &lt;span style="color:#50fa7b">page_offset&lt;/span>(pp);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#8be9fd">size_t&lt;/span> io_len &lt;span style="color:#ff79c6">=&lt;/span> PAGE_SIZE;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#50fa7b">ASSERT3U&lt;/span>(io_off, &lt;span style="color:#ff79c6">&amp;lt;&lt;/span>, i_size);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">if&lt;/span> (io_off &lt;span style="color:#ff79c6">+&lt;/span> io_len &lt;span style="color:#ff79c6">&amp;gt;&lt;/span> i_size)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> io_len &lt;span style="color:#ff79c6">=&lt;/span> i_size &lt;span style="color:#ff79c6">-&lt;/span> io_off;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Инвариант записан прямым текстом: смещение страницы обязано быть меньше размера файла. Ниже строчкой на этом инварианте построено вычитание. Всё честно — ровно до того момента, пока не заглянешь в определение &lt;code>ASSERT3U&lt;/code> для сборки без отладки, в &lt;a href="https://github.com/openzfs/zfs/blob/zfs-2.4.3/include/os/linux/spl/sys/debug.h">&lt;code>include/os/linux/spl/sys/debug.h&lt;/code>&lt;/a>:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">7
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-c" data-lang="c">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4">/*
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4"> * Debugging disabled (--disable-debug)
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4"> */&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">#ifdef NDEBUG
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">#define ASSERT3U(x, y, z) \
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6"> ((void) sizeof ((uintptr_t)(x)), (void) sizeof ((uintptr_t)(z)))
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Макрос разворачивается в два &lt;code>sizeof&lt;/code>. Он ничего не вычисляет и ничего не проверяет — он существует, чтобы аргументы не считались неиспользуемыми. А дистрибутивы, включая Proxmox, собирают ZFS именно без отладки. То есть единственная защита от переполнения выключена ровно в тех сборках, которые крутятся на проде.&lt;/p>
&lt;p>Дальше механика простая. Файл усекают на месте (&lt;code>O_TRUNC&lt;/code> при перезаписи), &lt;code>i_size&lt;/code> падает. Страница, которая уже стоит в очереди на подкачку, оказывается за новым концом файла: &lt;code>io_off &amp;gt; i_size&lt;/code>. Условие &lt;code>io_off + io_len &amp;gt; i_size&lt;/code> истинно, &lt;code>io_len = i_size - io_off&lt;/code> — и &lt;code>size_t&lt;/code> уезжает в «почти 2⁶⁴». &lt;code>dmu_read()&lt;/code> получает эту длину, видит за концом файла дыру и добросовестно заполняет нулями всё, до чего дотянется.&lt;/p>
&lt;p>Причину назвал участник обсуждения в &lt;a href="https://github.com/openzfs/zfs/issues/18787">issue #18787&lt;/a>, пользователь &lt;code>@Zaczero&lt;/code>, — и сформулировал её так:&lt;/p>
&lt;blockquote>
&lt;p>in &lt;code>zfs_fillpage()&lt;/code>, if a page is faulted in just after the file is truncated, &lt;code>io_len = i_size - io_off&lt;/code> underflows (unsigned) to ~2⁶⁴, and &lt;code>dmu_read()&lt;/code> then writes zeros forward across physical memory from that one page.&lt;/p>
&lt;/blockquote>
&lt;p>Там же назван и типичный триггер: «an mmap read (PHP &lt;code>readfile()&lt;/code>) racing an in-place truncate of the same file». В нашем дампе &lt;code>Comm: php-fpm&lt;/code> — попадание точное.&lt;/p>
&lt;p>Второе наблюдение из того же комментария объясняет, почему симптом так сбивает с толку:&lt;/p>
&lt;blockquote>
&lt;p>Kernel slab + two unrelated userspace heaps (redis, php-fpm) + journald&amp;rsquo;s mmap&amp;rsquo;d file all corrupted in the same second → a physical-page sweep, not a single-list bug. Explains why ZFS isn&amp;rsquo;t in the faulting stack (it&amp;rsquo;s a sweep on another CPU; the crashing threads are just victims).&lt;/p>
&lt;/blockquote>
&lt;p>Затирание идёт по физическим страницам, поэтому ZFS в стеке падающего процесса чаще всего вообще не появляется. Падают жертвы, а не виновник. Именно из-за этого поиск по симптому уводит куда угодно, только не к файловой системе.&lt;/p>
&lt;p>Отдельная деталь для тех, кто держит контейнеры: в дампе стоял &lt;code>UID 100082&lt;/code>. Расшифровывается механически — непривилегированный LXC по умолчанию маппит хостовые UID со смещением 100000, значит внутри контейнера это uid 82, а 82 в Alpine — это &lt;code>www-data&lt;/code>. Рядом в трейсах мелькал &lt;code>ld-musl-x86_64.so.1&lt;/code>, тоже Alpine. Так по одному числу локализуется, чей именно PHP дёргал файл: не хостовый, а тот, что в контейнере.&lt;/p>
&lt;h2 id="в-каких-версиях-баг-живёт-и-куда-обновляться">В каких версиях баг живёт и куда обновляться&lt;/h2>
&lt;p>Уязвимы все версии до 2.4.4 и 2.3.9, а вдобавок вся ветка 2.2 целиком, включая свежайшую 2.2.11. Фикс — &lt;a href="https://github.com/openzfs/zfs/pull/18715">PR #18715 «linux: handle mmap read beyond file size»&lt;/a>, коммит &lt;a href="https://github.com/openzfs/zfs/commit/223b8bc446851e5e796e5446ac24d03bbf468f43">&lt;code>223b8bc446&lt;/code>&lt;/a>, влит в master 30 июня 2026. Он добавляет ранний выход, когда страница целиком лежит за концом файла:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-c" data-lang="c">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4">/*
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4"> * The page may be faulted in after the file has been truncated.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4"> * There is no data to read; just zero-fill the page.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4"> */&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">if&lt;/span> (io_off &lt;span style="color:#ff79c6">&amp;gt;=&lt;/span> i_size) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#8be9fd">void&lt;/span> &lt;span style="color:#ff79c6">*&lt;/span>zva &lt;span style="color:#ff79c6">=&lt;/span> &lt;span style="color:#50fa7b">kmap&lt;/span>(pp);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#50fa7b">memset&lt;/span>(zva, &lt;span style="color:#bd93f9">0&lt;/span>, PAGE_SIZE);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#50fa7b">kunmap&lt;/span>(pp);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#50fa7b">ClearPageError&lt;/span>(pp);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#50fa7b">SetPageUptodate&lt;/span>(pp);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">return&lt;/span> (&lt;span style="color:#bd93f9">0&lt;/span>);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Симметричная правка сделана в &lt;code>zfs_getpage()&lt;/code>, чтобы range lock брался на корректный диапазон. Следом влит регрессионный тест — &lt;a href="https://github.com/openzfs/zfs/pull/18824">PR #18824&lt;/a>, 20 июля 2026.&lt;/p>
&lt;p>Релиз 2.4.3 вышел 12 июня 2026, то есть фикс опоздал к нему на 18 дней. Вот как обстоят дела с релизами сейчас:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Ветка&lt;/th>
&lt;th>Последний релиз&lt;/th>
&lt;th>Дата&lt;/th>
&lt;th>Фикс &lt;code>zfs_fillpage()&lt;/code>&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>2.4&lt;/td>
&lt;td>2.4.4&lt;/td>
&lt;td>21.08.2026&lt;/td>
&lt;td>есть&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2.3&lt;/td>
&lt;td>2.3.9&lt;/td>
&lt;td>21.08.2026&lt;/td>
&lt;td>есть&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2.2&lt;/td>
&lt;td>2.2.11&lt;/td>
&lt;td>21.08.2026&lt;/td>
&lt;td>&lt;strong>нет&lt;/strong>, уязвимый код на месте&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Proxmox&lt;/td>
&lt;td>&lt;code>zfsutils-linux&lt;/code> 2.4.4-pve1&lt;/td>
&lt;td>в &lt;code>pve-no-subscription&lt;/code>&lt;/td>
&lt;td>есть&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Таблицу я собрал не по анонсам, а сравнением &lt;code>zfs_fillpage()&lt;/code> в тегах репозитория: в &lt;code>zfs-2.2.11&lt;/code> строки &lt;code>ASSERT3U(io_off, &amp;lt;, i_size);&lt;/code> и &lt;code>io_len = i_size - io_off;&lt;/code> стоят ровно там же, где в 2.4.3. Ветка 2.2 на сегодня уязвима, хотя релиз у неё того же числа, что у исправленных. В списке изменений 2.4.4 и 2.3.9 фикс упомянут явной строкой &lt;code>linux: handle mmap read beyond file size #18715&lt;/code>, в описании 2.2.11 такой строки нет.&lt;/p>
&lt;p>Проверить, что стоит у вас, можно одной командой — она же покажет, дошёл ли пакет до вашего репозитория:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>modinfo zfs | grep -E &lt;span style="color:#f1fa8c">&amp;#39;^(version|srcversion)&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Ещё одна деталь, отрезвляющая насчёт «просто откатимся на прошлое ядро»: уязвимый код одинаков и в 2.4.2, и в 2.3.x, и в 2.1.15. Это не свежая регрессия, а давняя мина. Откат на запасное ядро с ZFS постарше не лечит ничего.&lt;/p>
&lt;p>Отдельно любопытна хронология. Фикс появился раньше issue и совсем не по нему: коммит влит 30 июня, автор патча — Brian Behlendorf, руководитель проекта OpenZFS, а в теле коммита стоит &lt;code>Reported-by: Iliya Polihronov (@vnsavage) (Automattic)&lt;/code>. То есть на баг независимо наткнулись в компании, которая держит WordPress.com. Issue же заведён 12 июля, и связали одно с другим только 16-го — через две с половиной недели после того, как патч уже лежал в master.&lt;/p>
&lt;p>Автор issue — &lt;code>@stgraber&lt;/code>, мейнтейнер Incus. У него это ловилось на хостах с сотней LAMP-контейнеров, причём тоже на ECC-памяти:&lt;/p>
&lt;blockquote>
&lt;p>All systems use ECC memory and I&amp;rsquo;ve confirmed that the system where I&amp;rsquo;ve captured the crash data here also has a completely clean SEL so physical memory should be fine.&lt;/p>
&lt;/blockquote>
&lt;p>11 августа он подтвердил, что после выкатки cherry-pick&amp;rsquo;а коммита падения прекратились: «haven&amp;rsquo;t heard about any more crashes since this got rolled out, so seems like that did the trick». Сам issue при этом до сих пор открыт — ориентироваться надо на номер PR, а не на статус тикета.&lt;/p>
&lt;h2 id="почему-отказ-длится-часами-а-не-секундами">Почему отказ длится часами, а не секундами&lt;/h2>
&lt;p>Отказ длится часами, потому что systemd по умолчанию замораживает упавший PID 1 навсегда вместо перезагрузки. Это вторая половина разгадки, и она дороже первой. Через считаные миллисекунды после начала затирания памяти в журнале появляется:&lt;/p>
&lt;pre tabindex="0">&lt;code>systemd[1]: Caught &amp;lt;ABRT&amp;gt;, from our own process.
systemd[1]: Caught &amp;lt;ABRT&amp;gt;, dumped core as pid …
systemd[1]: Freezing execution.
&lt;/code>&lt;/pre>&lt;p>У systemd есть параметр &lt;code>CrashAction&lt;/code>, и по умолчанию он равен &lt;code>freeze&lt;/code>. Документация systemd(1) описывает это без всякой двусмысленности:&lt;/p>
&lt;blockquote>
&lt;p>Takes one of &lt;code>freeze&lt;/code>, &lt;code>reboot&lt;/code> or &lt;code>poweroff&lt;/code>. Defaults to &lt;code>freeze&lt;/code>. If set to &lt;code>freeze&lt;/code>, the system will hang indefinitely when the system manager (PID 1) crashes. If set to &lt;code>reboot&lt;/code>, the system manager (PID 1) will reboot the machine automatically when it crashes, after a 10s delay.&lt;/p>
&lt;/blockquote>
&lt;p>«Hang indefinitely» — это ровно то, что мы видели. Логика дефолта понятна: замереть, чтобы администратор подошёл и изучил живую систему. На арендованном железе в чужом дата-центре она означает «зависни навсегда и жди, пока кто-нибудь заметит».&lt;/p>
&lt;p>Замороженный PID 1 отнимает у системы способность себя чинить. Упавшие службы никто не перезапустит, &lt;code>systemctl&lt;/code> виснет, штатное выключение невозможно. Дальше картина становится издевательской: мастер-процесс nginx жив, послушно форкает воркеров, воркеры наследуют испорченную память и умирают мгновенно. При частоте порядка двух падений в секунду за ночь набегают сотни тысяч строк в журнале. Обычный &lt;code>systemctl restart nginx&lt;/code> вылечил бы это за секунду — но выполнять его было некому.&lt;/p>
&lt;p>Через полчаса умирает и sshd:&lt;/p>
&lt;pre tabindex="0">&lt;code>sshd[…]: free(): invalid pointer
sshd[…]: recv_rexec_state: ssh_msg_recv failed
&lt;/code>&lt;/pre>&lt;p>После этой строки в журнале нет ни одного упоминания sshd — ни попыток входа, ни отказов. Порт 22 просто никто не слушал. Отсюда и полное молчание снаружи при живом ядре внутри.&lt;/p>
&lt;p>Смягчение — одна строка, и ставить её лучше drop-in&amp;rsquo;ом, а не правкой основного файла:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-ini" data-lang="ini">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4"># /etc/systemd/system.conf.d/10-crash-reboot.conf&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">[Manager]&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#50fa7b">CrashAction&lt;/span>&lt;span style="color:#ff79c6">=&lt;/span>&lt;span style="color:#f1fa8c">reboot&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>systemd-analyze cat-config systemd/system.conf &lt;span style="color:#6272a4"># посмотреть, что получится, ДО применения&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>systemctl daemon-reexec &lt;span style="color:#6272a4"># применить к живому PID 1&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Теперь повторение аварии стоит десяти секунд до автоперезагрузки. Прикиньте разницу сами: если PID 1 замрёт вечером, а первый человек посмотрит на сервер утром — это часы простоя вместо десяти секунд, и вся разница в одной строке конфига.&lt;/p>
&lt;p>Параметр &lt;code>CrashAction=&lt;/code> появился в systemd 256, вместе с ним завели и &lt;code>systemd.crash_action=&lt;/code> в командной строке ядра. В Debian 13 и Proxmox 9 приезжает systemd 257, так что опция там точно есть.&lt;/p>
&lt;h2 id="побочная-находка-restart-политика-которая-переживает-аварию-но-не-переживает-ребут">Побочная находка: restart-политика, которая переживает аварию, но не переживает ребут&lt;/h2>
&lt;p>Политика &lt;code>restart: on-failure&lt;/code> поднимает контейнеры после жёсткого ресета, но не поднимает их после штатной перезагрузки — и это даёт ложную уверенность в отказоустойчивости. У контейнеров стояло именно &lt;code>on-failure&lt;/code>. После жёсткого ресета они поднялись как миленькие: процессы убиты &lt;code>SIGKILL&lt;/code>, код выхода ненулевой, политика сработала.&lt;/p>
&lt;p>А теперь &lt;a href="https://docs.docker.com/engine/containers/start-containers-automatically/">документация Docker&lt;/a> про эту же политику:&lt;/p>
&lt;blockquote>
&lt;p>The &lt;code>on-failure&lt;/code> policy only prompts a restart if the container exits with a failure. It doesn&amp;rsquo;t restart the container if the daemon restarts.&lt;/p>
&lt;/blockquote>
&lt;p>При &lt;strong>штатной&lt;/strong> перезагрузке dockerd шлёт контейнерам &lt;code>SIGTERM&lt;/code>, и тот, кто завершается с кодом 0 — nginx именно так и делает, — под &lt;code>on-failure&lt;/code> не поднимется. Получается парадокс: конфигурация переживает аварию, но может не пережить плановый ребут. А люди тестируют отказоустойчивость как раз выдёргиванием питания и делают из этого неверный вывод.&lt;/p>
&lt;p>Лечится без простоя и без пересоздания контейнеров:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>docker update --restart unless-stopped &amp;lt;container&amp;gt;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Рядом нашлась и вторая мина: у контейнера с мониторингом директивы &lt;code>restart:&lt;/code> не было &lt;strong>вообще&lt;/strong>, а это значит &lt;code>no&lt;/code> — не поднимать никогда. После каждой перезагрузки он не стартовал, а зависящий от него прокси уходил в цикл перезапусков с &lt;code>host not found in upstream&lt;/code> — десятки рестартов за четверть часа. &lt;code>depends_on&lt;/code> тут не спасает: он задаёт порядок только внутри &lt;code>docker compose up&lt;/code>, а при автозапуске контейнеров демоном никакого порядка нет, есть только restart-политика у каждого контейнера по отдельности.&lt;/p>
&lt;h2 id="что-сделать-сейчас-по-шагам">Что сделать сейчас, по шагам&lt;/h2>
&lt;p>Минимум — обновить ZFS и переключить &lt;code>CrashAction&lt;/code>; первое убирает причину, второе ограничивает цену следующего краха PID 1 десятью секундами.&lt;/p>
&lt;ol>
&lt;li>Проверьте версию ZFS: &lt;code>modinfo zfs | grep ^version&lt;/code>. Всё, что ниже 2.4.4 или 2.3.9, а также вся ветка 2.2 включая 2.2.11 — уязвимо.&lt;/li>
&lt;li>Обновитесь. На Proxmox — &lt;code>apt update &amp;amp;&amp;amp; apt install zfsutils-linux zfs-initramfs&lt;/code> до 2.4.4-pve1, дальше перезагрузка: модуль ядра на живой системе не подменить.&lt;/li>
&lt;li>Если обновиться нельзя, снизьте вероятность триггера. Он — перезапись файла на месте под mmap-чтением. Вынесите кэш-каталоги, куда приложение пишет через &lt;code>O_TRUNC&lt;/code>, на tmpfs: где нет ZFS, там нет и бага. В приложении правильный путь — писать во временный файл и &lt;code>rename()&lt;/code>, но на легаси-кодовой базе полного покрытия не будет.&lt;/li>
&lt;li>Поставьте &lt;code>CrashAction=reboot&lt;/code> drop-in&amp;rsquo;ом. Это независимо от ZFS и полезно при любом крахе PID 1.&lt;/li>
&lt;li>Пройдитесь по restart-политикам контейнеров: &lt;code>docker inspect -f '{{.Name}} {{.HostConfig.RestartPolicy.Name}}' $(docker ps -aq)&lt;/code>. Всё, что &lt;code>no&lt;/code> или &lt;code>on-failure&lt;/code>, переведите на &lt;code>unless-stopped&lt;/code>.&lt;/li>
&lt;li>Заведите внешнюю проверку доступности. Хоть что-нибудь снаружи, что дёргает порт раз в минуту и пишет вам, когда он молчит.&lt;/li>
&lt;/ol>
&lt;p>Пункт 6 я поставил последним, а по важности он первый.&lt;/p>
&lt;h2 id="итог">Итог&lt;/h2>
&lt;p>В этой аварии три слоя вины, и починить самому можно два из них. Баг в ZFS — спусковой крючок, он от вас не зависел и уже исправлен апстримом: обновляйтесь до 2.4.4 или 2.3.9 и живите дальше. Дефолт &lt;code>CrashAction=freeze&lt;/code> превратил единичный сбой в многочасовой отказ — это чинится одной строкой и стоит того, чтобы прописать её на всех арендованных машинах заранее, до первого случая. А отсутствие внешнего мониторинга растянуло отказ до утра, и это, честно говоря, дыра крупнее самого бага в файловой системе.&lt;/p>
&lt;p>Практический вывод, который переживёт конкретно этот баг: когда ядро падает на &lt;code>memset&lt;/code> или &lt;code>memcpy&lt;/code> — посмотрите на регистр длины и проверьте, не аккуратное ли это отрицательное число. Если да, искать надо не того, кто испортил память, а того, кто неправильно посчитал размер. Это сразу отсекает всю ветку с диагностикой железа, а она самая дорогая по времени.&lt;/p>
&lt;p>И отдельно — про &lt;code>ASSERT&lt;/code> в чужом коде. Инвариант, записанный ассертом, в релизной сборке не существует. Он документирует намерение автора, а не гарантирует поведение системы. Читая исходники в поисках причины падения, каждый &lt;code>ASSERT&lt;/code> стоит мысленно читать как комментарий — и проверять, что будет, если он неверен.&lt;/p>
&lt;p>Первоисточники: &lt;a href="https://github.com/openzfs/zfs/issues/18787">issue openzfs/zfs#18787&lt;/a>, &lt;a href="https://github.com/openzfs/zfs/pull/18715">PR #18715 и коммит 223b8bc446&lt;/a>, &lt;a href="https://github.com/openzfs/zfs/pull/18824">регрессионный тест #18824&lt;/a>, &lt;a href="https://github.com/openzfs/zfs/releases/tag/zfs-2.4.4">релиз zfs-2.4.4&lt;/a>, &lt;a href="https://github.com/openzfs/zfs/blob/zfs-2.4.3/module/os/linux/zfs/zfs_vnops_os.c">&lt;code>zfs_vnops_os.c&lt;/code> в теге zfs-2.4.3&lt;/a>, &lt;a href="https://github.com/openzfs/zfs/blob/zfs-2.4.3/include/os/linux/spl/sys/debug.h">определение &lt;code>ASSERT3U&lt;/code> в &lt;code>debug.h&lt;/code>&lt;/a>, &lt;a href="https://www.freedesktop.org/software/systemd/man/latest/systemd.html">man systemd(1) про &lt;code>systemd.crash_action=&lt;/code>&lt;/a>, &lt;a href="https://docs.docker.com/engine/containers/start-containers-automatically/">документация Docker про restart-политики&lt;/a>.&lt;/p></content:encoded></item><item><title>PHP теряет сессию после апгрейда: звёздочка в имени cookie превращается в %2A</title><link>https://bitpage.ru/backend/php-session-cookie-name-urlencode-2a/</link><pubDate>Wed, 02 Sep 2026 10:00:00 +0300</pubDate><dc:creator>BitPage</dc:creator><guid>https://bitpage.ru/backend/php-session-cookie-name-urlencode-2a/</guid><description>PHP до 7.4.18 URL-кодировал имя session-cookie при отправке (звёздочка → %2A), а после фикса CVE-2020-7070 перестал декодировать имена входящих cookie. Имена перестают совпадать, сессия не находится, вход зацикливается. Окно поломки: 7.2.34, 7.3.23–7.3.33, 7.4.11–7.4.16, 8.0.1–8.0.3. Проверка — один session-id даёт 200 и 302 при разном написании имени.</description><content:encoded>&lt;p>&lt;strong>Короткий ответ:&lt;/strong> если в &lt;code>session.name&lt;/code> есть символ вне &lt;code>[0-9A-Za-z-._]&lt;/code>, PHP отправляет его в &lt;code>Set-Cookie&lt;/code> в URL-кодированном виде (&lt;code>*&lt;/code> → &lt;code>%2A&lt;/code>), а обратно принимает имя как есть, без декодирования. Имена перестают совпадать, сервер не находит session-id, стартует пустую сессию — пользователь навсегда гость. Ломается это ровно в окне версий 7.2.34, 7.3.23–7.3.33, 7.4.11–7.4.16 и 8.0.1–8.0.3: в этих релизах декодирование имён уже убрали (фикс CVE-2020-7070), а кодирование ещё не убрали. Лечится одной строкой — убрать спецсимвол из имени cookie.&lt;/p>
&lt;p>Легаси-портал на Yii2 переехал со старого хоста в Docker. Вход у него только через внешний SSO, своей формы логина нет. После переезда вход перестал доходить до конца: пользователь логинится на SSO, возвращается — и снова видит форму входа. Код приложения при этом не трогали ни строкой.&lt;/p>
&lt;p>Часа два ушло на детектив, из них полтора — на версии, которые оказались мимо. Разгадка была в одной строке заголовка с самого начала.&lt;/p>
&lt;h2 id="коротко-tldr">Коротко (TL;DR)&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Причина:&lt;/strong> &lt;code>php_url_encode()&lt;/code> применяется к &lt;code>session.name&lt;/code> при отправке, но имена входящих cookie с некоторого патча больше не декодируются. Round-trip имени разорван.&lt;/li>
&lt;li>&lt;strong>Симптом:&lt;/strong> сессия на диске корректная, идентити внутри есть, storage читается — а пользователь всё равно гость. Классический рефлекс «чини хранилище» здесь бесполезен.&lt;/li>
&lt;li>&lt;strong>Проверка за минуту:&lt;/strong> взять один валидный session-id и дёрнуть закрытую страницу дважды — с именем cookie как в конфиге и как в &lt;code>Set-Cookie&lt;/code>. &lt;code>200&lt;/code> против &lt;code>302&lt;/code> — диагноз.&lt;/li>
&lt;li>&lt;strong>Фикс:&lt;/strong> убрать из &lt;code>session.name&lt;/code> всё, кроме &lt;code>[0-9A-Za-z-._]&lt;/code>. Одна строка в конфиге, рантайм остаётся свежим.&lt;/li>
&lt;li>&lt;strong>Грабли:&lt;/strong> &lt;code>php:7.3-fpm-alpine&lt;/code> — это «последний 7.3», а не «тот же 7.3». Пин &lt;code>мажор.минор&lt;/code> не защищает от смены поведения.&lt;/li>
&lt;li>&lt;strong>Грабли:&lt;/strong> диагностируя руками, не подставляйте &lt;code>PHPSESSID&lt;/code> по привычке — при кастомном &lt;code>session.name&lt;/code> вы получите &lt;code>302&lt;/code> и ложный вывод.&lt;/li>
&lt;/ul>
&lt;h2 id="как-это-выглядит-снаружи">Как это выглядит снаружи&lt;/h2>
&lt;p>Снаружи это выглядит как сломанный SSO, хотя SSO ни при чём. Флоу входа обычный для самодельного single sign-on:&lt;/p>
&lt;ol>
&lt;li>Гость идёт на закрытую страницу — редирект на внешний SSO.&lt;/li>
&lt;li>Логинится там.&lt;/li>
&lt;li>SSO возвращает его обратно с &lt;code>?session_key=…&lt;/code>.&lt;/li>
&lt;li>Приложение проверяет ключ через API SSO, находит пользователя, вызывает &lt;code>login()&lt;/code>, редиректит на профиль.&lt;/li>
&lt;li>Профиль под сессией отдаёт &lt;code>200&lt;/code>.&lt;/li>
&lt;/ol>
&lt;p>После переезда шаг 5 не наступает никогда: профиль всегда отвечает &lt;code>302&lt;/code> обратно на вход. Круг замыкается. За пару суток после переезда в логе не нашлось ни одного успешного входа — только редиректы.&lt;/p>
&lt;p>Причём часть сигналов выглядела обнадёживающе, и это сбивало. Поле &lt;code>updated_at&lt;/code> у пользователей обновлялось — приложение пишет данные до финального &lt;code>login()&lt;/code>, так что «свежий вход» в базе означал только «нас коснулись». API SSO на запросы приложения отвечало &lt;code>200&lt;/code>: проверка ключа проходила. Оба сигнала намекали «всё почти работает» — и оба уводили от места поломки.&lt;/p>
&lt;h2 id="пять-версий-которые-оказались-мимо">Пять версий, которые оказались мимо&lt;/h2>
&lt;p>Все пять первых гипотез указывали наружу или в инфраструктуру, и ни одна не подтвердилась. Выписываю их не для красоты: каждая закрывает целое направление поиска, и если у вас похожий симптом — эти ветки можно не проходить заново.&lt;/p>
&lt;p>&lt;strong>Версия 1: сломался внешний SSO.&lt;/strong> В браузере кнопка «Войти» на форме SSO вела себя странно, будто не отправляла запрос. Логично обвинить чужой сервис. Отверг: на сервере SSO ничего не менялось, процессы подняты давно и не перезапускались, а его API на запросы приложения отвечало &lt;code>200&lt;/code>. Значит разрыв — после возврата, уже на нашей стороне.&lt;/p>
&lt;p>&lt;strong>Версия 2: кончился диск.&lt;/strong> Лог приложения SSO застыл на позавчерашней записи — классический признак заполненного раздела. Отверг: места и инодов свободно с запасом. Застывший лог оказался отдельной болячкой ротации и к входу отношения не имел.&lt;/p>
&lt;p>&lt;strong>Версия 3: чужая ручка отдаёт 500.&lt;/strong> В цепочке входа приложение дёргает у SSO ещё один эндпоинт, и тот стабильно отвечал &lt;code>500&lt;/code>. Выглядело как то, что рушит флоу до &lt;code>login()&lt;/code>. Отверг сравнением логов до и после переезда: эта ручка отдавала &lt;code>500&lt;/code> и раньше, когда вход работал. Давний безобидный дефект, не регрессия.&lt;/p>
&lt;p>&lt;strong>Версия 4: &lt;code>login()&lt;/code> не отрабатывает.&lt;/strong> Раз профиль считает гостем — может, идентити не находится: в Yii &lt;code>findIdentity()&lt;/code> фильтрует по статусу, и неактивный пользователь вернёт null. Отверг: на диске нашлись session-файлы с идентити внутри — условно &lt;code>__id|i:1234&lt;/code>. Значит &lt;code>login()&lt;/code> отрабатывает и сессию пишет, а пользователи из этих сессий в базе активны.&lt;/p>
&lt;p>&lt;strong>Версия 5: сломано хранилище сессий.&lt;/strong> Сессия пишется, но на следующем запросе не читается — похоже на разъехавшийся &lt;code>session.save_path&lt;/code> или эфемерный volume. Отверг временным скриптом: &lt;code>session_id($known); session_start();&lt;/code> спокойно прочитал сессию с идентити. Хранилище работает.&lt;/p>
&lt;p>Вот на пятой версии я и налажал сам. Проверяя «читается ли сессия по cookie», я слал запрос с заголовком &lt;code>Cookie: PHPSESSID=&amp;lt;sid&amp;gt;&lt;/code>, получал &lt;code>302&lt;/code> и чуть было не записал это в улики как «сессия не подхватывается». А приложение использует кастомное имя session-cookie — мой &lt;code>PHPSESSID&lt;/code> сервер просто игнорировал. Тест был бессмысленным с самого начала.&lt;/p>
&lt;p>Урок дешёвый, но я его оплатил: воспроизводя сессию руками, сперва посмотри реальный &lt;code>session.name&lt;/code>, а не подставляй &lt;code>PHPSESSID&lt;/code> по привычке.&lt;/p>
&lt;h2 id="что-на-самом-деле-две-половинки-которые-перестали-сходиться">Что на самом деле: две половинки, которые перестали сходиться&lt;/h2>
&lt;p>Развязку дал не поиск по симптому, а буквальное сравнение того, что сервер &lt;strong>пишет&lt;/strong> в &lt;code>Set-Cookie&lt;/code>, с тем, что он потом &lt;strong>ждёт&lt;/strong> во входящем &lt;code>Cookie&lt;/code>. Когда storage отпал, непроверенным осталось ровно одно звено — само имя.&lt;/p>
&lt;p>Имя session-cookie в конфиге приложения содержало звёздочки — условно &lt;code>myapp-front***end-s&lt;/code>. Смотрим, что реально уходит клиенту:&lt;/p>
&lt;pre tabindex="0">&lt;code>set-cookie: myapp-front%2A%2A%2Aend-s=aa5db984...; path=/; HttpOnly
&lt;/code>&lt;/pre>&lt;p>&lt;code>%2A%2A%2A&lt;/code> — это URL-кодированные &lt;code>***&lt;/code>. Сервер сам, своей рукой, записал имя в закодированном виде. А при чтении входящего заголовка он ищет сессию по исходному имени, со звёздочками. Имена не совпадают → session-id не найден → стартует новая пустая сессия → пользователь гость → редирект → круг.&lt;/p>
&lt;p>Легко прочитать &lt;code>%2A%2A%2A&lt;/code> в заголовке как «это браузер так прислал». Нет: это сервер так отдал. Смотреть надо обе стороны round-trip.&lt;/p>
&lt;p>Решающая проверка — один и тот же валидный session-id, два написания имени:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">7
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4"># имя с буквальными звёздочками — как оно записано в session.name&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>curl -s -o /dev/null -w &lt;span style="color:#f1fa8c">&amp;#39;%{http_code}\n&amp;#39;&lt;/span> https://portal.example/profile &lt;span style="color:#f1fa8c">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#f1fa8c">&lt;/span> -H &lt;span style="color:#f1fa8c">&amp;#39;Cookie: myapp-front***end-s=&amp;lt;sid&amp;gt;&amp;#39;&lt;/span> &lt;span style="color:#6272a4"># → 200, залогинен&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4"># имя в том виде, в каком сервер сам его прислал&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>curl -s -o /dev/null -w &lt;span style="color:#f1fa8c">&amp;#39;%{http_code}\n&amp;#39;&lt;/span> https://portal.example/profile &lt;span style="color:#f1fa8c">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#f1fa8c">&lt;/span> -H &lt;span style="color:#f1fa8c">&amp;#39;Cookie: myapp-front%2A%2A%2Aend-s=&amp;lt;sid&amp;gt;&amp;#39;&lt;/span> &lt;span style="color:#6272a4"># → 302, гость&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>&lt;code>200&lt;/code> против &lt;code>302&lt;/code> на одной и той же сессии, при одном и том же session-id. Содержимое сессии ни при чём — ломается именно совпадение имён.&lt;/p>
&lt;h2 id="почему-это-сломал-именно-security-фикс">Почему это сломал именно security-фикс&lt;/h2>
&lt;p>Вход держался на уязвимости, и его убил патч, который эту уязвимость закрыл. Механизм состоит из двух независимых половинок в разных частях php-src.&lt;/p>
&lt;p>&lt;strong>Половинка первая — отправка.&lt;/strong> Расширение session прогоняет &lt;code>session.name&lt;/code> через &lt;code>php_url_encode()&lt;/code>. В &lt;a href="https://github.com/php/php-src/blob/PHP-7.3/ext/session/session.c">&lt;code>ext/session/session.c&lt;/code>&lt;/a> ветки PHP-7.3 это сделано с говорящим комментарием:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-c" data-lang="c">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4">/* URL encode session_name and id because they might be user supplied */&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>e_session_name &lt;span style="color:#ff79c6">=&lt;/span> &lt;span style="color:#50fa7b">php_url_encode&lt;/span>(&lt;span style="color:#50fa7b">PS&lt;/span>(session_name), &lt;span style="color:#50fa7b">strlen&lt;/span>(&lt;span style="color:#50fa7b">PS&lt;/span>(session_name)));
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>e_id &lt;span style="color:#ff79c6">=&lt;/span> &lt;span style="color:#50fa7b">php_url_encode&lt;/span>(&lt;span style="color:#50fa7b">ZSTR_VAL&lt;/span>(&lt;span style="color:#50fa7b">PS&lt;/span>(id)), &lt;span style="color:#50fa7b">ZSTR_LEN&lt;/span>(&lt;span style="color:#50fa7b">PS&lt;/span>(id)));
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>А &lt;code>php_url_encode()&lt;/code> из &lt;a href="https://github.com/php/php-src/blob/PHP-7.3/ext/standard/url.c">&lt;code>ext/standard/url.c&lt;/code>&lt;/a> — это обычный &lt;code>urlencode&lt;/code>, не знающий ничего про cookie. Он оставляет как есть только цифры, латинские буквы, &lt;code>-&lt;/code>, &lt;code>.&lt;/code> и &lt;code>_&lt;/code>; пробел превращает в &lt;code>+&lt;/code>, всё прочее — в &lt;code>%XX&lt;/code>. Звёздочка (&lt;code>0x2A&lt;/code>) под исключения не попадает и уезжает как &lt;code>%2A&lt;/code>.&lt;/p>
&lt;p>&lt;strong>Половинка вторая — чтение.&lt;/strong> Раньше PHP декодировал имена входящих cookie, и это ровно компенсировало кодирование: &lt;code>%2A%2A%2A&lt;/code> на входе превращалось обратно в &lt;code>***&lt;/code>, имя совпадало с &lt;code>session.name&lt;/code>, всё работало. Два перекоса гасили друг друга.&lt;/p>
&lt;p>Именно это декодирование и оказалось дырой. В &lt;a href="https://bugs.php.net/bug.php?id=79699">security-баге #79699&lt;/a> репортёр описал проблему так: «The PHP cookie parser parses the HTTP_COOKIE string percent decoding the entire string» — и дальше показал, что cookie с именем &lt;code>__%48ost-&lt;/code> превращается сервером в &lt;code>__Host-&lt;/code>, обходя браузерную защиту префиксов. Его рекомендация была прямой: «I would not percent decode the names of cookies».&lt;/p>
&lt;p>Так и сделали. В &lt;a href="https://www.php.net/ChangeLog-7.php">официальном changelog PHP 7&lt;/a> для версии 7.3.23 от 1 октября 2020 года запись дословно такая:&lt;/p>
&lt;blockquote>
&lt;p>Fixed bug #79699 (PHP parses encoded cookie names so malicious &lt;code>__Host-&lt;/code> cookies can be sent). (CVE-2020-7070)&lt;/p>
&lt;/blockquote>
&lt;p>В коде это выглядит как одно условие в &lt;a href="https://github.com/php/php-src/blob/PHP-7.3/main/php_variables.c">&lt;code>main/php_variables.c&lt;/code>&lt;/a> — имя декодируется для чего угодно, кроме cookie:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-c" data-lang="c">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">if&lt;/span> (arg &lt;span style="color:#ff79c6">!=&lt;/span> PARSE_COOKIE) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#50fa7b">php_url_decode&lt;/span>(var, &lt;span style="color:#50fa7b">strlen&lt;/span>(var)); &lt;span style="color:#6272a4">/* имя: больше НЕ для cookie */&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>val_len &lt;span style="color:#ff79c6">=&lt;/span> &lt;span style="color:#50fa7b">php_url_decode&lt;/span>(val, &lt;span style="color:#50fa7b">strlen&lt;/span>(val)); &lt;span style="color:#6272a4">/* значение: всегда */&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Компенсация исчезла, кодирование осталось. Round-trip разорвался.&lt;/p>
&lt;p>Приложение при этом ничего не нарушало: по &lt;a href="https://www.rfc-editor.org/rfc/rfc6265#section-4.1.1">RFC 6265&lt;/a> имя cookie — это &lt;code>token&lt;/code>, а звёздочка входит в разрешённые символы &lt;code>token&lt;/code> и сепаратором не является. Имя было легальным. Просто PHP кодировал его generic-функцией, которая про RFC 6265 не знает.&lt;/p>
&lt;h2 id="в-каких-версиях-php-баг-живёт">В каких версиях PHP баг живёт&lt;/h2>
&lt;p>Баг живёт в узком окне: между релизом, где убрали декодирование, и релизом, где убрали кодирование. Обе даты я определил по тегам php-src, сравнивая &lt;code>ext/session/session.c&lt;/code> и &lt;code>main/php_variables.c&lt;/code>.&lt;/p>
&lt;p>Кодирование имени из &lt;code>session_send_cookie&lt;/code> убрали позже — в 7.4.18 и 8.0.5 (обе от 29 апреля 2021). Вместо него появилась валидация: имя не должно содержать &lt;code>=,; \t\r\n\013\014&lt;/code>, иначе &lt;code>E_WARNING&lt;/code> с текстом «session.name cannot contain any of the following». Звёздочка в этот запрет не входит, так что с 7.4.18 она в имени работает нормально — её и не кодируют, и не декодируют.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Ветка&lt;/th>
&lt;th>Декодирование убрано (CVE-2020-7070)&lt;/th>
&lt;th>Кодирование имени убрано&lt;/th>
&lt;th>Окно, где вход ломается&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>7.2&lt;/td>
&lt;td>7.2.34 (01.10.2020)&lt;/td>
&lt;td>не успели, ветка закрыта&lt;/td>
&lt;td>7.2.34&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>7.3&lt;/td>
&lt;td>7.3.23 (01.10.2020)&lt;/td>
&lt;td>не успели, ветка закрыта&lt;/td>
&lt;td>7.3.23 — 7.3.33&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>7.4&lt;/td>
&lt;td>7.4.11 (01.10.2020)&lt;/td>
&lt;td>7.4.18 (29.04.2021)&lt;/td>
&lt;td>7.4.11 — 7.4.16&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>8.0&lt;/td>
&lt;td>8.0.1 (07.01.2021)&lt;/td>
&lt;td>8.0.5 (29.04.2021)&lt;/td>
&lt;td>8.0.1 — 8.0.3&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>8.1 и новее&lt;/td>
&lt;td>—&lt;/td>
&lt;td>уже без кодирования&lt;/td>
&lt;td>не затронуты&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Две оговорки по таблице, обе проверяемые. Версий 7.4.17 и 8.0.4 не существует вовсе — их нет ни среди тегов php-src, ни в changelog: после 7.4.16 и 8.0.3 от 4 марта 2021 сразу идут 7.4.18 и 8.0.5 от 29 апреля. И отдельной changelog-записи про снятие кодирования с &lt;code>session.name&lt;/code> я не нашёл — изменение видно по коду, но в списке исправлений оно не поименовано.&lt;/p>
&lt;p>Практический вывод из таблицы: хуже всего веткам 7.2 и 7.3. Они закрылись, не дождавшись второй половины изменения, поэтому &lt;strong>любой последний патч 7.2 или 7.3 ломает спецсимвол в имени session-cookie навсегда&lt;/strong>. Наш случай ровно такой: старый хост работал на 7.3.16, официальный образ &lt;code>php:7.3-fpm-alpine&lt;/code> принёс 7.3.33 — то есть перепрыгнул фикс 7.3.23, стоящий ровно посередине.&lt;/p>
&lt;p>Отсюда же понятно, почему готового ответа на этот симптом почти не найти. Окно узкое, а совпасть должны три вещи разом: спецсимвол в &lt;code>session.name&lt;/code>, версия из окна и апгрейд через границу 7.3.23.&lt;/p>
&lt;h2 id="как-проверить-это-у-себя-за-минуту">Как проверить это у себя за минуту&lt;/h2>
&lt;p>Проверка не требует ни стенда, ни доступа к коду — хватит &lt;code>curl&lt;/code> и одного валидного session-id.&lt;/p>
&lt;ol>
&lt;li>Посмотрите, что сервер пишет в имя cookie:
&lt;code>curl -s -D - -o /dev/null https://portal.example/ | grep -i set-cookie&lt;/code>&lt;/li>
&lt;li>Найдите в выводе &lt;code>%&lt;/code> внутри &lt;strong>имени&lt;/strong> (до знака &lt;code>=&lt;/code>). &lt;code>%2A&lt;/code> — звёздочка, &lt;code>%21&lt;/code> — восклицательный знак, &lt;code>%24&lt;/code> — доллар. Если проценты только в значении — вы не здесь, ищите дальше.&lt;/li>
&lt;li>Сравните имя из &lt;code>Set-Cookie&lt;/code> с тем, что лежит в &lt;code>session.name&lt;/code> (&lt;code>php -i | grep session.name&lt;/code> или конфиг фреймворка). Расходятся — диагноз подтверждён.&lt;/li>
&lt;li>Добейте round-trip&amp;rsquo;ом: одну и ту же закрытую страницу с одним и тем же &lt;code>sid&lt;/code>, но двумя написаниями имени. Разные коды ответа — это оно.&lt;/li>
&lt;li>Проверьте версию: &lt;code>php -v&lt;/code>. Попадает в окно из таблицы выше — сомнений не осталось.&lt;/li>
&lt;/ol>
&lt;p>Шаг 2 — самый дешёвый и отсекает почти всё. Минута против пары часов гадания про &lt;code>save_path&lt;/code>, volume и права на каталог.&lt;/p>
&lt;h2 id="чем-чинить-четыре-варианта">Чем чинить: четыре варианта&lt;/h2>
&lt;p>Правильный вариант один — убрать спецсимвол из имени, остальные три либо консервируют дыру, либо усложняют схему.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Вариант&lt;/th>
&lt;th>Что делает&lt;/th>
&lt;th>Почему не выбран&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>Сменить &lt;code>session.name&lt;/code>&lt;/strong>&lt;/td>
&lt;td>убрать всё, кроме &lt;code>[0-9A-Za-z-._]&lt;/code>&lt;/td>
&lt;td>выбран: одна строка, рантайм остаётся свежим&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Закрепить старую патч-версию&lt;/td>
&lt;td>пин 7.3.16 в Dockerfile&lt;/td>
&lt;td>откатывает рантайм на релиз с незакрытыми уязвимостями, включая саму CVE-2020-7070&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Отключить кодирование в PHP&lt;/td>
&lt;td>—&lt;/td>
&lt;td>нечего отключать: это внутренняя логика отправки, параметром не управляется&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Переписывать имя в nginx&lt;/td>
&lt;td>подменять &lt;code>%2A&lt;/code> на &lt;code>*&lt;/code> на лету&lt;/td>
&lt;td>хрупко и непрозрачно; следующий человек будет искать это полдня&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Сам фикс — переопределение компонента сессии в локальном конфиге, который не лежит в git:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-php" data-lang="php">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4">// config/main-local.php
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4">&lt;/span>&lt;span style="color:#f1fa8c">&amp;#39;session&amp;#39;&lt;/span> &lt;span style="color:#ff79c6">=&amp;gt;&lt;/span> [
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#f1fa8c">&amp;#39;name&amp;#39;&lt;/span> &lt;span style="color:#ff79c6">=&amp;gt;&lt;/span> &lt;span style="color:#f1fa8c">&amp;#39;myapp-frontxxxend-s&amp;#39;&lt;/span>, &lt;span style="color:#6272a4">// было &amp;#39;myapp-front***end-s&amp;#39;
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4">&lt;/span>],
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Дальше &lt;code>docker compose restart php&lt;/code>. Все текущие сессии при смене имени обнуляются, но терять было нечего: залогиненных не было вовсе — все и так висели гостями.&lt;/p>
&lt;p>Звёздочки в имени cookie изначально были сомнительной практикой, так что фикс заодно убрал и её. Что осталось не сделанным: исходное имя со звёздочками всё ещё лежит в конфиге в репозитории, локальный override его лишь перебивает. Кто поднимет окружение с чистого git — наступит снова. Чинить надо в апстриме, и это долг.&lt;/p>
&lt;h2 id="итог">Итог&lt;/h2>
&lt;p>Когда сессия «не помнит» пользователя, первым делом сравните имя в &lt;code>Set-Cookie&lt;/code> с тем, которое сервер ждёт во входящем &lt;code>Cookie&lt;/code>. Если в имени есть хоть один символ вне &lt;code>[0-9A-Za-z-._]&lt;/code> — это главный подозреваемый, и проверка занимает минуту. Ветка «сломано хранилище» отсекается тем, что сессия на диске корректна: запись и чтение — два независимых шага, и ломается именно второй, на уровне имени, а не содержимого.&lt;/p>
&lt;p>Второй вывод — про пины. &lt;code>php:7.3-fpm-alpine&lt;/code> означает «последний 7.3», а не «тот же 7.3, что был на старом сервере». Между 7.3.16 и 7.3.33 лежит security-фикс, меняющий поведение, и приложение, завязанное на тонкости рантайма, переживёт такой прыжок не всегда. Если тащите легаси в контейнер — пиньте патч-версию и прогоняйте регресс-тест входа, а не только &lt;code>composer install&lt;/code>.&lt;/p>
&lt;p>И третий, менее приятный: работавший вход опирался на баг. Кодирование при отправке и декодирование при чтении гасили друг друга годами, и никто об этом не знал, потому что снаружи всё выглядело исправно. Такие связки вскрываются ровно в тот момент, когда одну половину чинят по соображениям безопасности.&lt;/p>
&lt;p>Первоисточники: &lt;a href="https://bugs.php.net/bug.php?id=79699">security-баг #79699 в трекере PHP&lt;/a>, &lt;a href="https://www.php.net/ChangeLog-7.php">changelog PHP 7&lt;/a> (записи 7.2.34, 7.3.23, 7.4.11 от 01.10.2020), &lt;a href="https://github.com/php/php-src/blob/PHP-7.3/ext/session/session.c">&lt;code>ext/session/session.c&lt;/code>&lt;/a> и &lt;a href="https://github.com/php/php-src/blob/PHP-7.3/main/php_variables.c">&lt;code>main/php_variables.c&lt;/code>&lt;/a> в php-src, &lt;a href="https://www.rfc-editor.org/rfc/rfc6265#section-4.1.1">RFC 6265 §4.1.1&lt;/a> про допустимые символы в имени cookie.&lt;/p></content:encoded></item><item><title>Patroni игнорирует правки patroni.yml: почему bootstrap.dcs работает один раз и как вернуть Ansible-роли управление кластером</title><link>https://bitpage.ru/databases/patroni-bootstrap-dcs-ansible-config-drift/</link><pubDate>Sat, 08 Aug 2026 10:00:00 +0300</pubDate><dc:creator>BitPage</dc:creator><guid>https://bitpage.ru/databases/patroni-bootstrap-dcs-ansible-config-drift/</guid><description>Секция bootstrap.dcs в patroni.yml применяется один раз — при инициализации кластера, дальше правки шаблона не действуют. Разбор четырёх разошедшихся слоёв конфигурации, перенос в postgresql.parameters всего, кроме полутора десятков параметров из CMDLINE_OPTIONS, и грабли: shared_buffers: 4G молча превращается в 128 МБ после рестарта.</description><content:encoded>&lt;p>&lt;strong>Короткий ответ:&lt;/strong> правки параметров PostgreSQL в секции &lt;code>bootstrap.dcs&lt;/code> шаблона &lt;code>patroni.yml&lt;/code> не действуют на живом кластере — секция читается один раз, при первичной инициализации, дальше конфигурацией управляет DCS (etcd) через &lt;code>patronictl edit-config&lt;/code>. Чтобы Ansible-роль снова стала источником правды, я перенёс в локальную секцию &lt;code>postgresql.parameters&lt;/code> всё, что не входит в &lt;code>CMDLINE_OPTIONS&lt;/code> Patroni: из полусотни параметров шаблона за DCS остаются только полтора десятка — ровно те, что уходят в командную строку, — а для всего остального приоритет у локального конфига. Выкат прошёл без единого рестарта, через SIGHUP.&lt;/p>
&lt;p>Кластер тот же, что в &lt;a href="https://bitpage.ru/databases/postgresql-conflict-with-recovery-patroni-haproxy/">истории про conflict with recovery&lt;/a>: PostgreSQL 15 под Patroni 3.0.2, конфигурация в etcd, синхронная реплика, pgBackRest в S3, PgBouncer, HAProxy. Тогда я мимоходом обнаружил, что ротация логов, аккуратно прописанная в шаблоне роли, не работала несколько лет и в каталоге тихо лежали десятки гигабайт. Эта статья — про то, что вскрылось, когда я сел разбираться до конца.&lt;/p>
&lt;p>Масштаб оказался больше одной ротации. Параметр, который в репозитории значится в часах, на сервере равен секундам. Заявленный гигабайт удержания WAL на деле — 128 МБ. Никакой диверсии: все правки делались добросовестно, просто они физически не могли примениться, и ничто в выводе Ansible об этом не сообщало.&lt;/p>
&lt;h2 id="коротко-tldr">Коротко (TL;DR)&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Решение:&lt;/strong> из полусотни параметров шаблона старт переживают только полтора десятка &lt;code>CMDLINE_OPTIONS&lt;/code>; всё остальное перенести из &lt;code>bootstrap.dcs&lt;/code> в локальную секцию &lt;code>postgresql.parameters&lt;/code> — там локальный файл выигрывает у DCS, и роль становится источником правды обычным прогоном плейбука.&lt;/li>
&lt;li>&lt;strong>Для остатка:&lt;/strong> cluster-wide минимум (15 параметров из &lt;code>CMDLINE_OPTIONS&lt;/code>) иначе как через DCS не задать — для него отдельный плейбук с идемпотентным &lt;code>PATCH /config&lt;/code>, намеренно не включённый в обычный прогон роли.&lt;/li>
&lt;li>&lt;strong>Обязательно:&lt;/strong> хендлер на &lt;code>systemctl reload&lt;/code> (SIGHUP). Таск, который пишет конфиг и никого не уведомляет, создаёт иллюзию управления. Рестарт не подходит: рестарт Patroni на лидере — это failover.&lt;/li>
&lt;li>&lt;strong>Грабли:&lt;/strong> &lt;code>shared_buffers: 4G&lt;/code> — невалидная единица (PostgreSQL принимает &lt;code>GB&lt;/code>, не &lt;code>G&lt;/code>). Patroni выбрасывает параметр целиком, сервер работает на старом значении до первого рестарта, после — поднимается со стоковыми 128 МБ.&lt;/li>
&lt;li>&lt;strong>Грабли:&lt;/strong> &lt;code>wal_keep_segments&lt;/code> удалён в PostgreSQL 13, но Patroni молча пересчитывает его в &lt;code>wal_keep_size&lt;/code> (8 × 16 МБ = 128 МБ). Параметр-призрак работает через слой совместимости — ровно до какого-нибудь обновления Patroni.&lt;/li>
&lt;li>&lt;strong>Диагностика:&lt;/strong> &lt;code>SELECT name, setting, source FROM pg_settings&lt;/code> — колонка &lt;code>source&lt;/code> точно говорит, из какого слоя пришло значение и как его оттуда убрать.&lt;/li>
&lt;/ul>
&lt;h2 id="почему-правка-patroniyml-ничего-не-меняет-на-живом-кластере">Почему правка patroni.yml ничего не меняет на живом кластере&lt;/h2>
&lt;p>Секция &lt;code>bootstrap.dcs&lt;/code> применяется ровно один раз за жизнь кластера — при первичной инициализации. Документация Patroni в разделе &lt;a href="https://patroni.readthedocs.io/en/latest/dynamic_configuration.html">Dynamic Configuration Settings&lt;/a> формулирует это без оговорок: «All later changes of &lt;code>bootstrap.dcs&lt;/code> will not take any effect! If you want to change them please use either &lt;code>patronictl edit-config&lt;/code> or Patroni REST API». После bootstrap конфигурация переезжает в распределённое хранилище — у нас etcd — и живёт там.&lt;/p>
&lt;p>То есть правка шаблона на живом кластере — это правка файла, который больше никто не читает. Ansible исправно рапортует &lt;code>changed&lt;/code>, файл на сервере действительно меняется, ревью проходит, коммит есть. А поведение базы — прежнее.&lt;/p>
&lt;p>За несколько лет так накопилось четыре независимых слоя конфигурации, каждый со своей историей:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Слой&lt;/th>
&lt;th>Что это&lt;/th>
&lt;th>Как разошёлся&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>git&lt;/td>
&lt;td>роль и group_vars&lt;/td>
&lt;td>обычные правки в репозитории&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>/etc/patroni/patroni.yml&lt;/code> на хосте&lt;/td>
&lt;td>что реально развёрнуто&lt;/td>
&lt;td>роль давно не прогонялась — файл отстал от git&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>DCS в etcd&lt;/td>
&lt;td>чем управляется живой кластер&lt;/td>
&lt;td>правки руками через &lt;code>patronictl edit-config&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Фактические значения&lt;/td>
&lt;td>что видит PostgreSQL&lt;/td>
&lt;td>сверху &lt;code>ALTER SYSTEM&lt;/code> и &lt;code>ALTER DATABASE&lt;/code>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Показательная деталь: файл на сервере &lt;strong>отставал&lt;/strong> от git, а DCS, наоборот, &lt;strong>опережал&lt;/strong> его. Кто-то грамотно поправил живой кластер и синхронно обновил репозиторий — но роль с тех пор ни разу не прогонял. Дисциплина у людей была. Не было архитектуры, при которой дисциплина не нужна.&lt;/p>
&lt;h2 id="какие-параметры-нельзя-забрать-из-dcs">Какие параметры нельзя забрать из DCS&lt;/h2>
&lt;p>Пятнадцать параметров Patroni передаёт PostgreSQL аргументами командной строки, и для них локальный конфиг не действует в принципе. В исходниках это словарь &lt;a href="https://github.com/patroni/patroni/blob/v3.0.2/patroni/postgresql/config.py#L268">&lt;code>CMDLINE_OPTIONS&lt;/code> в patroni/postgresql/config.py&lt;/a>: &lt;code>listen_addresses&lt;/code>, &lt;code>port&lt;/code>, &lt;code>cluster_name&lt;/code>, &lt;code>wal_level&lt;/code>, &lt;code>hot_standby&lt;/code>, &lt;code>max_connections&lt;/code>, &lt;code>max_wal_senders&lt;/code>, &lt;code>wal_keep_segments&lt;/code>, &lt;code>wal_keep_size&lt;/code>, &lt;code>max_prepared_transactions&lt;/code>, &lt;code>max_locks_per_transaction&lt;/code>, &lt;code>track_commit_timestamp&lt;/code>, &lt;code>max_replication_slots&lt;/code>, &lt;code>max_worker_processes&lt;/code>, &lt;code>wal_log_hints&lt;/code>.&lt;/p>
&lt;p>Причина уважительная: реплика в любой момент может стать мастером, поэтому такие параметры обязаны совпадать на всех нодах. &lt;a href="https://patroni.readthedocs.io/en/latest/patroni_configuration.html">Документация Patroni&lt;/a> говорит про них прямо: «values set either in the local patroni configuration files or via the environment variables take no effect».&lt;/p>
&lt;p>Для всех остальных параметров действует обратное правило: локальная конфигурация имеет приоритет над динамической. Это и есть рычаг, которым чинится вся история.&lt;/p>
&lt;p>Проверить разделение на живом кластере можно одним запросом — параметры из командной строки видны по источнику:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-sql" data-lang="sql">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">SELECT&lt;/span> name, setting, &lt;span style="color:#ff79c6">source&lt;/span> &lt;span style="color:#ff79c6">FROM&lt;/span> pg_settings &lt;span style="color:#ff79c6">WHERE&lt;/span> &lt;span style="color:#ff79c6">source&lt;/span> &lt;span style="color:#ff79c6">=&lt;/span> &lt;span style="color:#f1fa8c">&amp;#39;command line&amp;#39;&lt;/span>;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;h2 id="как-вернуть-ansible-роли-статус-источника-правды">Как вернуть Ansible-роли статус источника правды&lt;/h2>
&lt;p>План свёлся к трём ходам: развести параметры по тому, кто физически может ими управлять, и заставить роль уведомлять Patroni о каждой правке.&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Всё, что не входит в &lt;code>CMDLINE_OPTIONS&lt;/code>, — в локальную секцию &lt;code>postgresql.parameters&lt;/code>.&lt;/strong> Здесь локальный файл выигрывает у DCS, значит роль становится источником правды сама собой: обычный прогон плейбука, без вмешательства в etcd. В нашем случае под это подпали все параметры шаблона, кроме тех полутора десятков, что уходят в командную строку.&lt;/li>
&lt;li>&lt;strong>В &lt;code>bootstrap.dcs&lt;/code> остаётся только cluster-wide минимум&lt;/strong>, которым иначе управлять нельзя. Для него — отдельный плейбук с идемпотентным &lt;code>PATCH /config&lt;/code> через REST API Patroni. Он намеренно не включён в обычный прогон роли: рутинный деплой не должен трогать живой кластер.&lt;/li>
&lt;li>&lt;strong>Хендлер на reload.&lt;/strong> Таск деплоя конфига обязан кого-то уведомлять, иначе файл переписан, а Patroni об этом не знает. Правильный сигнал — SIGHUP (&lt;code>systemctl reload patroni&lt;/code>), не рестарт: рестарт Patroni на лидере означает потерю лидерства и failover на ровном месте.&lt;/li>
&lt;/ol>
&lt;p>Бонус от разделения: параметр, зависящий от объёма RAM, теперь разведён по нодам через &lt;code>group_vars&lt;/code> — раньше на кластер из разных по размеру машин стояло одно значение на всех, потому что DCS другого и не умеет.&lt;/p>
&lt;p>Выкатывал я в порядке «сначала реплика, потом мастер» — и этот порядок окупился на первом же шаге.&lt;/p>
&lt;h2 id="как-shared_buffers-4g-превращается-в-128-мб-без-единой-ошибки">Как shared_buffers: 4G превращается в 128 МБ без единой ошибки&lt;/h2>
&lt;p>Невалидная единица измерения не роняет Patroni и не ломает YAML — она молча выбрасывает параметр целиком. В конфиге лежало &lt;code>shared_buffers: 4G&lt;/code>. Выглядит нормально, читается нормально. Но PostgreSQL принимает другие суффиксы — &lt;a href="https://www.postgresql.org/docs/15/config-setting.html">документация по настройке&lt;/a> перечисляет их закрытым списком: «Valid memory units are &lt;code>B&lt;/code> (bytes), &lt;code>kB&lt;/code> (kilobytes), &lt;code>MB&lt;/code> (megabytes), &lt;code>GB&lt;/code> (gigabytes), and &lt;code>TB&lt;/code> (terabytes)». Голое &lt;code>G&lt;/code> в списке отсутствует, и валидатор Patroni реагирует так:&lt;/p>
&lt;pre tabindex="0">&lt;code>WARNING: Removing integer parameter=shared_buffers from the config due to the invalid value=4G
&lt;/code>&lt;/pre>&lt;p>Параметр не «падает в дефолт» — его просто нет в сгенерированном &lt;code>postgresql.conf&lt;/code>, и значение подхватывается из включаемого &lt;code>postgresql.base.conf&lt;/code>, где лежит стоковое &lt;code>128MB&lt;/code>. Работающий сервер этого не замечает: он держит старое значение в памяти. Ловушка взводится и ждёт следующего рестарта — нода поднимется с 128 МБ кэша вместо 4 ГБ. На реплике это деградация чтения, после failover — деградация продакшена.&lt;/p>
&lt;p>Самое коварное: &lt;code>4G&lt;/code> годами лежал в инертной &lt;code>bootstrap.dcs&lt;/code> и не вредил никому. Он ожил ровно в тот момент, когда я перенёс параметры в работающую секцию. Отсюда правило, которое я теперь произношу вслух перед каждым таким переносом: &lt;strong>перенос параметра из мёртвой секции в живую — это изменение, а не рефакторинг.&lt;/strong>&lt;/p>
&lt;p>Поймал я это на реплике, где цена ошибки нулевая, — одним запросом:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-sql" data-lang="sql">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">SELECT&lt;/span> name, setting, &lt;span style="color:#ff79c6">source&lt;/span>, pending_restart &lt;span style="color:#ff79c6">FROM&lt;/span> pg_settings &lt;span style="color:#ff79c6">WHERE&lt;/span> pending_restart;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Тот же прогон сразу на лидере оставил бы кластер в состоянии, где следующий рестарт — а он рано или поздно случается сам, без спроса — поднимает прод с кэшем, урезанным в тридцать два раза.&lt;/p>
&lt;h2 id="почему-wal_keep_segments-из-dcs-продолжает-работать-на-postgresql-15">Почему wal_keep_segments из DCS продолжает работать на PostgreSQL 15&lt;/h2>
&lt;p>Параметр, удалённый из PostgreSQL три мажорные версии назад, работал через слой совместимости Patroni — и это опаснее честной ошибки. В DCS лежал &lt;code>wal_keep_segments: 8&lt;/code>. &lt;a href="https://www.postgresql.org/docs/release/13.0/">Release notes PostgreSQL 13&lt;/a> описывают его судьбу: «Rename configuration parameter &lt;code>wal_keep_segments&lt;/code> to &lt;code>wal_keep_size&lt;/code> … It is specified in megabytes, rather than number of files as with the old parameter».&lt;/p>
&lt;p>Кластер на PG15, но никакой ошибки нет: Patroni &lt;a href="https://github.com/patroni/patroni/blob/v3.0.2/patroni/postgresql/config.py#L858">молча пересчитывает&lt;/a> старый параметр в новый по формуле 8 × 16 МБ = 128 МБ. В git при этом значился 1 ГБ — и не действовал: &lt;code>wal_keep_size&lt;/code> входит в &lt;code>CMDLINE_OPTIONS&lt;/code>, файл против DCS здесь бессилен.&lt;/p>
&lt;p>Опасность не в самом расхождении, а в его молчаливости. Пока слой совместимости работает, всё выглядит нормально. Исчезнет он при каком-нибудь обновлении Patroni — и удержание WAL изменится без единой правки в конфигах, и никто не свяжет одно с другим.&lt;/p>
&lt;h2 id="как-найти-откуда-на-самом-деле-пришло-значение-параметра">Как найти, откуда на самом деле пришло значение параметра&lt;/h2>
&lt;p>Единственный надёжный способ — колонка &lt;code>source&lt;/code> в &lt;code>pg_settings&lt;/code>: она прямо называет слой и подсказывает, как из него значение убрать.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>&lt;code>source&lt;/code>&lt;/th>
&lt;th>Где лежит&lt;/th>
&lt;th>Как убрать&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>default&lt;/code>&lt;/td>
&lt;td>нигде не задано&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>configuration file&lt;/code>&lt;/td>
&lt;td>&lt;code>postgresql.conf&lt;/code> (генерирует Patroni) или &lt;code>postgresql.auto.conf&lt;/code>&lt;/td>
&lt;td>смотреть &lt;code>sourcefile&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>command line&lt;/code>&lt;/td>
&lt;td>&lt;code>CMDLINE_OPTIONS&lt;/code>, только через DCS&lt;/td>
&lt;td>&lt;code>patronictl edit-config&lt;/code> / REST API&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>database&lt;/code>&lt;/td>
&lt;td>&lt;code>ALTER DATABASE … SET&lt;/code>&lt;/td>
&lt;td>&lt;code>ALTER DATABASE … RESET&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>user&lt;/code>&lt;/td>
&lt;td>&lt;code>ALTER ROLE … SET&lt;/code>&lt;/td>
&lt;td>&lt;code>ALTER ROLE … RESET&lt;/code>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Полный аудит ручных наслоений — три команды:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-sql" data-lang="sql">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">SELECT&lt;/span> &lt;span style="color:#ff79c6">*&lt;/span> &lt;span style="color:#ff79c6">FROM&lt;/span> pg_db_role_setting; &lt;span style="color:#6272a4">-- ALTER DATABASE / ALTER ROLE
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4">&lt;/span>&lt;span style="color:#ff79c6">SELECT&lt;/span> name, setting, &lt;span style="color:#ff79c6">source&lt;/span>, sourcefile
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">FROM&lt;/span> pg_settings &lt;span style="color:#ff79c6">WHERE&lt;/span> &lt;span style="color:#ff79c6">source&lt;/span> &lt;span style="color:#ff79c6">&amp;lt;&amp;gt;&lt;/span> &lt;span style="color:#f1fa8c">&amp;#39;default&amp;#39;&lt;/span>;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>cat &lt;span style="color:#8be9fd;font-style:italic">$PGDATA&lt;/span>/postgresql.auto.conf &lt;span style="color:#6272a4"># ALTER SYSTEM&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Отдельно про &lt;code>ALTER SYSTEM&lt;/code> под Patroni: он невидим и для git, и для самого Patroni, и теряется при пересборке ноды из бэкапа. Из всех способов «быстро поправить прод» этот — самый дорогой в обслуживании.&lt;/p>
&lt;p>Аудит DCS принёс ещё один сюрприз: теневую копию &lt;code>pg_hba&lt;/code> с ослабленным правилом доступа. Она годами лежала миной на случай, если из роли уберут локальную секцию, — и ушла вместе с мёртвым &lt;code>wal_keep_segments&lt;/code>.&lt;/p>
&lt;h2 id="сколько-ставить-wal_keep_size-считать-а-не-наследовать">Сколько ставить wal_keep_size: считать, а не наследовать&lt;/h2>
&lt;p>Значение удержания WAL надо выводить из измеренной скорости генерации, причём по часам — среднее за сутки скрывает пики. Старые 128 МБ и «правильный» 1 ГБ из git одинаково взяты с потолка; я решил посчитать.&lt;/p>
&lt;p>Если включён архив pgBackRest, дешёвый способ — распределение заливок сегментов по часам из лога PostgreSQL:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>grep &lt;span style="color:#f1fa8c">&amp;#34;pushed WAL file&amp;#34;&lt;/span> postgresql-&amp;lt;дата&amp;gt;.log | cut -c12-13 | sort | uniq -c
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Порядок величин, из которого удобно считать: возьмём кластер, который прокачивает под полтораста гигабайт WAL в сутки, — в среднем это около сотни мегабайт в минуту, а на всплесках кратно больше. Чтобы пережить транзиентный обрыв реплики минут в двадцать, достаточно пары гигабайт — столько и уехало в конфиг.&lt;/p>
&lt;p>Важная оговорка из &lt;a href="https://www.postgresql.org/docs/15/runtime-config-replication.html#GUC-WAL-KEEP-SIZE">документации PostgreSQL&lt;/a>: «This sets only the minimum size of segments retained in &lt;code>pg_wal&lt;/code>» — это пол удержания, не гарантия. При включённой архивации отставшая реплика доберёт сегменты из архива, а при живом слоте репликации параметр вообще не участвует. В современной конфигурации это третий по значимости слой защиты, раздувать его бессмысленно.&lt;/p>
&lt;p>Куда важнее оказался сосед, на которого никто не смотрел: &lt;code>max_slot_wal_keep_size&lt;/code>. Та же страница документации: «If &lt;code>max_slot_wal_keep_size&lt;/code> is -1 (the default), replication slots may retain an unlimited amount of WAL files». Слоты у нас включены. При таком потоке умершая реплика забивает диск мастера за считанные дни, а переполнение &lt;code>pg_wal&lt;/code> — это остановка записи в базу. Вот настоящий риск — в отличие от выбора между 128 МБ и гигабайтом, вокруг которого всё началось.&lt;/p>
&lt;h2 id="две-мелкие-ловушки-ansible-съевшие-полдня">Две мелкие ловушки Ansible, съевшие полдня&lt;/h2>
&lt;p>Обе не про Patroni, но обе всплыли в этом же выкате — оставляю здесь, потому что искал я их дольше, чем чинил.&lt;/p>
&lt;p>&lt;strong>&lt;code>--diff&lt;/code> печатает секреты в терминал и в лог.&lt;/strong> Шаблон рендерит конфиг pgBackRest с ключами S3. Ansible в режиме &lt;code>--diff&lt;/code> показывает изменённые строки с тремя строками контекста — в короткие файлы в это окно попадает соседняя строка с секретом, и дальше он оседает в &lt;code>ansible.log&lt;/code>, который живёт вечно. Соблазн закрыть всё через &lt;code>no_log: true&lt;/code> или &lt;code>diff: false&lt;/code> — плохой размен: на прод-выкате diff и есть главный контроль, слепое применение опаснее секрета на экране оператора, у которого и так есть ключи от хранилища. Проблема не в видимости, а в сохранении. Durable-фикс — вынести секреты из шаблонируемого конфига: pgBackRest читает &lt;code>/etc/pgbackrest/conf.d/*.conf&lt;/code>, так что ключи уехали в отдельный маленький файл под &lt;code>no_log&lt;/code>, а основной конфиг остался полностью диффабельным.&lt;/p>
&lt;p>&lt;strong>&lt;code>--check&lt;/code> ломается на связке shell + register.&lt;/strong> Классика, которая делает dry-run невозможным:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">9
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-yaml" data-lang="yaml">&lt;span style="display:flex;">&lt;span>- &lt;span style="color:#ff79c6">name&lt;/span>: Check if stanza exists
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">ansible.builtin.shell&lt;/span>: pgbackrest --stanza={{ stanza }} info
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">register&lt;/span>: stanza_check
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">changed_when&lt;/span>: &lt;span style="color:#ff79c6">false&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">failed_when&lt;/span>: &lt;span style="color:#ff79c6">false&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>- &lt;span style="color:#ff79c6">name&lt;/span>: Create stanza
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">ansible.builtin.shell&lt;/span>: pgbackrest --stanza={{ stanza }} stanza-create
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">when&lt;/span>: stanza_check.rc != 0
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>В check-режиме &lt;code>shell&lt;/code> пропускается, &lt;code>stanza_check&lt;/code> не получает &lt;code>rc&lt;/code>, следующий таск падает на неопределённой переменной. Read-only проверке лечение — одна строка: &lt;code>check_mode: false&lt;/code>.&lt;/p>
&lt;h2 id="пошагово">Пошагово&lt;/h2>
&lt;ol>
&lt;li>Снять фактическое состояние: &lt;code>SELECT name, setting, source, sourcefile FROM pg_settings WHERE source &amp;lt;&amp;gt; 'default'&lt;/code>, содержимое &lt;code>pg_db_role_setting&lt;/code>, &lt;code>postgresql.auto.conf&lt;/code> и &lt;code>patronictl show-config&lt;/code>. Четыре слоя — четыре снимка.&lt;/li>
&lt;li>Свести расхождения в таблицу git ↔ файл ↔ DCS ↔ факт. У каждого расхождения найти, какой слой прав по смыслу, а не по свежести.&lt;/li>
&lt;li>Разделить параметры: всё, что не в &lt;code>CMDLINE_OPTIONS&lt;/code>, — в &lt;code>postgresql.parameters&lt;/code> роли; cluster-wide минимум — в отдельный плейбук с &lt;code>PATCH /config&lt;/code>.&lt;/li>
&lt;li>Перед переносом провалидировать каждое значение — единицы измерения, удалённые параметры. Перенос из мёртвой секции в живую — изменение, а не рефакторинг.&lt;/li>
&lt;li>Добавить хендлер &lt;code>systemctl reload patroni&lt;/code> на таск деплоя конфига. Не рестарт.&lt;/li>
&lt;li>Прогнать на реплике, проверить &lt;code>SELECT … FROM pg_settings WHERE pending_restart&lt;/code> и &lt;code>source&lt;/code> ожидаемых параметров. Только потом — лидер.&lt;/li>
&lt;li>Убрать ручные наслоения: &lt;code>ALTER SYSTEM&lt;/code> — через &lt;code>ALTER SYSTEM RESET&lt;/code>, &lt;code>pg_db_role_setting&lt;/code> — через &lt;code>ALTER DATABASE/ROLE … RESET&lt;/code>, лишнее в DCS — через &lt;code>patronictl edit-config&lt;/code>.&lt;/li>
&lt;li>&lt;code>wal_keep_size&lt;/code> посчитать от измеренной генерации WAL по часам; &lt;code>max_slot_wal_keep_size&lt;/code> ограничить, если включены слоты.&lt;/li>
&lt;/ol>
&lt;h2 id="итог">Итог&lt;/h2>
&lt;p>Весь выкат лёг через SIGHUP, без единого рестарта. Все параметры теперь приходят из кода: &lt;code>source = configuration file&lt;/code>, &lt;code>pg_db_role_setting&lt;/code> пуст, &lt;code>postgresql.auto.conf&lt;/code> пуст, в DCS — только cluster-wide минимум без параметров-призраков. Ротация логов заработала: на мастере каталог сжался с десятков гигабайт до единиц, на реплике — с гигабайтов до сотен мегабайт.&lt;/p>
&lt;p>Из этой истории я унёс три вещи. Ansible-роль, которая пишет файл и никого не уведомляет, не сходится к желаемому состоянию — она создаёт иллюзию управления. Конфигурация, которую можно править из двух мест, будет расходиться — не «может», а будет, вопрос срока; лечится это не дисциплиной, а тем, что второе место убирают или делают производным от первого. И порядок «сначала реплика, потом мастер» — не перестраховка: он поймал ошибку с &lt;code>4G&lt;/code> там, где она стоила ноль, вместо прода с урезанным в 32 раза кэшем.&lt;/p>
&lt;p>Первоисточники, к которым я обращался по дороге:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://patroni.readthedocs.io/en/latest/dynamic_configuration.html">Patroni — Dynamic Configuration Settings&lt;/a> (про однократность &lt;code>bootstrap.dcs&lt;/code>)&lt;/li>
&lt;li>&lt;a href="https://patroni.readthedocs.io/en/latest/patroni_configuration.html">Patroni — Patroni configuration&lt;/a> (приоритеты слоёв, параметры только для DCS)&lt;/li>
&lt;li>&lt;a href="https://github.com/patroni/patroni/blob/v3.0.2/patroni/postgresql/config.py#L268">Patroni 3.0.2 — &lt;code>CMDLINE_OPTIONS&lt;/code> в patroni/postgresql/config.py&lt;/a> и &lt;a href="https://github.com/patroni/patroni/blob/v3.0.2/patroni/postgresql/config.py#L858">пересчёт wal_keep_segments → wal_keep_size&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://www.postgresql.org/docs/15/config-setting.html">PostgreSQL 15 — Setting Parameters, допустимые единицы&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://www.postgresql.org/docs/15/runtime-config-replication.html#GUC-WAL-KEEP-SIZE">PostgreSQL 15 — wal_keep_size и max_slot_wal_keep_size&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://www.postgresql.org/docs/release/13.0/">PostgreSQL 13 — Release Notes, переименование wal_keep_segments&lt;/a>&lt;/li>
&lt;/ul></content:encoded></item><item><title>PostgreSQL conflict with recovery: почему hot_standby_feedback со слотами опасен, а lag= в Patroni мерит не то</title><link>https://bitpage.ru/databases/postgresql-conflict-with-recovery-patroni-haproxy/</link><pubDate>Thu, 06 Aug 2026 10:00:00 +0300</pubDate><dc:creator>BitPage</dc:creator><guid>https://bitpage.ru/databases/postgresql-conflict-with-recovery-patroni-haproxy/</guid><description>Реплика отдавала около двух сотен конфликтов recovery за полторы недели и три десятка раз выпадала из HAProxy. hot_standby_feedback при включённых слотах переносит проблему на мастер, рост max_standby_streaming_delay усиливает флап. Реальное отставание — единицы миллисекунд по медиане и пара секунд в худшем случае — против порога health-check в 10 МБ.</description><content:encoded>&lt;p>&lt;strong>Короткий ответ:&lt;/strong> оба популярных лекарства от &lt;code>canceling statement due to conflict with recovery&lt;/code> в связке Patroni + HAProxy вредны. &lt;code>hot_standby_feedback=on&lt;/code> при включённых репликационных слотах сажает xmin в сам слот, и он переживает отключение реплики — проблема чтения превращается в риск для мастера. Рост &lt;code>max_standby_streaming_delay&lt;/code> конвертируется в отставание, по которому балансировщик выкидывает реплику: вместо двух десятков отменённых запросов в сутки получаются веерные обрывы всего чтения. Настоящий виновник нашёлся замером: отставание в покое — десятки килобайт, всплеск от крона — около 170 МБ за несколько секунд, а порог health-check стоял на 10 МБ.&lt;/p>
&lt;p>Прод: один праймари и одна реплика под Patroni 3.0.2 с etcd, PostgreSQL 15, синхронный режим, слоты включены. Веб читает с реплики через HAProxy 2.4.30, CLI ходит на мастер. Раз в четверть часа крон переписывает крупную таблицу целиком — удаляет и создаёт всё её содержимое; это чужая бизнес-логика, повлиять нельзя. Масштаб проблемы — около двух сотен отменённых запросов за полторы недели, почти все &lt;code>ERROR&lt;/code>, единицы &lt;code>FATAL&lt;/code>. Выглядят они так:&lt;/p>
&lt;pre tabindex="0">&lt;code>ERROR: canceling statement due to conflict with recovery
DETAIL: User query might have needed to see row versions that must be removed.
FATAL: terminating connection due to conflict with recovery
&lt;/code>&lt;/pre>&lt;p>Ко мне пришли с вопросом «правильно ли разработчик написал перехватчик 40001», а вылилось это в рабочий день расследования, по итогам которого выяснилось: проблема не одна, а две, и пересекаются они едва на двадцатую часть.&lt;/p>
&lt;h2 id="коротко-tldr">Коротко (TL;DR)&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Решение:&lt;/strong> убрать &lt;code>on-marked-down shutdown-sessions&lt;/code> из read-бэкенда HAProxy. Реплика всё ещё может выпасть, но живые read-запросы дорабатывают, а новые соединения уходят на мастер через &lt;code>use_backend master if { nbsrv(slave) eq 0 }&lt;/code>. Лечится ущерб, а не причина — осознанно.&lt;/li>
&lt;li>&lt;strong>Не делать:&lt;/strong> &lt;code>hot_standby_feedback=on&lt;/code> при &lt;code>use_slots: true&lt;/code> и единственной реплике. xmin оседает в слоте и переживает смерть реплики; мастер копит мёртвые строки до заполнения диска.&lt;/li>
&lt;li>&lt;strong>Не делать:&lt;/strong> поднимать &lt;code>max_standby_streaming_delay&lt;/code> с 30 с до 300 с, если health-check балансировщика смотрит на replayed-позицию. Отложенный накат — это и есть то отставание, по которому реплику выкидывают.&lt;/li>
&lt;li>&lt;strong>Грабли:&lt;/strong> &lt;code>lag=&lt;/code> в Patroni задаётся только в байтах, временного порога в эндпоинте нет в принципе. Бизнес формулирует требование в секундах, health-check меряет в мегабайтах, и на пачечной записи эти величины расходятся в разы.&lt;/li>
&lt;li>&lt;strong>Грабли:&lt;/strong> &lt;code>pg_replication_lag_seconds&lt;/code> на реплике в тишине растёт сам по себе — она показывает возраст последней транзакции, а не отставание.&lt;/li>
&lt;li>&lt;strong>Приём:&lt;/strong> длину всплеска можно восстановить из тайминга health-check (&lt;code>inter&lt;/code>/&lt;code>fall&lt;/code>/&lt;code>rise&lt;/code>), не заводя новых метрик. Две трети выпадений длились ровно минимально возможные 6 секунд.&lt;/li>
&lt;/ul>
&lt;h2 id="почему-hot_standby_feedback-при-включённых-слотах--плохой-размен">Почему hot_standby_feedback при включённых слотах — плохой размен&lt;/h2>
&lt;p>Со слотами &lt;code>hot_standby_feedback&lt;/code> перестаёт быть локальной настройкой реплики и становится долгом мастера, который тот не может списать. Это первое, что выдаёт любой поиск по тексту ошибки, и рекомендация правильная — документация PostgreSQL 15 в разделе про конфликты запросов говорит прямо: «The first option is to set the parameter &lt;code>hot_standby_feedback&lt;/code>, which prevents &lt;code>VACUUM&lt;/code> from removing recently-dead rows and so cleanup conflicts do not occur» (&lt;a href="https://www.postgresql.org/docs/15/hot-standby.html#HOT-STANDBY-CONFLICT">Hot Standby — Handling Query Conflicts&lt;/a>).&lt;/p>
&lt;p>Механика проста: реплика сообщает мастеру свой xmin, вакуум не трогает версии строк, которые ещё кому-то видны, конфликтов нет. Описание параметра там же честно предупреждает про цену — «can cause database bloat on the primary for some workloads» (&lt;a href="https://www.postgresql.org/docs/15/runtime-config-replication.html#GUC-HOT-STANDBY-FEEDBACK">runtime-config-replication&lt;/a>).&lt;/p>
&lt;p>Вся разница — в том, где живёт этот xmin. Без слотов он привязан к сессии walsender: реплика отвалилась — сессия закрылась — мастер свободен. Со слотами (&lt;code>postgresql.use_slots&lt;/code> в Patroni по умолчанию &lt;code>true&lt;/code>) xmin оседает в самом слоте, а слот — это персистентная сущность в каталоге. Описание колонки &lt;code>pg_replication_slots.xmin&lt;/code> формулирует это без оговорок: «The oldest transaction that this slot needs the database to retain. &lt;code>VACUUM&lt;/code> cannot remove tuples deleted by any later transaction» (&lt;a href="https://www.postgresql.org/docs/15/view-pg-replication-slots.html">pg_replication_slots&lt;/a>).&lt;/p>
&lt;p>Слот не знает, что реплика умерла. Он знает, что кто-то ещё вернётся за этими строками.&lt;/p>
&lt;p>Реплика в кластере одна, запасной нет. Включи я &lt;code>hot_standby_feedback&lt;/code>, и сценарий «реплика ушла в ребут на полчаса» превращается в «мастер полчаса не вакуумит таблицу, которую крон раз в четверть часа переписывает целиком». Меняем «падает отчёт на реплике» на «может лечь мастер». Совет из интернета не плохой — он молча предполагает отсутствие слотов и наличие второй реплики. У нас нет ни того, ни другого.&lt;/p>
&lt;h2 id="почему-поднять-max_standby_streaming_delay-в-схеме-с-балансировщиком-хуже-чем-ничего-не-делать">Почему поднять max_standby_streaming_delay в схеме с балансировщиком хуже, чем ничего не делать&lt;/h2>
&lt;p>Отложенный накат WAL на реплике буквально равен отставанию, по которому health-check выносит её из пула. Второй по популярности совет выглядит безобиднее первого: не убивать конфликтующий запрос, а подождать. Цена платится только на реплике, мастера не касается вообще. Я собирался поднять &lt;code>max_standby_streaming_delay&lt;/code> с дефолтных 30 секунд до 300 и уже писал правку.&lt;/p>
&lt;p>Остановила конфигурация балансировщика. Health-check у нас такой:&lt;/p>
&lt;pre tabindex="0">&lt;code>backend slave
option httpchk GET /replica?lag=10485760
http-check expect status 200
default-server inter 3s fall 3 rise 2 on-marked-down shutdown-sessions
&lt;/code>&lt;/pre>&lt;p>Patroni считает отставание по replayed-позиции — то есть по тому, сколько WAL реплика &lt;strong>применила&lt;/strong>. А документация PostgreSQL описывает суть параметра прямо: «it is the maximum total time allowed to apply WAL data once it has been received from the primary server» (&lt;a href="https://www.postgresql.org/docs/15/runtime-config-replication.html#GUC-MAX-STANDBY-STREAMING-DELAY">max_standby_streaming_delay&lt;/a>). Задержка наката — это ровно рост дельты между позицией мастера и replayed-позицией реплики. Тот самый показатель, на который смотрит балансировщик.&lt;/p>
&lt;p>Получается контур с положительной обратной связью. Конфликтный запрос тормозит накат → replayed отстаёт → health-check видит превышение порога → реплика уходит в DOWN → &lt;code>on-marked-down shutdown-sessions&lt;/code> обрывает все живые соединения к ней. Вместо десятка-двух отменённых запросов в сутки — веерный обрыв всего чтения на каждый всплеск. Хуже исходного состояния, причём заметно.&lt;/p>
&lt;p>Третий подход — поднять сам байтовый порог с 10 МБ до 512 МБ — отверг заказчик, и по делу. Требование сформулировано во времени: «секундное отставание приемлемо, полминуты — нет». Порог задан в байтах. Любое число в байтах здесь — попытка угадать время через объём.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Вариант&lt;/th>
&lt;th>Что чинит&lt;/th>
&lt;th>Чем платим&lt;/th>
&lt;th>Вердикт&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>hot_standby_feedback=on&lt;/code>&lt;/td>
&lt;td>Конфликты уходят полностью&lt;/td>
&lt;td>Со слотами xmin переживает смерть реплики → bloat на мастере&lt;/td>
&lt;td>Нет: единственная реплика, риск для праймари&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>max_standby_streaming_delay&lt;/code> 30 с → 300 с&lt;/td>
&lt;td>Запросы не отменяются&lt;/td>
&lt;td>Растёт replayed-отставание → флап в HAProxy&lt;/td>
&lt;td>Нет: усиливает вторую проблему&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Порог &lt;code>lag=&lt;/code> 10 МБ → 512 МБ&lt;/td>
&lt;td>Реплика не выпадает&lt;/td>
&lt;td>Порог в байтах не отвечает на требование в секундах&lt;/td>
&lt;td>Нет: угадывание времени через объём&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Убрать &lt;code>on-marked-down shutdown-sessions&lt;/code>&lt;/td>
&lt;td>Живые read-запросы не рвутся&lt;/td>
&lt;td>Выпадения остаются, лечится только ущерб&lt;/td>
&lt;td>Да, выкачено&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>agent-check&lt;/code> с порогом в секундах&lt;/td>
&lt;td>Корректный критерий&lt;/td>
&lt;td>Новая сущность в инфраструктуре&lt;/td>
&lt;td>Отложено до замера&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="что-на-самом-деле-выбивает-реплику-посекундный-замер-отставания">Что на самом деле выбивает реплику: посекундный замер отставания&lt;/h2>
&lt;p>Реплика отставала не потому, что тормозила накат, а потому, что WAL физически не успевал доехать. Развязку дал прямой замер на праймари — раз в секунду:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-sql" data-lang="sql">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">SELECT&lt;/span> now()::time(&lt;span style="color:#bd93f9">0&lt;/span>),
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn) &lt;span style="color:#ff79c6">AS&lt;/span> replay_bytes,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> pg_wal_lsn_diff(pg_current_wal_lsn(), flush_lsn) &lt;span style="color:#ff79c6">AS&lt;/span> flush_bytes
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">FROM&lt;/span> pg_stat_replication;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Если привести отсчёты к относительной шкале и огрубить величины, один всплеск выглядит так:&lt;/p>
&lt;pre tabindex="0">&lt;code>T+0 c replay= 0 МБ flush= 0 МБ
T+1 c replay=150 МБ flush=150 МБ ← старт пачки крона
T+2 c replay=170 МБ flush=170 МБ
T+3 c replay= 90 МБ flush= 90 МБ
T+5 c replay= 0 МБ flush= 0 МБ ← догнала
&lt;/code>&lt;/pre>&lt;p>Ключевое здесь — &lt;code>flush&lt;/code> практически равен &lt;code>replay&lt;/code>. Разница между ними и есть время наката: то, что реплика получила, но ещё не применила. Она околонулевая. Значит накат не заторможен вообще, реплика применяет всё, что успела принять, и отставание чисто транспортное — мастер генерирует WAL быстрее, чем тот уезжает по сети.&lt;/p>
&lt;p>В покое отставание держится на десятках килобайт. Всплеск от крона — около 170 МБ, и весь он укладывается в несколько секунд. Порог health-check при этом 10 МБ: до фона от него сотни раз вниз, до всплеска — семнадцать раз вверх. Всплеск перешагивает порог мгновенно и так же мгновенно кончается, и никакое значение между фоном и пиком не спасает — потому что промежуточных значений тут просто нет.&lt;/p>
&lt;p>Отдельно смешное: порог уже поднимали до нас — в &lt;code>group_vars&lt;/code> остался комментарий, что его увеличили, чтобы не ловить ложные DOWN на checkpoint и autovacuum. Не помогло, и теперь понятно почему: против всплеска в 170 МБ ни одно значение из десятков мегабайт не значит ничего.&lt;/p>
&lt;h2 id="почему-порог-lag-в-health-check-patroni-нельзя-задать-в-секундах">Почему порог lag= в health-check Patroni нельзя задать в секундах&lt;/h2>
&lt;p>Временного порога в этом эндпоинте нет в принципе: &lt;code>lag=&lt;/code> принимает исключительно байты. Документация Patroni формулирует это обтекаемо — «In addition to checks from &lt;code>replica&lt;/code>, it also checks replication latency and returns status code 200 only when it is below specified value» (&lt;a href="https://patroni.readthedocs.io/en/latest/rest_api.html">Patroni REST API&lt;/a>). Слово «latency» сбивает с толку: единицы приводятся тут же и все они объёмные (&lt;code>kB&lt;/code>, &lt;code>MB&lt;/code>, &lt;code>GB&lt;/code>), но пока не полезешь в исходник, остаётся ощущение, что секунды где-то рядом.&lt;/p>
&lt;p>Исходник версии 3.0.2 не оставляет пространства для интерпретации (&lt;a href="https://github.com/patroni/patroni/blob/v3.0.2/patroni/api.py#L101-L109">patroni/api.py, строки 101–109&lt;/a>):&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">9
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-python" data-lang="python">&lt;span style="display:flex;">&lt;span>leader_optime &lt;span style="color:#ff79c6">=&lt;/span> cluster &lt;span style="color:#ff79c6">and&lt;/span> cluster&lt;span style="color:#ff79c6">.&lt;/span>last_lsn &lt;span style="color:#ff79c6">or&lt;/span> &lt;span style="color:#bd93f9">0&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>replayed_location &lt;span style="color:#ff79c6">=&lt;/span> response&lt;span style="color:#ff79c6">.&lt;/span>get(&lt;span style="color:#f1fa8c">&amp;#39;xlog&amp;#39;&lt;/span>, {})&lt;span style="color:#ff79c6">.&lt;/span>get(&lt;span style="color:#f1fa8c">&amp;#39;replayed_location&amp;#39;&lt;/span>, &lt;span style="color:#bd93f9">0&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>max_replica_lag &lt;span style="color:#ff79c6">=&lt;/span> parse_int(self&lt;span style="color:#ff79c6">.&lt;/span>path_query&lt;span style="color:#ff79c6">.&lt;/span>get(&lt;span style="color:#f1fa8c">&amp;#39;lag&amp;#39;&lt;/span>, [sys&lt;span style="color:#ff79c6">.&lt;/span>maxsize])[&lt;span style="color:#bd93f9">0&lt;/span>], &lt;span style="color:#f1fa8c">&amp;#39;B&amp;#39;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">if&lt;/span> max_replica_lag &lt;span style="color:#ff79c6">is&lt;/span> &lt;span style="color:#ff79c6">None&lt;/span>:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> max_replica_lag &lt;span style="color:#ff79c6">=&lt;/span> sys&lt;span style="color:#ff79c6">.&lt;/span>maxsize
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>is_lagging &lt;span style="color:#ff79c6">=&lt;/span> leader_optime &lt;span style="color:#ff79c6">and&lt;/span> leader_optime &lt;span style="color:#ff79c6">&amp;gt;&lt;/span> replayed_location &lt;span style="color:#ff79c6">+&lt;/span> max_replica_lag
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>replica_status_code &lt;span style="color:#ff79c6">=&lt;/span> &lt;span style="color:#bd93f9">200&lt;/span> &lt;span style="color:#ff79c6">if&lt;/span> &lt;span style="color:#ff79c6">not&lt;/span> patroni&lt;span style="color:#ff79c6">.&lt;/span>noloadbalance &lt;span style="color:#ff79c6">and&lt;/span> &lt;span style="color:#ff79c6">not&lt;/span> is_lagging &lt;span style="color:#ff79c6">and&lt;/span> \
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> response&lt;span style="color:#ff79c6">.&lt;/span>get(&lt;span style="color:#f1fa8c">&amp;#39;role&amp;#39;&lt;/span>) &lt;span style="color:#ff79c6">==&lt;/span> &lt;span style="color:#f1fa8c">&amp;#39;replica&amp;#39;&lt;/span> &lt;span style="color:#ff79c6">and&lt;/span> response&lt;span style="color:#ff79c6">.&lt;/span>get(&lt;span style="color:#f1fa8c">&amp;#39;state&amp;#39;&lt;/span>) &lt;span style="color:#ff79c6">==&lt;/span> &lt;span style="color:#f1fa8c">&amp;#39;running&amp;#39;&lt;/span> &lt;span style="color:#ff79c6">else&lt;/span> &lt;span style="color:#bd93f9">503&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Три вещи, которые видно только отсюда:&lt;/p>
&lt;ol>
&lt;li>&lt;code>parse_int(..., 'B')&lt;/code> — единица измерения зашита, байты и ничего кроме.&lt;/li>
&lt;li>&lt;code>leader_optime&lt;/code> берётся из DCS и обновляется раз в &lt;code>loop_wait&lt;/code>, по умолчанию 10 секунд (&lt;a href="https://patroni.readthedocs.io/en/latest/SETTINGS.html">Patroni settings&lt;/a>). Сама величина, с которой сравнивают, уже несёт погрешность до десяти секунд.&lt;/li>
&lt;li>Проверки роли и состояния идут &lt;strong>независимо&lt;/strong> от проверки отставания, через &lt;code>and&lt;/code>.&lt;/li>
&lt;/ol>
&lt;p>Третий пункт оказался самым важным. Умершая реплика отсекается не по &lt;code>lag&lt;/code>, а по &lt;code>state&lt;/code>/&lt;code>role&lt;/code>: при обрыве стриминга строка в &lt;code>pg_stat_replication&lt;/code> просто исчезает, и байтовую метрику неоткуда взять. То есть &lt;code>lag=&lt;/code> покрывает ровно один сценарий — «реплика жива, но отстала». Всё остальное ловится другими условиями.&lt;/p>
&lt;h2 id="сколько-реплика-отстаёт-во-времени--и-почему-метрика-мониторинга-врёт">Сколько реплика отстаёт во времени — и почему метрика мониторинга врёт&lt;/h2>
&lt;p>Отставание во времени оказалось на три порядка меньше того, что заказчик считал неприемлемым. После выкатки я снял тот замер, которого не хватало для развязки: двадцать минут, раз в секунду, с праймари, колонки &lt;code>write_lag&lt;/code>/&lt;code>flush_lag&lt;/code>/&lt;code>replay_lag&lt;/code>. Документация описывает &lt;code>replay_lag&lt;/code> как «Time elapsed between flushing recent WAL locally and receiving notification that this standby server has written, flushed and applied it» (&lt;a href="https://www.postgresql.org/docs/15/monitoring-stats.html#MONITORING-PG-STAT-REPLICATION-VIEW">pg_stat_replication&lt;/a>) — то есть это полный путь байта от мастера до применения на реплике.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Метрика&lt;/th>
&lt;th>Значение&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Медиана&lt;/td>
&lt;td>единицы миллисекунд&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>p95&lt;/td>
&lt;td>десятки миллисекунд&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Максимум за всё наблюдение&lt;/td>
&lt;td>пара секунд&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Сэмплов выше 5 с&lt;/td>
&lt;td>ни одного&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Сэмплов выше 30 с&lt;/td>
&lt;td>ни одного&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Требование звучало как «секунда приемлема, полминуты — нет». Медиана ниже неприемлемого порога на три порядка, худший сэмпл за всё наблюдение — на порядок с лишним.&lt;/p>
&lt;p>Транспортная природа отставания подтвердилась окончательно. Разница &lt;code>replay_lag&lt;/code> минус &lt;code>flush_lag&lt;/code> на самих пиках — три крупнейших отсчёта окна, величины огрублены:&lt;/p>
&lt;pre tabindex="0">&lt;code>пик 1 ~160 МБ | flush 1,0 с | replay 1,0 с | разница — доли миллисекунды
пик 2 ~100 МБ | flush 0,7 с | replay 0,7 с | разница — доли миллисекунды
пик 3 ~95 МБ | flush 1,7 с | replay 1,7 с | разница — доли миллисекунды
&lt;/code>&lt;/pre>&lt;p>Накат добавляет доли миллисекунды к секунде-полутора транспорта. В окно попали два запуска крона — ровно четверть часа врозь.&lt;/p>
&lt;p>Дальше я полез в Prometheus за историей и чуть не сделал неверный вывод. Метрика &lt;code>pg_replication_lag_seconds&lt;/code> за месяц давала p99 в десятки секунд — то есть по её версии реплика регулярно была несвежей на десятки секунд. Это неправда. На реплике postgres_exporter считает её как &lt;code>now() - pg_last_xact_replay_timestamp()&lt;/code>, и в тишине между записями она растёт сама по себе: не потому что реплика отстала, а потому что новых транзакций не было. На праймари эта же метрика всегда ровно 0.&lt;/p>
&lt;p>Проверить легко — в актуальном upstream в запрос добавлена ветка-предохранитель именно на этот случай (&lt;a href="https://github.com/prometheus-community/postgres_exporter/blob/master/collector/pg_replication.go">collector/pg_replication.go&lt;/a>):&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-sql" data-lang="sql">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">CASE&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">WHEN&lt;/span> &lt;span style="color:#ff79c6">NOT&lt;/span> pg_is_in_recovery() &lt;span style="color:#ff79c6">THEN&lt;/span> &lt;span style="color:#bd93f9">0&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">WHEN&lt;/span> pg_last_wal_receive_lsn () &lt;span style="color:#ff79c6">=&lt;/span> pg_last_wal_replay_lsn () &lt;span style="color:#ff79c6">THEN&lt;/span> &lt;span style="color:#bd93f9">0&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">ELSE&lt;/span> GREATEST (&lt;span style="color:#bd93f9">0&lt;/span>, &lt;span style="color:#ff79c6">EXTRACT&lt;/span>(EPOCH &lt;span style="color:#ff79c6">FROM&lt;/span> (now() &lt;span style="color:#ff79c6">-&lt;/span> pg_last_xact_replay_timestamp())))
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">END&lt;/span> &lt;span style="color:#ff79c6">AS&lt;/span> lag
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Вторая строка &lt;code>CASE&lt;/code> — «получено равно применённому, значит лаг ноль» — гасит ровно тот эффект, который я наблюдал. В нашей версии её не было. Метрика не была сломана: она измеряла не то, что подразумевает её имя.&lt;/p>
&lt;p>Одну вещь этот же Prometheus дал бесплатно. За месяц лаг превышал минуту ровно один раз: единственный реальный отказ длился несколько часов — реплика осталась без сети после ребута гипервизора. Вот с чем стоит сравнивать порог: &lt;strong>пара секунд у худшего рабочего всплеска против нескольких часов у настоящей поломки, разница в тысячи раз.&lt;/strong> Порог &lt;code>lag=10MB&lt;/code> стоял вплотную к нижней границе этого диапазона.&lt;/p>
&lt;p>И ограничение самого инструмента: при scrape-интервале 15 секунд всплески длиной в несколько секунд Prometheus физически не разрешает. Для коротких событий источник — логи балансировщика, а не графики.&lt;/p>
&lt;h2 id="как-посчитать-длину-всплеска-по-таймингу-самого-health-check">Как посчитать длину всплеска по таймингу самого health-check&lt;/h2>
&lt;p>Длина всплеска = длительность &lt;code>DOWN&lt;/code> в логе плюс (&lt;code>fall&lt;/code> − &lt;code>rise&lt;/code>) × &lt;code>inter&lt;/code>, и всё нужное для этого балансировщик уже записал сам. Приём пригодился, когда встал вопрос «а что даст &lt;code>fall 3 → 5&lt;/code>», а разрешения мониторинга на такие короткие события не хватало.&lt;/p>
&lt;p>Арифметика прямая. Пусть всплеск начался в T₀ и кончился в T₁:&lt;/p>
&lt;ul>
&lt;li>&lt;code>DOWN&lt;/code> объявляется после &lt;code>fall 3&lt;/code> × &lt;code>inter 3s&lt;/code> = &lt;strong>9 секунд&lt;/strong> превышения порога подряд, то есть в T₀ + 9.&lt;/li>
&lt;li>&lt;code>UP&lt;/code> возвращается после &lt;code>rise 2&lt;/code> × &lt;code>inter 3s&lt;/code> = &lt;strong>6 секунд&lt;/strong> ниже порога, то есть в T₁ + 6.&lt;/li>
&lt;li>Длительность &lt;code>DOWN&lt;/code> = (T₁ + 6) − (T₀ + 9) = длина всплеска − 3 с.&lt;/li>
&lt;/ul>
&lt;p>Отсюда длина всплеска ≈ длительность &lt;code>DOWN&lt;/code> + 3 с.&lt;/p>
&lt;p>Разбор по логу HAProxy всех выпадений за неделю с небольшим — три десятка событий:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>DOWN длился&lt;/th>
&lt;th>Доля выпадений&lt;/th>
&lt;th>Всплеск был&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>6 с&lt;/td>
&lt;td>около двух третей&lt;/td>
&lt;td>~9 с&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>9 с&lt;/td>
&lt;td>единичные случаи&lt;/td>
&lt;td>~12 с&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>18 с&lt;/td>
&lt;td>единичные случаи&lt;/td>
&lt;td>~21 с&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>21 с&lt;/td>
&lt;td>примерно пятая часть&lt;/td>
&lt;td>~24 с&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>27 с&lt;/td>
&lt;td>один случай&lt;/td>
&lt;td>~30 с&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Две трети выпадений длились ровно минимально возможный &lt;code>DOWN&lt;/code> — всплеск едва перешагнул порог и тут же кончился. Отсюда конкретный ответ вместо мнения: &lt;code>fall 5&lt;/code> (нужно 15 секунд подряд) снял бы около 70 % выпадений. Оставшаяся треть — всплески по 21–30 секунд, они продолжали бы ронять реплику при любом &lt;code>fall&lt;/code>.&lt;/p>
&lt;p>Ради этого не пришлось ни заводить экспортёр, ни ждать неделю новых данных. Всё уже было в логах, надо было только вспомнить, что таймауты проверки — это тоже цена деления.&lt;/p>
&lt;h2 id="почему-конфликты-роняют-реплику-оказалось-совпадением-а-не-причиной">Почему «конфликты роняют реплику» оказалось совпадением, а не причиной&lt;/h2>
&lt;p>По времени совпала примерно треть выпадений и едва двадцатая часть конфликтов. Это разные события от одной причины, а не причина и следствие. И именно эту гипотезу я проверял последней, потратив до неё заметное время на две другие.&lt;/p>
&lt;p>Гипотеза про чекпоинты: &lt;code>checkpoint_timeout=15min&lt;/code>, чекпоинты в логе мастера идут раз в четверть часа. Выпадения ложатся на другие минуты — не совпадают ни разу.&lt;/p>
&lt;p>Гипотеза про окно бэкапа: скрипт на время &lt;code>pg_dump&lt;/code> выставляет &lt;code>max_standby_streaming_delay=3600s&lt;/code>, и версия «он и выбивает реплику» выглядела складно. Все выпадения длятся 6–27 секунд. Часовых окон нет ни одного.&lt;/p>
&lt;p>Гипотеза «конфликты роняют реплику» держалась дольше всех, потому что и то и другое кучковалось по одним и тем же минутам. Логи лежали в разных таймзонах, я привёл их к одной шкале и сопоставил по таймстампам — вышла двадцатая часть. Кучковались они по одним минутам просто потому, что у них общий источник: крон, который раз в четверть часа переписывает крупную таблицу целиком.&lt;/p>
&lt;p>Корреляцию по таймстампам надо было делать первой. Одна команда сопоставления за минуту опровергла то, на что я потратил часы.&lt;/p>
&lt;h2 id="что-выкачено-убрать-ущерб-если-нельзя-убрать-причину">Что выкачено: убрать ущерб, если нельзя убрать причину&lt;/h2>
&lt;p>Из read-бэкенда HAProxy убран &lt;code>on-marked-down shutdown-sessions&lt;/code> — реплика по-прежнему может выпасть, но живые read-запросы больше не обрываются:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-diff" data-lang="diff">&lt;span style="display:flex;">&lt;span> backend slave
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> option httpchk GET /replica?lag=10485760
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> http-check expect status 200
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#f55">- default-server inter 3s fall 3 rise 2 on-marked-down shutdown-sessions
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#f55">&lt;/span>&lt;span style="color:#50fa7b;font-weight:bold">+ default-server inter 3s fall 3 rise 2
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>В бэкендах &lt;code>master&lt;/code> и &lt;code>long_queries&lt;/code> опция оставлена — там она нужна для failover, чтобы при смене роли клиенты не остались висеть на бывшем мастере. На read-бэкенде она делала ровно противоположное задуманному. Документация HAProxy 2.4 описывает её так: «all connections to the server are immediately terminated when the server goes down. It might be used if the health check detects more complex cases than a simple connection status, and long timeouts would cause the service to remain unresponsive for too long a time» (&lt;a href="https://docs.haproxy.org/2.4/configuration.html">HAProxy 2.4 configuration, on-marked-down&lt;/a>). Формулировка описывает застрявшую базу. У нас база не застревала — она отставала на несколько секунд.&lt;/p>
&lt;p>В логах это выглядело так (&lt;code>N&lt;/code> — число живых сессий на момент выпадения, порядок величины — десятки):&lt;/p>
&lt;pre tabindex="0">&lt;code>Server slave/pg_replica is DOWN, reason: Layer7 wrong status, code: 503,
info: &amp;#34;Service Unavailable&amp;#34;, check duration: 4ms. 0 active and 0 backup
servers left. N sessions active, 0 requeued, 0 remaining in queue.
&lt;/code>&lt;/pre>&lt;p>Десятки живых сессий, убитых одним решением балансировщика о том, что реплика отстала на 10 мегабайт. Причём приложение видит при этом &lt;strong>не&lt;/strong> &lt;code>SQLSTATE[40001]&lt;/code>, а оборванный коннект — и перехватчик, с которого началась вся история, его не ловит вообще. Новые соединения уходят на мастер сами, это уже было настроено: &lt;code>use_backend master if { nbsrv(slave) eq 0 }&lt;/code>.&lt;/p>
&lt;p>Размен здесь честный: убран ущерб, не причина. Для корректного критерия нужен свой health-check — &lt;code>agent-check&lt;/code> HAProxy с агентом на реплике, отдающим &lt;code>up&lt;/code>/&lt;code>down&lt;/code> по &lt;code>now() - pg_last_xact_replay_timestamp()&lt;/code>. Это новая сущность в инфраструктуре, и решение о ней принимается с цифрами на руках. Теперь цифры есть.&lt;/p>
&lt;p>Приложению в ревью ушло другое: разделить &lt;code>ERROR&lt;/code> (запрос отменён, соединение живо) и &lt;code>FATAL&lt;/code> (соединение разорвано) — это разные сценарии восстановления; убрать недостижимый цикл повторов; добавить метрику вместо одного &lt;code>Log::warning&lt;/code>. Сам перехватчик оказался корректным. Половина моих первоначальных замечаний к нему не пережила перепроверки — полезное упражнение на отделение «я бы написал иначе» от «здесь дефект».&lt;/p>
&lt;h2 id="грабли-по-дороге-ansible-ротация-логов-и-ленивое-соединение">Грабли по дороге: Ansible, ротация логов и ленивое соединение&lt;/h2>
&lt;p>Три находки, не связанные с задачей напрямую, но каждая из них дороже самой правки.&lt;/p>
&lt;p>&lt;strong>&lt;code>--check --diff&lt;/code> вскрыл дрейф конфигурации.&lt;/strong> На сервере руками были дописаны &lt;code>weight 80&lt;/code> / &lt;code>weight 20&lt;/code>, в шаблоне их не было. Прогон плейбука молча стёр бы чужую работу. Пришлось сначала подтянуть код к фактическому состоянию сервера и только потом класть свою правку. Отдельно выяснилось, что один файл &lt;code>group_vars&lt;/code> оказался хардлинком на несколько инвентарей — правка в одном месте меняет все окружения сразу. Перед выкаткой на давно живущий прод надо читать весь diff, а не только «свой» кусок: это единственный шанс не стереть чужую работу.&lt;/p>
&lt;p>&lt;strong>&lt;code>pg_log&lt;/code> не ротировался: десятки гигабайт на мастере, единицы на реплике, файлы копятся не первый год.&lt;/strong> Причина не в logrotate, а в том, что рабочий конфиг разошёлся с шаблоном Patroni. Настройки лежали в &lt;code>bootstrap.dcs&lt;/code>, а документация Patroni про эту секцию высказывается недвусмысленно: «All later changes of &lt;code>bootstrap.dcs&lt;/code> will not take any effect! If you want to change them please use either &lt;code>patronictl edit-config&lt;/code> or Patroni REST API» (&lt;a href="https://patroni.readthedocs.io/en/latest/SETTINGS.html">Patroni SETTINGS&lt;/a>). Секция применяется ровно один раз, при первичном bootstrap кластера. Дальше живые параметры лежат в DCS, и правка шаблона не делает ничего.&lt;/p>
&lt;p>&lt;strong>Ленивое соединение с репликой терялось на любом запросе с биндингами.&lt;/strong> В проектной обвязке метод &lt;code>bindValues()&lt;/code> начинался с &lt;code>$this-&amp;gt;getPdo()-&amp;gt;getAttribute(...)&lt;/code>. В Laravel &lt;code>getPdo()&lt;/code> резолвит ленивое замыкание, и это буквально одна строка (&lt;a href="https://github.com/laravel/framework/blob/10.x/src/Illuminate/Database/Connection.php">Illuminate\Database\Connection&lt;/a>):&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">8
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-php" data-lang="php">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">public&lt;/span> &lt;span style="color:#ff79c6">function&lt;/span> &lt;span style="color:#50fa7b">getPdo&lt;/span>()
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>{
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">if&lt;/span> (&lt;span style="color:#8be9fd;font-style:italic">$this&lt;/span>&lt;span style="color:#ff79c6">-&amp;gt;&lt;/span>&lt;span style="color:#50fa7b">pdo&lt;/span> instanceof Closure) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">return&lt;/span> &lt;span style="color:#8be9fd;font-style:italic">$this&lt;/span>&lt;span style="color:#ff79c6">-&amp;gt;&lt;/span>&lt;span style="color:#50fa7b">pdo&lt;/span> &lt;span style="color:#ff79c6">=&lt;/span> call_user_func(&lt;span style="color:#8be9fd;font-style:italic">$this&lt;/span>&lt;span style="color:#ff79c6">-&amp;gt;&lt;/span>&lt;span style="color:#50fa7b">pdo&lt;/span>);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">return&lt;/span> &lt;span style="color:#8be9fd;font-style:italic">$this&lt;/span>&lt;span style="color:#ff79c6">-&amp;gt;&lt;/span>&lt;span style="color:#50fa7b">pdo&lt;/span>;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>}
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>&lt;code>$this-&amp;gt;pdo&lt;/code> — это write-соединение. Метод &lt;code>bindValues()&lt;/code> вызывается для любого запроса с биндингами, включая чисто читающий. То есть каждый параметризованный SELECT открывал соединение с мастером просто чтобы прочитать атрибут PDO. Ленивое подключение к праймари, ради которого read/write split и городится, не работало вообще.&lt;/p>
&lt;p>И ещё два наблюдения из логов. За две недели база залогировала примерно вдвое больше конфликтов, чем отрапортовало приложение, — оно видит хорошо если половину того, что с ним происходит. А &lt;code>application_name&lt;/code> пуст во всех событиях, поэтому атрибутировать запрос к конкретному сервису по логам БД невозможно: виден только пользователь БД, а под ним ходят все. Второе чинится одной строкой в DSN, первое — метрикой вместо записи в лог. Обе дырки обнаруживаются только когда садишься считать вручную.&lt;/p>
&lt;h2 id="пошагово">Пошагово&lt;/h2>
&lt;ol>
&lt;li>Прежде чем трогать &lt;code>hot_standby_feedback&lt;/code>, проверить &lt;code>SELECT slot_name, xmin, catalog_xmin FROM pg_replication_slots;&lt;/code> и число реплик. Слоты + одна реплика — параметр не включать.&lt;/li>
&lt;li>Снять посекундный замер &lt;code>pg_wal_lsn_diff&lt;/code> по &lt;code>replay_lsn&lt;/code> и &lt;code>flush_lsn&lt;/code> с праймари. Если &lt;code>flush ≈ replay&lt;/code>, отставание транспортное, и тормозить накат бессмысленно.&lt;/li>
&lt;li>Снять замер во времени — &lt;code>write_lag&lt;/code>/&lt;code>flush_lag&lt;/code>/&lt;code>replay_lag&lt;/code> из &lt;code>pg_stat_replication&lt;/code>, минимум несколько сотен сэмплов, с попаданием в пиковое окно.&lt;/li>
&lt;li>Сопоставить таймстампы конфликтов и выпадений реплики, приведя логи к одной таймзоне. Делать это первым, а не последним.&lt;/li>
&lt;li>Посчитать длину всплесков из лога балансировщика: длительность &lt;code>DOWN&lt;/code> + (&lt;code>fall&lt;/code> − &lt;code>rise&lt;/code>) × &lt;code>inter&lt;/code>. Отсюда — обоснованное значение &lt;code>fall&lt;/code>.&lt;/li>
&lt;li>Проверить, что метрика мониторинга измеряет заявленное. Для &lt;code>pg_replication_lag_seconds&lt;/code> — сравнить значение на реплике в простое со значением на праймари.&lt;/li>
&lt;li>Прогнать &lt;code>ansible-playbook --check --diff&lt;/code> целиком и вычитать весь diff, а не свой кусок.&lt;/li>
&lt;li>Выкатывать конфиг HAProxy через reload (&lt;code>kill -USR2&lt;/code> мастеру в master-worker режиме), а не рестартом. Сокеты держит мастер-процесс, порт не закрывается, старый воркер доживает текущие сессии.&lt;/li>
&lt;/ol>
&lt;p>Восьмой пункт — из тех, что пишешь по итогам собственной ошибки. Я выкатил рестартом и разом оборвал все соединения — ровно то, от чего избавляет сама правка.&lt;/p>
&lt;h2 id="итог">Итог&lt;/h2>
&lt;p>Байтовый порог отставания отвечает на вопрос «сколько WAL не доехало». Бизнесу нужен ответ на другой — «насколько несвежие данные я сейчас отдаю». На ровной нагрузке эти вопросы почти эквивалентны, на пачечной расходятся в разы: фон — десятки килобайт, порог 10 МБ, всплеск около 170 МБ. Вся дискуссия про «какой порог поставить» была бессмысленной, пока это не проговорили вслух.&lt;/p>
&lt;p>Порог, который срабатывает на рабочем всплеске в пару секунд, притом что настоящая поломка длится часами, — это не «настроено строго». Это настроено случайно: между этими двумя величинами почти четыре порядка, и порог сел вплотную к нижней. Правильный путь дальше — &lt;code>agent-check&lt;/code> с временным критерием через &lt;code>pg_last_xact_replay_timestamp()&lt;/code>; промежуточный и уже обоснованный — &lt;code>fall 3 → 5&lt;/code>, который снимает 70 % выпадений без риска отдать несвежие данные. Оставшиеся 30 % — всплески по 21–30 секунд; их закрывает либо более высокий байтовый порог, либо смена единицы измерения на секунды.&lt;/p>
&lt;p>Отдельный вывод для расследований: когда не хватает разрешения у мониторинга, считай из тайминга того, что уже логируется. Health-check с известными &lt;code>inter&lt;/code>/&lt;code>fall&lt;/code>/&lt;code>rise&lt;/code> — готовый прибор с секундной точностью, и он ведёт запись всё время, пока ты решаешь, какую метрику завести.&lt;/p>
&lt;p>Первоисточники, к которым я обращался по дороге:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://www.postgresql.org/docs/15/hot-standby.html#HOT-STANDBY-CONFLICT">PostgreSQL 15 — Hot Standby, Handling Query Conflicts&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://www.postgresql.org/docs/15/runtime-config-replication.html#GUC-HOT-STANDBY-FEEDBACK">PostgreSQL 15 — hot_standby_feedback и max_standby_streaming_delay&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://www.postgresql.org/docs/15/view-pg-replication-slots.html">PostgreSQL 15 — pg_replication_slots&lt;/a> и &lt;a href="https://www.postgresql.org/docs/15/monitoring-stats.html#MONITORING-PG-STAT-REPLICATION-VIEW">pg_stat_replication&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://github.com/patroni/patroni/blob/v3.0.2/patroni/api.py#L101-L109">Patroni 3.0.2 — patroni/api.py, обработка &lt;code>lag=&lt;/code>&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://patroni.readthedocs.io/en/latest/rest_api.html">Patroni — REST API&lt;/a> и &lt;a href="https://patroni.readthedocs.io/en/latest/SETTINGS.html">SETTINGS&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://docs.haproxy.org/2.4/configuration.html">HAProxy 2.4 — configuration manual, &lt;code>on-marked-down&lt;/code>&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://github.com/prometheus-community/postgres_exporter/blob/master/collector/pg_replication.go">postgres_exporter — collector/pg_replication.go&lt;/a>&lt;/li>
&lt;/ul></content:encoded></item><item><title>Gmail отбивает всю почту: 550-5.7.26 и три сломанные аутентификации разом</title><link>https://bitpage.ru/incidents/gmail-otbivaet-pochtu-spf-dkim-fcrdns/</link><pubDate>Wed, 29 Jul 2026 09:00:00 +0300</pubDate><dc:creator>BitPage</dc:creator><guid>https://bitpage.ru/incidents/gmail-otbivaet-pochtu-spf-dkim-fcrdns/</guid><description>За сутки — тысячи отказов против единиц доставленных писем. Gmail с февраля 2024 требует SPF или DKIM плюс сходящийся PTR — на легаси-релее не работало ни то, ни другое, ни третье, а OpenDKIM молча не подписывал почту из-за одного слова в конфиге.</description><content:encoded>&lt;p>&lt;strong>Короткий ответ:&lt;/strong> если Gmail отбивает письма с кодом &lt;code>550-5.7.26&lt;/code>, причина почти никогда не одна. У меня на легаси-релее одновременно не работали три механизма: SPF-записи не было вовсе (вместо неё лежал мёртвый SenderID), DKIM не подписывал письма из-за типа датасета в конфиге OpenDKIM (&lt;code>refile:&lt;/code> там, где нужен &lt;code>file:&lt;/code>), а PTR указывал на хост, чья A-запись вела на другой сервер. Чинить пришлось все три — пока сломан хотя бы один, суточная сводка остаётся прежней: &lt;code>bounced&lt;/code> четырёхзначный, &lt;code>sent&lt;/code> однозначный, то есть тысячи отказов против единиц доставленных.&lt;/p>
&lt;p>Сервер — исходящий релей для транзакционной почты: восстановление пароля, уведомления, письма от приложений с пары десятков хостов. Postfix плюс OpenDKIM 2.10.3 в легаси-контейнере OpenVZ, DNS в Route 53, входящая почта домена идёт мимо — в Google Workspace. Аптайм — больше двух лет, конфиги не трогали годами, контейнер вне Ansible. Никто ничего не менял; поменялся Gmail.&lt;/p>
&lt;h2 id="коротко-tldr">Коротко (TL;DR)&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Решение:&lt;/strong> чинить SPF, DKIM и FCrDNS одновременно, а проверять — по заголовкам доставленного письма, а не по логам Postfix. Итог: &lt;code>DKIM-Signature&lt;/code> на месте, &lt;code>spf=pass&lt;/code>, &lt;code>dmarc=pass&lt;/code>, &lt;code>250 2.0.0 OK&lt;/code>.&lt;/li>
&lt;li>&lt;strong>Альтернатива:&lt;/strong> отдать транзакционную почту внешнему сервису (Postmark, Unisender, SES) — если некому годами следить за репутацией IP и bounce rate.&lt;/li>
&lt;li>&lt;strong>Грабли:&lt;/strong> OpenDKIM при ненайденном ключе не пишет в лог ничего. &lt;code>systemctl is-active&lt;/code> → &lt;code>active&lt;/code> не доказывает, что подпись ставится. Ручная отправка с самого сервера проходит успешно и создаёт ложное ощущение победы.&lt;/li>
&lt;/ul>
&lt;h2 id="что-gmail-требует-с-2024-года-и-почему-сервер-сломался-сам">Что Gmail требует с 2024 года и почему сервер сломался «сам»&lt;/h2>
&lt;p>Требования ужесточились в феврале 2024, и легаси-конфиг, который до этого годами работал, перестал им соответствовать без единой правки на сервере. &lt;a href="https://support.google.com/a/answer/81126">Руководство для отправителей Google&lt;/a> требует от всех отправителей аутентификации хотя бы одним механизмом, а от массовых (от 5000 писем в сутки на адреса Gmail) — SPF &lt;strong>и&lt;/strong> DKIM, плюс DMARC, плюс валидные прямая и обратная DNS-записи, плюс долю жалоб на спам ниже 0,3 %.&lt;/p>
&lt;p>Отсюда главная ловушка этого инцидента: нет события «изменили X — упало Y». Искать «что я вчера сломал» бесполезно, потому что вчера ничего не ломали. Сломалось соответствие требованиям, которые переехали.&lt;/p>
&lt;p>Дословно Gmail говорил вот что:&lt;/p>
&lt;pre tabindex="0">&lt;code>550-5.7.26 Your email has been blocked because the sender is unauthenticated.
550-5.7.26 Gmail requires all senders to authenticate with either SPF or DKIM.
550-5.7.26 Authentication results:
550-5.7.26 DKIM = did not pass
550-5.7.26 SPF [example.org] with ip: [203.0.113.51] = did not pass
&lt;/code>&lt;/pre>&lt;p>Gmail честно перечислил оба провалившихся механизма — и SPF, и DKIM. Я это прочитал, но всё равно полез чинить их по очереди, ожидая после каждого шага, что письма пойдут. Не шли.&lt;/p>
&lt;h2 id="почему-я-начал-не-с-того-собственная-заметка-врала">Почему я начал не с того: собственная заметка врала&lt;/h2>
&lt;p>Первое, что я сделал — открыл свою же задачу в трекере с готовым диагнозом, и это стоило мне примерно получаса. В заметке, написанной днём ранее, было четыре вводных факта. Три оказались неверными.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Что было записано&lt;/th>
&lt;th>Что оказалось на самом деле&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>PTR в порядке, прямая и обратная сходятся&lt;/td>
&lt;td>PTR вёл на &lt;code>legacy.example.org&lt;/code>, чья A-запись — &lt;code>198.51.100.216&lt;/code>, другой сервер&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>DNS хостится на Cloudflare&lt;/td>
&lt;td>Route 53 (&lt;code>dig NS&lt;/code> → &lt;code>ns-*.awsdns-*&lt;/code>)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>DMARC отсутствует&lt;/td>
&lt;td>DMARC на месте: &lt;code>v=DMARC1; p=none&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Почта отбивается Gmail&lt;/td>
&lt;td>Верно&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Мораль скучная, но дорогая: перепроверять вводные руками, даже если их писал ты сам и даже если это было вчера. Особенно если это было вчера — свежая заметка вызывает больше доверия, чем заслуживает.&lt;/p>
&lt;h2 id="что-на-самом-деле-значит-550-5725-про-ptr">Что на самом деле значит 550-5.7.25 про PTR&lt;/h2>
&lt;p>Формулировка Gmail про PTR состоит из двух половин, и вторая важнее первой. Полностью она такая:&lt;/p>
&lt;pre tabindex="0">&lt;code>550-5.7.25 [203.0.113.51] The IP address sending this message does not have a PTR
550-5.7.25 record setup, or the corresponding forward DNS entry does not match the sending IP
&lt;/code>&lt;/pre>&lt;p>Я, как и все, прочитал до слов «does not have a PTR record», проверил &lt;code>dig -x&lt;/code>, увидел ответ и поставил галочку. А сообщение говорит «&lt;strong>or the corresponding forward DNS entry does not match&lt;/strong>» — то есть претензия может быть не к отсутствию PTR, а к тому, что цепочка не замыкается. Проверка занимает две команды:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>dig -x 203.0.113.51 +short &lt;span style="color:#6272a4"># -&amp;gt; legacy.example.org.&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>dig A legacy.example.org +short &lt;span style="color:#6272a4"># -&amp;gt; 198.51.100.216 (не тот IP)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Это классический FCrDNS: IP → имя → обратно должен получиться тот же IP. Здесь обратно получался чужой сервер. Чинить через A-запись &lt;code>legacy.example.org&lt;/code> было нельзя — хост живой и обслуживает посторонний сервис. Поэтому переклеил PTR в панели хостера на &lt;code>mail.example.org&lt;/code>, чья A-запись уже вела куда надо, и заодно выставил &lt;code>myhostname = mail.example.org&lt;/code>, чтобы HELO совпадал с PTR. Побочных эффектов — ноль.&lt;/p>
&lt;h2 id="почему-grep-по-maillog-ничего-не-доказывает-про-dkim">Почему grep по mail.log ничего не доказывает про DKIM&lt;/h2>
&lt;p>Проверять наличие DKIM-подписи через лог Postfix нельзя: Postfix не пишет заголовки писем в лог вообще. Я начал именно с этого:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>grep -c &lt;span style="color:#f1fa8c">&amp;#34;DKIM-Signature&amp;#34;&lt;/span> /var/log/mail.log &lt;span style="color:#6272a4"># -&amp;gt; 0&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Ноль выглядит убедительно. На самом деле он не значит ничего — этот метод не отличает «не подписывает» от «подписывает». Полчаса я строил гипотезы на показаниях прибора, который ничего не измеряет.&lt;/p>
&lt;p>Рабочая проверка ровно одна: отправить письмо на локальный ящик и прочитать заголовки &lt;strong>доставленного&lt;/strong> письма.&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>sendmail -f noreply@example.org nobody@localhost &lt;span style="color:#f1fa8c">&amp;lt;&amp;lt;&amp;#39;EOF&amp;#39;
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#f1fa8c">Subject: dkim test
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#f1fa8c">
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#f1fa8c">test
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#f1fa8c">EOF&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>grep -A3 &lt;span style="color:#f1fa8c">&amp;#34;DKIM-Signature&amp;#34;&lt;/span> /var/mail/nobody
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Если бы я построил этот способ проверки первым, а чинить начал вторым, каждый следующий шаг сразу показывал бы, помог он или нет. Я сделал наоборот и заплатил за это тремя ложными победами подряд.&lt;/p>
&lt;h2 id="три-настоящих-дефекта-которые-ничего-не-исправили">Три настоящих дефекта, которые ничего не исправили&lt;/h2>
&lt;p>Дальше я нашёл три реальные поломки, починил каждую — и подписи не появилось ни разу. Это стоит отдельного раздела, потому что ощущение «я же нашёл проблему» здесь работает против тебя.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Гипотеза&lt;/th>
&lt;th>Как проверял&lt;/th>
&lt;th>Вердикт&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>opendkim.conf&lt;/code> обрезан&lt;/td>
&lt;td>в файле 4 строки: ни &lt;code>Mode&lt;/code>, ни &lt;code>KeyTable&lt;/code>, ни &lt;code>SigningTable&lt;/code>&lt;/td>
&lt;td>Дефект настоящий. Дописал, рестарт — подписи нет&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Демон не стартует&lt;/td>
&lt;td>&lt;code>systemctl is-active opendkim&lt;/code> → &lt;code>active&lt;/code>, в логе чистый старт&lt;/td>
&lt;td>Не дефект&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Права на приватный ключ&lt;/td>
&lt;td>ключ принадлежал &lt;code>_apt:uuidd&lt;/code> с &lt;code>0600&lt;/code>, демон работает от &lt;code>opendkim&lt;/code>&lt;/td>
&lt;td>Дефект настоящий. Исправил — подписи нет&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Про конфиг: каталог &lt;code>/etc/opendkim/&lt;/code> трогали недавно, а файлы внутри были старше на годы — обновление пакета переписало часть содержимого, а заодно переставило владельца ключа на &lt;code>_apt&lt;/code>. Проверять доступ надо от лица сервисного пользователя, а не от root:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>su -s /bin/sh opendkim -c &lt;span style="color:#f1fa8c">&amp;#34;head -c 20 /etc/opendkim/default/mail.private&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4"># head: невозможно открыть ... Отказано в доступе&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>&lt;code>chown opendkim:opendkim&lt;/code> — обязателен. Но подписи после него так и не было. В этот момент гипотезы у меня кончились, и это оказалось лучшим, что случилось за весь инцидент.&lt;/p>
&lt;h2 id="почему-opendkim-молчал-file-против-refile">Почему OpenDKIM молчал: file: против refile:&lt;/h2>
&lt;p>Причина нашлась в man-странице на самом сервере — в одном абзаце про тип датасета, от которого зависит, найдётся ключ или нет. Читал я именно локальный &lt;code>man opendkim.conf&lt;/code> от версии 2.10.3: он авторитетнее любой веб-копии, потому что описывает ровно тот бинарник, который у тебя запущен. &lt;a href="https://manpages.debian.org/testing/opendkim/opendkim.conf.5.en.html">Та же страница онлайн&lt;/a> описывает поиск в &lt;code>SigningTable&lt;/code> так:&lt;/p>
&lt;blockquote>
&lt;p>If this table specifies a regular expression file (&amp;ldquo;refile&amp;rdquo;), then the keys are wildcard patterns that are matched against the address found in the From: header field. […] For all other database types, the full &lt;em>user@host&lt;/em> is checked first, then simply &lt;em>host&lt;/em>, then &lt;em>user@.domain&lt;/em>&lt;/p>
&lt;/blockquote>
&lt;p>То есть при &lt;code>refile:&lt;/code> шаблон сопоставляется с адресом &lt;strong>целиком&lt;/strong>. В таблицах у меня лежали записи простого формата:&lt;/p>
&lt;pre tabindex="0">&lt;code>example.org mail._domainkey.example.org
&lt;/code>&lt;/pre>&lt;p>А подключены они были как &lt;code>refile:&lt;/code>. Шаблон &lt;code>example.org&lt;/code> сопоставляется с адресом &lt;code>noreply@example.org&lt;/code> — не совпадает. Ключ не найден, письмо уходит неподписанным, и в логе &lt;strong>не появляется ни одной строки&lt;/strong>. При &lt;code>file:&lt;/code> вторым шагом ищется «simply &lt;em>host&lt;/em>» — совпадает, ключ находится.&lt;/p>
&lt;p>Фикс — одно слово в двух строках:&lt;/p>
&lt;pre tabindex="0">&lt;code>KeyTable file:/etc/opendkim/KeyTable
SigningTable file:/etc/opendkim/SigningTable
&lt;/code>&lt;/pre>&lt;p>Альтернатива — оставить &lt;code>refile:&lt;/code> и переписать записи в вид &lt;code>*@example.org&lt;/code>. Я её отверг: таблицы описывали несколько доменов, и править каждую строку данных ради сохранения неподходящего типа датасета рискованнее, чем поменять тип под уже существующий формат.&lt;/p>
&lt;h2 id="почему-ручной-тест-проходил-а-почта-приложений-уходила-голой">Почему ручной тест проходил, а почта приложений уходила голой&lt;/h2>
&lt;p>Вторая половина той же поломки — слишком узкий &lt;code>InternalHosts&lt;/code>, и она не видна ни одним тестом с самого сервера. Man описывает опцию так: «Identifies a set internal hosts whose mail should be signed &lt;strong>rather than verified&lt;/strong>» — то есть хосты вне списка не подписываются, а проверяются.&lt;/p>
&lt;p>Список указывал на &lt;code>TrustedHosts&lt;/code>, а там лежало меньше десятка адресов. В &lt;code>mynetworks&lt;/code> у Postfix их было вдвое-втрое больше. Хосты приложений в пересечение не попадали — их почта верифицировалась вместо подписи и уходила голой. А мой ручной тест шёл с &lt;code>127.0.0.1&lt;/code>, который в списке был, и проходил успешно.&lt;/p>
&lt;pre tabindex="0">&lt;code>InternalHosts refile:/etc/opendkim/TrustedHosts
&lt;/code>&lt;/pre>&lt;p>Здесь &lt;code>refile:&lt;/code> уместен: &lt;code>TrustedHosts&lt;/code> действительно содержит шаблоны и подсети. Синхронизировал список с &lt;code>mynetworks&lt;/code> — после синхронизации он вырос втрое и накрыл наконец те хосты приложений, которые релей и так пропускал через себя. Общий вывод дороже конкретной опции: тестировать надо тем же путём, которым ходит настоящий трафик. Отправка с самого сервера — не тот путь.&lt;/p>
&lt;h2 id="что-чинил-по-порядку">Что чинил по порядку&lt;/h2>
&lt;ol>
&lt;li>&lt;strong>SPF.&lt;/strong> Записи &lt;code>v=spf1&lt;/code> не было вовсе — лежал только SenderID вида &lt;code>spf2.0/mfrom,pra&lt;/code>. &lt;a href="https://www.rfc-editor.org/rfc/rfc7208.html#section-4.5">RFC 7208&lt;/a> требует «discard records that do not begin with a version section of exactly &lt;code>v=spf1&lt;/code>», так что для Gmail этой записи не существовало. Опубликовал &lt;code>v=spf1 ip4:203.0.113.51 include:_spf.google.com include:spf.example.net ~all&lt;/code> (последний include — рассылочный сервис, через который уходила часть писем).&lt;/li>
&lt;li>&lt;strong>DKIM, конфиг.&lt;/strong> Восстановил &lt;code>Mode&lt;/code>, &lt;code>Canonicalization&lt;/code>, &lt;code>KeyTable&lt;/code>, &lt;code>SigningTable&lt;/code>, &lt;code>InternalHosts&lt;/code>, &lt;code>ExternalIgnoreList&lt;/code>.&lt;/li>
&lt;li>&lt;strong>DKIM, владелец ключа.&lt;/strong> &lt;code>chown opendkim:opendkim&lt;/code> на приватный ключ, проверка через &lt;code>su -s /bin/sh opendkim&lt;/code>.&lt;/li>
&lt;li>&lt;strong>DKIM, тип датасета.&lt;/strong> &lt;code>refile:&lt;/code> → &lt;code>file:&lt;/code> для &lt;code>KeyTable&lt;/code> и &lt;code>SigningTable&lt;/code>.&lt;/li>
&lt;li>&lt;strong>DKIM, охват.&lt;/strong> &lt;code>TrustedHosts&lt;/code> синхронизирован с &lt;code>mynetworks&lt;/code> — список вырос втрое.&lt;/li>
&lt;li>&lt;strong>FCrDNS.&lt;/strong> PTR переклеен на &lt;code>mail.example.org&lt;/code>, &lt;code>myhostname&lt;/code> приведён к тому же имени.&lt;/li>
&lt;/ol>
&lt;p>Итоговый рабочий фрагмент &lt;code>/etc/opendkim.conf&lt;/code>:&lt;/p>
&lt;pre tabindex="0">&lt;code>Mode sv
Canonicalization relaxed/simple
KeyTable file:/etc/opendkim/KeyTable
SigningTable file:/etc/opendkim/SigningTable
ExternalIgnoreList refile:/etc/opendkim/TrustedHosts
InternalHosts refile:/etc/opendkim/TrustedHosts
&lt;/code>&lt;/pre>&lt;p>Сводка по трём механизмам:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Механизм&lt;/th>
&lt;th>Что было&lt;/th>
&lt;th>Чем проверять&lt;/th>
&lt;th>Что стало&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>SPF&lt;/td>
&lt;td>только мёртвый SenderID &lt;code>spf2.0/mfrom,pra&lt;/code>&lt;/td>
&lt;td>&lt;code>dig TXT example.org +short&lt;/code>&lt;/td>
&lt;td>&lt;code>v=spf1 … ~all&lt;/code>, &lt;code>spf=pass&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>DKIM&lt;/td>
&lt;td>подпись не ставилась, в логе ни строки&lt;/td>
&lt;td>заголовки доставленного письма&lt;/td>
&lt;td>&lt;code>d=example.org; s=mail&lt;/code>, &lt;code>dkim=pass&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>FCrDNS&lt;/td>
&lt;td>PTR → чужой хост → чужой IP&lt;/td>
&lt;td>&lt;code>dig -x IP&lt;/code> + &lt;code>dig A &amp;lt;ответ&amp;gt;&lt;/code>&lt;/td>
&lt;td>PTR → &lt;code>mail.example.org&lt;/code> → тот же IP&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>После шестого шага письмо на Gmail получило &lt;code>250 2.0.0 OK&lt;/code>.&lt;/p>
&lt;h2 id="что-осталось-сломанным-bounce-rate--шесть-писем-из-десяти">Что осталось сломанным: bounce rate — шесть писем из десяти&lt;/h2>
&lt;p>Аутентификация починена, но репутация — нет, и это отдельная задача не для админа, а для разработчиков приложения. Суточная сводка после починки: отказов по-прежнему больше, чем доставок, — примерно шесть писем из десяти уходит в никуда. Приложение годами шлёт на несуществующие адреса, включая опечатки в доменах — на &lt;code>@iclaud.com&lt;/code> набегает под сотню писем в сутки.&lt;/p>
&lt;p>При требуемых Google 0,3 % жалоб такой bounce rate рано или поздно уронит репутацию IP, и письма поедут в «Спам» уже при идеальных SPF и DKIM.&lt;/p>
&lt;p>Отдельно красивый механизм самосокрытия проблемы: сервер слал отчёты о недоставке на адрес отправителя, домен которого сам же считал локальным через &lt;code>mydestination&lt;/code>. Локального пользователя с таким именем не было, отчёт уничтожался. Круг замкнут: приложение шлёт на мёртвый адрес → отчёт об этом уничтожается → никто не узнаёт → приложение продолжает слать. Сотни уничтоженных отчётов в сутки.&lt;/p>
&lt;p>Ещё не решено: envelope sender совпадает с &lt;code>From&lt;/code> (правильно — отдельный технический адрес возврата с автоматической обработкой), certbot 0.12.0 стучится в отключённый &lt;code>acme-v01&lt;/code>, сертификат просрочен уже несколько лет, и все правки сделаны руками на сервере вне Ansible. Последнее — честный техдолг: в репозитории этого контейнера не существует, и следующий человек ничего не найдёт.&lt;/p>
&lt;h2 id="итог">Итог&lt;/h2>
&lt;p>Если Gmail отбивает почту с легаси-релея, не чините первую найденную причину — сначала постройте способ проверки, который не врёт. Для DKIM это заголовки доставленного письма, для FCrDNS — &lt;code>dig -x&lt;/code> с обратной проверкой A-записи, для SPF — &lt;code>dig TXT&lt;/code> с проверкой, что запись начинается ровно с &lt;code>v=spf1&lt;/code>. И держите в голове главное свойство OpenDKIM: при ненайденном ключе он не пишет в лог ничего, поэтому «демон активен, конфиг валиден, ключ читается» — это ноль доказательств того, что подпись ставится.&lt;/p>
&lt;p>Общее правило, которое я вынес из полутора часов: когда демон работает, но не делает свою работу, &lt;strong>и при этом молчит&lt;/strong> — гипотезы пора прекращать и идти в man за семантикой конкретной опции. Молчание — это не «всё хорошо», это отдельный класс поведения, и у него всегда есть описание в документации.&lt;/p></content:encoded></item><item><title>Как вывезти живой docker-проект с умирающего диска: стрим pg_dump, pull образов, якорный exclude</title><link>https://bitpage.ru/infra/migrate-docker-app-dying-disk/</link><pubDate>Fri, 24 Jul 2026 14:00:00 +0300</pubDate><dc:creator>BitPage</dc:creator><guid>https://bitpage.ru/infra/migrate-docker-app-dying-disk/</guid><description>Умирающий диск принимает чтение и вешает запись: pg_dump в локальный файл встал примерно на полутора гигабайтах, не сообщив об ошибке. Разбор миграции stateful docker-compose проекта на Proxmox LXC — стрим дампа через ssh -T, pull образов вместо сборки EOL-баз, якорный rsync-exclude.</description><content:encoded>&lt;p>&lt;strong>Короткий ответ:&lt;/strong> на умирающем диске pg_dump в локальный файл виснет молча — надо стримить дамп через &lt;code>ssh -T&lt;/code> на здоровый диск целевой машины, чтобы источник только читал. Образы — тянуть из registry, скопировав &lt;code>/root/.docker/config.json&lt;/code> с самого источника; чинить сборку из EOL-баз (buster/stretch, PGDG для PG11) — тупик: apt отдаёт 404. И &lt;code>rsync --exclude=log/&lt;/code> без ведущего слэша вырезает &lt;code>vendor/yiisoft/yii2/log/Logger.php&lt;/code>, а приложение отдаёт 500 «failed to open stream».&lt;/p>
&lt;p>Сервер под проектом — PHP-Yii2 + PostgreSQL порядка десяти гигабайт + Redis + пул воркеров очередей, полтора десятка сервисов в docker-compose — жил на OpenVZ-хосте с деградировавшим RAID. Диск сдох на записи, а трафик переключать было нельзя: нужно было поднять прод-подобную копию на новом Proxmox-узле (LXC + Docker), чтобы владелец пощупал вживую, но без исходящих писем и платежей. Пара часов работы, четыре тупика по дороге — все четыре из-за того, что на живом железе действуют одни правила, а на умирающем — другие.&lt;/p>
&lt;h2 id="коротко-tldr">Коротко (TL;DR)&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Решение:&lt;/strong> источник читает, цель пишет. &lt;code>pg_dump -Fc&lt;/code> на источнике, вывод по &lt;code>ssh -T&lt;/code> в файл на быстром диске цели → &lt;code>pg_restore -j 4&lt;/code> разворачивает те же примерно десять гигабайт и около сотни таблиц с расширением &lt;code>rum&lt;/code> на месте.&lt;/li>
&lt;li>&lt;strong>Решение:&lt;/strong> образы тянуть из registry, кред брать с источника — &lt;code>/root/.docker/config.json&lt;/code> копируется одним &lt;code>scp&lt;/code>, и &lt;code>docker compose pull&lt;/code> вытягивает весь набор образов (точные прод-версии).&lt;/li>
&lt;li>&lt;strong>Решение:&lt;/strong> для теста поднимать только web-контур через &lt;code>COMPOSE_FILE=docker/docker-compose.yml&lt;/code> — очереди и шедулер лежат, никакие письма и платежи никуда не уходят.&lt;/li>
&lt;li>&lt;strong>Грабли:&lt;/strong> дамп в дохлый диск не падает, а зависает — размер стоит, процесс жив. Мониторить надо дельту размера файла, а не &lt;code>pgrep&lt;/code>, который совпадает сам с собой.&lt;/li>
&lt;li>&lt;strong>Грабли:&lt;/strong> &lt;code>rsync --exclude=log/&lt;/code> рекурсивен — режет &lt;code>log/&lt;/code> на всех уровнях. Ошибку выдаёт приложение, а не rsync.&lt;/li>
&lt;li>&lt;strong>Грабли:&lt;/strong> внешние сервисы фильтруют по IP. С нового узла ClickHouse встречает &lt;code>Connection refused&lt;/code> — allowlist нового IP собирать заранее, а не по ходу дела.&lt;/li>
&lt;/ul>
&lt;h2 id="почему-pg_dump-на-умирающем-диске-виснет-а-не-падает">Почему pg_dump на умирающем диске виснет, а не падает&lt;/h2>
&lt;p>Дамп в мёртвый диск не отдаёт ошибку — он перестаёт двигаться, оставаясь живым процессом. Первое, что я попробовал — очевидное: &lt;code>pg_dump -Fc&lt;/code> внутри контейнера в bind-каталог &lt;code>backup/&lt;/code> на самом источнике. Файл дорос примерно до гигабайта и встал. Две проверки размера с интервалом в 6 секунд вернули одно и то же число, процесс — &lt;code>S+&lt;/code> в &lt;code>ps&lt;/code>. Диск сдох именно на записи, и из userspace это выглядит ровно так: pg_dump ждёт очередной write(), а тот не возвращается.&lt;/p>
&lt;p>Отдельно врал сторожок. Я мониторил жизнь дампа через &lt;code>pgrep -f pg_dump&lt;/code> — и получал ложный «STILL_RUNNING», потому что командная строка самого pgrep содержит подстроку &lt;code>pg_dump&lt;/code> и матчит саму себя. Проверки, которые не обманывают:&lt;/p>
&lt;ul>
&lt;li>&lt;code>ps -C pg_dump&lt;/code> — точное совпадение по имени процесса, а не по подстроке в командной строке.&lt;/li>
&lt;li>Дельта размера выходного файла между двумя &lt;code>stat -c %s&lt;/code> — если &lt;code>delta=0&lt;/code> за 6 с при живом процессе, дамп встал.&lt;/li>
&lt;/ul>
&lt;p>Мораль такая: &lt;code>pgrep -f&lt;/code> вообще не годится сторожем над командами с общеупотребимой подстрокой в имени — это ложный друг, который матчит наблюдателя.&lt;/p>
&lt;h2 id="стрим-дампа-любую-запись--с-источника-долой">Стрим дампа: любую запись — с источника долой&lt;/h2>
&lt;p>Решение — снять с источника работу «писать», оставив ему только «читать». &lt;code>pg_dump&lt;/code> запускается на источнике внутри контейнера с базой, но его вывод по &lt;code>ssh&lt;/code> уходит на здоровый диск целевого узла:&lt;/p>
&lt;pre tabindex="0">&lt;code>ssh -T target-host \
&amp;#34;cat &amp;gt; /srv/dumps/appdb.dump&amp;#34; \
&amp;lt; &amp;lt;(docker exec app-postgres pg_dump -Fc -U app appdb)
&lt;/code>&lt;/pre>&lt;p>Или наоборот — с цели pull&amp;rsquo;ом:&lt;/p>
&lt;pre tabindex="0">&lt;code>ssh -T old-host \
&amp;#39;docker exec app-postgres pg_dump -Fc -U app appdb&amp;#39; \
&amp;gt; /srv/dumps/appdb.dump
&lt;/code>&lt;/pre>&lt;p>Дамп с живой базы — норма для PostgreSQL, документация 11-й версии говорит это прямо: «pg_dump does not block other users accessing the database (readers or writers)» (&lt;a href="https://www.postgresql.org/docs/11/app-pgdump.html">pg_dump 11 reference&lt;/a>). Слоты снапшота держит сам pg_dump, приложение продолжает работать.&lt;/p>
&lt;p>Две детали в транспорте, без которых стрим бинарного &lt;code>-Fc&lt;/code> дампа испортится:&lt;/p>
&lt;ul>
&lt;li>&lt;code>ssh -T&lt;/code> — без выделения pseudo-tty. Иначе &lt;code>\n&lt;/code> в бинарном потоке превращается в &lt;code>\r\n&lt;/code> (мапинг ONLCR терминала), и &lt;code>pg_restore&lt;/code> потом рапортует про corrupt custom archive.&lt;/li>
&lt;li>&lt;code>docker exec&lt;/code> — без &lt;code>-t&lt;/code> по той же причине. Только &lt;code>docker exec app-postgres pg_dump ...&lt;/code>, без &lt;code>-it&lt;/code>.&lt;/li>
&lt;/ul>
&lt;p>С этими двумя флагами дамп в полтора гигабайта прошёл, &lt;code>EXIT=0&lt;/code>, а на цели:&lt;/p>
&lt;pre tabindex="0">&lt;code>pg_restore --no-owner -j 4 -d appdb /srv/dumps/appdb.dump
&lt;/code>&lt;/pre>&lt;p>Развернулся обратно в те же примерно десять гигабайт, около сотни таблиц, расширение &lt;code>rum&lt;/code> подхватилось. Проверил &lt;code>\dx&lt;/code> — на месте.&lt;/p>
&lt;h2 id="почему-eol-образы-нельзя-пересобрать-но-можно-вытянуть">Почему EOL-образы нельзя пересобрать, но можно вытянуть&lt;/h2>
&lt;p>Готовый образ в registry — это точная прод-версия, а сборка Dockerfile через несколько лет упирается в исчезнувшие apt-репозитории. Первый &lt;code>docker compose pull&lt;/code> на новом узле лёг:&lt;/p>
&lt;pre tabindex="0">&lt;code>Error response from daemon: error from registry: access forbidden
&lt;/code>&lt;/pre>&lt;p>Приватный GitLab-registry, новая машина против него не авторизована. Я пошёл не в ту сторону — решил, что раз pull не идёт, соберу из &lt;code>docker/&lt;/code> (Dockerfile&amp;rsquo;ы те же, что в проде). Собирать пришлось из EOL-баз, и apt по очереди вернул мне это:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>База образа&lt;/th>
&lt;th>Debian внутри&lt;/th>
&lt;th>Что говорит apt в 2026&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>nginx:1.17.4&lt;/code>&lt;/td>
&lt;td>buster&lt;/td>
&lt;td>&lt;code>Err:5 http://deb.debian.org/debian buster Release 404 Not Found&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>postgres:11&lt;/code>&lt;/td>
&lt;td>stretch&lt;/td>
&lt;td>&lt;code>E: Failed to fetch http://apt.postgresql.org/pub/repos/apt/dists/stretch-pgdg/11/binary-amd64/Packages 404 Not Found&lt;/code>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Buster ещё как-то перенаправляется на &lt;a href="https://www.debian.org/distrib/archive">archive.debian.org&lt;/a>. А stretch-based &lt;code>postgres:11&lt;/code> дальше не идёт: PGDG для EOL-мажора вычистили, официальных пакетов не существует. Я потратил на этот проулок полчаса, пока не сообразил, что решаю не ту задачу.&lt;/p>
&lt;p>Ответ всё это время лежал на самом источнике: он же откуда-то эти образы pull&amp;rsquo;ит. Значит, у него есть рабочие креды registry, и лежат они там, куда их складывает &lt;a href="https://docs.docker.com/reference/cli/docker/login/">&lt;code>docker login&lt;/code>&lt;/a> — в &lt;code>/root/.docker/config.json&lt;/code>. Копия одного файла:&lt;/p>
&lt;pre tabindex="0">&lt;code>scp old-host:/root/.docker/config.json /root/.docker/config.json
&lt;/code>&lt;/pre>&lt;p>И &lt;code>docker compose pull&lt;/code> вытянул весь набор образов — те самые байты, что крутятся в проде. Никакой сборки.&lt;/p>
&lt;h2 id="как-rsync-exclude-без-слэша-съел-framework">Как rsync-exclude без слэша съел framework&lt;/h2>
&lt;p>&lt;code>--exclude=log/&lt;/code> без ведущего слэша матчит любой каталог &lt;code>log/&lt;/code> на любой глубине, а не только корневой. Я добавил это, чтобы не тащить пару гигабайт логов приложения через сеть. Rsync отработал чисто, приложение поднялось — и отдал HTTP 500:&lt;/p>
&lt;pre tabindex="0">&lt;code>include(/app/vendor/yiisoft/yii2/log/Logger.php):
failed to open stream: No such file or directory
&lt;/code>&lt;/pre>&lt;p>Ошибка ведёт куда угодно, только не к rsync. «Битый vendor» — первая гипотеза, за ней я и погнался: перекачал vendor, проверил композер-lock, сравнил размеры. И на третьей минуте понял, что exclude&amp;rsquo;ы у меня рекурсивные — и они срезали &lt;code>vendor/yiisoft/yii2/log/&lt;/code> заодно с логами приложения.&lt;/p>
&lt;p>Правило в мане rsync проговаривается прямым текстом: «if the pattern starts with a / then it is anchored to a particular spot in the hierarchy of files, otherwise it is matched against the end of the pathname» (&lt;a href="https://download.samba.org/pub/rsync/rsync.1">rsync(1)&lt;/a>). Ключевое слово — «otherwise».&lt;/p>
&lt;p>Фикс — один символ:&lt;/p>
&lt;pre tabindex="0">&lt;code>rsync -a --exclude=/log/ code/ target:/app/
&lt;/code>&lt;/pre>&lt;p>Ведущий слэш якорит паттерн к корню передачи. Второй прогон rsync без изменений прислал только те &lt;code>log&lt;/code>-каталоги, что были ошибочно вырезаны — секунды инкрементального трафика.&lt;/p>
&lt;h2 id="тест-на-боевых-интеграциях-без-сайд-эффектов">Тест на боевых интеграциях без сайд-эффектов&lt;/h2>
&lt;p>Именно очереди и шедулер порождают исходящие эффекты — письма, платежи, запросы в CRM. Web-морда поверх копии БД безопасна. Приложение общалось с боевым платёжным шлюзом, CRM, SMTP и ClickHouse, и тестовая копия должна была подняться, никого не разбудив. Docker-compose был уже разбит на два файла — &lt;code>docker-compose.yml&lt;/code> (nginx, php-fpm, api) и &lt;code>docker-compose.queue.yml&lt;/code> (пул воркеров очередей и шедулер) — так что весь предохранитель свёлся к одной переменной:&lt;/p>
&lt;pre tabindex="0">&lt;code>COMPOSE_FILE=docker/docker-compose.yml docker compose up -d
&lt;/code>&lt;/pre>&lt;p>Поднялся только web-контур. Smoke-тест:&lt;/p>
&lt;ul>
&lt;li>API → &lt;code>200&lt;/code>&lt;/li>
&lt;li>сайт → &lt;code>302&lt;/code> на логин&lt;/li>
&lt;li>админка → рендерит форму входа&lt;/li>
&lt;/ul>
&lt;p>Одна внешняя интеграция всё-таки вылезла. Backend страниц, которые ходят в аналитический ClickHouse, отдавал 503, и в логе:&lt;/p>
&lt;pre tabindex="0">&lt;code>/dev/tcp/clickhouse.example:31450: Connection refused
&lt;/code>&lt;/pre>&lt;p>Не проблема сети, а allowlist: ClickHouse на удалённом узле фильтрует по IP, и новый узел в белом списке ещё не был. Это общая закономерность: любой сервис на белом списке (БД, mail, аналитика, платёжка) — часть чек-листа миграции, а не сюрприз в момент cutover.&lt;/p>
&lt;h2 id="пошагово">Пошагово&lt;/h2>
&lt;ol>
&lt;li>Понять сначала характер отказа диска. «Чтение живо, запись висит» переворачивает все дальнейшие решения — не пробовать дальше писать на источник.&lt;/li>
&lt;li>Скопировать &lt;code>/root/.docker/config.json&lt;/code> с источника на цель одним &lt;code>scp&lt;/code>. Убедиться, что &lt;code>docker compose pull&lt;/code> вытягивает все образы.&lt;/li>
&lt;li>Стрим дампа: &lt;code>ssh -T source 'docker exec db pg_dump -Fc -U app appdb' &amp;gt; /srv/dumps/appdb.dump&lt;/code> на целевом узле. Сторожок — дельта размера, не &lt;code>pgrep&lt;/code>.&lt;/li>
&lt;li>&lt;code>pg_restore --no-owner -j 4 -d appdb /srv/dumps/appdb.dump&lt;/code>. Проверить &lt;code>\dx&lt;/code> — расширения на месте.&lt;/li>
&lt;li>rsync кода и медиа с якорным exclude&amp;rsquo;ом: &lt;code>--exclude=/log/&lt;/code>, а не &lt;code>--exclude=log/&lt;/code>. Второй прогон покажет, что пропущено.&lt;/li>
&lt;li>Поднять web-контур через &lt;code>COMPOSE_FILE=docker/docker-compose.yml docker compose up -d&lt;/code> — очереди и шедулер лежат.&lt;/li>
&lt;li>Smoke-тест: API, публичная морда, админка. Собрать список внешних сервисов, у которых новый IP не в allowlist.&lt;/li>
&lt;li>Cutover — позже, в окно тишины: свежий дамп поверх, DNS, IP в белые списки, mail-репутация.&lt;/li>
&lt;/ol>
&lt;h2 id="итог">Итог&lt;/h2>
&lt;p>Умирающее железо меняет правила: источник — только на чтение, хочешь ты этого или нет. Из этого вытекает всё остальное — стримить дамп, а не писать локально; тянуть образы из registry, а не пересобирать EOL-базы; якорить exclude&amp;rsquo;ы до того, как они срежут &lt;code>vendor/&lt;/code>. Копия проекта поднялась за несколько часов, и большая часть потерянного времени — драки с проблемами (архивы Debian, «битый vendor»), которые уже были решены ограничениями задачи.&lt;/p>
&lt;p>Что осталось за скобками этой сессии: доступ нового IP к ClickHouse (получить allowlist), финальный cutover со свежим дампом и переключением DNS, репутация mail-IP на новом хосте. Cutover — отдельная история и, кажется, отдельная статья.&lt;/p>
&lt;p>Первоисточники, к которым я обращался по дороге:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://www.postgresql.org/docs/11/app-pgdump.html">PostgreSQL 11 — pg_dump reference&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://download.samba.org/pub/rsync/rsync.1">rsync(1) man page — exclude patterns&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://docs.docker.com/reference/cli/docker/login/">Docker CLI — &lt;code>docker login&lt;/code> и config.json&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://www.debian.org/distrib/archive">Debian archive — путь для EOL-релизов apt&lt;/a>&lt;/li>
&lt;/ul></content:encoded></item><item><title>CrowdSec на Debian 13: пять граблей, которых нет в туториалах</title><link>https://bitpage.ru/security/crowdsec-na-debian-13-grabli/</link><pubDate>Fri, 24 Jul 2026 08:40:00 +0300</pubDate><dc:creator>BitPage</dc:creator><guid>https://bitpage.ru/security/crowdsec-na-debian-13-grabli/</guid><description>CrowdSec на Debian 13 ставится за вечер, но свежий стек ломает старые гайды: OpenSSH 9.8 логирует от имени sshd-session, ssh-логи живут в journald, а бан-таблица nftables — в семействе ip, не inet. Без whitelist можно забанить самого себя.</description><content:encoded>&lt;p>&lt;strong>Короткий ответ:&lt;/strong> CrowdSec на Debian 13 (trixie) ставится за вечер, но три вещи ведут себя не так, как в гайдах: OpenSSH 9.8 пишет логи от имени &lt;code>sshd-session&lt;/code>, а не &lt;code>sshd&lt;/code>; ssh-логи надо забирать из journald, потому что &lt;code>/var/log/auth.log&lt;/code> больше не главный источник; бан-таблица nftables создаётся в семействе &lt;code>ip&lt;/code>, а не &lt;code>inet&lt;/code> — и её легко «не найти». Плюс два организационных правила: whitelist со своими IP — до первого запуска, на боевой прод — сначала режим detect-only без банов.&lt;/p>
&lt;p>На хостах у меня много лет жил самодельный слой защиты: nginx-конфиг с блокировкой по User-Agent и крон, который раз в сутки тянул список Tor-exit-нод и вбивал его в nginx через &lt;code>deny&lt;/code>. При переезде на Debian 13 я наконец разобрался, чего этот слой стоит, выкинул его и поставил CrowdSec с nftables-bouncer&amp;rsquo;ом. По дороге собрал пять граблей — ни одной из них нет в туториалах.&lt;/p>
&lt;h2 id="коротко-tldr">Коротко (TL;DR)&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Решение:&lt;/strong> CrowdSec (агент + nftables firewall-bouncer) с acquisition через journald. Bouncer держит собственную nft-таблицу и мирно живёт рядом с уже существующим кастомным firewall.&lt;/li>
&lt;li>&lt;strong>Альтернатива:&lt;/strong> fail2ban — если нужен только ssh и не нужны краудсорс-блоклисты. Но с &lt;code>sshd-session&lt;/code> из OpenSSH 9.8 наивные regex-фильтры сломаются точно так же.&lt;/li>
&lt;li>&lt;strong>Грабли:&lt;/strong> &lt;code>cscli metrics&lt;/code> показывает ssh-логи как unparsed (это норма); таблица бана — &lt;code>nft list table ip crowdsec&lt;/code>, не &lt;code>inet&lt;/code>; смена порта в &lt;code>sshd_config&lt;/code> игнорируется при socket-активации; без whitelist забанишь свой же IP; на прод с клиентами — сначала detect-only.&lt;/li>
&lt;/ul>
&lt;h2 id="почему-крон-с-tor-блоклистом--не-защита">Почему крон с Tor-блоклистом — не защита&lt;/h2>
&lt;p>Ежедневная выгрузка Tor-нод в nginx &lt;code>deny&lt;/code> — ритуал, а не защита, и держать его в 2026 году нет смысла. Разбор моего крона показал три проблемы. Во-первых, он закрывает только Tor, а реальные атаки приходят с VPN, облачных провайдеров и ботнетов — по ним списка нет. Во-вторых, после обновления списка крон делал &lt;code>nginx restart&lt;/code> вместо &lt;code>reload&lt;/code> — раз в сутки рвались все живые соединения. В-третьих, гигантский &lt;code>deny&lt;/code>-список nginx проверяет линейно на каждый запрос.&lt;/p>
&lt;p>То есть слой создавал видимость работы: что-то качается, что-то блокируется, в логах движение. А фактическую атаку — перебор паролей ssh, сканирование nginx — он не видел вовсе.&lt;/p>
&lt;h2 id="crowdsec-или-fail2ban">CrowdSec или fail2ban&lt;/h2>
&lt;p>Я выбрал CrowdSec: он закрывает те же сценарии, что fail2ban, плюс даёт краудсорс-репутацию IP, а риск «вендор умрёт» снимается лицензией и локальной архитектурой. Сравнение по пунктам, которые важны были мне:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>&lt;/th>
&lt;th>fail2ban&lt;/th>
&lt;th>CrowdSec&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Механизм детекта&lt;/td>
&lt;td>regex по строкам лога&lt;/td>
&lt;td>парсеры + сценарии (leaky bucket), многострочная логика&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Источники логов&lt;/td>
&lt;td>файлы&lt;/td>
&lt;td>файлы, journald, docker и другие&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Механизм бана&lt;/td>
&lt;td>сам пишет правила в iptables/nftables&lt;/td>
&lt;td>отдельный bouncer со своей nft-таблицей&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Репутация сообщества&lt;/td>
&lt;td>нет&lt;/td>
&lt;td>краудсорс-блоклисты (обмен сигналами)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Работа без вендора&lt;/td>
&lt;td>полная автономия&lt;/td>
&lt;td>полная: ядро под MIT, решения принимает локальный LAPI&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Последняя строка была для меня решающей. Был страх: CrowdSec — компания, а вдруг она закроется. Проверил: &lt;a href="https://github.com/crowdsecurity/crowdsec">ядро CrowdSec&lt;/a> распространяется под MIT, детект и баны работают полностью локально через Local API. Если компания исчезнет, потеряется только краудсорс-репутация — сам IPS продолжит работать.&lt;/p>
&lt;h2 id="как-поставить-агент-коллекции-bouncer">Как поставить: агент, коллекции, bouncer&lt;/h2>
&lt;p>Порядок такой: сначала агент с коллекциями и whitelist&amp;rsquo;ом, обкатка без банов, и только потом bouncer.&lt;/p>
&lt;ol>
&lt;li>Подключить репозиторий CrowdSec и поставить агент: &lt;code>apt install crowdsec&lt;/code>.&lt;/li>
&lt;li>Поставить коллекции под свой стек: &lt;code>cscli collections install crowdsecurity/sshd crowdsecurity/nginx crowdsecurity/linux&lt;/code>.&lt;/li>
&lt;li>Настроить acquisition: ssh — через journald, nginx — по glob лог-файлов (ниже).&lt;/li>
&lt;li>Добавить whitelist со своими IP и внутренними сетями (ниже, шаг обязательный).&lt;/li>
&lt;li>Перезапустить агент и проверить &lt;code>cscli metrics&lt;/code> — логи должны читаться.&lt;/li>
&lt;li>Обкатать detect-only: агент детектит и пишет решения в &lt;code>cscli decisions list&lt;/code>, но банить некому.&lt;/li>
&lt;li>Когда ложных срабатываний нет — поставить bouncer: &lt;code>apt install crowdsec-firewall-bouncer-nftables&lt;/code>.&lt;/li>
&lt;/ol>
&lt;p>Acquisition для ssh на Debian 13 надёжнее делать через journald, а не через файл (документация CrowdSec по &lt;a href="https://docs.crowdsec.net/docs/log_processor/data_sources/journald/">journald-источнику&lt;/a>):&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-yaml" data-lang="yaml">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4"># /etc/crowdsec/acquis.d/ssh.yaml&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">source&lt;/span>: journalctl
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">journalctl_filter&lt;/span>:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> - &lt;span style="color:#f1fa8c">&amp;#34;_SYSTEMD_UNIT=ssh.service&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">labels&lt;/span>:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">type&lt;/span>: syslog
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;h2 id="грабли-1-cscli-metrics-показывает-ssh-логи-как-unparsed">Грабли 1: cscli metrics показывает ssh-логи как unparsed&lt;/h2>
&lt;p>Строки «unparsed» в &lt;code>cscli metrics&lt;/code> по ssh — не признак поломки: парсер намеренно не разбирает записи об успешных логинах. Я на это попался: увидел ssh-логи в колонке unparsed и решил, что парсер не работает на свежем стеке.&lt;/p>
&lt;p>Причин запутаться сразу две. Первая — в &lt;a href="https://www.openssh.org/txt/release-9.8">release notes OpenSSH 9.8&lt;/a> сказано прямо: «the server has been split into a listener binary, sshd(8), and a per-session binary &amp;ldquo;sshd-session&amp;rdquo;». То есть строки о сессиях в логе теперь идут от процесса &lt;code>sshd-session&lt;/code>, и любой фильтр, который ждёт &lt;code>sshd[&lt;/code>, молча перестаёт находить их. Вторая — на Debian 13 auth-события живут в journald, и привычки смотреть &lt;code>/var/log/auth.log&lt;/code> больше недостаточно.&lt;/p>
&lt;p>Проверить, что детект жив, можно за минуту — скормить парсеру строку неудачного логина:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>cscli explain --type syslog --log &lt;span style="color:#f1fa8c">&amp;#34;Jul 23 10:00:00 host sshd-session[1234]: Failed password for root from 198.51.100.7 port 44444 ssh2&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>В выводе видно всю цепочку: строка парсится и попадает во все ssh-bruteforce-сценарии. Детект работал с самого начала — вводила в заблуждение метрика.&lt;/p>
&lt;h2 id="грабли-2-nft-list-table-inet-crowdsec-пустой">Грабли 2: nft list table inet crowdsec пустой&lt;/h2>
&lt;p>Bouncer создаёт таблицы в семействах &lt;code>ip&lt;/code> и &lt;code>ip6&lt;/code>, а не &lt;code>inet&lt;/code> — проверять бан надо командой &lt;code>nft list table ip crowdsec&lt;/code>. Я после первого бана полез в &lt;code>inet&lt;/code>, увидел пустоту и решил, что баны не работают.&lt;/p>
&lt;p>&lt;a href="https://docs.crowdsec.net/u/bouncers/firewall/">Документация firewall-bouncer&lt;/a> это подтверждает: для IPv4 создаётся &lt;code>table ip crowdsec&lt;/code> с сетом &lt;code>crowdsec-blacklists&lt;/code>, для IPv6 — &lt;code>table ip6 crowdsec6&lt;/code>. Рабочие команды диагностики:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>cscli decisions list &lt;span style="color:#6272a4"># что забанено с точки зрения CrowdSec&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>nft list table ip crowdsec &lt;span style="color:#6272a4"># что реально в firewall (IPv4)&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>nft list table ip6 crowdsec6 &lt;span style="color:#6272a4"># то же для IPv6&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Отдельная таблица — это и ответ на вопрос «не подерётся ли bouncer с моим кастомным firewall». На моих нодах уже был свой набор nftables-правил, и я боялся, что новый инструмент его сломает. Не подрался: у bouncer&amp;rsquo;а своя таблица, оба хука &lt;code>input&lt;/code> отрабатывают независимо, и DROP в любой из таблиц финален. Существующие правила я не трогал вообще.&lt;/p>
&lt;h2 id="грабли-3-смена-порта-в-sshd_config-не-работает">Грабли 3: смена порта в sshd_config не работает&lt;/h2>
&lt;p>Если ssh запущен через socket-активацию systemd, параметр &lt;code>Port&lt;/code> в &lt;code>sshd_config&lt;/code> игнорируется — слушающий сокет описан в юните &lt;code>ssh.socket&lt;/code>, и менять порт надо там либо возвращать классический service-режим. Я в рамках той же чистки решил увести ssh со стандартного порта, поправил конфиг, сделал рестарт — порт не изменился.&lt;/p>
&lt;p>Диагноз ставится одной командой: &lt;code>systemctl status ssh.socket&lt;/code>. Если сокет активен — правки &lt;code>Port&lt;/code> в конфиге бесполезны. Я вернул службу в обычный режим:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>systemctl disable --now ssh.socket
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>systemctl &lt;span style="color:#8be9fd;font-style:italic">enable&lt;/span> --now ssh.service
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>После этого &lt;code>sshd_config&lt;/code> снова источник правды. Прямого отношения к CrowdSec эти грабли не имеют, но наступил я на них в той же сессии, и в паре с «логи теперь от sshd-session» они складываются в общий вывод: на свежем Debian старые привычки вокруг ssh стоит перепроверять по одной.&lt;/p>
&lt;h2 id="почему-whitelist-нужен-до-первого-запуска">Почему whitelist нужен до первого запуска&lt;/h2>
&lt;p>Без whitelist CrowdSec забанит ваш собственный IP при первой же проверке типа «а задетектит ли он брутфорс» — и вы останетесь без доступа к хосту. &lt;a href="https://docs.crowdsec.net/u/getting_started/post_installation/whitelists/">Документация CrowdSec&lt;/a> определяет их так: «Whitelists are special parsers that allow you to &amp;ldquo;discard&amp;rdquo; events» — событие отбрасывается на этапе парсинга и до сценариев не доходит.&lt;/p>
&lt;p>Минимальный parser-whitelist:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-yaml" data-lang="yaml">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4"># /etc/crowdsec/parsers/s02-enrich/01-my-whitelist.yaml&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">name&lt;/span>: my/whitelist
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">description&lt;/span>: &lt;span style="color:#f1fa8c">&amp;#34;Не банить себя и внутренние сети&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">whitelist&lt;/span>:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">reason&lt;/span>: &lt;span style="color:#f1fa8c">&amp;#34;admin IPs and internal networks&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">ip&lt;/span>:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> - &lt;span style="color:#f1fa8c">&amp;#34;127.0.0.1&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">cidr&lt;/span>:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> - &lt;span style="color:#f1fa8c">&amp;#34;10.0.0.0/8&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> - &lt;span style="color:#f1fa8c">&amp;#34;198.51.100.0/24&amp;#34;&lt;/span> &lt;span style="color:#6272a4"># сюда — свои админские сети&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Меня этот файл однажды спас. На одном из хостов я забыл про план «сначала без банов» и поставил bouncer сразу — CrowdSec встал в боевой режим на работающем проде. Обошлось: whitelist не дал системе залочить меня самого, а забанены оказались только реальные сканеры. Но полагаться на такое везение на хосте с клиентским трафиком нельзя.&lt;/p>
&lt;h2 id="как-раскатывать-на-прод-сначала-detect-only">Как раскатывать на прод: сначала detect-only&lt;/h2>
&lt;p>На критичный прод CrowdSec ставится в два этапа: сначала агент без bouncer&amp;rsquo;а — детект есть, банов нет; и только после недели чистых логов — bouncer с реальными банами. «Режим» здесь — просто отсутствие пакета &lt;code>crowdsec-firewall-bouncer-nftables&lt;/code>: агент пишет решения в &lt;code>cscli decisions list&lt;/code>, а исполнять их некому.&lt;/p>
&lt;p>Логика простая. Ложный бан на внутреннем хосте — неприятность для админа. Ложный бан на edge-ноде — заблокированный клиент, то есть деньги. Поэтому раскатку по парку я в Ansible-роли развёл переключателем: некритичные хосты получают агент и bouncer сразу, боевые — сначала только агент. Список хостов под IPS — явным перечнем в плейбуке: попытка вывести «умные» группы под каждый инструмент — лишняя инженерия, которая на десятке хостов ничего не даёт.&lt;/p>
&lt;h2 id="итог">Итог&lt;/h2>
&lt;p>Если ставите CrowdSec на Debian 13 — закладывайте вечер и держите под рукой три команды: &lt;code>cscli explain&lt;/code> (проверить, что детект жив, не доверяя колонке unparsed), &lt;code>nft list table ip crowdsec&lt;/code> (смотреть баны в правильном семействе) и &lt;code>systemctl status ssh.socket&lt;/code> (понять, кто на самом деле слушает порт). Whitelist со своими IP — до первого запуска, bouncer на боевом проде — только после обкатки без банов. А самодельные блоклисты типа «крон с Tor-нодами» спокойно выкидывайте: после разбора оказалось, что мой не защищал ничего — только рестартовал nginx раз в сутки.&lt;/p></content:encoded></item><item><title>Какие нейросети потянет ваш компьютер — проверяем за минуту</title><link>https://bitpage.ru/ai-native/llmfit-kakie-nejroseti-zapustit-lokalno/</link><pubDate>Sun, 22 Feb 2026 06:30:00 +0300</pubDate><dc:creator>BitPage</dc:creator><guid>https://bitpage.ru/ai-native/llmfit-kakie-nejroseti-zapustit-lokalno/</guid><description>llmfit — утилита, которая анализирует ваше железо и показывает, какие из 157 LLM-моделей можно запустить локально. Установка одной командой.</description><content:encoded>&lt;p>&lt;strong>Короткий ответ:&lt;/strong> утилита llmfit сканирует ваш CPU, RAM и GPU, затем из базы в 157 моделей показывает, какие реально запустятся и с какой скоростью.&lt;/p>
&lt;h2 id="зачем-запускать-нейросети-локально">Зачем запускать нейросети локально&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Приватность&lt;/strong> — данные не уходят на сервера&lt;/li>
&lt;li>&lt;strong>Бесплатно&lt;/strong> — никаких подписок и лимитов&lt;/li>
&lt;li>&lt;strong>Офлайн&lt;/strong> — работает без интернета&lt;/li>
&lt;li>&lt;strong>Скорость&lt;/strong> — нет задержек на API&lt;/li>
&lt;/ul>
&lt;p>Проблема: непонятно, какую модель выбрать под своё железо. 7B? 13B? 70B? Хватит ли памяти?&lt;/p>
&lt;h2 id="llmfit-решает-эту-проблему">llmfit решает эту проблему&lt;/h2>
&lt;p>Одна команда — и видишь полную картину:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>curl -fsSL https://llmfit.axjns.dev/install.sh | sh
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Или через Homebrew:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>brew tap AlexsJones/llmfit
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>brew install llmfit
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;h2 id="как-использовать">Как использовать&lt;/h2>
&lt;p>Просто запусти:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>llmfit
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Откроется интерактивное меню. Модели уже отсортированы по совместимости с твоим железом.&lt;/p>
&lt;p>&lt;strong>Управление:&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;code>↑/↓&lt;/code> — навигация&lt;/li>
&lt;li>&lt;code>/&lt;/code> — поиск модели&lt;/li>
&lt;li>&lt;code>f&lt;/code> — фильтр (Perfect / Good / Marginal)&lt;/li>
&lt;li>&lt;code>d&lt;/code> — скачать через Ollama&lt;/li>
&lt;li>&lt;code>q&lt;/code> — выход&lt;/li>
&lt;/ul>
&lt;h2 id="что-показывает">Что показывает&lt;/h2>
&lt;p>Для каждой модели — четыре параметра:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Параметр&lt;/th>
&lt;th>Что значит&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>Quality&lt;/strong>&lt;/td>
&lt;td>Качество модели (репутация, бенчмарки)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Speed&lt;/strong>&lt;/td>
&lt;td>Прогноз скорости (токенов/сек)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Fit&lt;/strong>&lt;/td>
&lt;td>Насколько эффективно использует память&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Context&lt;/strong>&lt;/td>
&lt;td>Размер контекстного окна&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Плюс сразу видно:&lt;/p>
&lt;ul>
&lt;li>Сколько нужно RAM / VRAM&lt;/li>
&lt;li>Режим: GPU / CPU+GPU / только CPU&lt;/li>
&lt;li>Совместимость: Perfect / Good / Marginal / Too Tight&lt;/li>
&lt;/ul>
&lt;h2 id="157-моделей-от-30-провайдеров">157 моделей от 30 провайдеров&lt;/h2>
&lt;p>В базе: Llama, Mistral, Qwen, Gemma, Phi, DeepSeek, Cohere и другие.&lt;/p>
&lt;p>Категории:&lt;/p>
&lt;ul>
&lt;li>Общего назначения&lt;/li>
&lt;li>Код&lt;/li>
&lt;li>Рассуждения (reasoning)&lt;/li>
&lt;li>Чат&lt;/li>
&lt;li>Мультимодальные&lt;/li>
&lt;/ul>
&lt;h2 id="минимальные-требования">Минимальные требования&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Параметры модели&lt;/th>
&lt;th>RAM / VRAM&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>7-8B (Q4)&lt;/td>
&lt;td>6-8 GB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>13B (Q4)&lt;/td>
&lt;td>10-12 GB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>30B (Q4)&lt;/td>
&lt;td>20-24 GB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>70B (Q4)&lt;/td>
&lt;td>40+ GB&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Q4 — это квантование до 4 бит. Сжимает модель в 4 раза с минимальной потерей качества.&lt;/p>
&lt;h2 id="быстрый-старт-после-llmfit">Быстрый старт после llmfit&lt;/h2>
&lt;p>Если llmfit показал, что модель подходит — качай через Ollama:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4"># Установить Ollama&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>curl -fsSL https://ollama.com/install.sh | sh
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4"># Скачать и запустить модель&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>ollama run llama3.2
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Или прямо из llmfit: нажми &lt;code>d&lt;/code> на нужной модели.&lt;/p>
&lt;h2 id="итог">Итог&lt;/h2>
&lt;p>Перед тем как качать 40 GB модель, которая не запустится — проверь через llmfit. Минута на установку, и сразу видно, что реально потянет твоё железо.&lt;/p>
&lt;p>&lt;strong>GitHub:&lt;/strong> &lt;a href="https://github.com/AlexsJones/llmfit">github.com/AlexsJones/llmfit&lt;/a>&lt;/p></content:encoded></item><item><title>Какую нейросеть выбрать в 2026: короткий гайд</title><link>https://bitpage.ru/ai-native/kakuyu-neyroset-vybrat-2026/</link><pubDate>Sun, 22 Feb 2026 06:00:00 +0300</pubDate><dc:creator>BitPage</dc:creator><guid>https://bitpage.ru/ai-native/kakuyu-neyroset-vybrat-2026/</guid><description>DeepSeek, Qwen, ChatGPT, Claude — что для чего использовать. Сравнение цен и задач без воды.</description><content:encoded>&lt;p>&lt;strong>Короткий ответ:&lt;/strong> DeepSeek — для кода и экономии. ChatGPT — для текстов и креатива. Qwen — если нужен русский и бесплатно.&lt;/p>
&lt;h2 id="сравнение-цен-за-1-млн-токенов">Сравнение цен (за 1 млн токенов)&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Модель&lt;/th>
&lt;th>Ввод&lt;/th>
&lt;th>Вывод&lt;/th>
&lt;th>Итого дешевле&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>DeepSeek R1&lt;/td>
&lt;td>$0.55&lt;/td>
&lt;td>$2.19&lt;/td>
&lt;td>&lt;strong>в 20-30 раз&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Qwen 2.5&lt;/td>
&lt;td>бесплатно*&lt;/td>
&lt;td>бесплатно*&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>ChatGPT (GPT-5)&lt;/td>
&lt;td>$1.25&lt;/td>
&lt;td>$10&lt;/td>
&lt;td>база&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Claude 4.1&lt;/td>
&lt;td>$3&lt;/td>
&lt;td>$15&lt;/td>
&lt;td>дороже&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>*Qwen бесплатен через чат, API платный но дешёвый&lt;/p>
&lt;h2 id="что-для-чего">Что для чего&lt;/h2>
&lt;h3 id="код--deepseek">Код → DeepSeek&lt;/h3>
&lt;p>Лучший выбор для:&lt;/p>
&lt;ul>
&lt;li>Написания кода&lt;/li>
&lt;li>Дебага&lt;/li>
&lt;li>Алгоритмических задач&lt;/li>
&lt;li>Математики&lt;/li>
&lt;/ul>
&lt;p>DeepSeek R1 заточен под reasoning — он думает пошагово. Для кода это идеально.&lt;/p>
&lt;h3 id="тексты-и-креатив--chatgpt">Тексты и креатив → ChatGPT&lt;/h3>
&lt;p>Лучший выбор для:&lt;/p>
&lt;ul>
&lt;li>Статей и постов&lt;/li>
&lt;li>Сторителлинга&lt;/li>
&lt;li>Работы с тоном и стилем&lt;/li>
&lt;li>Длинных связных текстов&lt;/li>
&lt;/ul>
&lt;p>ChatGPT лучше держит стиль, понимает эмоции, пишет естественнее.&lt;/p>
&lt;h3 id="русский-язык--бесплатно--qwen">Русский язык + бесплатно → Qwen&lt;/h3>
&lt;p>Лучший выбор для:&lt;/p>
&lt;ul>
&lt;li>Работы на русском без VPN&lt;/li>
&lt;li>Быстрых задач без оплаты&lt;/li>
&lt;li>Анализа картинок и видео (Qwen-VL)&lt;/li>
&lt;/ul>
&lt;p>Qwen 2.5 работает в России без VPN, понимает русский, есть бесплатный чат.&lt;/p>
&lt;h3 id="сложные-задачи--claude">Сложные задачи → Claude&lt;/h3>
&lt;p>Лучший выбор для:&lt;/p>
&lt;ul>
&lt;li>Анализа больших документов&lt;/li>
&lt;li>Работы с контекстом 200K+ токенов&lt;/li>
&lt;li>Задач где важна точность&lt;/li>
&lt;/ul>
&lt;p>Claude дороже, но у него самое большое окно контекста.&lt;/p>
&lt;h2 id="практический-совет">Практический совет&lt;/h2>
&lt;p>Не выбирай одну модель на всё. Комбинируй:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>DeepSeek&lt;/strong> для кода — экономишь в 20 раз&lt;/li>
&lt;li>&lt;strong>Qwen&lt;/strong> для быстрых вопросов — бесплатно&lt;/li>
&lt;li>&lt;strong>ChatGPT/Claude&lt;/strong> для важных текстов — когда качество критично&lt;/li>
&lt;/ol>
&lt;h2 id="где-пользоваться">Где пользоваться&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>DeepSeek&lt;/strong>: chat.deepseek.com или API&lt;/li>
&lt;li>&lt;strong>Qwen&lt;/strong>: qwen.ai (работает в РФ)&lt;/li>
&lt;li>&lt;strong>ChatGPT&lt;/strong>: chat.openai.com (нужен VPN)&lt;/li>
&lt;li>&lt;strong>Claude&lt;/strong>: claude.ai (нужен VPN)&lt;/li>
&lt;/ul>
&lt;p>Или через агрегаторы типа OpenRouter — там все модели в одном месте.&lt;/p>
&lt;h2 id="итог">Итог&lt;/h2>
&lt;p>80% стартапов в Кремниевой долине уже перешли на китайские модели. Не потому что они лучше во всём — а потому что дешевле в 20 раз при сравнимом качестве.&lt;/p>
&lt;p>Для большинства задач разницы не заметишь. А бюджет заметишь.&lt;/p></content:encoded></item><item><title>Как я перестал злиться на AI и начал нормально кодить</title><link>https://bitpage.ru/ai-native/kak-rabotat-s-ai-coding-2026/</link><pubDate>Tue, 17 Feb 2026 08:00:00 +0300</pubDate><dc:creator>BitPage</dc:creator><guid>https://bitpage.ru/ai-native/kak-rabotat-s-ai-coding-2026/</guid><description>Практические советы по работе с AI-ассистентами: Cursor, Copilot, Claude Code. Как получить нормальный код, а не мусор. Проверено на своих проектах.</description><content:encoded>&lt;p>&lt;strong>Короткий ответ:&lt;/strong> AI пишет хороший код, если правильно с ним работать. Проблема не в моделях — проблема в том, как мы даём им задачи.&lt;/p>
&lt;p>Полгода назад я думал, что Copilot и Cursor — это хайп. Генерят мусор, приходится всё переписывать. Потом понял: я просто не умел с ними работать.&lt;/p>
&lt;h2 id="коротко-что-изменило-игру">Коротко: что изменило игру&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Перестать винить AI&lt;/strong> — если код плохой, проблема в задаче&lt;/li>
&lt;li>&lt;strong>Давать контекст&lt;/strong> — модель не телепат, ей нужна архитектура&lt;/li>
&lt;li>&lt;strong>Планировать до кода&lt;/strong> — сначала план, потом реализация&lt;/li>
&lt;li>&lt;strong>Тесты вместо ревью&lt;/strong> — глаз замыливается, тесты нет&lt;/li>
&lt;li>&lt;strong>Коммиты после каждого шага&lt;/strong> — иначе откатить невозможно&lt;/li>
&lt;/ul>
&lt;h2 id="главный-сдвиг-в-голове">Главный сдвиг в голове&lt;/h2>
&lt;p>Раньше я думал: &amp;ldquo;Cursor написал фигню, тупая модель&amp;rdquo;.&lt;/p>
&lt;p>Сейчас думаю: &amp;ldquo;Что я сделал не так в промпте?&amp;rdquo;&lt;/p>
&lt;p>Современные модели — это уверенный мидл-разработчик. Он умный, но не экстрасенс. Если дать ему задачу &amp;ldquo;сделай авторизацию&amp;rdquo;, получишь что-то абстрактное. Если дать контекст, план и примеры — получишь рабочий код.&lt;/p>
&lt;p>&lt;strong>Три причины плохого кода от AI:&lt;/strong>&lt;/p>
&lt;ol>
&lt;li>Контекст не собран или противоречив&lt;/li>
&lt;li>Инструкции слишком абстрактные&lt;/li>
&lt;li>Нет тестов и критериев &amp;ldquo;что значит готово&amp;rdquo;&lt;/li>
&lt;/ol>
&lt;h2 id="контекст--это-топливо">Контекст — это топливо&lt;/h2>
&lt;p>AI не видит твой проект целиком. Он видит то, что ты ему показал.&lt;/p>
&lt;p>&lt;strong>Что работает:&lt;/strong>&lt;/p>
&lt;ol>
&lt;li>
&lt;p>&lt;strong>Сверху вниз&lt;/strong> — начни с архитектуры, потом спускайся к модулям. Не кидай сразу в баг на 500-й строке.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Memory Bank&lt;/strong> — создай файлы с описанием проекта: структура, правила, зависимости. Я держу &lt;code>ARCHITECTURE.md&lt;/code> и &lt;code>CONVENTIONS.md&lt;/code> в корне.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Узкий фокус&lt;/strong> — когда AI понял общую картину, сужай: &amp;ldquo;теперь работаем только с модулем авторизации&amp;rdquo;.&lt;/p>
&lt;/li>
&lt;/ol>
&lt;h2 id="план-до-кода--обязательно">План до кода — обязательно&lt;/h2>
&lt;p>Самая частая ошибка: сразу просить код.&lt;/p>
&lt;p>&lt;strong>Как правильно:&lt;/strong>&lt;/p>
&lt;ol>
&lt;li>Попроси AI составить план реализации&lt;/li>
&lt;li>Проверь план — всё ли учтено?&lt;/li>
&lt;li>Попроси дополнить пробелы&lt;/li>
&lt;li>Сохрани план в &lt;code>.md&lt;/code> файл&lt;/li>
&lt;li>Только потом — реализация по пунктам&lt;/li>
&lt;/ol>
&lt;p>Даже топовые модели типа Claude теряют контекст на длинных задачах. Файл с планом — якорь, к которому можно вернуться.&lt;/p>
&lt;h2 id="тесты-вместо-code-review">Тесты вместо code review&lt;/h2>
&lt;p>Читать тысячи строк сгенерированного кода — бесполезно. Глаз замыливается, пропускаешь ошибки.&lt;/p>
&lt;p>&lt;strong>Что делаю я:&lt;/strong>&lt;/p>
&lt;ol>
&lt;li>Прошу AI написать e2e тест на фичу — это главное&lt;/li>
&lt;li>Запускаю тест&lt;/li>
&lt;li>Если падает — даю AI ошибку, он фиксит&lt;/li>
&lt;li>Повторяю пока не зелёный&lt;/li>
&lt;/ol>
&lt;p>Обратная связь через тесты работает лучше, чем &amp;ldquo;перепиши, мне не нравится стиль&amp;rdquo;.&lt;/p>
&lt;h2 id="верификация-не-верь-готово">Верификация: не верь &amp;ldquo;Готово&amp;rdquo;&lt;/h2>
&lt;p>AI любит говорить &amp;ldquo;Готово!&amp;rdquo;, когда сделано 70%.&lt;/p>
&lt;p>&lt;strong>Мой чеклист:&lt;/strong>&lt;/p>
&lt;ol>
&lt;li>Беру файл с планом&lt;/li>
&lt;li>Прохожу по каждому пункту&lt;/li>
&lt;li>Проверяю: есть код? есть тест?&lt;/li>
&lt;li>Если нет — возвращаю AI на доработку&lt;/li>
&lt;/ol>
&lt;p>Этот шаг спасает от 80% недоделок.&lt;/p>
&lt;h2 id="git-гигиена">Git-гигиена&lt;/h2>
&lt;p>Коммиться после каждого этапа. Серьёзно.&lt;/p>
&lt;ul>
&lt;li>Сформировал план → &lt;code>git commit -m &amp;quot;plan: auth module&amp;quot;&lt;/code>&lt;/li>
&lt;li>Реализовал пункт → &lt;code>git commit&lt;/code>&lt;/li>
&lt;li>Тесты прошли → &lt;code>git commit&lt;/code>&lt;/li>
&lt;/ul>
&lt;p>Если AI пошёл не туда — &lt;code>git reset&lt;/code> и пробуем иначе. Без этого откатить изменения в 15 файлах — боль.&lt;/p>
&lt;h2 id="какой-инструмент-выбрать">Какой инструмент выбрать&lt;/h2>
&lt;p>Пользуюсь всеми тремя, для разных задач:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Инструмент&lt;/th>
&lt;th>Лучше всего для&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;a href="https://github.com/features/copilot">GitHub Copilot&lt;/a>&lt;/td>
&lt;td>Повседневный код, автодополнение, бойлерплейт&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;a href="https://cursor.sh/">Cursor&lt;/a>&lt;/td>
&lt;td>Большие проекты, рефакторинг, работа с несколькими файлами&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;a href="https://claude.ai/">Claude Code&lt;/a>&lt;/td>
&lt;td>Сложные задачи, архитектура, объяснения&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Copilot для рутины, Cursor для проектов, Claude когда нужно подумать.&lt;/p>
&lt;h2 id="итог">Итог&lt;/h2>
&lt;p>AI coding работает, если:&lt;/p>
&lt;ul>
&lt;li>Даёшь контекст и план&lt;/li>
&lt;li>Проверяешь тестами, а не глазами&lt;/li>
&lt;li>Коммитишься часто&lt;/li>
&lt;li>Не веришь на слово &amp;ldquo;Готово&amp;rdquo;&lt;/li>
&lt;/ul>
&lt;p>Модели уже достаточно умные. Вопрос в том, насколько хорошо мы умеем с ними работать.&lt;/p>
&lt;hr>
&lt;p>&lt;em>Часть инсайтов взял из &lt;a href="https://habr.com/ru/articles/997098/">статьи на Хабре&lt;/a> и обсуждений на Reddit. Но проверил всё на своих проектах — работает.&lt;/em>&lt;/p></content:encoded></item><item><title>Manus в Телеграме — или как я перестал бояться нейросетей</title><link>https://bitpage.ru/ai-native/manus-ai-telegram-bot/</link><pubDate>Tue, 17 Feb 2026 07:15:00 +0300</pubDate><dc:creator>BitPage</dc:creator><guid>https://bitpage.ru/ai-native/manus-ai-telegram-bot/</guid><description>Manus AI теперь работает прямо в Telegram. Рассказываю, как бот разбирает голосовые, делает презентации и вообще пытается заменить мне секретаря.</description><content:encoded>&lt;p>Значит, сижу я вчера вечером, листаю ленту. Жена на диване что-то смотрит, кот орёт — есть хочет. И тут читаю: Manus теперь в Телеграме.&lt;/p>
&lt;p>Ну, думаю, опять какой-то бот, который будет отвечать &amp;ldquo;я вас не понял, уточните запрос&amp;rdquo;. Таких я уже штук пятнадцать пробовал.&lt;/p>
&lt;p>А вот и нет.&lt;/p>
&lt;h2 id="что-вообще-за-manus">Что вообще за Manus?&lt;/h2>
&lt;p>Если коротко — это ИИ-агент. Не просто чат-бот, который отвечает на вопросы. А штука, которая реально делает дела. Ну, или пытается.&lt;/p>
&lt;p>Подключаешь свой аккаунт, даёшь ему доступ к почте, календарю, заметкам — и он начинает в этом всём копаться. Звучит страшно? Мне тоже так показалось. Но любопытство победило.&lt;/p>
&lt;h2 id="что-он-умеет-из-того-что-я-попробовал">Что он умеет (из того, что я попробовал)&lt;/h2>
&lt;p>&lt;strong>Голосовые в текст.&lt;/strong> Вот это прям спасение. Присылает друг голосовое на три минуты — про то, как поругался с начальником, что делать с проектом и куда поехать в отпуск. Всё в одном сообщении, разумеется. Кидаю боту — он выдаёт конспект. С пунктами!&lt;/p>
&lt;p>&lt;strong>Разбор почты.&lt;/strong> Подключил рабочую почту (да, было страшно). Говорю: найди письма от Иванова за последнюю неделю и скажи, что он хотел. Нашёл. Сказал. Я в шоке.&lt;/p>
&lt;p>&lt;strong>Презентации.&lt;/strong> Это я ещё не до конца освоил, но в теории — говоришь &amp;ldquo;сделай презентацию про продажи за квартал&amp;rdquo;, и он делает. Со слайдами. Правда, потом всё равно надо проверять, но черновик — готов.&lt;/p>
&lt;p>&lt;strong>Картинки.&lt;/strong> Ну, это уже стандарт для всех ИИ. &amp;ldquo;Нарисуй кота в космосе&amp;rdquo; — рисует. Кот странный, но в космосе.&lt;/p>
&lt;h2 id="что-меня-удивило">Что меня удивило&lt;/h2>
&lt;p>Он помнит контекст. Нет, серьёзно. Я ему неделю назад объяснял, что мне нравится короткий стиль ответов, без воды. Сегодня спрашиваю про погоду — отвечает в два слова. Запомнил!&lt;/p>
&lt;p>И можно выбрать режим. Есть &amp;ldquo;быстрый&amp;rdquo; — для простых вопросов. И &amp;ldquo;глубокий&amp;rdquo; — когда надо подумать. Второй, конечно, медленнее, но и результат другой.&lt;/p>
&lt;h2 id="что-не-понравилось">Что не понравилось&lt;/h2>
&lt;p>Ну, во-первых, страшно давать доступ к почте и календарю. Да, я дал. Да, мне всё ещё тревожно. Но пока вроде ничего не сломалось и спам не посыпался.&lt;/p>
&lt;p>Во-вторых, платный аккаунт. Базовые функции вроде есть бесплатно, но для полного счастья нужна подписка. Сколько — не скажу, потому что курс пляшет, и вообще это тема для отдельного разговора.&lt;/p>
&lt;p>В-третьих, иногда он всё-таки тупит. Но тут уж — а кто не тупит? Я вот тоже иногда захожу на кухню и забываю, зачем пришёл.&lt;/p>
&lt;h2 id="почему-ещё-и-телеграм">Почему ещё и Телеграм?&lt;/h2>
&lt;p>У Manus и так есть сайт (manus.im) и своё приложение — они с марта 2025 года работают. Два миллиона человек в очереди на бету было, представляете?&lt;/p>
&lt;p>Но ребята решили пойти дальше — теперь и в Телеграме можно. Логично: зачем переключаться между приложениями, если можно всё в одном месте? Написал боту, он сделал — красота. Обещают ещё WhatsApp, Slack и Discord подключить.&lt;/p>
&lt;h2 id="итог">Итог&lt;/h2>
&lt;p>Короче, если вы как я — вечно заняты, вечно что-то забываете, вечно тонете в голосовых и письмах — попробуйте. Может, не заменит секретаря полностью (хотя бы потому что секретарь может сходить за кофе), но жизнь упростит.&lt;/p>
&lt;p>А мне пора. Кот всё ещё орёт.&lt;/p></content:encoded></item><item><title>Бесплатный стек для стартапа — собираем продукт за $0</title><link>https://bitpage.ru/ai-native/besplatnyj-stack-dlya-startapa-2026/</link><pubDate>Tue, 17 Feb 2026 07:00:00 +0300</pubDate><dc:creator>BitPage</dc:creator><guid>https://bitpage.ru/ai-native/besplatnyj-stack-dlya-startapa-2026/</guid><description>Полный список бесплатных инструментов для запуска продукта: IDE, база данных, авторизация, AI, деплой. Проверенный стек от разработчика с Reddit.</description><content:encoded>&lt;p>&lt;strong>Короткий ответ:&lt;/strong> да, в 2026 году можно собрать полноценный стек для продукта за $0. IDE, база, авторизация, AI-модели, деплой — всё бесплатно на старте.&lt;/p>
&lt;p>Наткнулся на &lt;a href="https://www.reddit.com/r/vibecoding/comments/1q1j9i8/i_optimised_my_vibe_coding_tech_stack_cost_to_0/">пост на Reddit&lt;/a>, где разработчик оптимизировал свой стек до нуля долларов. Разобрал его подход и добавил свои комментарии.&lt;/p>
&lt;h2 id="коротко-весь-стек">Коротко: весь стек&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Задача&lt;/th>
&lt;th>Сервис&lt;/th>
&lt;th>Бесплатный лимит&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>IDE&lt;/td>
&lt;td>&lt;a href="https://antigravity.google/">Google AntiGravity&lt;/a>&lt;/td>
&lt;td>Полностью бесплатно&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>База данных&lt;/td>
&lt;td>&lt;a href="https://supabase.com/">Supabase&lt;/a>&lt;/td>
&lt;td>Nano-план бесплатный&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Авторизация&lt;/td>
&lt;td>&lt;a href="https://stack-auth.com/">Stack Auth&lt;/a>&lt;/td>
&lt;td>До 10K пользователей&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>AI-модели&lt;/td>
&lt;td>&lt;a href="https://openrouter.ai/">OpenRouter&lt;/a> / &lt;a href="https://aistudio.google.com/">Gemini AI Studio&lt;/a>&lt;/td>
&lt;td>Есть бесплатные модели&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Своя модель&lt;/td>
&lt;td>&lt;a href="https://unsloth.ai/">Unsloth AI&lt;/a> + Google Colab&lt;/td>
&lt;td>Бесплатный файн-тюнинг&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Деплой&lt;/td>
&lt;td>&lt;a href="https://vercel.com/">Vercel&lt;/a>&lt;/td>
&lt;td>Бесплатный тир для пет-проектов&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Git&lt;/td>
&lt;td>&lt;a href="https://github.com/">GitHub&lt;/a> / &lt;a href="https://about.gitlab.com/">GitLab&lt;/a>&lt;/td>
&lt;td>Бесплатно&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Аналитика&lt;/td>
&lt;td>&lt;a href="https://posthog.com/">PostHog&lt;/a>, &lt;a href="https://clarity.microsoft.com/">Clarity&lt;/a>, &lt;a href="https://analytics.google.com/">GA&lt;/a>&lt;/td>
&lt;td>Всё бесплатно&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="разбор-по-категориям">Разбор по категориям&lt;/h2>
&lt;h3 id="ide-google-antigravity">IDE: Google AntiGravity&lt;/h3>
&lt;p>Новая бесплатная IDE от Google. Пока в бете, но уже рабочая. Альтернативы: VS Code (бесплатно), Cursor (есть бесплатный тир).&lt;/p>
&lt;h3 id="база-данных-supabase">База данных: Supabase&lt;/h3>
&lt;p>PostgreSQL + авторизация + realtime из коробки. Nano-план бесплатный — для MVP и тестов хватает. Когда вырастешь, можно мигрировать или платить.&lt;/p>
&lt;p>&lt;strong>Альтернативы:&lt;/strong> PlanetScale (MySQL), Neon (PostgreSQL), Firebase (NoSQL).&lt;/p>
&lt;h3 id="авторизация-stack-auth">Авторизация: Stack Auth&lt;/h3>
&lt;p>Бесплатно до 10 000 пользователей. Для старта — более чем достаточно. Если у тебя 10K юзеров, ты уже можешь позволить себе платить.&lt;/p>
&lt;p>&lt;strong>Альтернативы:&lt;/strong> Clerk, Auth0 (есть бесплатные тиры), Supabase Auth (если уже используешь Supabase).&lt;/p>
&lt;h3 id="ai-модели-openrouter--gemini">AI-модели: OpenRouter + Gemini&lt;/h3>
&lt;p>&lt;a href="https://openrouter.ai/">OpenRouter&lt;/a> — агрегатор моделей, есть бесплатные варианты. &lt;a href="https://aistudio.google.com/">Gemini через AI Studio&lt;/a> — бесплатно для экспериментов.&lt;/p>
&lt;p>Для прода можно файн-тюнить свою модель через &lt;a href="https://unsloth.ai/">Unsloth AI&lt;/a> прямо в Google Colab — тоже бесплатно.&lt;/p>
&lt;h3 id="деплой-vercel">Деплой: Vercel&lt;/h3>
&lt;p>Стандарт для Next.js проектов. Бесплатный тир покрывает пет-проекты и MVP.&lt;/p>
&lt;p>&lt;strong>Альтернативы:&lt;/strong> Netlify, Cloudflare Pages, Railway.&lt;/p>
&lt;h3 id="аналитика-три-инструмента">Аналитика: три инструмента&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>&lt;a href="https://posthog.com/">PostHog&lt;/a>&lt;/strong> — продуктовая аналитика, фичи-флаги, сессии&lt;/li>
&lt;li>&lt;strong>&lt;a href="https://clarity.microsoft.com/">Microsoft Clarity&lt;/a>&lt;/strong> — тепловые карты, записи сессий&lt;/li>
&lt;li>&lt;strong>&lt;a href="https://analytics.google.com/">Google Analytics&lt;/a>&lt;/strong> — трафик, источники, конверсии&lt;/li>
&lt;/ul>
&lt;p>Все три бесплатны и решают разные задачи.&lt;/p>
&lt;h2 id="честный-взгляд">Честный взгляд&lt;/h2>
&lt;p>Собрать стек за $0 — реально. Но есть нюанс:&lt;/p>
&lt;p>&lt;strong>Бесплатные тиры работают, пока ты маленький.&lt;/strong> Когда придут пользователи (если придут), начнутся расходы:&lt;/p>
&lt;ul>
&lt;li>Больше запросов к базе&lt;/li>
&lt;li>Больше трафика на хостинге&lt;/li>
&lt;li>Больше вызовов AI-моделей&lt;/li>
&lt;/ul>
&lt;p>И главное: &lt;strong>найти пользователей и заставить их платить — вот настоящая задача.&lt;/strong> Техническая часть сейчас самая дешёвая в истории. Маркетинг и продажи — нет.&lt;/p>
&lt;h2 id="что-бы-я-добавил">Что бы я добавил&lt;/h2>
&lt;p>Для российских разработчиков есть нюансы:&lt;/p>
&lt;ul>
&lt;li>Vercel может требовать карту при верификации&lt;/li>
&lt;li>Некоторые сервисы не работают без VPN&lt;/li>
&lt;li>Оплата при росте — отдельная боль&lt;/li>
&lt;/ul>
&lt;p>Но для MVP и тестирования идеи — стек рабочий.&lt;/p>
&lt;h2 id="итог">Итог&lt;/h2>
&lt;p>Если хочешь запустить продукт с нулевым бюджетом — это возможно. Supabase + Vercel + бесплатные AI-модели закрывают 90% потребностей на старте. Главное — не застрять в &amp;ldquo;идеальном стеке&amp;rdquo; и начать делать продукт.&lt;/p>
&lt;p>&lt;em>Стек — это 5% успеха. Остальные 95% — найти тех, кому это нужно.&lt;/em>&lt;/p></content:encoded></item><item><title>Какие нейросети работают в России без VPN</title><link>https://bitpage.ru/ai-native/neyroseti-bez-vpn-rossiya-2026/</link><pubDate>Wed, 03 Dec 2025 00:00:00 +0000</pubDate><dc:creator>BitPage</dc:creator><guid>https://bitpage.ru/ai-native/neyroseti-bez-vpn-rossiya-2026/</guid><description>Актуальный список нейросетей доступных в России без VPN в 2026 году. DeepSeek, GigaChat, YandexGPT и другие — что работает бесплатно.</description><content:encoded>&lt;p>После того как ChatGPT стал недоступен без VPN, а Claude вообще заблокировал регистрацию из России, пришлось искать альтернативы. Потратил время, потестил разные варианты. Делюсь, что реально работает.&lt;/p>
&lt;h2 id="что-важно">Что важно&lt;/h2>
&lt;p>Критерии простые:&lt;/p>
&lt;ul>
&lt;li>Работает без VPN&lt;/li>
&lt;li>Можно зарегистрироваться с российским номером/почтой&lt;/li>
&lt;li>Есть бесплатный доступ или оплата российской картой&lt;/li>
&lt;/ul>
&lt;h2 id="deepseek">DeepSeek&lt;/h2>
&lt;p>Китайская нейросеть, которая в начале 2025-го наделала шума. Работает на уровне GPT-4, но полностью бесплатна через веб-интерфейс.&lt;/p>
&lt;p>&lt;strong>Что умеет:&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>Отвечает на русском нормально&lt;/li>
&lt;li>Пишет и анализирует код&lt;/li>
&lt;li>Есть режим DeepThink для сложных задач — разбивает запрос на части и думает пошагово&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Как пользоваться:&lt;/strong>
Заходишь на chat.deepseek.com, регистрируешься по почте, пользуешься. VPN не нужен, лимитов на запросы нет. Платить нужно только за API, если хочешь интегрировать куда-то.&lt;/p>
&lt;p>&lt;strong>Минусы:&lt;/strong>
Иногда тупит на специфически российских темах — всё-таки китайская модель.&lt;/p>
&lt;h2 id="gigachat">GigaChat&lt;/h2>
&lt;p>Нейросеть от Сбера. Логично, что работает в России без проблем.&lt;/p>
&lt;p>&lt;strong>Что умеет:&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>Лучше всех понимает российский контекст&lt;/li>
&lt;li>Генерирует картинки (Kandinsky встроен)&lt;/li>
&lt;li>Интеграция с экосистемой Сбера&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Как пользоваться:&lt;/strong>
Входишь через Сбер ID — если есть карта Сбера, уже всё готово. Бесплатный тариф покрывает большинство задач.&lt;/p>
&lt;p>&lt;strong>Минусы:&lt;/strong>
По качеству ответов уступает DeepSeek и тем более GPT-4. Но для базовых задач хватает.&lt;/p>
&lt;h2 id="yandexgpt">YandexGPT&lt;/h2>
&lt;p>Доступен через Алису или напрямую в Яндекс Браузере.&lt;/p>
&lt;p>&lt;strong>Что умеет:&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>Хорошо работает с русским языком&lt;/li>
&lt;li>Интеграция с поиском Яндекса&lt;/li>
&lt;li>Умеет в контекст — помнит, о чём говорили&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Как пользоваться:&lt;/strong>
Проще всего через Алису — просто спрашиваешь. Или через ya.ru в браузере.&lt;/p>
&lt;p>&lt;strong>Минусы:&lt;/strong>
Ограничения на бесплатном тарифе. Для серьёзной работы нужна подписка Яндекс Плюс.&lt;/p>
&lt;h2 id="что-выбрать">Что выбрать&lt;/h2>
&lt;p>Если нужен универсальный инструмент для работы — &lt;strong>DeepSeek&lt;/strong>. Бесплатный, мощный, без ограничений.&lt;/p>
&lt;p>Если важен российский контекст и простота — &lt;strong>GigaChat&lt;/strong>. Вошёл через Сбер ID и работаешь.&lt;/p>
&lt;p>Если уже в экосистеме Яндекса — &lt;strong>YandexGPT&lt;/strong> через Алису.&lt;/p>
&lt;h2 id="а-что-с-chatgpt">А что с ChatGPT?&lt;/h2>
&lt;p>Работает через VPN. Оплатить подписку можно через сервисы-посредники, но это лишние заморочки. Если нет специфических задач, которые решает только GPT-4 — проще взять DeepSeek.&lt;/p>
&lt;hr>
&lt;p>Если знаете ещё варианты, которые нормально работают в России — пишите, добавлю в список.&lt;/p></content:encoded></item></channel></rss>