Автоматизация: архивы, резервное копирование, планировщики
Тема 13 показала, как написать скрипт Bash, тема 14 — как в нём ветвиться и повторять действия, тема 15 — как сделать скрипт надёжным: проверять коды возврата, очищать временные файлы по trap и не запускать две копии одновременно. Тема 5 научила соединять программы конвейерами, тема 7 — управлять процессами, тема 9 — запускать сервисы и читать журнал. Теперь эти навыки собираются в одну задачу: регулярно сохранять каталог проекта так, чтобы из любой копии его можно было восстановить, старые копии удалялись сами, а всё выполнялось без участия человека.
Задача распадается на четыре части: упаковать данные в архив и проверить его целостность (tar, gzip, xz, sha256sum); обновлять копию, а не переписывать её целиком (rsync, снимки с жёсткими ссылками); хранить копии по порядку (ротация, правило 3-2-1) и шифровать те, что покидают площадку (gpg, age); наконец, запускать всё по расписанию (cron, at, таймеры systemd) или по событию в файловой системе (inotifywait).
Изложение построено от простого к сложному: сначала архивы и их проверка, затем синхронизация rsync, схема снимков, ротация и шифрование копий, далее скрипт и планирование запусков. Все команды выполнялись в оболочке Bash на Ubuntu; выводы получены в учебном каталоге, имена пользователя и путей заменены на student и /home/student. Там, где запуск требует прав администратора или работающего systemd, вывод помечен как пример.
Архивы и сжатие
Архив (англ. archive) — один файл, внутри которого лежит целое дерево каталогов вместе с правами, владельцами и датами изменения файлов. Архивы удобны для переноса проекта, отправки по почте и хранения копий. Исторически в UNIX для этой задачи создан tar (от англ. tape archive, «архив на ленте»): программа записывает последовательность заголовков и содержимого файлов в один поток, который можно сохранить в файл, передать по сети или отправить в конвейер 1.
Важно понимать, что tar сам по себе не сжимает данные. Он только склеивает файлы в один поток. Сжатие выполняется отдельной программой — gzip или xz, которую tar вызывает по опции. Поэтому расширения .tar.gz и .tar.xz означают два шага: сначала архив, затем сжатие. Такое разделение соответствует философии UNIX: одна программа делает одно дело, а результаты соединяются конвейером.
Создание, просмотр и распаковка
Основные режимы tar задаются первой буквой опции: -c создаёт архив (англ. create), -t выводит список содержимого (англ. table of contents), -x извлекает файлы (англ. extract). Опция -f указывает имя архива, -v выводит подробности, -z включает сжатие gzip, -J — сжатие xz. Коротких опций можно писать группой, например -czf; опция -f должна быть последней в группе, потому что сразу за ней tar ожидает имя архива.
Подготовим учебный каталог: сайт из двух файлов и таблицу из пяти тысяч строк.
mkdir -p ~/demo/site/css && cd ~/demo
echo '<h1>Северный узел</h1>' > site/index.html
echo 'body { margin: 2em; }' > site/css/style.css
seq 1 5000 > data.txt
ls -la ~/demo
total 36
drwxr-xr-x 3 student student 4096 Oct 12 14:23 .
drwxr-xr-x 3 student student 4096 Oct 12 14:23 ..
-rw-r--r-- 1 student student 23893 Oct 12 14:23 data.txt
drwxr-xr-x 3 student student 4096 Oct 12 14:23 site
Создадим сжатый архив каталога site и посмотрим его содержимое с подробностями:
tar -czf site.tar.gz site
tar -tzvf site.tar.gz
drwxr-xr-x student/student 0 2026-10-12 14:23 site/
drwxr-xr-x student/student 0 2026-10-12 14:23 site/css/
-rw-r--r-- student/student 22 2026-10-12 14:23 site/css/style.css
-rw-r--r-- student/student 35 2026-10-12 14:23 site/index.html
Каждая строка описывает один элемент архива: права доступа, владельца, размер в байтах, дату изменения и имя внутри архива. Имена начинаются с site/, а не с полного пути на диске, и это определяет поведение при распаковке.
Для распаковки используется режим -x. Опция -C задаёт каталог, в который tar переходит перед работой:
mkdir -p ~/restore
tar -xzf site.tar.gz -C ~/restore
find ~/restore | sort
/home/student/restore
/home/student/restore/site
/home/student/restore/site/css
/home/student/restore/site/css/style.css
/home/student/restore/site/index.html
Без -C файлы распаковываются в текущий каталог. Поэтому на практике распаковку почти всегда делают с -C в пустой каталог: иначе файлы архива могут перемешаться с существующими и перезаписать их. Проверим это на примере: распакуем тот же архив в пустой каталог other, перейдя в него командой cd.
mkdir -p ~/other && cd ~/other && tar -xzf ~/demo/site.tar.gz && ls -A
site
Результат совпадает с предыдущим, но теперь файлы созданы относительно текущего каталога. Тот же эффект дал бы -C ~/other, однако явная опция -C делает команду самодокументированной: видно, куда попадут файлы.
Относительные пути и опция -C
Пути внутри архива определяются тем, как вызвана команда создания. Если архив создаётся из каталога ~/demo и указан относительный путь site, в архиве окажутся имена site/.... Если указан абсолютный путь, имена в архиве содержат весь путь к каталогу, и при распаковке дерево воссоздаётся внутри каталога назначения с неожиданной вложенностью. Поэтому tar по умолчанию удаляет начальный слеш и сообщает об этом.
tar -czf ~/site-c.tar.gz -C ~/demo site
tar -tzf ~/site-c.tar.gz
tar -czf ~/site-abs.tar.gz ~/demo/site
tar -tzf ~/site-abs.tar.gz
site/
site/css/
site/css/style.css
site/index.html
tar: Removing leading `/' from member names
home/student/demo/site/
home/student/demo/site/css/
home/student/demo/site/css/style.css
home/student/demo/site/index.html
Первый архив создан с опцией -C ~/demo: tar перешёл в ~/demo и записал относительный путь site, поэтому имена переносимы. Второй архив содержит весь путь от корня. Правило: архивировать относительные пути из родительского каталога, а -C использовать и при создании, и при распаковке.
Сообщение Removing leading `/' from member names — предупреждение, а не ошибка: tar сам изменил имена, и это стоит учитывать при переносе архива.
Сжатие gzip и xz
Программа gzip использует алгоритм DEFLATE, давно применяемый в формате .gz, и работает быстро. Программа xz использует алгоритм LZMA2 и обычно даёт меньший размер ценой большего времени сжатия и памяти. Оба инструмента читают стандартный поток ввода и пишут в стандартный поток вывода, поэтому их удобно использовать в конвейерах. Сравним размеры одного и того же файла:
gzip -c data.txt | wc -c
xz -c data.txt | wc -c
wc -c data.txt
11124
1900
23893
Первая строка — размер после сжатия gzip, вторая — после xz, третья — исходный размер. Для реальных текстов разница меньше, но порядок сохраняется: xz обычно меньше, gzip быстрее.
Архив, созданный tar -czf, сжимается целиком, а не файл за файлом. Поэтому размер tar.gz зависит от всего набора данных, и сравнивать его с суммой размеров файлов нужно аккуратно: блоки заголовков tar и выравнивание добавляют накладные расходы. В следующем примере создадим три варианта одного каталога:
tar -cf site.tar site
tar -cJf site.tar.xz site
ls -l site.tar site.tar.gz site.tar.xz
-rw-r--r-- 1 student student 10240 Oct 12 14:23 site.tar
-rw-r--r-- 1 student student 262 Oct 12 14:23 site.tar.gz
-rw-r--r-- 1 student student 292 Oct 12 14:23 site.tar.xz
Несжатый архив занимает 10 240 байт, хотя полезных данных в нём меньше сотни байт: tar дополняет архив до записи в 10 240 байт и добавляет служебные блоки. На крошечном примере сравнивать алгоритмы нельзя, он показывает только порядок величин.
Проверить целостность сжатого файла можно программой gzip -t (для .gz) или xz -t (для .xz). Эти программы читают весь файл и проверяют контрольные суммы, встроенные в формат, и возвращают код 0, если файл не повреждён.
Контрольные суммы и проверка целостности
Контрольная сумма (англ. checksum, hash) — короткая строка фиксированной длины, однозначно связанная с содержимым файла. Если изменить хотя бы один бит, сумма меняется. Для резервных копий используют криптографические хеш-функции, например SHA-256: найти два разных файла с одинаковой суммой практически невозможно, поэтому совпадение суммы означает совпадение содержимого 2.
Утилита sha256sum вычисляет суммы, а режим -c проверяет список сумм из файла. Формат списка — строка на файл: сумма, два пробела, имя файла.
sha256sum data.txt site/index.html > SHA256SUMS
cat SHA256SUMS
sha256sum -c SHA256SUMS
23f90f8b2c3a4b5f3b5e156339994afd5c2718b378aca6f0e17111f80a70d4ec data.txt
4d4c7084d361233f5b62d3e026bf02756973441e6fe580889ffb29503ed0cca0 site/index.html
data.txt: OK
site/index.html: OK
Теперь изменим файл и повторим проверку. Мы добавим в конец data.txt одно слово, как если бы кто-то случайно отредактировал данные:
echo 'правка' >> data.txt
sha256sum -c SHA256SUMS
echo "код возврата: $?"
data.txt: FAILED
site/index.html: OK
sha256sum: WARNING: 1 computed checksum did NOT match
код возврата: 1
Программа назвала только изменённый файл и вернула код 1. Поэтому проверку можно встраивать в скрипт: if ! sha256sum -c SHA256SUMS; then ... срабатывает при повреждении. Суммы не защищают от злоумышленника, который может подменить и сам список; для этого их подписывают или хранят отдельно.
Суммы имеет смысл сохранять рядом с архивом или снимком и проверять сразу после создания, а не в момент, когда копия понадобилась.
Синхронизация каталогов: rsync
Чем rsync отличается от копирования
Команда cp -r копирует всё заново при каждом запуске, что расточительно для проекта, который меняется в нескольких файлах в день. Программа rsync сравнивает источник и назначение и передаёт только различающееся. Локально она сравнивает размер и дату изменения, по сети может передавать только изменённые блоки файлов 4.
Основной набор опций для резервного копирования:
| Опция | Назначение |
|---|---|
-a | архивный режим: рекурсивно, с сохранением прав, владельца, времени изменения, симлинков; равно -rlptgoD |
-v | выводить список переданных файлов |
-n, --dry-run | ничего не копировать, только показать, что было бы сделано |
--delete | удалить в назначении файлы, которых нет в источнике |
--link-dest=КАТАЛОГ | для неизменённых файлов создавать жёсткие ссылки на файлы из КАТАЛОГА вместо копий |
--stats | вывести итоговую статистику, включая объём переданных данных |
-i, --itemize-changes | для каждого файла показать, что с ним сделано, в компактном коде |
Опция -a объединяет несколько флагов, и её почти всегда включают при резервном копировании: без неё права доступа и даты изменения в копии были бы потеряны. Опция --dry-run стоит первой в любом опасном эксперименте, потому что показывает результат без изменений.
Завершающий слеш
Самая частая ошибка новичков с rsync связана с завершающим слешем после пути источника. Слеш меняет смысл команды: без него копируется сам каталог, со слешем — его содержимое. Посмотрим оба варианта в режиме пробного запуска.
mkdir -p ~/rs/x ~/rs/y
rsync -a --dry-run -v ~/demo ~/rs/x/
rsync -a --dry-run -v ~/demo/ ~/rs/y/
sending incremental file list
demo/
demo/SHA256SUMS
demo/data.txt
demo/site.tar
demo/site.tar.gz
demo/site.tar.xz
demo/site/
demo/site/index.html
demo/site/css/
demo/site/css/style.css
sent 313 bytes received 49 bytes 724.00 bytes/sec
total size is 34,914 speedup is 96.45 (DRY RUN)
sending incremental file list
SHA256SUMS
data.txt
site.tar
site.tar.gz
site.tar.xz
site/
site/index.html
site/css/
site/css/style.css
sent 296 bytes received 45 bytes 682.00 bytes/sec
total size is 34,914 speedup is 102.39 (DRY RUN)
В первом случае в списке есть строка demo/ — имя самого каталога. Значит, в назначении появится ~/rs/x/demo/. Во втором случае имени каталога в списке нет: файлы попадут непосредственно в ~/rs/y/. Выполним копирование по-настоящему и проверим дерево:
rsync -a ~/demo ~/rs/x/
rsync -a ~/demo/ ~/rs/y/
find ~/rs/x ~/rs/y | sort | head -8
/home/student/rs/x
/home/student/rs/x/demo
/home/student/rs/x/demo/SHA256SUMS
/home/student/rs/x/demo/data.txt
/home/student/rs/x/demo/site
/home/student/rs/x/demo/site.tar
/home/student/rs/x/demo/site.tar.gz
/home/student/rs/x/demo/site.tar.xz
Для резервной копии это принципиально: если путь к снимку должен содержать файлы проекта непосредственно, а не каталог project внутри него, слеш после источника обязателен. Ошибка без слеша даёт вложенную структуру, которая ничем не мешает копированию, но ломает восстановление: команда, рассчитывающая на latest/README.md, не найдёт файл, потому что он лежит в latest/project/README.md.
Удаление лишних файлов: --delete
Опция --delete делает назначение точной копией источника: файлы, которых больше нет в источнике, удаляются. Это полезно для зеркала, но опасно при перепутанных аргументах: команда, в которой источник и назначение поменяны местами, уничтожит данные. Поэтому сначала выполняем пробный запуск.
mkdir -p ~/rs/z/old && echo x > ~/rs/z/old/x.txt
rsync -a --delete --dry-run -v ~/demo/ ~/rs/z/
rsync -a --delete -v ~/demo/ ~/rs/z/
find ~/rs/z | sort | head -6
sending incremental file list
deleting old/x.txt
deleting old/
SHA256SUMS
data.txt
site.tar
site.tar.gz
site.tar.xz
site/
site/index.html
site/css/
site/css/style.css
sent 35,490 bytes received 185 bytes 71,350.00 bytes/sec
total size is 34,914 speedup is 0.98
/home/student/rs/z
/home/student/rs/z/SHA256SUMS
/home/student/rs/z/data.txt
/home/student/rs/z/site
/home/student/rs/z/site.tar
/home/student/rs/z/site.tar.gz
Строки deleting в пробном запуске совпадают с реальными действиями. Правило: пробный запуск с --dry-run и флагом -v обязателен перед каждой новой командой с --delete, особенно в скрипте, который будет запускаться без присмотра. Если назначение указано неверно и на нём лежат чужие файлы, --delete удалит их без корзины.
Инкрементные снимки и ротация
Полная, дифференциальная и инкрементная копии
Стратегии резервного копирования различаются тем, какие данные копируются при каждом запуске. Полная копия (англ. full backup) содержит все данные и проста в восстановлении, но занимает много места. Дифференциальная копия (англ. differential backup) содержит изменения с последней полной копии, инкрементная (англ. incremental backup) — изменения с последней копии любого вида. Инкременты экономят место, но для восстановления нужна вся цепочка 3.
Жёсткие ссылки и inode
Чтобы понять, почему это работает, нужно вспомнить устройство файловой системы. Файл на диске — это данные и служебная запись (англ. inode), которая хранит права, размеры, даты и адреса блоков данных. Имя файла в каталоге — лишь ссылка на inode. Жёсткая ссылка (англ. hard link) — второе имя для того же inode. Удаление одного имени не удаляет данные, пока остаётся хотя бы одно имя, а счётчик ссылок в inode показывает число имён 2.
Опция --link-dest=КАТАЛОГ говорит rsync: если файл в источнике совпадает с файлом в КАТАЛОГЕ, не копировать его, а создать жёсткую ссылку на существующий inode. Файлы, которые изменились, копируются как новые. Посмотрим на практике. Сначала сделаем первый снимок, затем изменим один файл и сделаем второй снимок, ссылаясь на первый.
mkdir -p ~/snap
rsync -a ~/demo/ ~/snap/2026-10-11/
echo 'правка' >> ~/demo/site/index.html
rsync -a --link-dest=/home/student/snap/2026-10-11 ~/demo/ ~/snap/2026-10-12/
ls -li ~/snap/2026-10-11/data.txt ~/snap/2026-10-12/data.txt \
~/snap/2026-10-11/site/index.html ~/snap/2026-10-12/site/index.html \
~/snap/2026-10-12/site/css/style.css ~/snap/2026-10-11/site/css/style.css
stat -c '%h %i %n' ~/snap/2026-10-1*/data.txt
460094 -rw-r--r-- 2 student student 23906 Oct 12 14:23 /home/student/snap/2026-10-11/data.txt
460099 -rw-r--r-- 2 student student 22 Oct 12 14:23 /home/student/snap/2026-10-11/site/css/style.css
460098 -rw-r--r-- 1 student student 35 Oct 12 14:23 /home/student/snap/2026-10-11/site/index.html
460094 -rw-r--r-- 2 student student 23906 Oct 12 14:23 /home/student/snap/2026-10-12/data.txt
460099 -rw-r--r-- 2 student student 22 Oct 12 14:23 /home/student/snap/2026-10-12/site/css/style.css
460103 -rw-r--r-- 1 student student 48 Oct 12 14:23 /home/student/snap/2026-10-12/site/index.html
2 460094 /home/student/snap/2026-10-11/data.txt
2 460094 /home/student/snap/2026-10-12/data.txt
Первый столбец вывода ls -li — номер inode. Файлы data.txt и style.css имеют одинаковые номера в обоих снимках: это один и тот же объект на диске. Счётчик ссылок — число после прав доступа в том же выводе ls — равен 2. Файл index.html, который был изменён, в новом снимке получил новый номер inode (460103), а старый снимок сохранил прежнее содержимое (460098). Команда stat -c '%h %i %n' выводит счётчик ссылок, номер inode и имя файла: два снимка указывают на один inode 460094.
Теперь о месте на диске. Команда du считает занятое место, и её поведение с жёсткими ссылками требует внимания.
du -sh ~/snap/2026-10-11 ~/snap/2026-10-12
du -sh ~/snap/2026-10-11
du -sh ~/snap/2026-10-12
du -sh ~/snap
68K /home/student/snap/2026-10-11
16K /home/student/snap/2026-10-12
68K /home/student/snap/2026-10-11
68K /home/student/snap/2026-10-12
88K /home/student/snap
Если оба каталога указаны в одном вызове du, программа учитывает каждый inode один раз, и второй снимок показывает только собственный вклад: 16 килобайт, в основном каталоги и изменённый файл. Если вызывать du отдельно, общие файлы учитываются дважды, и кажется, что каждый снимок занимает полный объём. Поэтому экономию нужно оценивать по суммарному размеру каталога снимков (88 килобайт), а не по сумме отдельных результатов.
Заметим, что rsync не изменяет файлы на месте: он пишет новую версию во временный файл и переименовывает её. Иначе старый снимок, ссылавшийся на тот же inode, получил бы новое содержимое. Поэтому изменение текущей копии не трогает старые снимки.
На рисунке видно, что неизменённые файлы указывают на один inode, а изменённый получает новый. Удаление старого снимка не влияет на новый: inode освобождается только тогда, когда исчезают все имена, которые на него ссылаются. Это свойство делает ротацию безопасной.
Ротация и очистка
Ротация — удаление старых снимков — строится на том же свойстве: снимки лежат в каталогах, имена которых сортируются по дате, например 2026-10-12_023000. Скрипт оставляет N самых новых и удаляет остальные командой rm -rf. Удаление каталога стирает только его имена; inode остаются, пока на них ссылается хотя бы один оставшийся снимок, поэтому ротация не ломает более новые копии.
Программа find (тема 2) нужна в резервном копировании для поиска файлов и для очистки старых. Условие по времени задаётся опциями -mtime, -mmin, -newer. Выражение -mtime +30 означает «изменён более 30 полных суток назад», -mtime -7 — «менее семи суток назад». Без знака проверяется ровно одно число суток, и это почти никогда не нужно.
Сначала создадим набор журналов с разными датами и проверим, что будет выбрано:
mkdir -p ~/logs && cd ~/logs
touch -d '40 days ago' a.log b.log
touch c.log
ls -l --time-style=+%Y-%m-%d
find ~/logs -name '*.log' -mtime +30 -print
total 0
-rw-r--r-- 1 student student 0 2026-09-02 a.log
-rw-r--r-- 1 student student 0 2026-09-02 b.log
-rw-r--r-- 1 student student 0 2026-10-12 c.log
/home/student/logs/a.log
/home/student/logs/b.log
Опция -print выводит найденное и ничего не меняет. Это правильный первый шаг: сначала смотрим, что будет выбрано, и только потом заменяем -print на -delete. Действие -delete удаляет каждый найденный файл без подтверждения и без корзины.
find ~/logs -name '*.log' -mtime +30 -delete
ls ~/logs
c.log
Для выполнения команды над найденными файлами служит действие -exec. Завершающий \; запускает команду отдельно для каждого файла, а завершающий + передаёт файлы группами: при ста тысячах файлов первый вариант запустит сто тысяч процессов, а второй — лишь несколько, потому что число файлов в одном вызове ограничено размером командной строки.
find ~/demo -type f -exec wc -l {} +
2 /home/student/demo/site.tar.gz
2 /home/student/demo/site.tar
1 /home/student/demo/site/css/style.css
2 /home/student/demo/site/index.html
5001 /home/student/demo/data.txt
0 /home/student/demo/site.tar.xz
2 /home/student/demo/SHA256SUMS
5010 total
Здесь wc вызван один раз со всем списком, поэтому появилась строка total. Имена с пробелами безопасно обрабатываются -exec ... {} + или конвейером find -print0 | xargs -0; цикл for f in $(find ...) ломается на пробелах, как показано в теме 14.
Правило 3-2-1
Правило 3-2-1 — общепринятая формулировка стратегии резервного копирования: хранить три копии данных, на двух разных носителях, одна копия должна находиться вне основной площадки. Оригинал считается первой копией. Смысл правила в том, что каждый отдельный риск покрыт хотя бы одной копией: сбой диска, ошибка оператора или вирус, при котором пострадал и оригинал, и копия на том же устройстве, и пожар или кража в помещении.
Рисунок показывает связь правила с инструментами: снимки rsync на втором диске дают вторую копию, синхронизация того же каталога на удалённый сервер по SSH (тема 11) — третью. Удалённая копия тоже должна иметь ротацию. Частоту копирования задаёт допустимая потеря данных: если нельзя потерять данные за день, копировать нужно ежедневно. Копию, которая уходит с площадки, дополнительно шифруют; об этом — в следующем подразделе.
Наконец, копия, которую никогда не восстанавливали, не даёт уверенности. Проверка восстановления — обязательная часть регламента. Это может быть распаковка архива во временный каталог и сравнение diff -r, либо восстановление снимка на тестовой машине. В разделе о скрипте показано, как это сделать простыми командами.
Шифрование копий, покидающих площадку
Третья копия по правилу 3-2-1 хранится вне основной площадки: на арендованном сервере, в облачном хранилище, на диске в сейфе другого офиса. Это решает проблему пожара или кражи, но создаёт другую: содержимое копии теперь доступно администраторам чужого сервера, всем, кто найдёт потерянный носитель, и злоумышленнику, получившему доступ к хранилищу. Архив «Северного узла» содержит файл .env с паролями, таблицы клиентов и заказов; в открытом виде отдавать его нельзя. Решение — шифровать архив до отправки, так чтобы ключ не покидал основную площадку. Шифрование защищает конфиденциальность, но не защищает от повреждения или утраты копии: зашифрованный и повреждённый архив не прочитает никто.
Для шифрования файлов в Linux чаще всего применяют gpg (GnuPG, реализация стандарта OpenPGP) и более новую простую программу age. Два режима различаются тем, чем защищён файл. Симметричное шифрование (англ. symmetric) использует одну парольную фразу и для шифрования, и для расшифровки. Шифрование на открытый ключ (англ. public-key) использует пару ключей: зашифровать файл может любой, у кого есть открытый ключ, расшифровать — только владелец закрытого. Принцип пары ключей тот же, что у SSH в теме 11.
Начнём с симметричного режима. Для опытов возьмём архив небольшого каталога и запомним его контрольную сумму до шифрования (раздел о контрольных суммах выше):
tar -czf backup.tar.gz -C src .
sha256sum backup.tar.gz | tee backup.tar.gz.sha256
6ae4534fe82b3dc59b68fa7132e644aa4b77c5d19125ccf8a1fb0da24cdb89a9 backup.tar.gz
Шифрование одной командой: gpg --symmetric --cipher-algo AES256 backup.tar.gz. Программа запросит парольную фразу дважды через окно pinentry и создаст файл backup.tar.gz.gpg; исходный архив остаётся на месте, и его после проверки нужно удалить. Опция --cipher-algo AES256 выбирает шифр AES с 256-битным ключом; без неё gpg использует шифр по умолчанию, который зависит от версии. Для скрипта, который работает без присмотра, парольную фразу негде вводить, поэтому её читают из файла. Для этого нужны три опции вместе: --batch запрещает любые вопросы, --pinentry-mode loopback передаёт запрос парольной фразы вызывающей программе вместо окна, --passphrase-file указывает файл, первая строка которого и есть парольная фраза:
echo 'correct horse battery staple' > pass.txt
chmod 600 pass.txt
gpg --batch --pinentry-mode loopback --passphrase-file pass.txt \
--symmetric --cipher-algo AES256 -o backup.tar.gz.gpg backup.tar.gz
ls -l backup.tar.gz backup.tar.gz.gpg
file backup.tar.gz.gpg
-rw-r--r-- 1 student student 216 Oct 18 20:06 backup.tar.gz
-rw-r--r-- 1 student student 296 Oct 18 20:06 backup.tar.gz.gpg
backup.tar.gz.gpg: GPG symmetrically encrypted data (AES256 cipher)
(Фразу correct horse battery staple в реальных копиях не используют: это пример, на практике фразу создаёт генератор паролей.) Зашифрованный файл чуть больше исходного: в него добавлены заголовок и данные для проверки целостности. Расшифровка выполняется командой gpg -d с тем же файлом парольной фразы, а проверка — сравнением суммы до шифрования и после расшифровки:
gpg --batch --pinentry-mode loopback --passphrase-file pass.txt \
-d -o restored.tar.gz backup.tar.gz.gpg
echo "$(cut -d' ' -f1 backup.tar.gz.sha256) restored.tar.gz" | sha256sum -c
gpg: AES256.CFB encrypted data
gpg: encrypted with 1 passphrase
restored.tar.gz: OK
Если подставить неверную фразу, gpg не создаст файл и завершится с ошибкой:
gpg: decryption failed: Bad session key
Код возврата в этом случае равен 2, и скрипт проверки восстановления должен считать его провалом. Главный риск пакетного режима — хранение пароля. Файл pass.txt с правами 600 защищён только от других пользователей той же системы; тот, кто получил доступ к основной площадке, получит и его. Нельзя класть файл с фразой рядом с копией или в тот же каталог, который уходит на внешний сервер: шифрование тогда бесполезно. Нельзя передавать фразу опцией --passphrase фраза: аргументы командной строки видны другим пользователям в выводе ps, а также остаются в истории оболочки. Нельзя и забывать фразу: с её потерей теряются все копии без возможности восстановления, поэтому фраза хранится отдельно от самих копий, например в менеджере паролей.
Шифрование на открытый ключ
Симметричный режим требует, чтобы пароль был и на машине, которая делает копии, и на той, которая восстанавливает. Для автоматических копий удобнее другое распределение ролей. Владелец создаёт пару ключей на своём компьютере. Открытый ключ копируется на сервер, который делает копии, и скрипт использует только его. Закрытый ключ остаётся у владельца в сейфе или на защищённой машине. Тогда злоумышленник, получивший доступ к серверу с копиями, может узнать открытый ключ, но не прочитать ни одной прежней копии. Скрипту не нужно хранить секрет, который позволяет расшифровку.
Создадим пару ключей gpg без парольной фразы на закрытый ключ (так сделано только в учебном примере, чтобы пакетный режим не требовал ввода; на реальной машине закрытый ключ защищают фразой). Параметры ключа записаны в файле:
gpg --batch --generate-key keyparams
gpg --list-keys backup@example.org
pub ed25519 2026-10-18 [SCA]
7E312CC5530BE5E40604BB9404EDEEB72491CACD
uid [ultimate] Backup Student <backup@example.org>
sub cv25519 2026-10-18 [E]
Файл keyparams содержит строки %no-protection, Key-Type: eddsa, Key-Curve: ed25519, Subkey-Type: ecdh, Subkey-Curve: cv25519, Name-Real, Name-Email, Expire-Date: 0 и %commit. Зашифровать архив получателем ключа можно командой gpg -e -r адрес:
gpg --batch --yes -r backup@example.org -o backup.tar.gz.pub.gpg -e backup.tar.gz
Теперь изобразим сервер: отдельный каталог ключей, в который импортирован только открытый ключ. Попытка расшифровать копию на «сервере» заканчивается ошибкой, а у владельца с закрытым ключом расшифровка проходит без парольной фразы, и сумма совпадает с той, что была до шифрования:
gpg --export backup@example.org > pub.key
GNUPGHOME=/tmp/srv gpg --import pub.key
GNUPGHOME=/tmp/srv gpg --batch -d -o /dev/null backup.tar.gz.pub.gpg
gpg --batch -d -o restored2.tar.gz backup.tar.gz.pub.gpg
sha256sum restored2.tar.gz
gpg: key 04EDEEB72491CACD: public key "Backup Student <backup@example.org>" imported
gpg: encrypted with 255-bit ECDH key, ID EC557A68206F52C5, created 2026-10-18
"Backup Student <backup@example.org>"
gpg: decryption failed: No secret key
gpg: encrypted with 255-bit ECDH key, ID EC557A68206F52C5, created 2026-10-18
"Backup Student <backup@example.org>"
6ae4534fe82b3dc59b68fa7132e644aa4b77c5d19125ccf8a1fb0da24cdb89a9 restored2.tar.gz
Переменная GNUPGHOME задаёт каталог ключей для одной команды; в опыте она позволяет не трогать настоящую связку ключей ~/.gnupg. Первая расшифровка завершилась сообщением No secret key: у «сервера» нет закрытого ключа, хотя зашифровал файл именно он. Есть и тонкость: если открытый ключ только импортирован, gpg считает его недоверенным и в пакетном режиме отказывается шифровать, сообщая There is no assurance this key belongs to the named user и Unusable public key. На сервере в скрипте либо добавляют опцию --trust-model always (оправдано, если ключ скопирован на сервер своими руками и его происхождение известно), либо один раз вручную подтверждают доверие командой gpg --edit-key.
Файл не нужно сначала сохранять в открытом виде. Шифрование включается в конвейер: tar пишет архив в стандартный вывод, а gpg шифрует его на лету, поэтому незашифрованный архив на диск не попадает. Сумма открытого архива считается в том же потоке: tee копирует архив в дополнительный дескриптор 3, а тот направлен в sha256sum:
{ tar -czf - -C src . | tee /dev/fd/3 \
| gpg --batch --trust-model always -r backup@example.org -e -o stream.tar.gz.gpg; } 3>&1 >/dev/null \
| sha256sum | cut -d' ' -f1 > stream.sum
cat stream.sum
gpg --batch -d stream.tar.gz.gpg 2>/dev/null | sha256sum | cut -d' ' -f1
6ae4534fe82b3dc59b68fa7132e644aa4b77c5d19125ccf8a1fb0da24cdb89a9
6ae4534fe82b3dc59b68fa7132e644aa4b77c5d19125ccf8a1fb0da24cdb89a9
Суммы совпали: расшифрованный поток побайтно равен исходному архиву. Перенаправление 3>&1 >/dev/null после группы { ...; } связывает дескриптор 3 с каналом к sha256sum, а обычный вывод группы отправляет в /dev/null, так что в канал попадает только копия архива из tee. Оболочка дожидается завершения всех команд конвейера, поэтому к следующей команде файл stream.sum уже записан. Короткий вариант с подстановкой процесса tee >(sha256sum > stream.sum) здесь ненадёжен: Bash не ждёт процесс внутри >(...), и cat stream.sum сразу после конвейера может застать файл ещё пустым. (Код конвейера — код последней команды; чтобы поймать сбой tar, в скрипте включают set -o pipefail, тема 15.)
Программа age решает ту же задачу проще и без связки ключей. Пара ключей создаётся командой age-keygen, открытый ключ — короткая строка, начинающаяся с age1, шифрование — опция -r, расшифровка — -d -i файл_с_закрытым_ключом:
age-keygen -o age-key.txt
age-keygen -y age-key.txt
age -r age1r8vg2z2cxaagfx39c9c2cuc94ktync9s3g6nzguv34d6thfluv5sgas5qa -o backup.tar.gz.age backup.tar.gz
age -d -i age-key.txt -o restored3.tar.gz backup.tar.gz.age
sha256sum restored3.tar.gz
age -d -i other-key.txt -o /dev/null backup.tar.gz.age
Public key: age1r8vg2z2cxaagfx39c9c2cuc94ktync9s3g6nzguv34d6thfluv5sgas5qa
age1r8vg2z2cxaagfx39c9c2cuc94ktync9s3g6nzguv34d6thfluv5sgas5qa
6ae4534fe82b3dc59b68fa7132e644aa4b77c5d19125ccf8a1fb0da24cdb89a9 restored3.tar.gz
age: error: no identity matched any of the recipients
Первая строка вывода идёт в поток ошибок и показывает открытый ключ при создании; вторая получена командой age-keygen -y, которая выводит открытый ключ из файла закрытого. Последняя команда показывает, что чужой закрытый ключ расшифровать файл не может. Пакет age есть в репозитории universe; примеры получены в Ubuntu 22.04 с версией 1.0.0, а в Ubuntu 24.04 устанавливается версия 1.1.1. Выбор между gpg и age — дело вкуса и инфраструктуры: gpg присутствует почти везде и умеет подписи, age проще в использовании. Для курса достаточно понимать принцип: скрипту на площадке достаточно открытого ключа.
Шифрование не заменяет проверку восстановления, а добавляет в неё шаг. Полный цикл проверки выглядит так: сумма открытого архива записана до шифрования; зашифрованный файл отправлен на внешнее хранилище и скачан обратно; он расшифрован закрытым ключом, который хранится отдельно; сумма расшифрованного архива совпала; архив распакован и сравнен с исходным каталогом командой diff -r. Если хотя бы один шаг не выполнялся, возможна ситуация, при которой копии есть, но прочесть их нельзя: ключ потерян, парольная фраза набрана с ошибкой, файл усечён при загрузке. Закрытый ключ и парольные фразы сами нужно резервировать по тем же правилам, но отдельно от зашифрованных копий: утрата закрытого ключа эквивалентна утрате всех копий. Сведения об опциях gpg и формате OpenPGP приведены в справке man gpg 6, а общие вопросы хранения копий и ключей — в руководстве по системному администрированию 3.
Надёжный скрипт резервного копирования
Требования к скрипту
Скрипт резервного копирования запускается без присмотра, поэтому требования к нему строже, чем к учебным скриптам. Он должен работать с путями, содержащими пробелы; проверять аргументы и сообщать об ошибке понятным текстом; возвращать различимые коды выхода; не оставлять незавершённых снимков; не запускаться дважды одновременно; вести журнал с датами; удалять только собственные снимки. Все эти свойства опираются на инструменты тем 13–15. О правилах кавычек и опасных конструкциях, которые ломают такие скрипты, подробно написано в руководстве Wooledge 5.
Для скрипта полезно заранее определить коды выхода, потому что они нужны и для журнала, и для планировщика, который по коду понимает, был ли сбой. В учебной версии применяются такие коды: 0 — успех, 1 — ошибка копирования, 2 — ошибка аргументов, 3 — недоступен источник или каталог копий, 4 — уже выполняется другой запуск. Код 4 важен: если копирование заняло больше суток, следующий запуск по расписанию не должен начать второе копирование поверх первого.
Ключевые фрагменты
Блокировка от повторного запуска выполняется утилитой flock. Файл блокировки открывается на запись под отдельным номером дескриптора (тема 5), и flock -n немедленно завершается с ошибкой, если блокировка занята:
exec 9> "$dest/.backup.lock"
flock -n 9 || die 4 "другой запуск уже выполняется"
Блокировка снимается автоматически, когда процесс завершается, поэтому аварийный выход не оставляет «зависшей» блокировки. Это важное преимущество перед проверкой наличия файла-флажка: флажок, оставшийся после сбоя, навсегда блокировал бы копирование.
Незавершённый снимок создаётся под временным именем incomplete-..., и только после успешного rsync переименовывается в окончательное имя. Так в каталоге копий никогда не появляется снимок, который выглядит полным, но им не является. Очистка временного каталога при сбое делается в обработчике выхода. Обработчик EXIT срабатывает при любом завершении, в том числе по сигналу, если сигналы переведены в обычный выход:
cleanup() {
local rc=$?
if [[ $ok -eq 0 && -d $tmp ]]; then
rm -rf -- "$tmp"
fi
exit "$rc"
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
Переменная ok устанавливается в единицу только после успешного завершения копирования, поэтому при любом сбое временный каталог удаляется. Строки trap 'exit 130' INT и trap 'exit 143' TERM нужны, чтобы прерывание сигналом проходило через обработчик EXIT: коды 130 и 143 соответствуют 128 плюс номер сигнала. В отличие от примера темы 15, здесь функция очистки завершается командой exit "$rc": это сделано намеренно, чтобы код выхода скрипта был ровно тем, с которым он завершался до очистки, даже если последняя команда внутри cleanup вернула другой код.
Для инкрементного копирования --link-dest должен указывать на предыдущий снимок абсолютным путём. Относительный путь rsync отсчитывает от каталога назначения, и ошибка проявляется тихо: выводится предупреждение --link-dest arg does not exist, делается полная копия, код возврата нулевой. Абсолютный путь получается через cd -P и pwd, даже если latest — симлинк:
if [[ -d $latest ]]; then
base=$(cd -P -- "$latest" && pwd)
rsync_args+=("--link-dest=$base")
fi
Обновление ссылки latest выполняется атомарно: новая ссылка создаётся под временным именем и переименовывается командой mv -T. Переименование в пределах одной файловой системы выполняется как одна операция, поэтому читатель никогда не увидит отсутствующую или наполовину обновлённую ссылку.
Ротация отбирает снимки по шаблону имени. Шаблон с классами символов, например [0-9][0-9][0-9][0-9], работает с nullglob: без совпадений массив остаётся пустым, а не содержит буквальный шаблон. Имена начинаются с даты, поэтому сортировка совпадает с хронологией. Чтобы порядок не зависел от правил локали (тема 8), в начале скрипта задаётся LC_ALL=C.
shopt -s nullglob
snaps=( "$dest"/[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]_[0-9][0-9][0-9][0-9][0-9][0-9] )
extra=$(( ${#snaps[@]} - keep ))
for (( i = 0; i < extra; i++ )); do
rm -rf -- "${snaps[i]}"
done
Здесь важны кавычки вокруг "${snaps[i]}" и -- перед аргументом rm: имя каталога с пробелом без кавычек распалось бы на части, а rm -rf с пустой переменной, как показано в теме 15, может удалить не тот каталог.
Журнал и проверка восстановления
Журнал — текстовый файл, в который каждая строка добавляется с датой и временем. Для скрипта, запущенного по расписанию, он важнее вывода на экран, который никто не видит. Строки должны отвечать на вопросы «что запускалось», «когда», «что получилось» и «почему не получилось». Пример строк из реального запуска:
2026-10-12 14:24:13 backup.sh INFO: базового снимка нет: будет полная копия
2026-10-12 14:24:13 backup.sh INFO: начало копирования: project -> backups/2026-10-12_142413
2026-10-12 14:24:13 backup.sh INFO: передано, байт: 65,571
2026-10-12 14:24:13 backup.sh INFO: готово: снимок 2026-10-12_142413, всего снимков: 1
Журнал не доказывает пригодность копии: её надёжно показывает только восстановление. Проверка — копирование снимка в пустой каталог и сравнение с оригиналом:
rsync -a ~/backups/latest/ ~/restored/
diff -r ~/project ~/restored && echo "восстановление совпадает с исходными данными"
Если оригинал продолжал меняться после создания снимка, diff покажет расхождения. Это нормально: снимок отражает состояние на момент копирования. Для архивов тот же приём — распаковка tar -xzf архив -C каталог и сравнение с diff -r.
Планирование запусков
Формат crontab
Демон cron запускает команды по расписанию, заданному в таблице, которая называется crontab. Каждый пользователь имеет собственную таблицу. Она редактируется командой crontab -e, выводится командой crontab -l и загружается из файла командой crontab файл. Команда crontab -r удаляет всю таблицу без подтверждения, поэтому перед изменением полезно сохранить старую версию в файл. Строка таблицы состоит из пяти полей времени и команды; поля разделяются пробелами или табуляциями 6. Структура строки показана на рисунке.
Значения полей ограничены: минуты от 0 до 59, часы от 0 до 23, день месяца от 1 до 31, месяц от 1 до 12 или название, день недели от 0 до 7, где 0 и 7 означают воскресенье. Звёздочка обозначает любое значение, запятая — список, дефис — диапазон, косая черта с числом — шаг. Примеры расписаний, которые обычно нужны при резервном копировании:
| Расписание | Смысл |
|---|---|
30 2 * * * | каждый день в 02:30 |
30 2 * * 1-5 | по будням в 02:30 |
*/15 * * * * | каждые 15 минут |
0 3 1,15 * * | 1-го и 15-го числа каждого месяца в 03:00 |
0 9 * * 0 | по воскресеньям в 09:00 |
@reboot | один раз при загрузке (нестандартное сокращение, поддерживаемое cron Ubuntu) |
Проверим, что crontab принимает строку. Синтаксис проверяется при установке, и это полезная проверка до того, как задача начнёт работать без присмотра. Создадим файл с правильной строкой и файл с ошибкой, в котором минута равна 61:
printf '%s\n' '61 2 * * * echo bad' > bad-cron.txt
crontab bad-cron.txt
echo "код возврата: $?"
"bad-cron.txt":0: bad minute
errors in crontab file, can't install.
код возврата: 1
Программа указывает файл, причину и номер строки; в cron Ubuntu номер считается с нуля, поэтому первая строка файла обозначена как 0. Осторожно: команда crontab файл при успешной установке целиком заменяет текущую таблицу пользователя, поэтому перед опытами сохраните её командой crontab -l > crontab.bak. Здесь замены не произошло, потому что файл содержит ошибку. Обратите внимание, что при ошибке старая таблица не заменяется: установка либо проходит полностью, либо не выполняется. Это важно при работе с боевой таблицей.
Окружение cron и типичная ошибка
Задания cron запускаются в окружении, сильно отличающемся от интерактивной сессии. Cron задаёт HOME, LOGNAME и SHELL, а PATH наследуется от демона и обычно содержит только системные каталоги; по справочной странице crontab(5) SHELL по умолчанию равен /bin/sh 6. Симуляцию такого окружения даёт команда env -i, которая запускает программу с пустым окружением:
env -i HOME=/home/student LOGNAME=student SHELL=/bin/sh PATH=/usr/bin:/bin /bin/sh -c 'env | sort'
HOME=/home/student
LOGNAME=student
PATH=/usr/bin:/bin
PWD=/home/student/demo
SHELL=/bin/sh
Типичная ошибка: скрипт работает из терминала и не работает из cron. Причина почти всегда в PATH. Проверим, что будет видно из минимального окружения:
env -i PATH=/usr/bin:/bin /bin/sh -c 'command -v rsync; command -v node || echo "node не найден"'
/usr/bin/rsync
node не найден
Если программа установлена в ~/.local/bin или в каталоге менеджера версий, она будет найдена в терминале, но не из cron. Надёжное решение — указывать в скрипте и в crontab абсолютные пути либо задавать PATH в начале скрипта, как сделано в фрагментах скрипта выше.
Вторая ошибка связана с символом процента. В crontab символ % означает перевод строки в команде, и date +%Y-%m-%d сломается. Его нужно экранировать: date +\%Y-\%m-\%d. Третья ошибка — потерянный вывод: cron пытается отправить вывод по почте, а на машине почты нет. Надёжнее перенаправить вывод в файл журнала: >> ~/backups/cron.log 2>&1. Четвёртая — рабочий каталог: cron запускает команду из домашнего каталога, поэтому все пути в команде должны быть абсолютными или явно связанными с каталогом скрипта.
Проверить, что задание реально сработало, можно по журналу системы. Строка в журнале выглядит примерно так (пример вывода, из журнала Ubuntu):
Oct 12 02:30:01 host CRON[18342]: (student) CMD (/home/student/bin/backup.sh /home/student/project /home/student/backups >> /home/student/backups/cron.log 2>&1)
Эта строка говорит, что демон запустил задание, но не говорит, успешно ли оно завершилось: для этого смотрят журнал самого скрипта. Поэтому скрипт должен писать собственный журнал, как описано выше.
Таймеры systemd
Таймер systemd — это пара юнитов: сервис описывает, что запускать, таймер — когда. Имена совпадают, различаются суффиксы. Сервис резервного копирования имеет тип oneshot: он выполняется до конца и завершается (тема 9). Директива OnCalendar= задаёт календарное расписание тем же синтаксисом, что описан в systemd.time(7) 7.
Пара файлов для пользовательского сервиса, которые размещаются в каталоге ~/.config/systemd/user/:
; nsu-backup.service
[Unit]
Description=Резервное копирование проекта «Северный узел»
[Service]
Type=oneshot
ExecStart=/home/student/bin/backup.sh /home/student/project /home/student/backups
; nsu-backup.timer
[Unit]
Description=Ежедневный запуск резервного копирования
[Timer]
OnCalendar=*-*-* 02:30:00
Persistent=true
[Install]
WantedBy=timers.target
Директива Persistent=true сохраняет на диске время последнего срабатывания: если запуск был пропущен, потому что компьютер был выключен, сервис запускается сразу после включения. Согласно справочной странице systemd.timer, эта опция действует только для таймеров с OnCalendar= 7. Для ноутбука или учебного компьютера это важное отличие от cron, который пропущенное задание просто не выполняет.
Проверить синтаксис календарного выражения можно без запущенного systemd командой systemd-analyze calendar. Она показывает ближайшие срабатывания по местному часовому поясу и по UTC:
systemd-analyze calendar --iterations=2 'Mon..Fri *-*-* 18:00'
systemd-analyze calendar 'daily'
Original form: Mon..Fri *-*-* 18:00
Normalized form: Mon..Fri *-*-* 18:00:00
Next elapse: Mon 2026-10-12 18:00:00 MSK
(in UTC): Mon 2026-10-12 15:00:00 UTC
From now: 3h 36min left
Iter. #2: Tue 2026-10-13 18:00:00 MSK
(in UTC): Tue 2026-10-13 15:00:00 UTC
From now: 1 day 3h left
Original form: daily
Normalized form: *-*-* 00:00:00
Next elapse: Tue 2026-10-13 00:00:00 MSK
(in UTC): Mon 2026-10-12 21:00:00 UTC
From now: 9h left
Выражение daily раскрывается в *-*-* 00:00:00: это равнозначная запись, и systemd.time(7) перечисляет подобные сокращения 7. Ошибки в выражении ловятся проверкой юнитов. Вывод systemd-analyze verify для таймера с недопустимым временем (значение 25:99; путь сокращён):
bad.timer:5: Failed to parse calendar specification, ignoring: *-*-* 25:99:00
bad.timer: Timer unit lacks value setting. Refusing.
Unit bad.timer has a bad unit file setting.
Для корректных юнитов команда systemd-analyze verify ничего не выводит и завершается с кодом 0. Проверка ExecStart тоже входит в неё: если путь к скрипту не существует или файл не исполняемый, verify сообщит об этом до запуска.
После проверки таймер подключается командами пользовательского менеджера systemd, которые не требуют прав администратора (пример вывода, так как в учебной среде пользовательский менеджер systemd может быть недоступен):
$ systemctl --user daemon-reload
$ systemctl --user enable --now nsu-backup.timer
Created symlink /home/student/.config/systemd/user/timers.target.wants/nsu-backup.timer -> /home/student/.config/systemd/user/nsu-backup.timer.
$ systemctl --user list-timers
NEXT LEFT LAST PASSED UNIT ACTIVATES
Tue 2026-10-13 02:30:00 MSK 12h left n/a n/a nsu-backup.timer nsu-backup.service
$ journalctl --user -u nsu-backup.service -n 3
Журнал сервиса доступен через journalctl, и это отличие от cron важно: каждый запуск сохраняется с временем, кодом выхода и выводом скрипта, а команда systemctl --user status nsu-backup.service показывает код последнего запуска.
Запуск по событию: inotifywait
Все рассмотренные планировщики отвечают на вопрос «когда»: в 02:30, каждый день, через пять минут. Но часть задач привязана не ко времени, а к событию: в каталог inbox положили новый отчёт, и его нужно сразу сжать и перенести в архив. Решение «проверять каталог по cron каждую минуту» работает, но даёт задержку до минуты и впустую запускает скрипт, когда ничего не изменилось. Ядро Linux умеет само сообщать об изменениях в файловой системе через механизм inotify (англ. inode notify) 6: программа подписывается на каталог и получает событие при каждом создании, записи, переименовании или удалении файла. Из командной строки этим механизмом пользуется программа inotifywait из пакета inotify-tools:
sudo apt install inotify-tools
Посмотрим, какие события порождают обычные команды. Опция -m (англ. monitor) заставляет inotifywait работать непрерывно, а не завершаться после первого события, опции -e перечисляют интересующие события. Запустим наблюдение в одном терминале:
inotifywait -m -e create,modify,close_write,moved_to,delete inbox
и выполним во втором терминале несколько команд:
echo 'строка 1' > inbox/report.txt
echo 'строка 2' >> inbox/report.txt
echo 'черновик' > /tmp/tmpfile && mv /tmp/tmpfile inbox/data.csv
rm inbox/report.txt
В первом терминале появляется поток событий, по одной строке на событие: каталог, имя события и имя файла.
Setting up watches.
Watches established.
inbox/ CREATE report.txt
inbox/ MODIFY report.txt
inbox/ CLOSE_WRITE,CLOSE report.txt
inbox/ MODIFY report.txt
inbox/ CLOSE_WRITE,CLOSE report.txt
inbox/ MOVED_TO data.csv
inbox/ DELETE report.txt
Вывод показывает главную тонкость. Событие MODIFY приходит при каждой записи в файл, в том числе когда программа записала только первую часть данных, а CLOSE_WRITE — когда программа, открывшая файл на запись, закрыла его. Реагировать на MODIFY или CREATE поэтому нельзя: обработчик получит недописанный файл. Правильные события для обработки — close_write (файл записан и закрыт) и moved_to (готовый файл переименован в каталог). Второе событие важно потому, что многие программы, в том числе редакторы и rsync, сначала пишут данные во временный файл с другим, часто скрытым именем в том же каталоге, а затем переименовывают его. Например, rsync -a file.txt inbox/ порождает CREATE и CLOSE_WRITE для временного имени вида .file.txt.M2eAbP и только потом MOVED_TO file.txt. Поэтому обработчик должен ловить moved_to и пропускать временные и скрытые имена: иначе он схватит временный файл, который rsync тут же попытается переименовать. Основные события сведены в таблицу.
| Событие | Когда возникает |
|---|---|
create | в каталоге создан файл или подкаталог |
modify | в файл записаны данные (может прийти много раз подряд) |
close_write | файл, открытый на запись, закрыт: запись завершена |
moved_to, moved_from | файл переименован в каталог или из него |
delete | файл удалён из каталога |
Поток событий удобно передать по конвейеру в цикл while read (тема 14). Опция -q убирает служебные строки, а --format '%w%f' печатает только полный путь к файлу: %w — наблюдаемый каталог, %f — имя файла. Сценарий watch-inbox.sh сжимает каждый полностью записанный файл и переносит его в архив. Скрытые файлы (имя начинается с точки) и файлы с суффиксом .part пропускаются: это временные имена, под которыми данные ещё загружаются, а готовый файл придёт событием moved_to под окончательным именем:
cat watch-inbox.sh
#!/usr/bin/env bash
# Сжимает каждый полностью записанный в inbox файл и переносит его в archive
set -euo pipefail
in=${1:?каталог inbox}
out=${2:?каталог archive}
inotifywait -m -q -e close_write -e moved_to --format '%w%f' "$in" |
while IFS= read -r path; do
name=${path##*/}
case $name in
.*|*.part) continue ;; # временное или скрытое имя: файл ещё пишется
esac
gzip -9c -- "$path" > "$out/$name.gz"
rm -- "$path"
echo "$(date +%T) в архиве: $name.gz"
done
Запустим сценарий в фоне (./watch-inbox.sh inbox archive &) и положим в inbox три файла: короткий отчёт с пробелом в имени, файл, который пишется с паузой в секунду, и файл, который сначала загружается под именем big.part, а затем переименовывается:
echo 'id;сумма' > "inbox/отчёт март.csv"
{ echo 'часть 1'; sleep 1; echo 'часть 2'; } > inbox/slow.txt
echo 'данные' > inbox/big.part
mv inbox/big.part inbox/big.dat
ls archive
18:58:30 в архиве: отчёт март.csv.gz
18:58:31 в архиве: slow.txt.gz
18:58:32 в архиве: big.dat.gz
big.dat.gz
slow.txt.gz
отчёт март.csv.gz
Файл slow.txt обработан через секунду, когда запись завершилась, и архив содержит обе его строки, а big.dat — по событию переименования. Заметим, почему результат gzip записывается сразу в archive, а не рядом с исходным файлом: команда gzip файл создала бы в наблюдаемом каталоге файл файл.gz, его закрытие породило бы новое событие CLOSE_WRITE, и сценарий попытался бы обработать собственный результат. Сценарий, который пишет в наблюдаемый каталог, всегда рискует вызвать сам себя.
Без опции -m программа inotifywait дожидается одного события и завершается. Вместе с опцией -t секунды это удобный способ подождать появления файла в скрипте: код возврата 0 означает, что событие произошло, 2 — что истекло время ожидания.
У inotify есть ограничения, которые нужно знать.
- Число наблюдений ограничено. Опция
-rнаблюдает каталог рекурсивно, но ставит отдельное наблюдение на каждый подкаталог. Предел задаёт параметр ядраfs.inotify.max_user_watches(cat /proc/sys/fs/inotify/max_user_watches); на больших деревьях его приходится увеличивать. - События теряются, пока обработчик не запущен. Файлы, появившиеся до старта
inotifywaitили пока сценарий был остановлен, событий не дадут. Поэтому надёжный обработчик при запуске сначала обрабатывает уже лежащие в каталоге файлы, а затем переходит к ожиданию событий. - Не все файловые системы сообщают об изменениях. Сетевые файловые системы (NFS,
sshfsиз темы 11) не передают события об изменениях, сделанных на другой машине. В WSL2 то же относится к дискам Windows в/mnt/c: в проверке для этой темыinotifywait -t 2на каталоге в/mnt/cзавершился с кодом 2 даже для файла, записанного из самой WSL. Наблюдаемый каталог в WSL2 должен лежать в файловой системе Linux, например в домашнем каталоге.
Сценарий-наблюдатель работает непрерывно, поэтому запускать его по расписанию не нужно; его оформляют как службу systemd с Restart=on-failure (тема 9), чтобы он перезапускался после сбоя. В systemd есть и встроенная замена простым наблюдателям — юнит пути (.path), который запускает сервис с тем же именем, когда указанный каталог изменился или в нём появились файлы 7:
; nsu-inbox.path
[Unit]
Description=Обработка новых отчётов в inbox
[Path]
DirectoryNotEmpty=/home/student/inbox
[Install]
WantedBy=paths.target
Юнит пути устроен так же, как таймер: он лишь включает сервис nsu-inbox.service, а сам сервис типа oneshot обрабатывает все файлы в каталоге и завершается. Директива DirectoryNotEmpty= срабатывает, пока в каталоге есть файлы, поэтому сервис обязан убирать обработанные файлы из inbox, иначе он будет запускаться снова и снова.
Сравнение cron, at и таймеров
Команда at ставит одноразовое задание на указанное время, например at 02:30 tomorrow или at now + 5 minutes. Очередь просматривается командой atq, задание удаляется командой atrm. Пакет at в Ubuntu по умолчанию не установлен, его установка требует прав администратора. Задания at выполняются с минимальным окружением, как и cron, поэтому всё сказанное о PATH относится и к ним. Для регулярного резервного копирования at не подходит: после каждого запуска задание нужно ставить заново.
Выбор между cron и таймером зависит от условий. Ниже сравниваются основные свойства.
| Свойство | cron | таймер systemd |
|---|---|---|
| Описание расписания | строка из пяти полей | выражение OnCalendar= с проверкой systemd-analyze |
| Пропущенный запуск | не выполняется | при Persistent=true выполняется после включения |
| Точность | минута | по умолчанию в пределах одной минуты (AccuracySec=) |
| Журнал | syslog и вывод, перенаправленный вручную | journalctl с кодом выхода каждого запуска |
| Окружение | минимальное, задаётся в таблице | окружение сервиса, задаётся директивами Environment= |
| Зависимости от других сервисов | не описываются | директивы After=, Requires= |
| Пользовательские задания | crontab -e | systemctl --user при включённом пользовательском менеджере |
| Наличие | демон есть в большинстве систем, в WSL2 может не работать | встроен в systemd; в WSL2 нужно включить systemd |
Оба механизма дают один результат, и понимать нужно оба: cron встречается в старых системах и чужих инструкциях, таймеры — в современных. Если же задача привязана не ко времени, а к появлению файла, ни cron, ни таймер не нужны: подходит наблюдатель на inotifywait или юнит пути systemd. Общее правило одно: скрипт обязан вести журнал, возвращать осмысленный код выхода и проверяться восстановлением.
Итоги темы
Тема собрала в одну задачу инструменты, изученные по отдельности. Архив tar объединяет дерево файлов, сжатие добавляет gzip или xz, контрольная сумма sha256sum доказывает, что копия не повреждена. rsync передаёт только изменения, а жёсткие ссылки через --link-dest превращают набор полных снимков в экономный набор, где неизменённые файлы хранятся один раз. Ротация через сортировку по имени ограничивает глубину истории, правило 3-2-1 задаёт число копий и их места. Копия, которая покидает площадку, шифруется gpg или age, лучше на открытый ключ: тогда на машине с копиями нет секрета, позволяющего их прочитать, а закрытый ключ резервируется отдельно.
Скрипт, связывающий эти инструменты, отличается от учебного предсказуемым поведением: заранее определёнными кодами выхода, блокировкой, временным каталогом, журналом, абсолютными путями и проверкой восстановления. Планировщик лишь запускает его, но требует собственной дисциплины: минимального окружения, абсолютных путей, журнала и понимания ошибок, которые не видны сразу. Задачи, привязанные не ко времени, а к появлению файлов, решает inotifywait (события close_write и moved_to, пропуск временных имён) или юнит пути systemd .path. Копия без проверки восстановления остаётся предположением, поэтому последний шаг работы с резервными копиями — восстановление на тестовом месте.
Освоенные здесь инструменты понадобятся в курсе «Компьютерные сети и сетевые технологии»: там конфигурации и журналы сетевых узлов сохраняются тем же способом, а rsync по SSH переносит копии на удалённые машины.
Литература
- Shotts W. The Linux Command Line: A Complete Introduction. 2nd ed. — No Starch Press, 2019. Открытая версия: https://linuxcommand.org/tlcl.php
- Free Software Foundation. GNU Coreutils Manual. — https://www.gnu.org/software/coreutils/manual/
- Nemeth E., Snyder G., Hein T. R., Whaley B., Mackin D. UNIX and Linux System Administration Handbook. 5th ed. — Addison-Wesley, 2017.
- Документация rsync. — https://rsync.samba.org/documentation.html
- Wooledge G. Bash FAQ и Bash Pitfalls. — https://mywiki.wooledge.org/BashPitfalls
- The Linux man-pages project. — https://man7.org/linux/man-pages/
- systemd. Документация systemd.unit, systemd.service, systemd.timer, journalctl. — https://www.freedesktop.org/software/systemd/man/