Заняття 11 · місяць 3

А якщо все впаде

90 хвилин

Мета заняття: відпрацювати руками три поломки, які трапляються насправді, — видалена база, зупинений процес і заповнений диск — і навчитися повертати сервіс до роботи за кілька хвилин, користуючись власною письмовою інструкцією.

Мій сайт
http://91.219.61.4/u/<логін>/
Мій кабінет
http://91.219.61.4/k/<логін>/
Вхід на сервер
ssh <логін>@91.219.61.4

Замість <логін> підставляєш свій логін виду bohdan.g9: імʼя, крапка, номер групи. Кутові дужки не пишуться. Кабінет — сторінка, де проти кожного критерію стоїть відмітка, зарахований він чи ні. У прикладах на цій сторінці скрізь написано bohdan.g9 — читай це як свій логін і виправляй у кожній команді, яку копіюєш. Це важливо саме сьогодні: у розкладі команд шляхи доведеться писати повністю, і чужий логін там просто нічого не знайде.

Що ти зробиш сьогодні

Ти налаштуєш щоденну копію своєї бази: невеликий скрипт зробить копію, лишить сім останніх і запускатиметься сам щоночі за розкладом, без твоєї участі. Потім ти по-справжньому перевіриш цю копію — відкриєш її окремо від робочої бази й звіриш, чи всі рядки на місці.

Далі ти власноруч зупиниш свою службу командою kill -9 і подивишся, як вона піднімається сама. Служба — це твоя програма, яку сервер тримає запущеною постійно і сам запускає знову після зупинки; ти зробив її на занятті 02, вона називається club-api. Потім розберешся, як шукати й звільняти місце, коли диск заповнився. Запишеш файл RUNBOOK.md — коротку інструкцію з командами на випадок, коли сервіс не відповідає. Наприкінці заняття викладач видалить твою базу, і ти повернеш сервіс до роботи за цією ж інструкцією, засікаючи час.

Перші 10 хвилин заняття — розбір домашнього із заняття 10 Ти показував сервіс трьом людям і записував їхні зауваження. Дістань цей список: частина правок піде в роботу перед демо-днем, а найчастіше повторюване зауваження варто виправити сьогодні ж, поки є час.

Теорія

1. Три поломки, які трапляються насправді

Твій сервіс працює три місяці. За цей час він жодного разу не зупинявся надовго, і коли щось працює саме, легко забути, що воно взагалі може зупинитися. Сьогодні розберемо три поломки. Вони не вигадані для заняття: кожну з них ти цілком можеш зустріти на своєму сервері, і всі три трапляються без жодної складної причини.

Перша: зникли або зіпсувалися дані. Уся твоя база — це один файл ~/data/app.db. Його можна видалити командою rm, набраною не в тій теці. Його можна зіпсувати запитом DELETE FROM habits, у якому забули WHERE, — і тоді таблиця лишиться, а рядків у ній не буде. Його можна перезаписати, скопіювавши старий файл поверх нового по FTP — це спосіб передавати файли між своїм компʼютером і сервером, для нього на компʼютері ставлять окрему програму-клієнт. У всіх трьох випадках код цілий, служба працює, сторінка відкривається — і на ній порожньо.

Друга: зупинився процес.

Процес
Запущена програма. Коли ти набираєш python main.py, система створює процес: виділяє йому памʼять, дає номер і починає виконувати код. Закриється процес — програма перестане відповідати, навіть якщо файл із кодом нікуди не подівся.
PID
Номер процесу (process identifier). Ціле число, яким система розрізняє запущені програми між собою. За цим номером процесу можна надіслати команду, наприклад «завершитися».
Служба
Процес, який сервер тримає запущеним постійно і сам запускає знову, якщо той зупинився. Твоя служба називається club-api, ти налаштував її на занятті 02.
systemctl --user
Команда, якою дивляться стан своїх служб і керують ними: запустити, зупинити, перезапустити. Частина --user означає «мої служби, запущені від мого імені», а не системні — на них у тебе немає прав. Далі на сторінці ця команда трапляється постійно: systemctl --user status club-api показує докладний стан, systemctl --user is-active club-api — одне слово (active, failed або inactive).
Журнал, journalctl
Усе, що твоя програма друкує під час роботи, система записує в журнал — спільне сховище повідомлень від усіх служб. Читають його командою journalctl --user -u club-api -n 30 --no-pager: -u (unit) обмежує вивід однією службою, -n 30 лишає останні 30 рядків, --no-pager друкує їх одразу, без гортання. Саме туди потрапляють повідомлення про помилки, які інакше ніхто б не побачив.
Порт
Число від 1 до 65535, за яким на одному сервері розрізняють програми, що приймають запити. Твоя служба слухає свій порт, наприклад 8014, і саме тому адреса http://127.0.0.1:8014/api/ping потрапляє до неї, а не до чужої програми. Два процеси не можуть зайняти той самий порт: другий не стартує.

Твій процес може зупинитися з трьох причин. Помилка в коді: якщо в main.py зʼявився SyntaxError, то uvicorn — програма, яка запускає твій застосунок і приймає для нього запити, — не зможе стартувати взагалі. Перевищення обмежень: службі можна задати межу памʼяті рядком на кшталт MemoryMax=200M, і якщо твій код почне складати в список мільйони рядків, система зупинить процес, щойно він цю межу перевищить. І людина: хтось виконав команду завершення процесу, зокрема ти сам, коли перевіряв щось у терміналі.

Третя: скінчилося місце на диску. Це найменш очевидна з трьох, бо вона настає поступово. Файли логів стають більшими з кожним записом, копій бази щодня додається ще одна, і в якийсь день на розділі не лишається вільних байтів. Тоді SQLite — програма, яка веде твій файл бази, — не може записати новий рядок і відповідає помилкою database or disk is full. Служба при цьому запущена, сторінка відкривається, читання працює — не працює тільки додавання.

Спільне в усіх трьох поломок одне: коли вони трапляються, ти дізнаєшся про це не одразу, а коли хтось спробує скористатися сервісом. Тому сьогодні ми робимо дві речі — готуємо копію даних заздалегідь і записуємо порядок дій, поки є час подумати спокійно.

Питання на розуміння

У Дарини сайт відкривається, список записів показується, а форма додавання повертає помилку. systemctl --user status club-api показує active (running), у журналі — рядок sqlite3.OperationalError: database or disk is full. Що сталося?

2. Копія бази і чому звичайне копіювання підводить

Бекап
Копія бази, збережена в окремому файлі на випадок, коли робочий файл зникне або зіпсується. Слово походить від англійського backup — «запасний». Українською кажуть також «резервна копія»; далі на сторінці — «копія».

Перше, що спадає на думку: скопіювати файл бази командою cp. Так роблять, і здебільшого це працює, але в цьому способі є дві проблеми.

Проблема перша: одне імʼя. Команда cp ~/data/app.db ~/backups/app.db щоразу створює файл із тим самим імʼям, тобто затирає попередній. Через тиждень у тебе буде одна копія — учорашня. Уяви, що в суботу в коді зʼявилася помилка й він почав записувати порожні назви. У вівторок ти це помічаєш, відкриваєш копію — а вона зроблена в понеділок і містить ті самі порожні назви. Ось чому копій має бути кілька, з різними іменами, і серед них — старіші за помилку.

Ротація копій
Правило, за яким старі копії видаляються, щойно їхня кількість перевищує задану. У нас зберігається сім останніх: цього досить, щоб повернутися на тиждень назад, і при цьому копії не заповнять диск.

Проблема друга: копіювання під час запису. SQLite зберігає базу не одним шматком, а сторінками — блоками по кілька кілобайтів. Коли ти додаєш запис, змінюється відразу кілька сторінок у різних місцях файлу. Якщо cp почне читати файл посеред такого запису, він устигне прочитати частину сторінок у новому стані, а частину — у старому. Файл скопіюється, розмір буде правильний, а всередині опиниться суміш двох станів. SQLite назве такий файл malformed — пошкодженим — і відкрити його не вдасться.

Ймовірність цього невелика: запис триває мілісекунди. Але копія робиться раз на добу протягом місяців, і рано чи пізно копіювання збіжиться із записом. Розвʼязання просте: копію робить сам SQLite. У Python для цього є метод backup() — він працює через ту саму бібліотеку, що й звичайні запити, тому знає про сторінки й блокування і віддає узгоджений файл.

src = sqlite3.connect(DB)          # робоча база
dst = sqlite3.connect(out)         # новий файл під копію
with dst:
    src.backup(dst)                # копіюємо силами самої SQLite

І ще одне про місце зберігання. Наші копії лежать у ~/backups — на тому самому диску, що й база. Від видаленого файлу це захищає, від відмови самого диска — ні. Тому копію корисно час від часу забирати до себе на компʼютер по FTP: тоді вона є у двох різних місцях. Для курсу цього достатньо.

Питання на розуміння

Богдан налаштував копіювання командою cp ~/data/app.db ~/backups/app.db щодня о третій ночі. У вівторок він помітив, що ще в суботу його код почав записувати в базу порожні назви. Чому копії йому не допоможуть?

3. Перевірка відновлення

Копія, яку жодного разу не відкривали, нічого не гарантує. У теці можуть лежати сім файлів по сорок кілобайтів, а всередині — порожні бази: скрипт відпрацьовував, але брав шлях до бази, якої вже немає, і щоразу створював новий порожній файл. Це виявляється тільки тоді, коли копію відкривають, — тобто коли вона вже потрібна. Тому копію треба хоча б раз відкрити й подивитися, що в ній.

Перевірка складається з двох питань. Перше: чи файл цілий? Друге: чи в ньому ті самі дані, що в робочій базі?

PRAGMA integrity_check
Команда SQLite, яка читає всю базу від початку до кінця і перевіряє, чи узгоджені між собою її внутрішні структури. Якщо все гаразд, вона повертає одне слово ok. Якщо ні — перелік того, що зламано. Слово PRAGMA означає, що це не запит до даних, а вказівка самій базі щось зробити або щось про себе розповісти.
Тимчасова тека
Тека, куди кладуть файли на короткий час. Копію бази перед перевіркою переносять туди навмисно: SQLite під час відкриття може дописати до файлу службові дані, і робити це просто в теці з копіями не варто — нехай копії лишаються такими, якими їх записали.

Друге питання — про кількість рядків. Якщо в робочій базі 34 записи, а в копії 34, копія свіжа й повна. Якщо в копії 12 — вона стара або зроблена не з того файлу. Якщо 0 — скрипт копіює порожнечу, і це треба виправляти сьогодні, а не тоді, коли копія знадобиться.

Саме ці два кроки виконує автоперевірка заняття: бере останній файл із ~/backups, кладе його в тимчасову теку, виконує PRAGMA integrity_check і звіряє кількість рядків із твоєю робочою базою. Назву головної таблиці програма бере з project.yaml, з поля main_table, — того самого, яке ти заповнював на занятті 04.

Питання на розуміння

У Софії в теці ~/backups сім файлів, кожен по 40 КБ, найсвіжіший зроблено сьогодні вночі. Що з переліченого справді дає підставу вважати, що з цих файлів можна відновити дані?

4. crontab: розклад команд без участі людини

Копія, яку треба робити руками, рано чи пізно перестане робитися. Значить, запускати скрипт має система.

cron
Програма, яка постійно працює на сервері й раз на хвилину дивиться, чи не настав час запустити якусь із запланованих команд. Якщо настав — запускає.
crontab
Особистий розклад команд одного користувача: список рядків, де в кожному спочатку записано «коли», а потім «що запустити». У кожного користувача сервера свій crontab, і команди з нього виконуються від його імені. Саме тому sudo тут не потрібен — ти плануєш свої команди у своїй теці, а не системні.

Рядок розкладу складається з пʼяти чисел і команди:

хвилина  година  день_місяця  місяць  день_тижня   команда
   17       3          *          *        *       /home/bohdan.g9/app/backup.py

Зірочка означає «будь-яке значення». Рядок вище читається так: коли хвилина дорівнює 17, а година дорівнює 3, у будь-який день місяця, у будь-який місяць, у будь-який день тижня — запусти цю команду. Тобто щодня о 3:17 ночі.

РядокКоли спрацює
17 3 * * *щодня о 3:17
0 * * * *щогодини, на початку години: 0:00, 1:00, 2:00 і далі
*/10 * * * *кожні десять хвилин
30 20 * * 1щопонеділка о 20:30 (день тижня: 1 — понеділок, 0 — неділя)
0 6 1 * *першого числа кожного місяця о 6:00

Чому саме 3:17, а не рівно третя. О рівних годинах у системі запускається багато чого одразу, і дванадцять учнівських скриптів у ту саму секунду створять непотрібне навантаження. Дивна хвилина розводить запуски в часі. Це звичайна практика, і в тебе може бути своя хвилина.

Далі — три речі, через які cron найчастіше не спрацьовує в того, хто налаштовує його вперше.

Оточення майже порожнє. Коли ти працюєш у терміналі, у тебе є змінні оточення, зокрема PATH — список тек, де система шукає програми за іменем. cron запускає команду з коротким PATH і без твоїх налаштувань. Тому в розкладі шляхи пишуться повністю: /home/bohdan.g9/app/backup.py, а не backup.py і не ~/app/backup.py.

Вивід нікуди не видно. Термінал не відкритий, тому все, що скрипт друкує, не побачить ніхто. Щоб не втратити цей вивід, його спрямовують у файл:

17 3 * * * /home/bohdan.g9/app/backup.py >> /home/bohdan.g9/logs/backup.log 2>&1

Знак >> означає «дописати в кінець файлу», один знак > означав би «затерти файл і записати заново». Запис 2>&1 додає до цього файлу ще й потік помилок: у Unix програма пише в два окремі потоки — звичайний вивід (номер 1) і повідомлення про помилки (номер 2), і без 2>&1 помилки пройшли б повз лог. А саме вони цікавлять найбільше.

Файл має бути запускним. cron не здогадається, що .py треба віддати Python. Тому скрипт починається з рядка, який називає потрібну програму, і йому ставлять ознаку «можна запускати» командою chmod +x. Про це — на практиці.

Рядок у розкладі працює доти, доки ти його звідти не прибереш: cron виконуватиме команду в заданий час щодня, незалежно від того, чи памʼятаєш ти про неї і чи є хтось на сервері.

Питання на розуміння

Оля додала в свій crontab рядок 0 4 * * * backup.py. Команда crontab -l цей рядок показує, скрипт запущений руками працює і створює копію. Але за три дні в ~/backups не зʼявилося жодного нового файлу. Найімовірніша причина?

5. Restart=always: чому процес піднімається сам

На занятті 02 ти зробив зі свого коду службу, і відтоді вона працює. Сьогодні розберемо, що саме тримає її запущеною.

systemd
Програма, яка запускається на сервері першою і відповідає за всі інші: стартує їх у потрібному порядку, стежить за їхнім станом і перезапускає, коли вони зупиняються. Твоя club-api — одна з таких служб.
unit-файл
Текстовий файл, у якому описано службу: яку команду запускати, з якої теки, що робити після зупинки. Твій лежить за шляхом ~/.config/systemd/user/club-api.service. Слово «unit» тут — просто назва такого опису.
Restart=always
Рядок в unit-файлі, який означає: щойно процес завершився, з будь-якої причини — запусти його знову. Цей рядок стоїть у твоєму файлі із заняття 02, сьогодні ми його не додаємо, а перевіряємо, що він на місці, і дивимося, як він працює.
RestartSec=3
Скільки секунд зачекати перед повторним запуском. Без паузи systemd намагався б перезапустити процес сотні разів на секунду.

Щоб побачити це в дії, процес треба зупинити. Для цього процесу надсилають сигнал.

Сигнал
Коротке повідомлення від системи до процесу. Найчастіші два. SIGTERM — прохання завершитися: програма отримує його, встигає закрити файли й вийти акуратно. SIGKILL — розпорядження зупинити процес негайно; його виконує саме ядро системи, і програма не може ні перехопити його, ні відкласти, ні щось доробити наостанок.
kill -9
Команда, яка надсилає процесові сигнал SIGKILL (він має номер 9). Після неї процес зникає миттєво. Це найгрубіший спосіб зупинки — і саме тому він годиться для перевірки: якщо служба піднімається після kill -9, вона підніметься й після мʼякших випадків.

Важлива межа, яку варто розуміти зараз. Restart=always повертає процес, коли той зупинився з причини, зовнішньої щодо коду: сигнал, перевищення памʼяті, перезавантаження сервера. Але якщо процес падає одразу після старту через помилку в самому коді, перезапуск не допоможе: він запуститься і впаде знову, і так по колу. Тому в systemd є обмеження на кількість спроб — типово пʼять спроб за десять секунд. Після них служба переходить у стан failed і сама більше не стартує. Це зроблено навмисно, щоб зламаний код не запускався нескінченно.

Стан failed знімається командою systemctl --user reset-failed club-api, але спершу треба виправити код, інакше все повториться. Скільки разів служба вже перезапускалася, показує окрема команда — вона буде на практиці.

Питання на розуміння

Раніше служба Максима завжди піднімалася сама після kill -9. Сьогодні він поправив main.py, зробив перезапуск і пішов на перерву. Повернувся — systemctl --user status club-api показує failed, і служба сама не стартує. У журналі — SyntaxError: invalid syntax. Що відбулося?

6. RUNBOOK: інструкція, якою користуються під тиском

RUNBOOK
Файл із покроковими діями на випадок, коли сервіс не відповідає: які команди виконати, у якому порядку, що має бути у відповіді й що робити, якщо відповідь інша. Слово прийшло з практики адміністраторів і означає приблизно «інструкція для чергового».
Код відповіді
Тризначне число, яким сервер відповідає на кожен запит, ще до самої сторінки. 200 означає «запит оброблено, ось відповідь», 404 — «такої адреси немає», 500 — «на сервері сталася помилка під час обробки». Далі на сторінці ми питатимемо саме це число командою curl, і 200 буде ознакою, що сервіс відповідає.

Питання, яке тут доречне: навіщо записувати те, що й так знаєш. Відповідь у тому, коли цим доведеться скористатися. Сервіс зупиняється тоді, коли зупиняється, — увечері, у вихідний, за день до показу. Розбиратися доводиться швидко й часто з телефона, а згадувати в такий момент точний синтаксис команди довго, і помилитися легко.

Тому RUNBOOK пишуть за трьома правилами.

Пояснень «як усе влаштовано» в RUNBOOK не пишуть: для цього є README. Тут потрібні тільки дії — команда, очікувана відповідь і що робити, коли відповідь інша.

Питання на розуміння

О десятій вечора сайт не відкривається, ти дивишся в RUNBOOK з телефона. Який запис справді допоможе?

Практика

Далі — тринадцять кроків. Скрипти пишемо на Python: він уже встановлений на сервері за шляхом /opt/club/venv — це окрема тека з Python і потрібними бібліотеками, спільна для всіх учнів. Ставити нічого не треба, достатньо звертатися саме до цього шляху. Усі команди виконуються на сервері, тому спершу заходимо туди.

ssh bohdan.g9@91.219.61.4
Скільки на що часу Перші 10 хвилин — розбір домашнього, далі приблизно 25 хвилин теорії, 35 хвилин практики й останні 20 хвилин — навчальна аварія. За 35 хвилин усі тринадцять кроків не встигнути, і це передбачено. На занятті роби кроки 1–7 (копія, перевірка, розклад), 9–10 (перезапуск служби) і 12 (RUNBOOK): без RUNBOOK аварія втрачає сенс. Кроки 8, 11 і 13 доробляєш удома того самого дня — усі три коротші за попередні.
1

Подивитися, що маємо зараз

Перш ніж щось налаштовувати, корисно побачити початковий стан: скільки місця на диску, який розмір бази, чи є вже щось у теці копій. Спершу створюємо дві теки, які знадобляться далі: якщо вони вже є, команда нічого не зробить і помилки не буде — про це й каже ключ -p.

mkdir -p ~/logs ~/backups
df -h /home
ls -lh ~/data/
ls -lh ~/backups/
du -sh ~/app ~/data ~/logs ~/backups

Що показує кожна команда:

Запиши десь розмір app.db і вільне місце: наприкінці заняття корисно порівняти.

2

Написати скрипт копіювання

Створюємо файл у теці ~/app, щоб він потрапив у git разом із рештою коду. git — програма, яка зберігає історію твоїх файлів: ти налаштував її на занятті 03, і відтоді кожен коміт (один запис в історії: набір змін, дата й короткий опис) фіксує стан теки ~/app. Файли поза цією текою в історію не потрапляють.

nano ~/app/backup.py
#!/opt/club/venv/bin/python
"""Копія бази з ротацією. Заняття 11."""
import sqlite3
import sys
import time
from pathlib import Path

import yaml

APP = Path.home() / "app"
CFG = yaml.safe_load((APP / "project.yaml").read_text(encoding="utf-8"))

DB = Path.home() / "data" / CFG["database"]["file"]
DIR = Path.home() / "backups"
KEEP = 7

if not DB.is_file():
    sys.exit(f"немає файлу бази {DB}: копію не роблю")

DIR.mkdir(exist_ok=True)

stamp = time.strftime("%Y-%m-%d_%H%M%S")
out = DIR / f"app-{stamp}.db"

src = sqlite3.connect(DB)
dst = sqlite3.connect(out)
with dst:
    src.backup(dst)
dst.close()
src.close()

copies = sorted(DIR.glob("app-*.db"), reverse=True)
for old in copies[KEEP:]:
    old.unlink()

print(f"{time.strftime('%Y-%m-%d %H:%M:%S')}  {out.name}  "
      f"{out.stat().st_size} байт  копій у теці: {len(copies[:KEEP])}")

Розбір рядків, які варто розуміти:

Зберегти в nano: Ctrl+O, Enter, вийти — Ctrl+X.

3

Дозволити запуск і зробити першу копію

chmod +x ~/app/backup.py
~/app/backup.py

chmod +x ставить файлу ознаку «його можна запускати». Без неї система відповість Permission denied, навіть якщо всередині все правильно.

Очікуваний вивід — один рядок приблизно такого вигляду:

2026-09-06 12:04:31  app-2026-09-06_120431.db  45056 байт  копій у теці: 1

Перевіримо, що файл справді зʼявився:

ls -lh ~/backups/

Два випадки, коли треба зупинитися тут і розібратися, бо далі налаштовувати розклад немає сенсу:

4

Перевірити, що зайві копії видаляються

Ротацію легко перевірити одразу: запустимо скрипт девʼять разів поспіль і подивимося, скільки файлів лишилося.

for i in $(seq 1 9); do ~/app/backup.py; sleep 1; done
ls -1 ~/backups/app-*.db | wc -l

Запис for i in $(seq 1 9); do ... ; done — цикл у bash: виконати команди в дужках девʼять разів. sleep 1 робить паузу в секунду перед наступним запуском, і вона тут обовʼязкова: у мітці часу %Y-%m-%d_%H%M%S найдрібніша одиниця — секунда, тому два запуски в межах однієї секунди дали б однакове імʼя файлу, і друга копія просто затерла б першу. Через паузу цикл триватиме приблизно десять секунд.

ls -1 друкує імена в один стовпчик, wc -l рахує рядки. Маска app-*.db тут не випадкова: скрипт видаляє старі копії саме за цією маскою, тому й рахувати треба тільки їх, інакше сторонній файл у теці зіпсує перевірку. Разом із копією з кроку 3 запусків було десять, а число має вийти 7. Подивись на самі імена:

ls -1t ~/backups/

-t впорядковує за часом зміни, найновіші згори. Три найстаріші копії зникли — саме це й робить copies[KEEP:]. Часові позначки в іменах ідуть із різницею в одну секунду, бо ми запускали скрипт підряд із паузою. Далі він працюватиме раз на добу, і різниця буде добова.

5

Написати перевірку відновлення

Другий скрипт відкриває останню копію й дивиться, що всередині.

nano ~/app/check-backup.py
#!/opt/club/venv/bin/python
"""Перевірка, що остання копія читається й повна. Заняття 11."""
import shutil
import sqlite3
import sys
import tempfile
from pathlib import Path

import yaml

APP = Path.home() / "app"
CFG = yaml.safe_load((APP / "project.yaml").read_text(encoding="utf-8"))

DB = Path.home() / "data" / CFG["database"]["file"]
TABLE = CFG["database"]["main_table"]

if not TABLE:
    sys.exit("у project.yaml порожнє поле database.main_table — заповни його")

copies = sorted((Path.home() / "backups").glob("app-*.db"), reverse=True)
if not copies:
    sys.exit("копій немає: скрипт backup.py жодного разу не відпрацював")

last = copies[0]

tmpdir = Path(tempfile.mkdtemp())
tmp = tmpdir / last.name
shutil.copy(last, tmp)

conn = sqlite3.connect(tmp)
status = conn.execute("PRAGMA integrity_check").fetchone()[0]
rows_copy = conn.execute(f"SELECT COUNT(*) FROM {TABLE}").fetchone()[0]
conn.close()
shutil.rmtree(tmpdir)

live = sqlite3.connect(DB)
rows_live = live.execute(f"SELECT COUNT(*) FROM {TABLE}").fetchone()[0]
live.close()

print(f"копія:            {last.name}")
print(f"integrity_check:  {status}")
print(f"рядків у копії:   {rows_copy}")
print(f"рядків у базі:    {rows_live}")

if status == "ok" and rows_copy == rows_live:
    print("ПІДСУМОК: копія ціла й повна")
else:
    print("ПІДСУМОК: розбіжність, дивись рядки вище")
    sys.exit(1)

Три місця, які варто пояснити окремо.

6

Запустити перевірку

chmod +x ~/app/check-backup.py
~/app/check-backup.py

Очікуваний вивід:

копія:            app-2026-09-06_120438.db
integrity_check:  ok
рядків у копії:   34
рядків у базі:    34
ПІДСУМОК: копія ціла й повна

Це і є перевірка відновлення. Тепер про копії відомо не «файл є», а «файл відкривається, цілий, і в ньому стільки ж рядків, скільки в робочій базі».

Якщо кількість рядків відрізняється на одиницю-дві — імовірно, ти додав запис через сайт уже після того, як зробив копію. Запусти ~/app/backup.py ще раз і повтори перевірку.

7

Поставити копіювання в розклад

Додамо рядок у свій crontab. Щоб не відкривати для цього редактор, скористаємося готовою командою: вона бере поточний розклад, дописує до нього рядок і повертає назад.

( crontab -l 2>/dev/null; echo "17 3 * * * /home/bohdan.g9/app/backup.py >> /home/bohdan.g9/logs/backup.log 2>&1" ) | crontab -

Що тут відбувається. crontab -l друкує поточний розклад; 2>/dev/null ховає повідомлення no crontab for bohdan.g9, яке зʼявляється, коли розкладу ще немає. echo додає новий рядок. Дужки обʼєднують вивід обох команд в один потік, а | crontab - віддає цей потік команді crontab, яка знаком - позначає «читай новий розклад зі стандартного входу». Стандартний вхід — це потік, з якого програма читає дані, коли їх передають не файлом, а напряму від іншої команди; знак | і зʼєднує вивід однієї команди зі стандартним входом наступної.

Не запускай цю команду двічі — рядок додасться вдруге, і копія робитиметься двічі за ніч. Перевір результат:

crontab -l

Маєш побачити свій рядок із повними шляхами. Двічі перечитай логін у шляхах: у прикладі bohdan.g9, у тебе має бути твій.

Правити розклад руками Команда crontab -e відкриває розклад у редакторі. Якщо система пропонує обрати редактор, обирай nano. Можна задати його наперед: export EDITOR=nano, а потім crontab -e. Видалити помилковий рядок можна саме там.

Чекати до 3:17 ночі не будемо. Щоб переконатися, що рядок робочий, тимчасово поставимо запуск за дві хвилини від зараз. Подивись поточний час і додай рядок із потрібною хвилиною:

date +'%H:%M'
export EDITOR=nano
crontab -e

Допиши другий рядок, підставивши свої числа (тут — 12:47). Хвилина йде першою, година другою. Якщо дві хвилини потрапляють на наступну годину — скажімо, зараз 12:59, а треба 13:01, — то й рядок починається з 1 13, а не з 61 12: числа більші за 59 у полі хвилин cron не приймає.

47 12 * * * /home/bohdan.g9/app/backup.py >> /home/bohdan.g9/logs/backup.log 2>&1

Через дві хвилини подивись лог і теку:

cat ~/logs/backup.log
ls -1t ~/backups/ | head -3

Якщо в логу зʼявився рядок, а в теці — нова копія, розклад працює. Тепер прибери тестовий рядок через crontab -e: щоденного о 3:17 достатньо.

8

Погодинна перевірка, що сервіс відповідає

Копія зберігає дані. Але корисно знати ще одне: коли саме сервіс переставав відповідати. Для цього — маленький скрипт, який щогодини звертається до /api/ping і дописує результат у лог.

nano ~/app/health.sh
#!/usr/bin/env bash
# Погодинна перевірка, що сервіс відповідає. Заняття 11.

UNIT="$HOME/.config/systemd/user/club-api.service"
PORT=$(grep -oP '(?<=--port[ =])\d+' "$UNIT")
LOG="$HOME/logs/health.log"

mkdir -p "$HOME/logs"

if [ -z "$PORT" ]; then
  echo "$(date '+%Y-%m-%d %H:%M:%S')  порт не знайдено в $UNIT" >> "$LOG"
  exit 1
fi

CODE=$(curl -s -m 5 -o /dev/null -w '%{http_code}' "http://127.0.0.1:$PORT/api/ping")
STATE=$(systemctl --user is-active club-api)

echo "$(date '+%Y-%m-%d %H:%M:%S')  ping=$CODE  служба=$STATE" >> "$LOG"

Пояснення:

Дозволяємо запуск, пробуємо руками, ставимо в розклад:

chmod +x ~/app/health.sh
~/app/health.sh
cat ~/logs/health.log
( crontab -l 2>/dev/null; echo "0 * * * * /home/bohdan.g9/app/health.sh" ) | crontab -
crontab -l

Цю команду теж не запускай двічі — рядок додасться вдруге. Якщо це вже сталося, прибери зайвий рядок через crontab -e.

Тепер у розкладі два рядки: копія щодня о 3:17 і перевірка щогодини. Записи з часом у ~/logs/ — це заразом критерій L08.2 із заняття 08.

9

Звірити unit-файл

Рядок Restart=always стоїть у твоєму unit-файлі з заняття 02. Сьогодні ми його не додаємо — перевіряємо, що він на місці й ніхто його не прибрав.

grep 'Restart' ~/.config/systemd/user/club-api.service

Очікуваний вивід — два рядки:

Restart=always
RestartSec=3

Ще одна команда показує, що systemd бачить це саме так. Вона читає не файл, а те налаштування, яке зараз застосоване до служби:

systemctl --user show club-api -p Restart -p RestartUSec -p NRestarts
Restart=always
RestartUSec=3s
NRestarts=0

NRestarts — скільки разів служба перезапускалася відтоді, як її востаннє запускали командою. Зараз там нуль. За хвилину це число зміниться.

10

Зупинити процес і подивитися, що буде

Тепер зупинимо процес найгрубішим способом. Спершу дізнаємося його номер.

systemctl --user show club-api -p MainPID --value

MainPID — номер головного процесу служби, --value друкує саме число без назви поля. Побачиш щось на кшталт 18432.

Зупиняємо і одразу дивимося стан:

kill -9 $(systemctl --user show club-api -p MainPID --value)
systemctl --user is-active club-api

Запис $( ) виконує команду в дужках і підставляє її вивід у зовнішню команду. Тобто kill -9 отримує номер процесу без того, щоб ти набирав його руками.

Одразу після цього is-active швидше за все відповість activating: систему налаштовано чекати три секунди перед повторним запуском, і саме ці секунди ти й застав. Зачекай пʼять секунд і подивись ще раз:

sleep 5
systemctl --user show club-api -p MainPID -p NRestarts
systemctl --user is-active club-api

Тепер має бути active, NRestarts=1, а MainPID — інший номер. Це новий процес: старий зник назавжди, systemd запустив на його місце новий. Тут ми пишемо show без ключа --value, щоб у виводі лишилися назви полів: інакше побачиш два голі числа й не зрозумієш, котре з них котре.

Перевіряємо, що сервіс справді відповідає:

PORT=$(grep -oP '(?<=--port[ =])\d+' ~/.config/systemd/user/club-api.service)
echo "порт: $PORT"
curl -s -o /dev/null -w '%{http_code}\n' "http://127.0.0.1:$PORT/api/ping"

Має бути 200. Це і є критерій L11.2: після kill -9 служба піднімається сама.

Якщо echo надрукував «порт: » і далі порожньо — у твоєму ExecStart порт записаний інакше, ніж очікує ця команда. Відкрий unit-файл, подивись рядок ExecStart і далі підставляй свій порт числом: PORT=8014. Якщо порт порожній, curl звернеться в нікуди й надрукує 000 — «відповіді не було взагалі».

Чому ця перевірка не зайва, хоча була на занятті 02 Тоді ти дивився, що це працює. Зараз ти знаєш, який саме рядок це робить, скільки секунд триває пауза і де подивитися кількість перезапусків. Різниця між цими двома станами зʼясується сьогодні під час навчальної аварії.
11

Третя поломка: знайти, що зайняло місце

Заповнити диск по-справжньому не можна: він спільний, і після тебе на ньому працюють ще сорок людей. Але вся робота при цій поломці — не в тому, щоб диск заповнити, а в тому, щоб швидко знайти, що саме займає місце, і прибрати зайве. Це й відпрацюємо на файлі, який створимо самі.

Спершу запиши, скільки вільного місця зараз:

df -h /home | tail -1

Подивись на колонку Avail. Якщо вільного менше за 1G, файл не створюй — диск спільний, і місця може не вистачити комусь іншому; тоді пропусти наступну команду й переходь одразу до пошуку. Якщо місця вистачає, створюємо файл на 100 мегабайтів у теці логів і одразу дивимося, як змінилося число:

dd if=/dev/zero of=~/logs/velykyi.tmp bs=1M count=100
df -h /home | tail -1

dd копіює дані блоками. if=/dev/zero — джерело, з якого система віддає скільки завгодно нулів; of= — куди писати; bs=1M count=100 — сто блоків по мегабайту. Вільного місця в другому df має стати приблизно на 100M менше.

Тепер шукаємо, що займає місце, — так, ніби не знаємо, звідки цей файл узявся:

du -sh ~/app ~/data ~/logs ~/backups
du -sh ~/logs/* | sort -h | tail -5

Перша команда показує розмір кожної теки й одразу видно, яка з них найбільша. Друга розкладає найбільшу теку на окремі файли: sort -h впорядковує за розміром з урахуванням літер K, M, G, а tail -5 лишає пʼять найбільших рядків. Якщо ти створював velykyi.tmp, він має бути останнім у списку — найбільший файл у теці.

Прибираємо і звіряємо. Ключ -f у першому рядку означає «не повідомляти про помилку, якщо файлу немає»: він потрібен тим, хто dd пропустив.

rm -f ~/logs/velykyi.tmp
ls -1t ~/backups/app-*.db | tail -n +8 | xargs -r rm --
df -h /home | tail -1

Другий рядок видаляє всі копії бази, крім семи найновіших: tail -n +8 лишає рядки, починаючи з восьмого, а xargs передає ці імена команді rm; ключ -r означає «нічого не роби, якщо список порожній». Зараз копій рівно сім, і команда нічого не видалить — але саме цей рядок ти виконуватимеш, коли копій набереться за півроку. Вільного місця має стати стільки ж, скільки було на початку кроку.

Ці чотири команди — df, два du і прибирання — і є весь порядок дій при заповненому диску. У наступному кроці ми запишемо їх у RUNBOOK, щоб потім не пригадувати по одній.

12

Написати RUNBOOK.md

Файл кладемо в ~/app, поруч із README.

nano ~/app/RUNBOOK.md

Нижче — заготовка. Підстав свій логін і свій порт (його покаже команда grep -oP '(?<=--port[ =])\d+' ~/.config/systemd/user/club-api.service, а якщо вона нічого не надрукувала — візьми число з рядка ExecStart у unit-файлі), а далі доповнюй файл тим, з чим стикався сам.

# RUNBOOK

Що робити, коли сервіс не відповідає.
Логін: bohdan.g9 · порт: 8014 · база: ~/data/app.db · копії: ~/backups/

## 1. Зрозуміти, що саме зламалося

    ssh bohdan.g9@91.219.61.4
    systemctl --user is-active club-api
    curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8014/api/ping
    df -h /home | tail -1

Має бути: active, 200, вільного місця більше за 200M.
Далі йди до того розділу, який відповідає побаченому.

## 2. Служба не active

    journalctl --user -u club-api -n 30 --no-pager

- SyntaxError або ImportError у main.py — код зламаний.
  Повернути версію main.py, збережену в git останнім комітом
  (усі незбережені зміни при цьому зникнуть):
      cd ~/app && git checkout -- main.py
      systemctl --user restart club-api
- Стан failed після виправлення коду:
      systemctl --user reset-failed club-api
      systemctl --user start club-api

## 3. Немає місця на диску

    df -h /home | tail -1
    du -sh ~/app ~/data ~/logs ~/backups
    du -sh ~/logs/* | sort -h | tail -5
    ls -1t ~/backups/app-*.db | tail -n +8 | xargs -r rm --
    : > ~/logs/health.log

Після цього перезапустити службу й перевірити ping.

## 4. База зникла або порожня

    ls -1t ~/backups/app-*.db | head -3
    systemctl --user stop club-api
    rm -f ~/data/app.db-wal ~/data/app.db-shm
    cp ~/backups/ІМʼЯ_КОПІЇ.db ~/data/app.db
    systemctl --user start club-api
    ~/app/check-backup.py

Далі відкрити сайт і переконатися, що список записів на місці.

## 5. Нічого з переліченого не допомогло

Написати викладачеві: логін, час, останні 20 рядків журналу
(journalctl --user -u club-api -n 20 --no-pager).

Кілька зауважень до цієї заготовки.

Файл має вміщатися на один-два екрани. Довшу інструкцію в потрібний момент не читають.

13

Репетиція: відновити базу з копії

Перед навчальною аварією пройди відновлення сам, спокійно, з інструкцією перед очима. Спершу переконайся, що свіжа копія є:

~/app/backup.py
~/app/check-backup.py

Тепер прибираємо робочу базу. Спершу зупиняємо службу, щоб вона не тримала базу відкритою, і разом із базою прибираємо файли -wal і -shm — у них лежать найсвіжіші зміни, і на новому файлі вони тільки нашкодять. Саму базу не видаляємо, а перейменовуємо: якщо щось піде не так, її можна повернути.

systemctl --user stop club-api
mv ~/data/app.db ~/data/app.db.vidkladena
rm -f ~/data/app.db-wal ~/data/app.db-shm
systemctl --user start club-api
PORT=$(grep -oP '(?<=--port[ =])\d+' ~/.config/systemd/user/club-api.service)
curl -s -o /dev/null -w '%{http_code}\n' "http://127.0.0.1:$PORT/api/ping"

Подивись, що показує сайт: /api/ping може й далі відповідати 200, бо він не звертається до бази, а от список записів має видати помилку. Це той стан, у якому ти опинишся під час аварії: служба працює, дані зникли.

Відновлюємо. Дивимося, які копії є, і беремо найновішу:

ls -1t ~/backups/app-*.db | head -3
systemctl --user stop club-api
rm -f ~/data/app.db-wal ~/data/app.db-shm
cp ~/backups/app-2026-09-06_120438.db ~/data/app.db
systemctl --user start club-api
~/app/check-backup.py

Імʼя копії підстав своє з попередньої команди. Той самий порядок — зупинити, прибрати -wal і -shm, скопіювати, запустити — записаний у розділі 4 твого RUNBOOK. Відкрий сайт і переконайся, що записи на місці.

Прибери відкладений файл, щоб він не займав місце й не плутав:

rm ~/data/app.db.vidkladena

Тепер запишемо в історію git усе, що написали сьогодні:

cd ~/app
git add backup.py check-backup.py health.sh RUNBOOK.md
git commit -m "щоденна копія бази, перевірка відновлення і RUNBOOK"
git push origin master
Копії в git не кладемо Файли з ~/backups в історію git не потрапляють: вони лежать поза ~/app. Це навмисно — база містить дані користувачів, і в комітах їм не місце. Те саме правило діє з заняття 03 для .env і самої бази.
Навчальна аварія — останні 20 хвилин заняття Викладач видалить твою базу. Копія в тебе вже є, RUNBOOK написаний. Завдання: повернути сервіс до робочого стану й записати, скільки хвилин це зайняло. Засікай час від моменту, коли дізнався про поломку, до моменту, коли сайт показує список записів. Якщо вийшло більше за десять хвилин — дивись, на якому кроці ти зупинявся й гортав сторінку, і додай цей крок до RUNBOOK готовою командою.

Що перевіряє автоматика

Чотири критерії заняття 11. Три перших перевіряє програма, четвертий зараховує викладач. Перевірка запускається за розкладом, тому відмітка в кабінеті зʼявляється не в ту саму секунду, коли ти виконав команду.

КодКритерійЩо це означаєНе зараховано — що робити
L11.1 бекап свіжий і відновлюється Програма дивиться в ~/backups: копії мають бути, найсвіжіша — не старша за добу з невеликим запасом. Далі вона копіює цю копію в тимчасову теку, відкриває її як базу SQLite, виконує PRAGMA integrity_check і звіряє кількість рядків головної таблиці з робочою базою. Назву таблиці бере з поля database.main_table у project.yaml. Тобто рахується не сам факт наявності файлів, а те, що з них справді можна відновити дані. Спершу виконай свою ж перевірку: ~/app/check-backup.py — вона робить те саме. Порожня тека означає, що backup.py не відпрацював: перевір crontab -l і cat ~/logs/backup.log. Копія стара — рядок у розкладі є, але команда падає; причина буде в тому ж логу, найчастіше це відносний шлях або відсутнє chmod +x. integrity_check повернула не ok — пошкоджена або сама копія, або робоча база. Спершу перевір базу: /opt/club/venv/bin/python -c "import sqlite3,pathlib; print(sqlite3.connect(pathlib.Path.home()/'data'/'app.db').execute('PRAGMA integrity_check').fetchone()[0])". Якщо там ok, копію робили звичайним cp — запусти ~/app/backup.py і перевір ще раз. Якщо не ok, пошкоджена робоча база, і нова копія буде такою самою: тоді бери давнішу копію, у якої перевірка проходить, і відновлюйся з неї за розділом 4 RUNBOOK. Кількість рядків нуль — у project.yaml вказано не той файл бази.
L11.2 після kill -9 служба піднімається сама Програма дізнається номер твого процесу, зупиняє його сигналом SIGKILL, чекає сім секунд і звертається до /api/ping. Має бути код 200. Це той самий механізм, що перевірявся на занятті 02, — рядок Restart=always в unit-файлі. Перевір рядок: systemctl --user show club-api -p Restart --value — має надрукувати always. Якщо там no, значить unit-файл правили: відкрий ~/.config/systemd/user/club-api.service, поверни рядок Restart=always, потім systemctl --user daemon-reload і systemctl --user restart club-api. Якщо рядок на місці, а служба все одно не піднімається, дивись journalctl --user -u club-api -n 30: найімовірніше процес падає одразу після старту через помилку в коді, і systemd припинив спроби. Тоді виправ код, зроби systemctl --user reset-failed club-api і запусти службу.
L11.3 RUNBOOK.md описує дії при відмові Файл ~/app/RUNBOOK.md має існувати й містити конкретні дії на випадок, коли сервіс не відповідає: команди для перевірки стану, звернення до журналу і порядок відновлення бази з копії. Програма дивиться, що файл непорожній і що в ньому є команди, а не самі заголовки. Перевір, що файл лежить саме в ~/app і названий точно RUNBOOK.md: великі літери мають значення, runbook.md — інший файл. Якщо файл є, а критерій не зараховано, найчастіша причина — у ньому лише опис словами. Додай самі команди рядками, які можна скопіювати: systemctl --user, journalctl --user, cp з ~/backups. Заготовка — у кроці 12 практики.
L11.4 навчальна аварія пройдена за 10 хвилин Це людський критерій: його зараховує викладач на занятті. Він видаляє твою базу (копія до цього моменту вже зроблена), а ти повертаєш сервіс до роботи й називаєш час. Понад десять хвилин — критерій не зараховується одразу, і ти доробляєш RUNBOOK, щоб з наступного разу вкластися. Пройди відновлення заздалегідь, як у кроці 13 практики: спокійно, з секундоміром. Найбільше часу зазвичай займають дві речі — згадати, як називається тека з копіями, і знайти потрібну команду. Обидві зникають, щойно ці рядки записані в RUNBOOK. Якщо не встиг на занятті, домовся з викладачем про повторну спробу до кінця тижня.

Домашнє завдання

Основне, частина перша: підготувати демо. Наступне заняття — реліз і показ. Виступ триває пʼять хвилин, і його треба підготувати заздалегідь. Напиши в окремому файлі ~/app/DEMO.md три речі:

Основне, частина друга: почати CHANGELOG.md. Це список того, що зʼявилося в проєкті, від старішого до новішого. На занятті 12 у ньому має бути щонайменше вісім записів, і писати їх зараз простіше, ніж за двадцять хвилин до демо, коли деталі вже забулися.

Найлегший спосіб згадати — переглянути власні коміти, тобто записи в історії git: кожен із них має дату й короткий опис, який ти сам написав:

cd ~/app
git log --reverse --pretty=format:'%ad  %s' --date=short

--reverse показує коміти від найстаріших, --pretty=format задає вигляд рядка: дата й повідомлення. Далі перенеси в файл те, що справді додало можливостей.

nano ~/app/CHANGELOG.md
# Що змінювалося

## 2026-06-28
- перша сторінка на сервері й опис проєкту в BRIEF.md

## 2026-07-05
- сервіс відповідає на /api/ping, служба піднімається сама після перезавантаження

## 2026-07-12
- історія в git, окреме сховище для неї в ~/repo.git

...

Записуй те, що змінилося для того, хто користується сервісом, а не назви файлів. Рядок «додав habits.py» нічого не каже; рядок «зʼявився список звичок і кнопка додавання» — каже.

Закомить обидва файли:

git add DEMO.md CHANGELOG.md
git commit -m "план демо і перші записи CHANGELOG"
git push origin master

Додатково. Забери одну копію бази до себе на компʼютер по FTP (адреса 91.219.61.4, порт 21, шифрування «явний FTP через TLS», логін і пароль ті самі). Тека backups, файл — найновіший. Тоді копія буде у двох різних місцях, і навіть відмова диска на сервері не залишить тебе без даних.

Якщо щось не працює

crontab: command not found або no crontab for bohdan.g9

Друге повідомлення не є помилкою: воно просто означає, що розкладу в тебе ще немає. Додай рядок командою з кроку 7, і повідомлення зникне.

Перше означає, що cron на сервері не встановлений. Тоді скористайся розкладом systemd — той самий механізм, що вже тримає твою службу. Потрібні два файли.

mkdir -p ~/.config/systemd/user
nano ~/.config/systemd/user/backup.service
[Unit]
Description=Щоденна копія бази

[Service]
Type=oneshot
ExecStart=/home/bohdan.g9/app/backup.py
nano ~/.config/systemd/user/backup.timer
[Unit]
Description=Запуск копії бази о 3:17

[Timer]
OnCalendar=*-*-* 03:17:00
Persistent=true

[Install]
WantedBy=timers.target
systemctl --user daemon-reload
systemctl --user enable --now backup.timer
systemctl --user list-timers backup.timer

Type=oneshot означає «запустити команду, дочекатися завершення й не тримати службу запущеною». Persistent=true — якщо в потрібний час сервер був вимкнений, запустити команду одразу після ввімкнення. Остання команда покаже, коли таймер спрацює наступного разу.

Рядок у crontab є, а нових копій немає

Спочатку подивись лог — саме для цього ми додавали >> ~/logs/backup.log 2>&1:

cat ~/logs/backup.log
crontab -l

Далі за тим, що побачив.

KeyError: 'main_table' або no such table під час перевірки

Скрипт перевірки читає назву головної таблиці з project.yaml. Подивись, що там записано:

grep -A5 '^database:' ~/app/project.yaml

-A5 означає «показати пʼять рядків після знайденого». Поле main_table має містити назву твоєї таблиці із заняття 04, наприклад habits. Якщо там порожні лапки, скрипт зупиниться з підказкою; заповни поле.

Повідомлення no such table: habits означає інше: поле заповнене, але такої таблиці в базі немає. Подивись, які таблиці є насправді:

/opt/club/venv/bin/python -c "import sqlite3,pathlib; print([r[0] for r in sqlite3.connect(pathlib.Path.home()/'data'/'app.db').execute(\"SELECT name FROM sqlite_master WHERE type='table'\")])"

sqlite_master — службова таблиця, у якій SQLite тримає перелік усього, що є в базі. Виправ назву в project.yaml так, щоб вона збігалася посимвольно.

Після kill -9 служба не піднялася

Дивимось стан і журнал:

systemctl --user status club-api --no-pager
journalctl --user -u club-api -n 30 --no-pager

--no-pager друкує все одразу, без гортання. Три типові випадки.

database disk image is malformed або No space left on device

Перше повідомлення означає, що файл копії пошкоджений. Найчастіша причина — копію робили командою cp у момент, коли в базу писали. Зроби копію правильним способом і перевір ще раз:

~/app/backup.py
~/app/check-backup.py

Якщо нова копія теж не проходить перевірку, пошкоджена сама робоча база: копія знімається з неї, тому повторювати backup.py немає сенсу — вийде такий самий пошкоджений файл. Тоді перебирай копії від найновішої до давніших, поки якась не дасть ok, і відновлюйся з неї за розділом 4 свого RUNBOOK.

Друге повідомлення означає, що місця на диску немає. Подивись, скільки лишилося і що займає найбільше:

df -h /home | tail -1
du -sh ~/app ~/data ~/logs ~/backups
du -sh ~/logs/* | sort -h | tail -5

sort -h впорядковує за розміром з урахуванням одиниць, tail -5 лишає пʼять найбільших. Далі прибирай за розміром: зайві копії, великі файли логів, усе, що ти сам колись поклав у домашню теку й забув.

ls -1t ~/backups/app-*.db | tail -n +8 | xargs -r rm --
: > ~/logs/health.log
df -h /home | tail -1

Другий рядок спорожнює лог, лишаючи сам файл. Двокрапка тут — команда, яка нічого не робить, а > перед іменем файлу затирає його вміст.

Прибирати треба саме те, що лежить у твоїй домашній теці: df -h /home рахує місце тільки на цьому розділі. Журнал systemd лежить в іншому місці — /var/log/journal, — тому команда journalctl --user --vacuum-size=20M вільного місця в /home не додасть, і прав на ці файли у звичайного користувача зазвичай немає. Якщо всі твої теки маленькі, а місця однаково немає, — його зайняв не ти, напиши викладачеві.

Після звільнення місця перезапусти службу і перевір, що запис у базу знову працює: додай запис через форму на своєму сайті.