Я расскажу, как у меня устроена пересборка статического блога, который живёт на одном сервере, а собирается на другом. Главная страница, RSS и sitemap пересобираются по таймеру systemd каждые 15 минут, и за полтора месяца эта схема прошла 3 907 прогонов. Ниже конфиги и куски скриптов: как тянуть с удалённого сервера только нужное через фильтры rsync, как не дать двум прогонам наложиться друг на друга, как не затереть правку, сделанную руками во время сборки, и что делать, когда сборщик сломан. Отдельно про файлы, которые незаметно утекали наружу из вебрута.

Как это устроено

Серверов два. На одном, в России, лежат сайты и работает nginx. На втором, в Германии, работают все мои скрипты и фоновые задачи. Летом я развёл их окончательно: на немецком больше нет копий российских сайтов. Копии лежали там мёртвым грузом и путали: однажды презентацию записали в старую копию, а живой сайт не поменялся. Теперь всё, что касается сайтов, ходит на российский сервер по ssh под отдельным пользователем без sudo.

Сам блог статический: в нём 194 статьи, у каждой своя папка с index.html и картинками. Черновики статей готовит отдельный скрипт-генератор, и это потом сыграет роль в истории с утечками. После каждой новой статьи надо обновить три файла: главную с карточками, RSS для агрегаторов и sitemap. Их собирают три скрипта на Python, и запускаются они на немецком сервере, потому что Python и всё окружение лежат там.

Запуск висит на таймере systemd:

# /etc/systemd/system/blog-cards.timer
[Timer]
OnBootSec=5min
OnUnitActiveSec=15min
Persistent=true
Unit=blog-cards.service

# /etc/systemd/system/blog-cards.service.d/runative.conf
[Service]
ExecStart=
ExecStartPost=
ExecStart=/home/user/bin/blog-publish-ru.sh

Пустые ExecStart= и ExecStartPost= в drop-in файле я оставил специально. В исходном юните было два ExecStart со старыми сборщиками и ExecStartPost с выгрузкой, которые смотрели в папку, удалённую при переезде. Для сервиса с Type=oneshot systemd складывает все ExecStart в список, и если просто дописать новый, старые тоже выполнятся. Пустое присваивание обнуляет список, это прямо написано в man systemd.service в описании ExecStart=. Таймер с OnUnitActiveSec считает 15 минут от последнего запуска сервиса, а Persistent=true в моём случае почти ничего не даёт: он работает только с OnCalendar. Я его не убираю, вреда от него нет.

Что было не так до 30 сентября

Первая версия скрипта была прямолинейной. Она скачивала с российского сервера весь блог, около 472 МБ, собирала три файла и заливала обратно всё целиком, вместе со статьями и картинками. Каждые 15 минут гонять полгигабайта туда и обратно уже неприятно. А если в это время кто-то поправлял статью прямо на российском сервере, через несколько минут скрипт привозил старую версию и затирал правку.

Был и второй источник проблем. Сам таймер два прогона не наложит: пока сервис с Type=oneshot не завершился, systemd его повторно не запускает. Но тот же скрипт вызывается при публикации статьи, мимо systemd. Если публикация попадала на момент, когда таймер уже начал сборку, два прогона одновременно писали в одну папку.

В конце сентября я устроил аудит всего блога, и по его итогам 30 сентября пересборку переписали. Вот начало нового скрипта:

exec 9>/home/user/.blog-publish.lock
if ! flock -w 900 9; then
  echo "другой прогон держит блокировку 15 минут – пропуск" >> "$LOG"
  "$ALERT" lock "Блог: пересборка зависла" "Предыдущий прогон висит."
  exit 1
fi

rsync -a --delete -m -e "$SSHOPT" \
  --include='/index.html' --include='/rss.xml' --include='/sitemap.xml' \
  --include='/covers/' --include='/covers/*.webp' \
  --include='/*/' --include='/*/index.html' --include='/*/.archive' \
  --include='/*/cover.jpg' --include='/*/cover.jpeg' --include='/*/cover.png' \
  --exclude='*' "$RUHOST:$RUDIR/" "$MIRROR/"

Файловый дескриптор 9 открывается на файл блокировки, и flock держит второй прогон до 900 секунд, пока первый его не отпустит. Дольше ждать нет смысла, следующий запуск таймера всё равно скоро придёт. Если блокировку так и не дали, значит, предыдущий прогон завис, и об этом надо сообщить.

Над фильтрами rsync пришлось посидеть. Они проверяются по порядку, первое совпадение выигрывает. Финальный --exclude='' отрезает всё, что не попало в список выше. Правило --include='//' нужно, чтобы rsync вообще зашёл в папки статей: без него он отбросит каталог целиком и до index.html внутри не доберётся. Ведущий слэш привязывает шаблон к корню передачи, так что /*/index.html берёт только первый уровень. Файл .archive в папке статьи – это метка: такая статья остаётся в sitemap, но не попадает на главную и в RSS. Флаг -m убирает пустые каталоги, которые иначе остаются от папок без подходящих файлов. Подробно правила фильтров расписаны в разделе FILTER RULES в man rsync.

Зеркало теперь постоянное и лежит в отдельной папке. Сейчас оно весит 183 МБ, и почти всё это скачалось один раз. В обычном прогоне по сети едут только изменения, и вся пересборка занимает 7–8 секунд.

Как не затереть чужую правку

Главная страница блога иногда правится руками прямо на российском сервере. Сборщик переписывает в ней только блок карточек между комментариями-маркерами <!-- CARDS:START --> и <!-- CARDS:END -->, всё остальное он берёт из текущего файла. Значит, ручную правку шапки или подвала можно потерять, только если залить главную, собранную из старой копии. Сборка длится секунды, но совпадение всё равно возможно. Поэтому до сборки скрипт запоминает контрольную сумму главной в зеркале, а перед заливкой спрашивает сумму на российском сервере:

HOME_MD5_BEFORE=$(md5sum "$MIRROR/index.html" | cut -d' ' -f1)
# ... сборка ...
PUSH=("$MIRROR/rss.xml" "$MIRROR/sitemap.xml")
REMOTE=$($SSHOPT "$RUHOST" "md5sum $RUDIR/index.html" 2>/dev/null | cut -d' ' -f1)
if [ "$REMOTE" = "$HOME_MD5_BEFORE" ]; then
  PUSH+=("$MIRROR/index.html")
else
  echo "SKIP push index.html: главную изменили на РУ во время сборки" >> "$LOG"
fi
rsync -rtz --checksum -i -e "$SSHOPT" "${PUSH[@]}" "$RUHOST:$RUDIR/"

Если суммы разошлись, главная в этот раз не заливается. Следующий прогон скачает свежую версию и соберёт карточки уже поверх правки. Если российский сервер не ответил, переменная окажется пустой, и результат будет тот же: главную пропускаем. По-хорошему это гонка между проверкой и заливкой, окно там меньше секунды, и для блога, который правят пару раз в день, мне этого хватает. Если бы правок было больше, я бы делал блокировку на стороне российского сервера.

Флаг --checksum заставляет rsync сравнивать файлы по содержимому вместо размера и времени. Сборщики пишут файлы заново при каждом запуске, время у них всегда новое, и без этого флага rsync каждый раз гнал бы все три файла. Ключ -i печатает в лог, что именно уехало. По логу видно, как это выглядит в спокойный день: в большинстве прогонов не уезжает ничего. Когда вышла статья, в логе появляются строки <fcst...... index.html и <fcst...... rss.xml.

Статьи и картинки эта пересборка не трогает совсем, их выкладывает отдельный скрипт публикации.

Когда ломается сам сборщик

Сборщик главной у меня меняется чаще, чем хотелось бы. В нём живут правила рубрик, и под статью новой темы туда иногда дописывается правило. Рядом со скриптом лежит 15 резервных копий с датами, это примерно столько правок за четыре месяца. Одна опечатка в таком файле, и главная перестаёт обновляться совсем, а заметить это можно только по тому, что новых статей на ней нет.

Поэтому каждый сборщик запускается через обёртку. Привожу её сокращённо:

run_builder() {
  local name="$1" script="$BIN/$1" lkg="$LKG/$1" out rc
  if ! python3 -c 'import ast,sys; ast.parse(open(sys.argv[1]).read())' "$script"; then
    "$ALERT" "syntax-$name" "Блог: сломан $name" "Сборка идёт по последней рабочей версии."
    [ -f "$lkg" ] || return 1
    script="$lkg"
  fi
  out=$(BLOG_DIR="$MIRROR" python3 "$script" 2>&1); rc=$?
  if [ $rc -ne 0 ] && [ "$script" != "$lkg" ] && [ -f "$lkg" ]; then
    "$ALERT" "run-$name" "Блог: $name падает" "Код $rc. $(printf '%s' "$out" | tail -5)"
    out=$(BLOG_DIR="$MIRROR" python3 "$lkg" 2>&1); rc=$?
  elif [ $rc -eq 0 ] && [ "$script" = "$BIN/$name" ]; then
    cmp -s "$script" "$lkg" || cp -p "$script" "$lkg"
  fi
  return $rc
}

Сначала файл разбирается через ast.parse. Так ловятся синтаксические ошибки, и код для этого исполнять не нужно. Если разбор прошёл, сборщик запускается. Удачный запуск свежей версии копирует её в папку .lkg (last known good), и при следующем сбое обёртка возьмёт оттуда последнюю версию, которая точно отработала. Новая статья в таком случае может не попасть в свою рубрику. Главная продолжит обновляться, а я получу сообщение с хвостом ошибки.

У схемы есть дыра, и я о ней знаю. Копия в .lkg обновляется после любого запуска с кодом 0, поэтому ошибку в логике она не ловит. Сборщик, который вывел главную без половины карточек и вернул ноль, станет новой «рабочей» версией и уедет на сервер. Строка «ПРОПУЩЕНА», которую обёртка пересылает мне, тут не поможет: её печатает сам сборщик про отдельную статью без заголовка или вводного абзаца. Следующее, что я сюда добавлю, – сравнение числа карточек с прошлым прогоном перед заливкой. Ещё одна оговорка: в .lkg копируется один файл, и это работает, потому что сборщики используют только стандартную библиотеку Python. Со своими модулями пришлось бы сохранять их тоже.

Когда запасной версии нет или упала и она, прогон останавливается до заливки, и на российский сервер ничего не уходит. На сайте остаётся вчерашняя главная, и меня это устраивает.

С 30 сентября запасная версия ни разу не понадобилась, в логе нет ни одной записи о синтаксической ошибке. Я её всё равно оставляю, обходится она в одну папку и пару строк.

Сообщения без спама

Таймер запускается 96 раз в сутки. Если российский сервер лёг на ночь, наивная схема прислала бы мне три десятка одинаковых сообщений. Скрипт оповещения запоминает время последней отправки по ключу и молчит, если с неё прошло меньше 6 часов:

KEY="$(printf '%s' "${1:-blog}" | tr -cd 'a-zA-Z0-9_-')"
STAMP="$STATE/$KEY"
if [ -f "$STAMP" ] && [ $(( $(date +%s) - $(stat -c %Y "$STAMP") )) -lt 21600 ]; then
  exit 0
fi
printf '%s\n' "$BODY" | notify.sh "$TITLE" && touch "$STAMP"

Ключ очищается от всего, кроме латиницы, цифр, дефиса и подчёркивания, потому что из него строится имя файла. Файл-метка обновляется только после успешной отправки, так что неушедшее сообщение следующий прогон попробует отправить снова.

Что утекало наружу

Это нашёл тот же сентябрьский аудит. Генератор собирает статью в рабочей папке, и рядом с index.html у него лежат черновики, исходники баннеров, превью, скрипты проверки. Первая версия публикации заливала папку целиком. В итоге сайт отдавал наружу скрипт проверки статьи gate.py: можно было открыть его по адресу статьи и прочитать.

Похожая история случилась с резервными копиями. В августе я выяснил, что копия страницы, сохранённая рядом с оригиналом, отдаётся сервером как обычная страница. Для поисковика это двойник статьи с другим адресом. С тех пор все скрипты, которые правят страницы, кладут копии в отдельную папку вне вебрута, это прописано прямо в их коде.

Теперь при публикации rsync не берёт рабочие файлы:

rsync -az -e "$SSHOPT" \
  --exclude='*.bak*' --exclude='*.orig' --exclude='*.py' --exclude='*.sh' --exclude='*.md' \
  --exclude='body.html' --exclude='cover.html' --exclude='banner-[0-9]*.html' \
  --exclude='_preview*' --exclude='preview*' --exclude='*.log' --exclude='*.tmp' \
  --exclude='._*' --exclude='.DS_Store' \
  "$SRC/" "$RUHOST:$RUDIR/$SLUG/"

А на случай, если что-то всё же попадёт на сервер другим путём, в nginx подключён общий фрагмент, который отдаёт 404 на служебные расширения и скрытые файлы:

location ~* \.(bak|backup|orig|old|save|swp|tgz|tar|zip|7z|rar|py|pyc|sh|sql|sqlite|db|log)([-._~]|$) {
  return 404;
}
location ~ /\.(?!well-known/) { return 404; }
location ~* (^|/)body\.html$ { return 404; }

Подключается он через include первой строкой в блоке server. Location с регулярными выражениями nginx проверяет в том порядке, в каком они записаны, и берёт первое совпадение, так что любая регулярка выше по конфигу, которая тоже подходит под адрес, перехватит запрос. Есть и исключения: точное совпадение location = и префикс с ^~ срабатывают раньше регулярок вообще, а регулярки, вложенные в совпавший префиксный location, проверяются раньше регулярок уровня server. Если у вас такие вложения есть, защитный фрагмент нужен и внутри них. Порядок выбора расписан в документации к директиве location на nginx.org.

Хвост ([-._~]|$) в первом выражении нужен, чтобы ловить копии с датой и суффиксами: index.html.bak-20260930, build.py~, site.tar.gz. Привязка к одному $ их пропустила бы. А совсем без хвоста правило задело бы лишнее: .sh совпал бы с началом .shtml, а .db с .dbf. Побочный эффект тоже есть: папка с именем вроде nginx.log-разбор получит 404, и об этом стоит помнить, когда придумываете имена папок. Второе правило закрывает .git, .env и мусорные файлы macOS вида ._index.html, которые появляются при копировании с Мака, но оставляет /.well-known/ для выпуска сертификатов.

Проверить себя можно за минуту: берёте любой адрес статьи, дописываете к нему index.html.bak или gate.py и смотрите код ответа через curl -I. До сентября gate.py по такому адресу у меня открывался.

Проверка перед публикацией

Последний фильтр стоит перед самой выкладкой статьи. Скрипт смотрит, что в index.html есть заголовок, вводный абзац, надзаголовок и подключение общего подвала через SSI. Если чего-то нет, статья не уезжает, скрипт выходит с кодом 3, а мне приходит сообщение с причиной. До этой проверки статья без подвала молча выходила на сайт, а статья без заголовка пропадала с главной, потому что сборщик карточек её пропускал.

Там же есть исправление, связанное с картинками. Каркас статьи содержит <picture> с вариантами в AVIF и WebP, а конвейер баннеров иногда делает только JPG. Браузер выбирает первый подходящий <source>, получает 404 и показывает пустое место, до запасного <img> дело не доходит. Поэтому перед выкладкой скрипт вырезает <source>, для которых нет файла в папке:

def keep(m):
    fn = re.search(r'srcset="([^ "]+)', m.group(0))
    return "" if (fn and not os.path.exists(os.path.join(d, fn.group(1)))) else m.group(0)
s = re.sub(r'<source type="image/(?:avif|webp)"[^>]*>', keep, s)

Код рассчитан на наш каркас: пути в srcset относительные, type стоит перед srcset, вариант в каждом <source> один. С абсолютным путём os.path.join отбросил бы папку статьи и вырезал рабочий тег. Повторный запуск на исправленном файле ничего не меняет.

Что показал лог

С 30 сентября по 8 ноября в логе 3 907 прогонов, 3 894 закончились успешно. Десять раз не получилось скачать данные с российского сервера и три раза не получилось залить. Во всех тринадцати случаях это сеть: Connection closed by ... port 22 или connect to host ... port 22: Connection timed out. Следующий прогон по таймеру каждый раз проходил сам, без моего участия. Шесть раз скрипт пропустил главную, потому что сумма на российском сервере разошлась с зеркалом. Лог не различает, правил ли её кто-то в этот момент или сервер не ответил на md5sum. Это надо писать разными строками, иначе цифра ничего не говорит.

Напоследок короткая история не из этого скрипта. 6 ноября на российском сервере у /dev/null сменились владелец и права: вместо root root с правами 666 там оказался обычный пользователь и crw-r-----. После этого scp от пользователя выгрузки падал с scp: Connection closed, а с флагом -O писал Couldn't open /dev/null. Мои мелкие обёртки «скачать файл» и «залить файл» были на scp и сломались. Зато rsync поверх ssh и команды вида ssh host 'cat файл' работали, поэтому блог эту поломку не заметил. Права может вернуть только root, и 8 ноября их вернули. Если scp внезапно падает с невнятной ошибкой, первым делом смотрите ls -l /dev/null на той стороне.

Что бы я сделал сразу

Если собирать такое с нуля, я бы с первого дня заливал на сервер только то, что собрал, и только по контрольной сумме. Почти все неприятности первой версии выросли из того, что скрипт заливал больше, чем менял. А список исключений в rsync я бы дополнял запретом в nginx и проверял curl'ом снаружи, потому что рабочие файлы в вебрут рано или поздно попадают.

Как вы решаете похожую задачу: собираете статику на том же сервере, где она раздаётся, или держите сборку отдельно? И чем ловите сборщик, который сломался, но молча вернул ноль?