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