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

Сабдомен зі значком замка

90 хвилин

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

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

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

<домен> — назва, яку викладач називає на початку заняття. Вона вже записана у твоєму файлі ~/app/project.yaml, розділ server, поле url; пиши її точно так, як там, латинськими літерами. У прикладах на цій сторінці замість неї скрізь стоїть klub.example. Перший крок практики — прочитати справжню назву звідти, вигадувати нічого не треба. Якщо в тому полі досі стоїть __DOMAIN__, спитай викладача, перш ніж рухатися далі: без адреси половина сьогоднішніх команд не спрацює.

Зверни увагу: кабінет поки що відкривається по http за IP-адресою. Сертифіката на IP-адресу немає — чому так, пояснено в розділі 3 теорії, — тому пароль від кабінету передається без шифрування. Не став туди пароль, який використовуєш десь іще. Коли кабінет переїде на адресу з іменем і замком, викладач скаже нову адресу.

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

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

Друга частина — швидкість. Ти зміряєш, скільки важить твоя сторінка й за скільки мілісекунд відповідає API. API — це адреси на твоєму сервері, за якими програма віддає короткі дані для інших програм; оформленої сторінки для читання за такою адресою немає. У тебе це /api/ping: за нею служба club-api відповідає коротким повідомленням про те, що вона працює. Відповідь там маленька, тому на ній зручно міряти час. Далі стиснеш власні картинки, зміряєш ще раз і запишеш обидва числа в README.md. Вони лишаться у файлі, і до них можна буде повернутися на наступних заняттях.

Теорія

1. Як браузер знаходить сервер: IP, домен, сабдомен, DNS

Досі ти відкривав свій сайт за адресою 91.219.61.4. Сьогодні в нього з’являється друга адреса — словами. Щоб зрозуміти, що між ними спільного, потрібні чотири поняття.

IP-адреса
Числова адреса машини в мережі. У нашого сервера вона 91.219.61.4 — чотири групи цифр через крапку. Саме з такими числами працює мережа: у кожному запиті, який надсилає браузер, стоїть IP-адреса, а не назва.
Домен
Ім’я, яке набирають люди замість числа. Чотири групи цифр важко запам’ятати й майже неможливо продиктувати телефоном без помилки, а ім’я з двох слів — можна.
Сабдомен
Частина імені перед основним доменом, відділена крапкою. В адресі bohdan.g9.klub.example сабдомен — це bohdan.g9. Одному доменові можна зробити скільки завгодно сабдоменів, і всі вони можуть вести на той самий сервер. Так зроблено і в нас: у кожного в групі своє ім’я, машина за ними одна.
DNS
Система серверів, яка на запитання «яка IP-адреса в цього імені» повертає число. Браузер сам туди звертається — ти цього не бачиш, бо все відбувається за частки секунди перед першим запитом.

DNS зберігає пари «ім’я — IP-адреса». Список цих пар спільний на весь світ і лежить на окремих серверах; на твоєму комп’ютері його немає, тому по відповідь щоразу звертаються туди.

Порядок такий. Ти набираєш ім’я. Браузер питає в системи: «яка тут IP-адреса». Система, якщо не пам’ятає відповіді з попереднього разу, питає в DNS-сервера — зазвичай це сервер провайдера або шкільного роутера. У відповідь приходить 91.219.61.4. І лише після цього браузер надсилає запит на цю IP-адресу.

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

Далі запит приходить на сервер, і там його приймає nginx — програма, яка слухає порти 80 і 443 та вирішує, кому передати запит. Порт — номер, за яким операційна система розрізняє, якій програмі віддати запит; докладніше про це в наступному розділі. Усі імена вашої групи ведуть на одну машину, і розрізняє їх nginx за іменем, яке браузер написав у запиті. Тому за адресою bohdan.g9.… відкривається тека /home/bohdan.g9/www, а не чиясь чужа.

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

Богдан відкрив у браузері свій сабдомен — сторінка з’явилася. Потім він набрав 91.219.61.4/u/bohdan.g9/ — і побачив ту саму сторінку. Що з цього випливає?

2. Що означає значок замка

Замок в адресному рядку з’являється, коли сторінка відкрита по HTTPS. Щоб зрозуміти, що він дає, спершу треба побачити, що відбувається без нього.

HTTP
Правила, за якими браузер просить сторінку, а сервер її віддає. Усе, що передається, — звичайний текст: адреса сторінки, службові рядки на початку запиту й вміст форми, яку ти надіслав.
Заголовок
Один такий службовий рядок на початку запиту або відповіді, перед вмістом сторінки. У заголовках браузер повідомляє, яке ім’я сервера він питає і які формати розуміє, а сервер — якого типу вміст віддає й скільки часу цей вміст можна зберігати. Заголовків в одному запиті буває кілька десятків, і людина їх зазвичай не бачить.
Кука
Короткий рядок, який сервер просить браузер зберегти й надсилати назад із кожним наступним запитом — теж у заголовку. Саме так сервер упізнає тебе після того, як ти один раз увів пароль: у куці лежить позначка про твій вхід. Хто прочитає чужу куку, той зайде в сервіс замість її власника.
HTTPS
Той самий HTTP, тільки вміст запиту й відповіді зашифровані. Літера S у кінці — від слова secure.
Шифрування
Перетворення тексту на набір байтів, з якого без ключа прочитати нічого не вдасться. Ключі браузер і сервер узгоджують між собою на початку з’єднання, і в кожного з’єднання вони свої.
Служба
Програма, яка постійно працює на сервері й чекає на запити. Її ніхто не запускає руками щоразу: вона запускається разом із сервером і працює, поки її не зупинять. Твоя програма club-api — саме така служба.
Порт
Номер, який каже, до якої саме програми на машині йде запит. Для HTTP це 80, для HTTPS — 443. В адресному рядку їх не пишуть, бо браузер підставляє потрібний сам. Служба club-api теж має свій порт, і nginx передає запити на нього.

Тепер про шкільний Wi-Fi. Між твоїм ноутбуком і сервером стоять точка доступу, шкільний роутер і обладнання провайдера. Коли сторінка відкрита по HTTP, кожна з цих машин бачить повний текст запиту. На занятті 07 ти зробив вхід із паролем — по HTTP цей пароль передається відкритим текстом, і його видно всім, хто має доступ до мережевого обладнання або просто підключив ноутбук до тієї самої відкритої мережі.

Коли сторінка відкрита по HTTPS, збоку видно тільки дві речі: ім’я сервера, з яким ти з’єднався (без нього сервер не зрозуміє, який сертифікат віддавати), і приблизний обсяг переданих даних. Адреса сторінки, заголовки, куки, вміст форми і пароль зашифровані.

Важливо не плутати два різні паролі. Пароль на Wi-Fi закриває доступ до самої мережі й нічого не робить із твоїм трафіком усередині неї. HTTPS шифрує вміст незалежно від того, чи є пароль на мережі й хто ще до неї підключений.

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

Марта сидить у кав’ярні на відкритому Wi-Fi без пароля, відкриває свій сервіс по https:// і вводить пароль у форму входу. За сусіднім столиком людина записує весь трафік цієї мережі. Що вона побачить?

3. Сертифікат і ланцюжок сертифікатів

Шифрування саме по собі не доводить, з ким ти розмовляєш. Якщо хтось у мережі відповість замість справжнього сервера, з’єднання теж буде зашифроване — тільки з ним. Тому потрібна друга частина: перевірка імені.

Сертифікат
Файл, у якому написано, для якого імені він виданий, лежить відкритий ключ сервера, стоять дати «діє з» і «діє до» та підпис організації, яка його видала. Сервер надсилає цей файл браузеру на початку кожного з’єднання.
Центр сертифікації
Організація, яка перевіряє, що той, хто просить сертифікат, справді керує цим доменом, і ставить свій підпис. Наш сертифікат виданий центром Let’s Encrypt — він безкоштовний, і ним користуються мільйони сайтів.
Ланцюжок сертифікатів
Сертифікат сервера підписаний проміжним сертифікатом центру, а проміжний — кореневим. Кореневі сертифікати вже лежать усередині браузера й операційної системи: їх туди поклали виробники. Браузер перевіряє підписи один за одним і, якщо доходить до кореневого зі свого списку, показує замок.

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

Сертифікати Let’s Encrypt діють 90 днів і оновлюються автоматично. Прострочений сертифікат — одна з найчастіших причин, чому в цілком робочого сайту раптом зникає замок.

У нашому випадку сертифікат один на всю групу: він виданий на ім’я із зірочкою — *.g9.klub.example для дев’ятої групи, *.g13.klub.example для тринадцятої. Зірочка замінює рівно одну частину імені — ту, що стоїть на її місці, тобто твоє ім’я перед номером групи. Тому для bohdan.g9.klub.example і marta.g9.klub.example підходить один і той самий файл. Двох частин одразу зірочка не замінює: під *.klub.example ім’я bohdan.g9.klub.example уже не підпадає, бо перед доменом у ньому стоять дві частини, а зірочка покриває тільки одну. Коли на практиці ти надрукуєш поле subject свого сертифіката й побачиш там зірочку з номером своєї групи — це саме той сертифікат.

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

Кирило набрав https://91.219.61.4/u/kyrylo.g13/ і побачив попередження: «сертифікат не дійсний для цієї адреси». За іменем сабдомена той самий сайт відкривається із замком. Чому так?

4. Чому http сам перекидає на https

Люди набирають адресу без https:// — просто ім’я. Багато браузерів у такому разі звертаються спершу на порт 80, тобто по HTTP. Якщо сервер віддасть там сторінку, її вміст передається відкритим текстом, і замок не з’явиться, хоч сертифікат і є.

Тому в налаштуваннях nginx на порт 80 стоїть інша відповідь — перенаправлення.

Код відповіді
Число на самому початку відповіді сервера, яке каже, чим закінчився запит. Код 200 OK означає «ось потрібна сторінка», і далі йде її вміст. Код 404 означає, що такого файлу на сервері немає.
Код 301
Відповідь, яка означає «ця адреса переїхала назавжди». Вміст сторінки в такій відповіді порожній.
Заголовок Location
У відповіді 301 обов’язково є заголовок Location із новою адресою — браузер читає його і одразу йде туди. Людина бачить тільки те, що в адресному рядку сама собою з’явилася адреса з https.

Є ще одна річ, яку легко зламати власноруч. Якщо сторінка відкрита по HTTPS, а всередині неї стоїть посилання на файл по http://, браузер такий файл не завантажить і напише Mixed Content у консолі — це вікно, куди браузер друкує повідомлення про помилки на сторінці; відкривається воно клавішею F12, вкладка Console. Картинки не буде, а замок стане з попередженням або зникне. Найчастіша причина — повна адреса з цифрами, залишена в коді сторінки з перших занять: <img src="http://91.219.61.4/u/bohdan.g9/foto.jpg">.

Виправляється це просто: посилання на власні файли пиши відносними — <img src="foto.jpg">. Тоді браузер сам підставить ту адресу й той протокол, з якого відкрита сторінка, і файл завантажиться по HTTPS.

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

Оля відкрила свій сайт по https://. Сторінка з’явилася, текст на місці, але замість фото — порожній прямокутник, а в консолі (клавіша F12, вкладка Console) написано Mixed Content. У коді стоїть <img src="http://91.219.61.4/u/olia.g11/foto.jpg">. Що робити?

5. Скільки важить сторінка і чому це важливо

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

Статика
Файли, які сервер віддає такими, як вони лежать на диску: HTML, CSS, JavaScript, картинки, шрифти. Вони однакові для всіх відвідувачів, тому для них працюють стиснення й кеш.
Стиснення gzip
Сервер перед відправкою пакує текстові файли, браузер розпаковує їх у себе. HTML, CSS і JavaScript стискаються в 3–5 разів, бо в них багато повторів. Картинки вже стиснуті всередині свого формату, пакувати їх удруге сенсу немає — у налаштуваннях їх у списку й немає.
Кеш
Копія файлу, яку браузер зберіг у себе на диску, щоб не завантажувати його вдруге. Сервер заголовком Cache-Control каже, скільки цю копію можна тримати. У нас для картинок, стилів і скриптів стоїть сім днів.

Тепер числа. Мобільний інтернет у школі на перерві дає приблизно 2–5 Мбіт/с — це реальна швидкість, коли до тієї самої мережі одночасно підключені кілька десятків людей. Сторінка на 4 МБ за таких умов завантажується близько десяти секунд. Стільки ніхто не чекає: людина закриває вкладку раніше, ніж побачить, що ти зробив.

Майже вся вага — це картинки. Фото з телефона важить 3–6 МБ і має 4000 пікселів завширшки. На сторінці воно показується шириною 800 пікселів, тобто решта даних передається каналом і викидається браузером.

Звідси дві дії, які дають основний результат:

  1. Зменшити картинку в пікселях до тієї ширини, у якій її реально видно. 1200 пікселів вистачає навіть для екрана ноутбука.
  2. Зберегти у WebP — формат картинок, який розуміють усі браузери з 2020 року. Той самий знімок у ньому займає у 2–4 рази менше, ніж JPEG тієї самої якості.

З кешем пов’язана помилка, яка трапляється часто. Якщо ти заміниш файл на сервері, лишивши ту саму назву, людина, яка заходила вчора, ще сім днів бачитиме стару копію: браузер навіть не питатиме сервер, чи файл змінився. Тому, коли міняєш картинку, міняй і її ім’я — foto-2.webp замість foto.webp. Собі для перевірки достатньо натиснути Ctrl+Shift+R (на Mac Cmd+Shift+R): це оновлення сторінки з повторним завантаженням усіх файлів, повз кеш.

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

Богдан стиснув foto.jpg і поклав на сервер новий файл із тією самою назвою. У себе він бачить нову картинку — оновив сторінку через Ctrl+Shift+R. Друг, який заходив учора, відкриває сторінку й бачить стару. Чому?

6. Як міряти чесно

Числа «до» і «після» щось означають лише тоді, коли обидва зміряні однаково. Тут потрібні три поняття.

Час відповіді
Скільки минає від моменту, коли запит пішов, до моменту, коли прийшла остання частина відповіді. Міряється в мілісекундах: 1000 мс — це одна секунда.
Медіана
Середнє за розташуванням значення у списку, впорядкованому за зростанням: половина вимірів менша за неї, половина більша. Для десяти вимірів беруть п’ятий і шостий і рахують їхнє середнє.
Викид
Один вимір, який різко відрізняється від решти. У мережі такі трапляються постійно: хтось у цій самій мережі почав завантажувати відео, і роутер на секунду перестав устигати за всіма запитами.

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

Різницю між медіаною і середнім арифметичним видно на оцінках. Десять оцінок: дев’ять по 9–10 і одна двійка за пропущену роботу. Середнє арифметичне через цю двійку стане помітно меншим, а медіана дорівнюватиме дев’яти — і саме це число правильно описує, як ти зазвичай пишеш.

Друга умова — звідки міряти. 127.0.0.1 — це адреса, за якою машина звертається сама до себе. Такий запит не виходить за межі сервера: він не проходить каналом до відвідувача, не йде через шкільний Wi-Fi і обладнання провайдера, а на порту 80 ще й не шифрується. Число вийде маленьке, але воно не покаже, скільки чекає людина з телефоном. Тому автоперевірка міряє з сервера, але через публічну адресу — так само, як це робить браузер відвідувача.

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

Артем зміряв час відповіді десять разів і отримав: 180, 190, 175, 900, 185, 178, 195, 182, 188, 176 мс. У README.md він написав «у середньому 255 мс». Що тут не так?

Практика

Кроки 1–4 і 6–11 робляться в SSH-сесії на сервері. Крок 5 — у браузері; у кроці 8 сторінку теж доведеться відкрити в браузері й перевірити картинки. Якщо картинок у теці зовсім немає, знадобиться ще й домашній комп’ютер: файл кладуть на сервер по FTP саме з нього.

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

1

Дізнатися свою адресу й покласти її у змінну

ssh bohdan.g9@91.219.61.4
grep -A 2 '^server:' ~/app/project.yaml

grep -A 2 друкує знайдений рядок і два наступні за ним. Побачиш приблизно таке:

server:
  subdomain: bohdan.g9
  url: "https://bohdan.g9.klub.example/"

Якщо замість імені домену стоїть __DOMAIN__, далі не йди — спитай викладача адресу й впиши її у project.yaml. Автоперевірка бере адресу саме звідси.

Тепер збережемо адресу у змінну, щоб не набирати її двадцять разів:

MY=$(grep -A 5 '^server:' ~/app/project.yaml | grep -m 1 'url:' | grep -o 'https://[^"]*' | sed 's:/*$::')
echo "$MY"

Розберімо рядок по частинах. Перший grep бере рядок server: і п’ять наступних за ним — тобто саме той розділ файлу, у якому лежить адреса. Другий бере з них перший рядок зі словом url: (-m 1 означає «зупинись на першому збігу»). Третій друкує не весь рядок, а лише ту частину, що збіглася з шаблоном: https:// і все до першої лапки. Так у змінну потрапляє саме поле url з розділу server, а не якесь інше посилання, що може стояти в файлі вище. sed 's:/*$::' прибирає косі риски в кінці, щоб далі писати $MY/api/ping і не отримати дві риски підряд. Усе разом у $(…) означає «виконай це і поклади вивід у змінну».

echo має надрукувати твою адресу без косої риски в кінці. Порожній рядок означає, що в project.yaml немає адреси, — вернись на початок кроку.

Змінна живе лише в цій сесії Якщо вийдеш із SSH і зайдеш знову, $MY буде порожня. Просто виконай ту саму команду ще раз.
2

Перевірити, що ім’я перетворюється на IP-адресу

getent hosts "${MY#https://}"

${MY#https://} — це та сама змінна, але без початку https://: решітка означає «прибери з початку рядка ось це». Виходить чисте ім’я, а саме його й хоче getent.

getent hosts питає в системи IP-адресу для імені — тим самим шляхом, яким це робить браузер. У відповідь має прийти рядок:

91.219.61.4     bohdan.g9.klub.example

Ти щойно побачив те, про що йшлося в теорії: ім’я і число вказують на ту саму машину — на ту, у якій ти зараз сидиш по SSH.

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

3

Перевірити перенаправлення з http на https

curl -sI "http://${MY#https://}/" | head -n 5

curl — програма, яка робить той самий запит, що й браузер, і друкує відповідь у термінал. -s прибирає смужку прогресу, -I просить лише заголовки, без вмісту сторінки. Зверни увагу: адреса тут навмисно починається з http://, тобто запит іде на порт 80.

Очікувана відповідь:

HTTP/1.1 301 Moved Permanently
Server: nginx
Location: https://bohdan.g9.klub.example/

Це і є те перенаправлення з теорії: код 301 і заголовок Location з новою адресою. Якщо тут 200 OK, сервер віддав сторінку по відкритому HTTP — критерій L10.3 не зарахується, скажи викладачеві.

А тепер пройдемо весь шлях так, як його проходить браузер:

curl -sIL "http://${MY#https://}/" | grep -E '^HTTP|^[Ll]ocation'

-L означає «іди за перенаправленнями». grep -E лишає тільки рядки з кодом відповіді й адресою переходу. Побачиш два кроки: спершу 301 із Location, потім 200 уже по HTTPS.

4

Прочитати сертифікат командою

openssl s_client -connect "${MY#https://}:443" -servername "${MY#https://}" </dev/null 2>/dev/null \
  | openssl x509 -noout -subject -issuer -dates

Що тут відбувається. openssl s_client відкриває з’єднання на порт 443 і друкує все, що сервер надіслав, зокрема сам сертифікат. -servername повідомляє ім’я, яке ми питаємо, — без нього сервер не зрозуміє, чий сертифікат віддавати, бо імен на машині багато. </dev/null означає «нічого не вводитиму з клавіатури, закривай з’єднання одразу», інакше команда чекатиме на введення. 2>/dev/null ховає службові повідомлення. Далі текст іде до openssl x509, який дістає з нього три речі: subject — для якого імені виданий, issuer — хто підписав, dates — з якого й до якого числа діє.

Приблизна відповідь:

subject=CN = *.g9.klub.example
issuer=C = US, O = Let's Encrypt, CN = R11
notBefore=Aug 14 07:22:10 2026 GMT
notAfter=Nov 12 07:22:09 2026 GMT

Зірочка в subject — той самий спільний сертифікат на всю групу, а після неї стоїть номер твоєї групи. У issuer замість R11 цілком може бути R10, R12 чи інша схожа назва: Let’s Encrypt час від часу міняє свої проміжні сертифікати, і це не ознака помилки. notAfter — дата, після якої браузери почнуть показувати попередження; до неї сертифікат оновиться сам.

Тепер подивись увесь ланцюжок:

openssl s_client -connect "${MY#https://}:443" -servername "${MY#https://}" </dev/null 2>/dev/null \
  | sed -n '/Certificate chain/,/^---/p'

sed -n '/від/,/до/p' друкує шматок виводу між двома рядками. Побачиш пари рядків: s: — чий це сертифікат (subject), i: — хто його підписав (issuer). Рядок i: одного сертифіката збігається з рядком s: наступного — так і виглядає ланцюжок. Останню ланку, кореневий сертифікат, сервер не надсилає: вона вже є в браузері.

5

Знайти той самий сертифікат у браузері

Відкрий свою адресу з https:// у браузері й натисни на значок замка зліва від адреси.

У вікні сертифіката знадобляться три поля. Common Name (скорочено CN, українською «загальна назва») — основне ім’я, для якого виданий сертифікат; це те саме, що subject у виводі команди. Subject Alternative Name (SAN, «додаткові імена») — список усіх імен, які цей сертифікат покриває; саме цей список перевіряють сучасні браузери. Issuer — хто сертифікат підписав.

Знайди три речі й порівняй їх із тим, що надрукувала команда з кроку 4:

  1. ім’я, для якого виданий сертифікат (Common Name або Subject Alternative Name);
  2. хто видав (Issuer);
  3. до якого числа діє.

Значення мають збігтися: браузер і openssl дивляться на той самий файл. Різниця лише в тому, що браузер робить це щоразу, коли ти відкриваєш сторінку, і мовчки.

6

Зміряти «до»: скільки важить сторінка

Створи теку для маленьких допоміжних програм і файл у ній:

mkdir -p ~/app/tools
nano ~/app/tools/page-weight.py

Встав текст цілком:

#!/usr/bin/env python3
"""Скільки важить сторінка: сам HTML плюс файли, які він тягне за собою."""
import re
import sys
import urllib.request
from urllib.parse import urljoin

if len(sys.argv) < 2:
    sys.exit("Вкажи адресу: page-weight.py https://bohdan.g9.klub.example/")

url = sys.argv[1]


def download(link):
    request = urllib.request.Request(link, headers={"User-Agent": "page-weight"})
    with urllib.request.urlopen(request, timeout=15) as answer:
        return answer.read()


page = download(url)
text = page.decode("utf-8", "replace")
total = len(page)
print(f"{len(page):>9} Б   сама сторінка")

files = re.findall(r'<(?:img|script|source)[^>]+src=["\']([^"\']+)', text, re.I)
files += re.findall(r'<link[^>]+href=["\']([^"\']+)', text, re.I)

seen = set()
for item in files:
    if item.startswith(("data:", "#", "javascript:")):
        continue
    full = urljoin(url, item)
    if full in seen:
        continue
    seen.add(full)
    try:
        size = len(download(full))
    except Exception as error:
        print(f"{'?':>9}     {item}  ({error})")
        continue
    total += size
    print(f"{size:>9} Б   {item}")

print("-" * 44)
print(f"{total:>9} Б   разом = {total / 1024:.0f} КБ")

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

Запусти:

/opt/club/venv/bin/python ~/app/tools/page-weight.py "$MY/"

На початку команди стоїть повний шлях /opt/club/venv/bin/python — це Python зі спільного оточення. Оточення (англійською virtual environment, скорочено venv) — окрема тека, у якій лежать Python і встановлені для нього бібліотеки. На нашому сервері таке оточення одне на всіх і лежить у /opt/club/venv; бібліотеки в нього ставить адміністратор. Тому шлях до Python пишемо повністю: у системному python3 потрібних бібліотек немає.

Запиши число «разом» — це твоє «до». Найзручніше одразу вписати його в чернетку в ~/app/README.md, бо через двадцять хвилин ти його не згадаєш.

Заразом подивись, що дає стиснення:

curl -s -o /dev/null -w 'без стиснення: %{size_download} Б\n' "$MY/"
curl -s --compressed -o /dev/null -w 'зі стисненням: %{size_download} Б\n' "$MY/"

-o /dev/null означає «вміст нікуди не зберігай», -w — «надрукуй ось таке після завершення», а %{size_download} — це скільки байтів сервер реально надіслав. --compressed додає до запиту заголовок Accept-Encoding: gzip, тобто повідомляє серверу, що браузер уміє розпаковувати. Друге число має бути в кілька разів менше. Обидва теж запиши.

7

Зміряти «до»: час відповіді API

export LC_ALL=C
for i in $(seq 10); do curl -s -o /dev/null -w '%{time_total}\n' "$MY/api/ping"; done \
  | sort -n | awk 'NR==5 || NR==6 {s+=$1} END {printf "медіана: %.0f мс\n", s/2*1000}'

Розбір: seq 10 дає числа від 1 до 10, тож цикл робить десять запитів. %{time_total} — повний час запиту в секундах. sort -n впорядковує числа за зростанням. awk бере п’ятий і шостий рядки, рахує їхнє середнє й переводить секунди в мілісекунди. Це та сама медіана з теорії.

Рядок export LC_ALL=C потрібен через десятковий роздільник. Якщо на сервері стоять українські мовні налаштування, curl надрукує 0,182 з комою, а sort і awk чекають на крапку й дадуть неправильний результат. LC_ALL=C вмикає для команд просту англійську локаль із крапкою. Виконати цей рядок досить один раз за сесію.

Запиши число. Для критерію L10.2 воно має бути меншим за 300 мс.

Якщо цікаво, на що саме йде час, є докладніший вимір:

curl -s -o /dev/null "$MY/api/ping" \
  -w 'DNS %{time_namelookup}  з’єднання %{time_connect}  шифрування %{time_appconnect}  перший байт %{time_starttransfer}  разом %{time_total}\n'

Числа йдуть наростаючим підсумком, у секундах. Різниця між time_connect і time_appconnect — це узгодження шифрування й перевірка сертифіката; різниця між time_appconnect і time_starttransfer — час, який твоя програма витратила на відповідь. Так одразу видно, де саме затримка: у мережі чи в коді.

8

Стиснути власні картинки

Спершу подивись, що взагалі лежить у теці сайту й скільки важить:

ls -lhS ~/www
du -sh ~/www

-S сортує за розміром, найбільші зверху; -h друкує розміри в зрозумілих одиницях. du -sh показує вагу всієї теки.

Якщо картинок немає Тоді сторінка вже легка, і стискати нічого. Постав у теку одне фото, яке має стосунок до твого проєкту, — з домашнього комп’ютера, по FTP. FTP — спосіб передавати файли між своїм комп’ютером і сервером; підключитися можна з файлового менеджера або з програми на зразок FileZilla. Налаштування: адреса 91.219.61.4, порт 21, шифрування «явний FTP через TLS», логін і пароль ті самі, що для SSH. TLS — той самий спосіб шифрування, на якому тримається HTTPS; без нього логін і пароль передаватимуться відкритим текстом. Числа «до» і «після» тобі все одно потрібні, а перевіряти стиснення на порожній теці нема на чому.

Перевір, що бібліотека для роботи з картинками є у спільному оточенні:

/opt/club/venv/bin/python -c "import PIL; print('Pillow', PIL.__version__)"

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

Тепер сам скрипт:

nano ~/app/tools/optimize-images.py
#!/usr/bin/env python3
"""Зменшує картинки в ~/www і зберігає копію у форматі WebP.

Оригінали лишаються на місці: скрипт їх не чіпає.
"""
from pathlib import Path

from PIL import Image

MAX_WIDTH = 1200      # ширини понад цю на сторінці все одно не видно
QUALITY = 80          # 80 — межа, після якої різниці для ока майже немає
FOLDER = Path.home() / "www"

for source in sorted(FOLDER.rglob("*")):
    if source.suffix.lower() not in (".jpg", ".jpeg", ".png"):
        continue
    rel = str(source.relative_to(FOLDER))
    was = source.stat().st_size
    picture = Image.open(source)
    picture = picture.convert("RGBA" if picture.mode in ("P", "LA", "RGBA") else "RGB")
    if picture.width > MAX_WIDTH:
        height = round(picture.height * MAX_WIDTH / picture.width)
        picture = picture.resize((MAX_WIDTH, height), Image.LANCZOS)
    result = source.with_suffix(".webp")
    if result.exists():
        print(f"{rel:<26} пропущено: {result.name} уже є")
        continue
    picture.save(result, "WEBP", quality=QUALITY, method=6)
    became = result.stat().st_size
    if became >= was:
        result.unlink()
        print(f"{rel:<26} {was // 1024:>6} КБ  ->  WebP вийшов не меншим, лишаю оригінал")
        continue
    print(f"{rel:<26} {was // 1024:>6} КБ  ->  "
          f"{result.name:<26} {became // 1024:>6} КБ  "
          f"{picture.width}×{picture.height} пікселів")

Рядок з convert приводить картинку до формату кольорів, який WebP точно збереже: з прозорістю — RGBA, без прозорості — RGB. rglob("*") обходить теку разом із підтеками, тому в списку можуть з’явитися й файли з підтек. Щоб було видно, звідки кожен файл, у лівому стовпці друкується шлях від ~/www: foto.jpg або foto/dvir.jpg. resize зменшує зображення, зберігаючи пропорції: висота рахується з тієї самої пропорції, що й ширина.

Дві перевірки в скрипті потрібні через те, що WebP не завжди виграє. Якщо поруч лежать foto.jpg і foto.png, обидва дали б один файл foto.webp, і другий мовчки затер би перший — тому скрипт пропускає картинку, для якої такий .webp уже є, і пише про це. А маленькі PNG у WebP часто виходять більшими за оригінал; у такому разі скрипт видаляє щойно створений файл і лишає оригінал. Якщо треба перезібрати картинку заново, спершу видали старий .webp руками.

Останні числа в рядку — ширина й висота готової картинки в пікселях. Вони знадобляться в розмітці, тому не закривай вивід одразу.

/opt/club/venv/bin/python ~/app/tools/optimize-images.py

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

mkdir -p ~/backups
cp ~/www/index.html ~/backups/index-$(date +%F).html
nano ~/www/index.html

mkdir -p створює теку, а якщо вона вже є — нічого не робить і не свариться. Без цього рядка cp завершиться помилкою No such file or directory, і копії не буде.

У кожному <img> заміни розширення на .webp і перевір, що посилання відносне:

<img src="foto.webp" width="1200" height="800" alt="Головна сторінка мого сервісу">

width і height — це справжні розміри картинки в пікселях. Візьми ті два числа, які скрипт надрукував для цього файлу; 1200 і 800 тут стоять просто як приклад. Браузер із цих чисел рахує пропорції ще до того, як картинка завантажилася, і одразу лишає під неї місце потрібної висоти — тому текст під картинкою не стрибає. Якщо вписати числа навмання, браузер лишить місце неправильної висоти, і текст усе одно стрибне.

Щоб на вузькому екрані картинка не виходила за межі сторінки, у стилях має бути таке правило:

img { max-width: 100%; height: auto; }

Без нього width="1200" задає ширину на екрані рівно 1200 пікселів, і на телефоні сторінку доведеться прокручувати вбік. З ним картинка звужується до ширини екрана, а height: auto тримає пропорції. Перевір, чи є це правило у <style> твого index.html; якщо немає — додай.

alt — текст, який побачить людина, якщо картинка не завантажилася, і який прочитає програма озвучування; його ти вже додавав на занятті 06.

Відкрий сторінку в браузері з Ctrl+Shift+R і переконайся, що картинки на місці. І лише після цього прибери старі файли:

rm ~/www/foto.jpg

foto.jpg тут — приклад. Видаляй ті файли, які скрипт справді перетворив: їхні шляхи стоять у лівому стовпці його виводу. Якщо картинка лежала в підтеці, пиши шлях разом із нею — rm ~/www/foto/dvir.jpg. Файли, для яких скрипт написав «лишаю оригінал», видаляти не можна: .webp для них немає. Файли з позначкою «пропущено» теж лиши на місці — .webp із таким іменем зроблено з іншої картинки.

Спершу перевірка, потім видалення — у зворотному порядку легко лишитися без картинок зовсім.

9

Зміряти «після»

Ті самі команди, що в кроках 6 і 7: умови виміру мають бути однакові, інакше числа «до» і «після» не порівняти. Вимірів чотири — вага сторінки, розмір без стиснення, розмір зі стисненням і медіана часу відповіді.

export LC_ALL=C
/opt/club/venv/bin/python ~/app/tools/page-weight.py "$MY/"
curl -s -o /dev/null -w 'без стиснення: %{size_download} Б\n' "$MY/"
curl -s --compressed -o /dev/null -w 'зі стисненням: %{size_download} Б\n' "$MY/"
for i in $(seq 10); do curl -s -o /dev/null -w '%{time_total}\n' "$MY/api/ping"; done \
  | sort -n | awk 'NR==5 || NR==6 {s+=$1} END {printf "медіана: %.0f мс\n", s/2*1000}'

Заразом перевір, що кеш на статику справді ввімкнений. Ім’я файлу підстав своє — те, яке тепер стоїть у index.html; якщо картинка лежить у підтеці, додай і її: "$MY/foto/dvir.webp".

curl -sI "$MY/foto.webp" | grep -i -E 'cache-control|expires|content-type'

grep -i шукає без огляду на великі й малі літери. Має бути рядок cache-control: max-age=604800 — 604800 секунд, тобто рівно сім днів, — і expires із датою приблизно за тиждень. Це те, що вмикав адміністратор; тепер ти бачиш ці заголовки у відповіді сервера.

Вага сторінки має стати помітно меншою. Якщо вона майже не змінилася, вернись до кроку 8: найчастіше в index.html лишилося старе посилання на .jpg, і сторінка завантажує обидва файли.

Час відповіді /api/ping сьогодні змінюватися й не повинен: картинки та розмітка на роботу служби не впливають. Якщо в цьому рядку числа «до» і «після» вийшли майже однакові — це правильний результат, так і запиши.

10

Записати числа в README

nano ~/app/README.md

Знайди розділ ## Швидкість роботи — у заготовці там стоїть рядок курсивом «Заняття 10: сюди записати числа…». Прибери цей рядок і встав на його місце свої результати. Ось зразок — числа підстав свої:

## Швидкість роботи

Виміряно 6 вересня 2026, заняття 10. Адреса: https://bohdan.g9.klub.example/

| Що міряв | До | Після |
|---|---|---|
| Сторінка з усіма файлами | 1840 КБ | 214 КБ |
| `/api/ping`, медіана з 10 запитів | 178 мс | 176 мс |

Головна сторінка без стиснення — 34 КБ, зі стисненням gzip — 9 КБ.

Що зробив: зменшив три фото до 1200 пікселів завширшки, зберіг у WebP і
замінив посилання в `index.html`. Стиснення gzip і кеш на статику ввімкнув
адміністратор у налаштуваннях nginx. Час відповіді `/api/ping` майже не
змінився: сьогоднішні правки стосувалися картинок, а не коду служби.

Одиниці пиши обов’язково — «214» без «КБ» нічого не означає. Пиши те, що справді надрукували команди: якщо якесь число майже не змінилося, так і запиши, це нормальний результат виміру.

11

Зберегти зміни в git

Репозиторій — тека, за якою стежить git: він запам’ятовує кожну збережену версію файлів у ній. У тебе це ~/app. Тека ~/www в репозиторій не входить, тому копію сторінки в ~/app оновлюємо руками — так само, як на занятті 06:

cp ~/www/index.html ~/app/www-index.html
cd ~/app
git add README.md www-index.html tools/
git status --short

Коміт — одна збережена версія файлів разом із підписом, що саме змінилося. git status --short покаже список того, що в цей коміт увійде. Переконайся, що в ньому немає нічого зайвого — картинок із ~/www там бути не повинно, вони не в репозиторії.

git commit -m "Швидкість: WebP замість JPEG, числа до і після в README"
git push origin master

Код служби сьогодні не змінювався, тому перезапускати club-api й запускати deploy.sh не потрібно. Якщо все ж запустиш — нічого не зламається, просто зміни в статиці від цього не залежать.

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

Чотири критерії заняття 10, усі чотири перевіряє програма. Перевірка запускається за розкладом, тому відмітка в кабінеті з’являється не в ту саму секунду, коли ти виконав команду. Публічну адресу програма бере з ~/app/project.yaml, розділ server, поле url — якщо там неправильна адреса, не зарахується нічого.

КодКритерійЩо це означаєНе зараховано — що робити
L10.1 сторінка важить менше 500 КБ Програма завантажує твою головну сторінку разом із картинками, стилями й скриптами, на які вона посилається, і додає їхні розміри. Сума має бути меншою за 500 КБ. Це приблизно та сама перевірка, яку ти робив скриптом page-weight.py. Запусти page-weight.py і подивись на список: у ньому одразу видно найважчий файл. Майже завжди це картинка, яку не стиснули або яка лишилася в .jpg, бо посилання в index.html не оновили. Перевір, що в сторінці немає посилань на старі файли: grep -o 'src="[^"]*"' ~/www/index.html. Якщо найважчий файл — не картинка, а сама сторінка, подивись, чи не вставлено в неї картинку прямо в текст. Таку вставку видно по тому, що в src замість адреси файлу стоїть довгий рядок, який починається з data:image: це закодована сама картинка, і сторінка важчає рівно на її розмір.
L10.2 API відповідає швидше ніж за 300 мс Програма робить десять запитів на /api/ping з сервера, але через публічну адресу — тобто через nginx і шифрування, як це робить браузер. З десяти результатів береться медіана, і вона має бути меншою за 300 мс. Повтори вимір із кроку 7 і подивись докладний розклад часу (друга команда того ж кроку). Якщо велика різниця між time_appconnect і time_starttransfer, час іде в твоєму коді: подивись, чи не читає /api/ping базу і чи не лишилося в ньому print із великим виводом. Якщо код відповідає швидко, а загальний час великий, перевір, чи не зупиняється служба й не запускається знову без кінця. Це видно в журналі — списку рядків, які служба пише про свою роботу; читає його команда journalctl --user -u club-api -n 30, де -u вказує службу, а -n 30 — скільки останніх рядків показати.
L10.3 сертифікат валідний, http веде на https Програма перевіряє дві речі: що адреса на http відповідає кодом 301 із заголовком Location на https, і що сертифікат на порту 443 дійсний — виданий на це ім’я, не прострочений, підписаний відомим центром сертифікації. Налаштування робить адміністратор, твоя частина — правильна адреса в project.yaml і перевірка. Повтори кроки 3 і 4 практики. Якщо на http приходить 200 замість 301 або сертифікат прострочений — це на боці сервера, скажи викладачеві. Якщо openssl пише, що ім’я в сертифікаті не збігається, дивись, що записано в полі url: там має бути твій сабдомен, а не IP-адреса й не чужий логін.
L10.4 у README є цифри «до» і «після» Програма читає ~/app/README.md і шукає в розділі про швидкість числа «до» і «після» — розмір сторінки й час відповіді. Заготовковий рядок курсивом має бути прибраний: поки він на місці, критерій не зараховується. Перевір, що в файлі: grep -A 12 'Швидкість роботи' ~/app/README.md. Числа мають бути з одиницями («214 КБ», «176 мс»), обидва стовпці заповнені, а рядка «Заняття 10: сюди записати числа…» уже немає. Якщо все на місці, а критерій не зараховано — перевір, що ти зробив коміт і git push origin master.

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

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

Записуй два види зауважень: чого людина не зрозуміла (де зупинилася, що шукала) і що зламалося (де сторінка повелася не так). Створи файл ~/app/FEEDBACK.md:

# Що сказали люди

## 1. Сестра, телефон, мобільний інтернет
Пробувала: додати запис і подивитися список.
Зупинилася на: не зрозуміла, що робить кнопка «Зберегти» — думала, що вона надсилає щось мені.
Сказала дослівно: «а це кудись відправиться?»
Що з цим робити: перейменувати кнопку на «Додати до списку».

## 2. Дмитро з паралельного класу, ноутбук, шкільний Wi-Fi
Пробував: ...
Зупинився на: ...
Сказав дослівно: ...
Що з цим робити: ...

Рядок «сказав дослівно» — найважливіший. Свій переказ ти через тиждень прочитаєш як «і так усе зрозуміло», а чужа фраза лишиться такою, як була. З цього файлу складеться список правок перед показом сервісу всім на занятті 12.

cd ~/app
git add FEEDBACK.md
git commit -m "FEEDBACK: зауваження трьох людей після показу сервісу"
git push origin master

Без git push файл лишиться тільки на сервері, і викладач його не побачить.

Друге. Відкрий свою адресу з телефона, вимкнувши Wi-Fi, — саме на мобільному інтернеті. Заміряй секундоміром, скільки минає від натискання посилання до моменту, коли сторінка повністю з’явилася. Додай це число окремим рядком у розділ «Швидкість роботи» в README.md, з позначкою, що вимір із телефона: числа з сервера й числа з телефона відрізняються, і обидва варто мати. Після правки README теж збережи й надішли:

cd ~/app
git add README.md
git commit -m "README: час завантаження з телефона на мобільному інтернеті"
git push origin master

Критерій L10.4 дивиться на README, який лежить у надісланому репозиторії, тому git push тут обов’язковий.

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

Браузер пише «не вдалося знайти сервер» за іменем сабдомена

Спершу перевір, чи знає це ім’я DNS:

getent hosts bohdan.g9.klub.example

Порожня відповідь означає, що запис ще не з’явився або ще не дійшов до цієї мережі. Друга часта причина — одрук: зайва крапка, пропущена частина логіна, переплутані схожі літери в назві домену. Порівняй те, що набрав, із полем url у project.yaml посимвольно.

Якщо на сервері getent знаходить ім’я, а на телефоні сторінка не відкривається, річ у мережі телефона: шкільний Wi-Fi має свій DNS-сервер, і новий запис міг до нього ще не дійти. Перевір із мобільного інтернету — якщо там працює, лишається просто зачекати.

Замість сторінки — попередження про сертифікат

Прочитай, що саме написано в попередженні: причина завжди вказана дрібним текстом.

У будь-якому разі не тисни «продовжити попри попередження» — спершу розберись, чому воно з’явилося.

Замок є, а картинок немає

Відкрий F12, вкладку Console. Рядок зі словами Mixed Content означає, що сторінка відкрита по HTTPS, а файл у ній запитується по http://, і браузер його заблокував. Знайди винні рядки:

grep -n 'http://' ~/www/index.html

-n друкує номери рядків. Кожне посилання на власний файл заміни на відносне: src="foto.webp" без адреси на початку. Посилання на чужі сайти теж краще писати з https://, інакше буде те саме попередження.

Розмір сторінки після стиснення майже не змінився

Перевір по черзі три речі:

  1. Чи справді стиснулися файли: ls -lhS ~/www. Якщо поруч із foto.jpg лежить foto.webp майже такого ж розміру, картинка, найімовірніше, вже була невеликою — тоді основна вага деінде, дивись увесь список.
  2. Чи бере сторінка нові файли: grep -o 'src="[^"]*"' ~/www/index.html. Якщо там ще .jpg, сторінка тягне старі картинки, скільки їх не стискай.
  3. Чи не рахуєш ти обидва файли одразу: поки foto.jpg лежить у ~/www і згадується в розмітці, вага сторінки включає і його, і новий .webp.

Якщо скрипт узагалі завершився помилкою ModuleNotFoundError: No module named 'PIL' — бібліотеки в спільне оточення /opt/club/venv ставить адміністратор. Скажи викладачеві, самому нічого встановлювати не треба.

Медіана більша за 300 мс

Спочатку з’ясуй, де саме зникає час. Порівняй два виміри — через публічну адресу і прямо до служби:

PORT=$(grep -o -- '--port [0-9]*' ~/.config/systemd/user/club-api.service | grep -o '[0-9]*')
curl -s -o /dev/null -w 'публічна адреса: %{time_total} с\n' "$MY/api/ping"
curl -s -o /dev/null -w 'прямо до служби: %{time_total} с\n' "http://127.0.0.1:$PORT/api/ping"

Перша команда дістає номер порту з файлу служби. Служби на сервері запускає й доглядає systemd — частина операційної системи, яка стежить, щоб потрібні програми працювали й запускалися знову після перезавантаження сервера. На кожну службу в неї є файл із налаштуваннями; у твоєї це ~/.config/systemd/user/club-api.service. У цьому файлі записано, якою саме командою запускати club-api, а в тій команді стоїть і номер порту — звідти grep його й дістає. Правити цей файл сьогодні не треба.

Якщо друге число теж велике, затримка в твоєму коді: подивись, чи не відкриває /api/ping базу на кожен запит, чи не робить він важкого запиту без потреби і чи не лишилося в ньому print, який друкує великий список у журнал. Якщо друге число маленьке, а перше велике, справа не в коді — покажи обидва числа викладачеві.

Окремо перевір, що служба не перезапускається постійно: systemctl --user status club-api. Рядок active (running) і час роботи в кілька годин — усе гаразд. Якщо служба запустилася секунду тому, подивись останні тридцять рядків її журналу: journalctl --user -u club-api -n 30. Там буде видно, через що вона зупинилася минулого разу. Перший запит після запуску завжди найповільніший.