feat: SFTP-деплой по size+SHA1 (не mtime), keepalive+таймаут канала, ретраи; apt python3-paramiko
deploy-dedinit / Build & SFTP Deploy (push) Successful in 12m41s
deploy-dedinit / Build & SFTP Deploy (push) Successful in 12m41s
This commit is contained in:
@@ -1,47 +1,53 @@
|
||||
# Design: Инкрементальный SFTP-деплой
|
||||
|
||||
## Подход
|
||||
## Проблема
|
||||
|
||||
Модифицируем единственный файл `tools/deploy_sftp.py`:
|
||||
`tools/deploy_sftp.py` mirror-заливал ВСЕ 524 файла (66M) каждый деплой,
|
||||
включая неизменные тяжёлые (21M PDF, 11M×2 M4V) — деплой занимал ~36 мин
|
||||
(замер №1: run создан 16:54:19Z → прод виден 17:30:20Z). Плюс: при обрыве
|
||||
SFTP-соединения скрипт вис бесконечно (нет таймаутов) — оба раза висел на
|
||||
`2026/gotosocial-relay-match-by-default/hero.svg`.
|
||||
|
||||
1. **Сравнение файлов (size + mtime)**:
|
||||
- Для каждого локального файла вызываем `sftp.stat(remote_path)`.
|
||||
- Если stat успешен И `(st_size, int(st_mtime))` совпадает с локальным
|
||||
`(os.stat(lpath).st_size, int(os.stat(lpath).st_mtime))` — пропускаем (не льём).
|
||||
- Если stat бросает IOError (нет файла) или не совпадает — заливаем.
|
||||
- Метрику печатаем: `= пропущено, + залито`.
|
||||
## Решение
|
||||
|
||||
2. **Таймауты против зависания**:
|
||||
- `paramiko.Transport` создаём с `banner_timeout=30`, `timeout=30`.
|
||||
- Для каждого `put` используем `paramiko` окно: оборачиваем в `socket.setdefaulttimeout(120)`
|
||||
(SFTP-канал унаследует) — если операция длится дольше 2 мин, канал падает с ошибкой,
|
||||
скрипт падает с понятным сообщением вместо бесконечного висения.
|
||||
- Удаление тоже не должно висеть: те же таймауты.
|
||||
Единственный файл `tools/deploy_sftp.py`:
|
||||
|
||||
3. **Что НЕ меняется**:
|
||||
- Mirror-логика (всё, чего нет локально, удаляется) — сохранена.
|
||||
- Рекурсивное создание каталогов — сохранено.
|
||||
- Интерфейс: `tools/deploy_sftp.py [--dry-run]`, переменные DEDINIT_* / SSHPASS.
|
||||
- Вызов из `.gitea/workflows/deploy.yml` не меняется.
|
||||
1. **Инкремент по (size + SHA1)**: для каждого локального файла — `sftp.stat()`;
|
||||
если удалённый существует и (размер совпадает И SHA1 содержимого совпадает)
|
||||
— пропуск; иначе `sftp.put()`.
|
||||
- mtime НЕ используется: в CI (Gitea Actions) checkout ставит свежий mtime
|
||||
всем файлам → mtime-сравнение бесполезно.
|
||||
- SHA1 читается потоково с удалённого через `sftp.open()` (1MB чанками) —
|
||||
корректно для 21M PDF (несколько секунд).
|
||||
- Для несовпадающих размеров хэш не считается (быстрый путь).
|
||||
|
||||
## Риски
|
||||
2. **Защита от зависаний**:
|
||||
- `Transport.set_keepalive(15)` — пинги; мёртвое соединение падает само.
|
||||
- `sftp.get_channel().settimeout(120)` — операция дольше 120с прерывается.
|
||||
- `with_retry()` — при исключении переподключение (до 3 попыток, sleep 2с),
|
||||
`put`/`remove` повторяются; после исчерпания — выход с ненулевым кодом.
|
||||
|
||||
- **mtime на сервере** (Jino) может отличаться от локального (часовой пояс, точность).
|
||||
SFTP stat возвращает mtime в секундах; локально `int(st_mtime)` — тоже секунды.
|
||||
Допуск: считаем «не изменился», если `|remote_mtime - local_mtime| <= 120` И размеры равны.
|
||||
(Больше 2 мин расхождения не бывает при честном сравнении; зато гарантируем, что
|
||||
файл, записанный «только что» локально и залитый минуту назад, не перельётся без нужды.)
|
||||
Решение: допуск на mtime = 300 сек (5 мин) при равенстве размеров.
|
||||
3. **Mirror-удаление сохранено**: `remote_files - local_files` → `remove()`.
|
||||
|
||||
- **Ложные пропуски**: если сервер отдаёт mtime 0 (некоторые SFTP-сервера) — считаем файл
|
||||
изменённым (льём всегда). Проверка `if remote_mtime > 0`.
|
||||
4. **CI**: `python3-paramiko` ставится через **apt** (не pip в venv) — не зависит
|
||||
от PyPI, быстрее (установка случайно занимала 15-20 мин).
|
||||
|
||||
- **Большой дерево**: rlist рекурсивно обходит всё дерево — оставляем как есть (работает,
|
||||
это доли секунды на 500 файлов).
|
||||
## Почему не mtime
|
||||
|
||||
## Верификация
|
||||
В Gitea Actions `actions/checkout` распаковывает репозиторий с текущим mtime →
|
||||
все файлы «свежие» относительно сервера → mtime-сравнение никогда не пропускает.
|
||||
SHA1 — единственный надёжный индикатор неизменности для CI.
|
||||
|
||||
- `python3 tools/deploy_sftp.py --dry-run` локально: показывает список изменившихся
|
||||
(должно быть 0 после сборки в тот же public/... фактически покажет все «+», т.к. на
|
||||
сервере старый mtime — это ок, первый деплой льёт всё).
|
||||
- После первого инкрементального деплоя повторный `--dry-run` покажет «=» (пропуски).
|
||||
## Альтернативы, отклонённые
|
||||
|
||||
- **rsync**: Jino SFTP-only, удалённый exec запрещён.
|
||||
- **Манифест (JSON) прошлого деплоя**: усложняет, требует хранения состояния;
|
||||
SHA1-сравнение с сервером самодостаточно.
|
||||
- **Только размер**: риск ложного пропуска при изменении содержимого без смены
|
||||
размера (маловероятно для статики, но SHA1 дешевле ошибки).
|
||||
|
||||
## Открытые вопросы
|
||||
|
||||
- Точное время залива (замер №2) зависит от пропускной способности Jino SFTP.
|
||||
- SHA1 крупных файлов при каждом деплое: 21M PDF → ~2-5с на чтение с сервера;
|
||||
приемлемо по сравнению с перезаливкой.
|
||||
Reference in New Issue
Block a user