✻ Урок 2.4 · Тема 2: Сеть, HTTP, DNS, nginx и TLS
HTTP: запросы, заголовки, коды ответов, curl
Содержание урока
Зачем это нужно
Почти любой инцидент (внезапная поломка сервиса, урок 10.1) с веб-сервисом начинается словами «отдаёт 502» или «тормозит». HTTP это язык, на котором браузер и сервер разговаривают в вебе: запрос «дай страницу» и ответ «вот она» или «нет такой». Каждый ответ начинается с трёхзначного кода ответа (status code): 200 значит «всё хорошо», 404 «нет такой страницы», 502 «сервер-посредник не дозвонился до приложения». Код сразу говорит, чья это проблема: клиента (коды 4xx), приложения (500) или того, что стоит перед ним (502, 503, 504). Прокси (proxy) это такой посредник: программа, которая принимает запрос от клиента и пересылает его настоящему серверу, а ответ возвращает обратно; пример, nginx, урок 2.5. Заголовки (headers) это служебные строки в запросе и ответе: кто ты, какой формат принимаешь, сколько байтов в теле, как конверт с пометками. Кто не умеет собрать запрос руками и прочитать заголовки, гадает по скриншотам из браузера.
На работе ты будешь каждый день использовать curl (консольная программа, которая отправляет HTTP-запрос и печатает ответ, как браузер без окна): проверять сервис после выкладки новой версии, воспроизводить жалобу клиента, мерить время ответа, проверять, что прокси передаёт нужные заголовки. Вопросы про коды ответов и curl задают на каждом собеседовании.
Шаг проекта: app.py становится версией v3. Он начинает говорить на HTTP/1.1, в каждом ответе ставит правильный Content-Length (заголовок с длиной тела ответа в байтах: по нему клиент знает, где ответ кончается) и получает три демонстрационных эндпоинта: /headers, /slow и /error. Эндпоинт (endpoint) это адрес внутри сервиса, который отвечает за одно действие: например, /healthz отвечает «жив ли сервис».
Что нужно знать
- Урок 2.1: адреса и маршруты - IP-адрес, шлюз и байты: HTTP-запрос уходит в сеть пакетами, которые идут по маршруту.
- Урок 2.2: порты, TCP и SSH - HTTP едет поверх TCP-соединения с портом сервера; ошибки
Connection refusedиtimed outвозникают до HTTP. Там жеnc(netcat): маленькая утилита, которая открывает TCP-соединение и позволяет печатать в него текст вручную; она понадобится для запроса руками. - Урок 2.3: DNS - запись
notes.labв/etc/hosts: имя из URL (URL это полный веб-адрес, напримерhttp://notes.lab:8080/notes: схема, имя, порт, путь) попадает в заголовокHost. - Урок 1.4: процессы и сигналы - что происходит при остановке и перезапуске приложения.
- Урок 1.8: systemd - «Заметки» работают как сервис
notes; перезапуск черезsystemctl, лог черезjournalctl.
Состояние стенда на начало урока: сервис notes запущен от пользователя notes, слушает 127.0.0.1:8080 и работает на версии v2.2 (код в /opt/notes/app.py, твоя рабочая копия в ~/notes/app.py), в /etc/hosts есть строка 127.0.0.1 notes.lab. Слова «экземпляр» и «состояние стенда» здесь значат простое: экземпляр это одна запущенная копия программы, а состояние стенда это то, что должно быть настроено до начала урока. Приложение отдельно в терминале запускать не нужно: им управляет systemd, а второй экземпляр упрётся в занятый порт 8080.
Картина целиком
Открывать сайт похоже на заказ по почте. Ты берёшь бланк, пишешь сверху, что хочешь («прислать каталог»), в графах указываешь, кто ты и в каком формате принимаешь ответ, и отправляешь. В ответ приходит письмо: сверху отметка «выполнено» или «такого товара нет», ниже служебные графы, а в конце содержимое. Бланк и ответное письмо устроены по правилам, которые знают обе стороны. Эти правила и есть HTTP. Оговорка: у почты письмо идёт один раз, а по одному сетевому соединению HTTP умеет обменяться десятком писем подряд.
Вот что происходит между «ввёл адрес» и «увидел страницу»:
flowchart TD
U["Ты вводишь: https://notes.example.com/notes"] --> S1["1. DNS: имя превращается в IP<br>урок 2.3"]
S1 --> S2["2. Маршрут: пакеты находят дорогу<br>урок 2.1"]
S2 --> S3["3. TCP: соединение с портом 443<br>урок 2.2"]
S3 --> S4["4. TLS: канал шифруется<br>урок 2.6"]
S4 --> S5["5. HTTP-запрос: «GET /notes»<br>этот урок"]
S5 --> S6["6. HTTP-ответ: код, заголовки и тело<br>этот урок"]
Шаги 1-4 готовят канал, шаги 5 и 6 это сам HTTP. После ответа соединение остаётся открытым для следующих запросов.
Шаги 1-4 готовят канал: узнали адрес (урок 2.3), проложили путь (урок 2.1), открыли соединение с портом (урок 2.2), зашифровали его (урок 2.6). Шаги 5 и 6, сам разговор внутри готового канала, разбираются здесь. В нашем стенде адрес http://notes.lab:8080, то есть без шифрования: шаг 4 пропускается, остальное как на схеме. На сервере запрос сначала принимает веб-сервер nginx и передаёт его приложению, это тема урока 2.5. Пока считай, что клиент разговаривает с приложением напрямую.
За урок ты разберёшь запрос и ответ по частям, научишься по коду ответа определять, «чья это проблема», поймёшь, зачем нужны заголовки и почему Content-Length так важен, и получишь curl как инструмент, которым проверяется всё перечисленное.
Теория
Что такое HTTP и из чего состоит адрес
Программе-клиенту (браузеру, curl, мобильному приложению) нужно попросить программу-сервер о чём-то и получить понятный ответ. Если каждая пара придумывает свой формат, ничего не совместимо: браузер не откроет чужой сайт. Поэтому договорились об одном общем формате. Он называется HTTP (HyperText Transfer Protocol, «протокол передачи гипертекста»). Протокол (protocol) это набор правил: кто пишет первым, в каком порядке и в каком виде.
Вспомни заказ по почте бланком из «Картины целиком»: у бланка строгая форма, поэтому любой почтовый работник его поймёт. Где аналогия ломается: HTTP не помнит прошлые заказы. Каждый запрос это новый бланк, сервер сам не знает, что ты уже писал ему секунду назад (потому и нужны отдельные механизмы вроде токенов, о них ниже).
HTTP это текстовый протокол «запрос-ответ» поверх TCP. Напомним из урока 2.2: TCP устанавливает соединение между IP-адресом и портом клиента и IP-адресом и портом сервера и гарантирует, что байты дойдут целыми и по порядку. HTTP лежит на нём как письмо в конверте: TCP довозит, HTTP объясняет, что в письме. Порядок всегда один:
sequenceDiagram
participant C as Клиент
participant S as Сервер
Note over C,S: TCP-соединение уже установлено
C->>S: запрос: «GET /healthz»
Note over S: ищет, что ответить
S->>C: ответ: «200 OK», заголовки, тело
Инициатор всегда клиент: сервер сам ничего не присылает, он только отвечает на запросы. Слово «текстовый» значит, что запрос и ответ можно прочитать глазами, это обычные строки. Ниже ты отправишь запрос без всяких программ, только текстом.
Адрес, по которому клиент обращается, называется URL (Uniform Resource Locator, «единый указатель ресурса»). Разберём его на настоящем примере из этого урока:
http://notes.lab:8080/slow?sec=2
\__/ \______/ \__/ \___/ \____/
| | | | |
| | | | параметры запроса: sec равно 2
| | | путь: какой ресурс (эндпоинт) на сервере
| | порт (для http по умолчанию 80, для https 443)
| хост: имя или IP сервера
схема: какой протокол (http или https)
Схема https это тот же HTTP, но внутри шифрованного канала TLS (урок 2.6). Хост клиент превращает в адрес через DNS (урок 2.3). Знак ? отделяет путь от параметров запроса (query string): пар имя=значение, которые несколько раз соединяют знаком &, например ?sec=2&mode=fast. Если в значении встречаются пробел или кириллица, их кодируют: пробел становится %20. Поэтому сырой пробел в URL недопустим, и curl отказывается его отправлять (ошибка в практике).
Осторожно: Путь и имя файла. /notes не обязательно лежит файлом на диске: это условное название, а что за ним стоит, решает программа. В нашем приложении путь /notes означает «список заметок», и ни одного файла с таким именем нет.
Прикинь сам: на каком порту окажется запрос к
http://shop.lab/cart?id=7, если порт в адресе не указан?
На 80: это порт по умолчанию для http. Для https было бы 443.
Главное: адрес состоит из схемы, хоста, порта, пути и параметров после
?.
Проверь понимание: в URL
http://shop.lab/cart?id=7назови схему, хост, порт, путь и параметры.
Ответ
Схема http, хост shop.lab, порт не указан, значит 80 (стандартный для http), путь /cart, параметры id=7.
Мы знаем, что такое HTTP и из чего состоит адрес. Теперь разберём сам запрос: из каких частей он собран.
Запрос по частям
Серверу нужно узнать про запрос три вещи: что сделать, над чем и с какими условиями. Для каждой отдельное место, поэтому запрос состоит из трёх частей.
Сам запрос похож на бланк заказа: в первой строке крупно «что сделать и что именно», ниже графы с подробностями (кто заказывает, в каком виде отвечать), в конце приложенный текст. Оговорка: приложение, «тело», бывает не у всех бланков.
Запрос состоит из:
- Стартовой строки (request line): метод (method), путь (path) и версия протокола, через пробелы. Метод говорит, что сделать.
- Заголовков (headers): по одному на строке, в виде
Имя: значение. Это служебные сведения о запросе. - Пустой строки, которая отделяет заголовки от тела.
- Тела (body), необязательного: сами данные, которые ты отправляешь.
Вот запрос на создание заметки, каким его видит сервер:
POST /notes HTTP/1.1
Host: notes.lab
Content-Type: application/json
Content-Length: 32
{"text":"купить хлеб"}
POSTметод: «создай»./notesпуть: «в списке заметок».HTTP/1.1версия протокола.Host: notes.labкакое имя ты запрашивал (зачем оно нужно, в разделе про заголовки).Content-Type: application/jsonформат тела. JSON (JavaScript Object Notation) это текстовый формат для данных: фигурные скобки, ключи в кавычках, двоеточие и значение. Так почти все веб-сервисы обмениваются данными.Content-Length: 32длина тела в байтах. Посчитаем:{"text":"это 9 символов,купить хлебэто 11 символов,"}это 2. Всего 22 символа. Но русская буква в кодировке UTF-8 занимает 2 байта: 10 букв дают 20 байт, пробел 1 байт. Итого 9 + 21 + 2 = 32 байта, а не 22. Запомни это расхождение: длину всегда считают в байтах, а не в символах, и мы ещё вернёмся к нему, когда речь дойдёт до ломающихся ответов.- Пустая строка между заголовками и телом обязательна: по ней сервер понимает, что заголовки закончились.
Строки в HTTP разделены не одним символом перевода строки \n, а парой \r\n (CRLF: возврат каретки и перевод строки, наследие пишущих машинок). Это важно, когда шлёшь запрос руками через nc: нужно самому поставить \r\n в конце каждой строки.
Осторожно: Тело есть не у каждого запроса. GET /healthz отправляется без тела, всё нужное содержится в пути и заголовках. И наоборот: положить данные в тело GET формально можно, но так не делают, и многие серверы его проигнорируют.
Прикинь сам: чем запрос отличается от ответа по структуре?
Почти ничем, кроме первой строки: у запроса это метод, путь и версия, у ответа версия, код и пояснение. Дальше у обоих заголовки, пустая строка и тело.
Главное: сообщение HTTP это стартовая строка, заголовки, пустая строка и тело, строки разделены
\r\n.
Проверь понимание: сколько байт в теле
{"text":"хлеб"}?
Ответ
{"text":" 9 байт, слово хлеб 4 буквы по 2 байта это 8, затем "} 2 байта. Итого 19. Символов было бы 15.
Первая строка запроса начинается с метода. Что означают методы и чем они различаются, разберём дальше.
Методы: что сделать с ресурсом
Один и тот же путь /notes можно захотеть прочитать, дополнить или удалить. Чтобы для этого не придумывать разные пути, действие вынесено в отдельное слово: метод.
Метод напоминает библиотечную карточку: с одной и той же книгой можно сделать «прочитать», «добавить новую», «заменить», «списать». Оговорка: сервер не обязан выполнять то, что означает слово. Метод это договорённость, а не магия: DELETE ничего не удалит, если программист не написал соответствующий код.
Основные методы:
| Метод | Что означает | Есть ли тело | Безопасен (ничего не меняет) |
|---|---|---|---|
GET |
прочитать | нет | да |
HEAD |
то же, что GET, но ответ без тела: только заголовки |
нет | да |
POST |
создать (или выполнить действие) | да | нет |
PUT |
заменить целиком | да | нет |
DELETE |
удалить | обычно нет | нет |
Для надёжности важно свойство идемпотентности (idempotent). Метод идемпотентный, если повтор запроса даёт то же состояние, что и одно выполнение. GET, PUT, DELETE, HEAD идемпотентны: «замени заметку на этот текст» два раза подряд даёт тот же результат, что один. POST нет: «создай заметку» два раза подряд создаёт две заметки.
Представь, что ты нажал «Оформить заказ», страница подвисла, и ты нажал ещё раз. Если «оформить» сделано через POST, а сервис ничего не проверяет, заказов будет два. Поэтому клиент спокойно повторяет GET после обрыва связи, а повторять POST вслепую нельзя. Для платежей клиент добавляет к запросу уникальный ключ в заголовке Idempotency-Key. Сервер запоминает: «этот ключ уже обработан» и на повтор возвращает результат первого запроса, а не создаёт новый заказ.
Осторожно: «POST для создания, PUT для изменения» верно лишь примерно. PUT заменяет ресурс целиком по известному адресу и идемпотентен, POST создаёт новый, адрес которого назначает сервер. И ещё одна путаница: HEAD не «странный GET», а способ дёшево узнать заголовки, например размер файла, не скачивая его.
Прикинь сам: какие методы можно безопасно повторить при обрыве связи?
Идемпотентные: GET, HEAD, PUT, DELETE. Повтор даёт то же состояние. POST повторять нельзя без защиты: получишь дубль.
Главное: POST не идемпотентен, от дублей защищают
Idempotency-Keyили уникальное ограничение в базе.
Проверь понимание: ты дважды нажал «Оформить заказ», а сервис создал два заказа. Какое свойство метода
POSTтут сработало и чем от этого защищаются?
Ответ
POST не идемпотентен: каждый вызов создаёт новую сущность. Защищаются ключом идемпотентности (Idempotency-Key) или уникальным ограничением в базе данных (два одинаковых заказа она не пропустит).
Запрос отправлен, и сервер должен ответить. Посмотрим, как устроен ответ и что говорят коды статуса.
Ответ по частям и коды статуса
Клиенту нужно быстро понять три вещи: получилось ли, если нет, то по чьей вине, и что делать дальше. Для этого ответ начинается с числа, кода статуса (status code), а подробности идут следом.
Ответ похож на ответное письмо на бланке: сверху штамп «выполнено» или «отказ» с номером причины, ниже служебные графы, в конце само содержимое. Оговорка: штамп ставит сам сервер, и он может ошибиться или солгать: 200 на ответ, в теле которого написано «ошибка», встречается, хотя так делать не надо.
Ответ состоит из тех же частей, что и запрос, но стартовая строка другая:
HTTP/1.1 201 Created <- строка статуса: версия, код, пояснение
Server: BaseHTTP/0.6 Python/3.12.3 <- заголовки ответа
Date: Wed, 30 Sep 2026 12:00:24 GMT
Content-Type: application/json; charset=utf-8
Content-Length: 9
<- пустая строка
{"id": 1} <- тело
Код это число из трёх цифр, следом идёт пояснение для людей (Created, Not Found), которое клиент не разбирает. Первая цифра кода это класс, и она первой отвечает на вопрос «где искать проблему»:
| Класс | Смысл | Примеры |
|---|---|---|
| 2xx | успех | 200 OK, 201 Created, 204 No Content |
| 3xx | «иди в другое место» (перенаправление) | 301 (навсегда), 302 (временно), 304 Not Modified |
| 4xx | ошибка клиента: запрос неправильный | 400, 401, 403, 404, 405, 429 |
| 5xx | ошибка сервера: запрос был нормальный, но не получилось | 500, 502, 503, 504 |
Полезно помнить, что 4xx чаще вина клиента (неверный путь, нет прав, битое тело), а 5xx наша, серверная. Поэтому для SRE 5xx важнее: именно они съедают SLO (Service Level Objective, целевой уровень надёжности: например, «99,9% запросов заканчиваются успешно»). Если сервис отвечает 500 на каждый десятый запрос, он нарушает такой SLO, даже если работает.
Различие видно на живых запросах. Все три «ответа про отсутствие» на настоящих запросах к нашему приложению, они различаются кодом и смыслом:
GET /nope -> 404 Not Found пути /nope нет вообще
DELETE /notes -> 405 Method Not Allowed путь /notes есть, метод DELETE для него не разрешён
POST /notes -> 400 Bad Request путь и метод верные, но тело запроса пустое
Коды 404 и 405 нередко путают, а различие ценное: 404 говорит «ошибся адресом», 405 говорит «адрес верный, ошибся действием». При 405 сервер обязан вернуть заголовок Allow со списком допустимых методов, чтобы клиент мог подсказать себе, что делать.
Ещё пары, которые путают:
- 401 и 403. 401 значит «ты не представился»: нет учётных данных или они неверные. 403 значит «представился, но прав нет». Без токена 401, с токеном обычного пользователя на админский путь 403.
- 500. Приложение само ответило, что внутри что-то сломалось (необработанная ошибка в коде). Смотри лог приложения.
- 502, 503, 504. Отвечает не приложение, а то, что стоит перед ним: прокси (proxy, программа-посредник, которая принимает запросы клиентов и передаёт их приложению; в курсе это nginx, урок 2.5) или балансировщик (программа, которая раздаёт запросы между несколькими копиями приложения). 502 (Bad Gateway): прокси не получил внятного ответа от приложения, например порт закрыт. 503 (Service Unavailable): сервис сам сказал «не готов» или прокси не нашёл ни одной живой копии. 504 (Gateway Timeout): прокси соединился, но ответа не дождался.
- 429. Клиент превысил лимит запросов. Сервер в заголовке
Retry-Afterподсказывает, через сколько секунд повторить. Повторять нужно с растущей паузой: 1 секунда, 2, 4, 8. Такая схема называется backoff (отступление), она не даёт лавине повторов добить сервер.
Схема «кто из участников цепочки мог ответить»:
flowchart LR
C["Клиент"] --> P["Прокси (nginx)<br>отвечает 502, 503, 504:<br>«приложение не ответило как надо»"]
P --> A["Приложение<br>само формирует ответ:<br>200, 404, 405, 500"]
Код подсказывает, кто в цепочке сформировал ответ: 502, 503 и 504 это слово прокси, а 200, 404, 405 и 500 слово приложения.
Если в ответе Server: nginx, а код 502, значит, приложение дошло до прокси плохо или вообще не дошло. Если код 500 от самого приложения, его же лог и надо читать. Первый вопрос при любой жалобе: кто прислал этот код.
Осторожно: «5xx значит, что сервер сломан». Не всегда: 503 может быть намеренным ответом «я перегружен, подожди». Другое частое заблуждение: «404 это ошибка сервера». Нет, 404 это ответ, что искомого нет, и сервер при этом здоров.
Прикинь сам: 405 и 404, в чём разница?
404: такого пути нет. 405: путь есть, но метод для него не разрешён, сервер в заголовке Allow перечисляет допустимые.
Главное: первая цифра кода говорит, чья проблема: 4xx клиента, 5xx сервера. 502, 503, 504 отвечает прокси, 500 само приложение.
Проверь понимание:
curlполучил 502, а в логе приложения за это время нет ни одной записи. Что это говорит?
Ответ
Приложение запросов не получало: 502 сформировал прокси, потому что не смог с ним связаться (приложение не запущено, слушает другой порт или недоступно по сети). Искать надо не в коде приложения, а в его статусе и в error.log прокси.
В запросе и ответе есть ещё служебная часть, которую мы пока лишь упоминали. Это заголовки.
Заголовки: служебная переписка между клиентом и сервером
Кроме «что сделать» и данных, сторонам нужно обмениваться условиями: на каком языке отвечать, кто спрашивает, сколько длится тело, можно ли хранить копию. Чтобы не менять формат запроса под каждую мелочь, всё это вынесено в заголовки: список пар Имя: значение, к которому можно добавлять свои.
Заголовки это графы на бланке: «фамилия», «обратный адрес», «формат ответа». Оговорка: часть граф заполняет сам клиент (браузер или curl), а часть добавляют посредники по дороге, и клиент об этом не знает.
Имена заголовков не зависят от регистра (host и Host одно и то же), значения зависят по смыслу. Самые нужные:
Host: какое имя сайта запросил клиент. Обязателен в HTTP/1.1.Content-Type: формат тела (application/json,text/plain). Без него сервер может не разобрать тело.Content-Length: длина тела в байтах. По нему клиент понимает, где кончился ответ.Authorization: учётные данные. Часто в видеBearer <токен>: токен это длинная случайная строка, которую сервер выдал тебе как «пропуск»; словоBearer(«предъявитель») значит «пропуск действует для того, кто его предъявил». Токены нельзя показывать в скриншотах и логах.User-Agent: кто спрашивает (curl/8.5.0, название браузера).Cache-Control: можно ли хранить ответ в кэше (cache, временной копии: чтобы не спрашивать сервер, пока копия свежая) и как долго.X-Forwarded-For: настоящий адрес клиента, который добавляет прокси. Без него приложение за прокси видит адрес только самого прокси.Allow: в ответе 405 или на запросOPTIONS, список допустимых методов.
Заголовки с приставкой X- не входят в стандарт, а придуманы сообществом. Но X-Forwarded-For и X-Forwarded-Proto встречаются везде.
Хороший пример заголовка Host это виртуальные хосты. Один сервер с одним IP-адресом и портом 80 может обслуживать десятки сайтов. Как он поймёт, чей сайт открыть, если все запросы приходят на один и тот же адрес? По заголовку Host:
один сервер, IP 203.0.113.10, порт 80
+-----------------------------------+
запрос "Host: shop.lab" ---> -> сайт магазина
запрос "Host: blog.lab" ---> nginx смотрит в Host -> сайт блога
запрос "Host: notes.lab" ---> -> приложение «Заметки»
+-----------------------------------+
Такая схема называется виртуальными хостами (virtual hosts). Она объясняет, почему при обращении к сайту по «голому» IP часто видишь не тот сайт, а страницу-заглушку: заголовок Host содержит адрес, а не имя, и сервер не знает, какой сайт показать. Эту особенность ты будешь использовать, когда попадёшь на nginx (урок 2.5), а эндпоинт /headers из этого урока покажет заголовки глазами приложения.
Осторожно: Заголовки не защищены. Клиент может написать в них что угодно: X-Forwarded-For: 1.2.3.4 или чужое имя в Host. Поэтому приложение доверяет X-Forwarded-For только когда запрос пришёл от известного прокси, иначе злоумышленник подделает свой адрес в логах.
Прикинь сам: какой IP клиента увидит приложение за nginx без дополнительных заголовков?
IP самого nginx: соединение приходит от него. Настоящий адрес прокси передаёт в X-Forwarded-For.
Главное: заголовки это служебная переписка.
Hostвыбирает виртуальный хост,X-Forwarded-Forклиент может подделать.
Проверь понимание: зачем приложению за прокси заголовок
X-Forwarded-For, если TCP-соединение и так содержит адрес клиента?
Ответ
Соединение приложение получает от прокси, а не от клиента, поэтому в нём адрес прокси. Настоящий адрес клиента прокси кладёт в X-Forwarded-For, и только так приложение узнаёт, кто пришёл на самом деле (для логов, лимитов, ограничения по странам).
Заголовки описывают сообщение, но не канал, по которому оно едет. Дальше разберём, как живёт соединение и как приёмник понимает, где сообщение кончилось.
Соединение: keep-alive и Content-Length
Установка TCP-соединения стоит времени: три сообщения туда-обратно (рукопожатие, урок 2.2). Если на каждый запрос открывать новое соединение, страница с тридцатью картинками сделает тридцать рукопожатий. Поэтому соединение стараются использовать повторно.
Соединение похоже на телефонный звонок: можно позвонить, задать один вопрос и положить трубку, а можно остаться на линии и задать десять. Оговорка: пока трубка снята, линия занята, и у сервера число одновременных соединений ограничено.
С версиями протокола поведение разное:
- HTTP/1.0: на каждый запрос новое соединение. Сервер отвечает и закрывает его. Клиент понимает, что ответ закончился, по закрытию соединения.
- HTTP/1.1: по умолчанию соединение остаётся открытым (keep-alive, «остаться живым»). По одному соединению идут запросы один за другим.
Но тут возникает вопрос: если сервер больше не закрывает соединение, как клиент узнает, что ответ закончился? Нужна явная граница. Их две:
- Заголовок
Content-Length: N: «в теле ровно N байт». Клиент читает N байт и знает, что ответ кончился. - Заголовок
Transfer-Encoding: chunked(передача кусками): сервер шлёт тело частями, каждая с длиной, последняя длиной 0. Это нужно, когда размер заранее неизвестен (например, ответ формируется по мере готовности).
одно TCP-соединение, HTTP/1.1, keep-alive:
клиент сервер
|-- GET /healthz ---------------------------------->|
|<-- 200, Content-Length: 2, тело "ok" ------------| клиент прочитал ровно 2 байта
|-- GET / ------------------------------------------>| то же соединение, без нового рукопожатия
|<-- 200, Content-Length: 19, тело "Notes service..."|
| (соединение остаётся открытым для следующего) |
Что будет, если Content-Length неверный? Возьмём ответ, где сервер написал Content-Length: 100, а отдал только 40 байт и оставил соединение открытым. Клиент считает: «ответ ещё не кончился, жду ещё 60 байт». Сервер молчит, клиент висит до таймаута. В опыте из практики ты увидишь такой вывод curl:
curl: (28) Operation timed out after 3008 milliseconds with 40 out of 100 bytes received
Если сервер после 40 байт закрыл соединение, клиент поймёт, что данных не хватило, и закончит с ошибкой curl: (18) transfer closed with 60 bytes remaining to read («соединение закрыто, осталось прочитать 60 байт»).
Обратный случай: заявлена длина 5, а отдано 10. Клиент честно прочтёт 5 байт и решит, что ответ закончился, а оставшиеся 5 байт останутся в соединении. Когда по тому же соединению придёт следующий ответ, эти лишние байты станут его «началом» и всё испортят: клиент увидит мусор вместо строки HTTP/1.1 200 OK.
Отсюда правило для любого самописного сервера: Content-Length всегда равен реальной длине тела в байтах. Самая частая ошибка: посчитать длину не в байтах, а в символах строки. Для английского текста разницы нет (1 символ = 1 байт), для русского есть: см. пример с 22 символами и 32 байтами выше. Поэтому в коде app.py v3 длина считается так: str(len(data)), где data это уже закодированные байты.
Стандартный обработчик Python (BaseHTTPRequestHandler, класс из стандартной библиотеки, на котором построены «Заметки») по умолчанию говорит на HTTP/1.0 и закрывает соединение после ответа. Чтобы включить HTTP/1.1 с keep-alive, нужно задать protocol_version = "HTTP/1.1", и с этого момента обязанность ставить правильный Content-Length в каждом ответе ложится на нас.
Осторожно: «HTTP/1.1 это то же самое, что HTTP/1.0 с другим номером». Нет: главное отличие в том, что соединение живёт дольше, и это меняет ответственность сервера. Ещё есть HTTP/2 и HTTP/3, которые сейчас использует большинство сайтов в интернете: они упаковывают те же методы, коды и заголовки иначе (двоичными кадрами), но всё, что ты выучил здесь, работает и там. Изучать формат на HTTP/1.1 удобно именно потому, что он текстовый.
Прикинь сам: сервер обещал 100 байт, а прислал 60 и закрыл соединение. Что увидит
curl?
Ошибку curl: (18) transfer closed with 40 bytes remaining. Если сервер просто молчит, клиент зависнет в ожидании.
Главное: границы ответа задаёт
Content-LengthилиTransfer-Encoding: chunked. HTTP/1.1 держит соединение открытым (keep-alive).
Проверь понимание: сервер прислал
Content-Length: 100, но отдал 40 байт и оставил соединение открытым. Что увидит клиент?
Ответ
Клиент будет ждать оставшиеся 60 байт до таймаута: запрос «повиснет». Если сервер закроет соединение раньше, клиент сообщит, что передача оборвана (transfer closed with 60 bytes remaining to read в curl). Это типичная причина «зависших» запросов при самописном сервере.
Content-Length считает байты, а не символы. Почему это разные вещи, нужно понять, чтобы не ошибаться с длиной.
Кодировки: почему байты и символы это разные вещи
Компьютер хранит и передаёт только числа, байты. Текст на экране это символы: буквы, цифры, знаки. Чтобы превратить символы в байты и обратно, нужна договорённость, кодировка (encoding): таблица «символ и его байты». Без неё получится «кракозябры», как в старых текстах, где вместо русских букв видны непонятные знаки. HTTP передаёт байты, а не символы, поэтому вопрос кодировки возникает в каждом ответе с не-английским текстом.
Кодировка работает как азбука Морзе: буква превращается в набор точек и тире, и приёмщик обязан знать, по какой таблице расшифровывать. Оговорка: в Морзе у каждой буквы свой набор, а в современных кодировках разные символы занимают разное число байт, и это как раз источник ошибок.
Стандартная кодировка интернета называется UTF-8. В ней английская буква, цифра и знак препинания занимают 1 байт, русская буква 2 байта, а эмодзи и редкие иероглифы до 4 байт. Кодировка тела указывается в заголовке Content-Type: text/plain; charset=utf-8 значит «это текст в кодировке UTF-8». Если сервер и клиент понимают кодировку по-разному, вместо букв появляются ошибки вроде пÑ....
Возьмём слово хлеб и посчитаем, сколько оно занимает:
символы: х л е б 4 символа
байты: 2 2 2 2 8 байт (каждая русская буква 2 байта в UTF-8)
слово "bread": b r e a d 5 символов, 5 байт (английские по 1 байту)
Длину строки в Python даёт len("хлеб"), и это 4: считаются символы. Чтобы получить байты, строку сначала кодируют: "хлеб".encode("utf-8"), и у результата len уже 8. Именно так устроен _send в приложении: data = body.encode("utf-8"), потом len(data). Для Content-Length нужны только байты.
Осторожно: «Символ равно байт»: это верно только для английского текста, поэтому ошибка с длиной годами живёт незамеченной: на тестах с английским всё работает, а на первом русском сообщении ответ обрезается. И ещё: curl и браузер сами не «исправляют» неверную длину, они верят заголовку.
Прикинь сам: почему длина тела в байтах не равна числу символов?
Кириллица в UTF-8 занимает по 2 байта на букву, латиница по 1.
Главное: по сети идут байты.
Content-Lengthсчитает байты, а не символы.
Проверь понимание: сколько байт в UTF-8 у строки
Привет? А в заголовке, если ошибочно посчитать символы, какое число получится?
Ответ
В слове 6 русских букв, по 2 байта, значит 12 байт. По символам получилось бы 6: клиент прочтёт только первые 6 байт (три буквы При) и отбросит остальное.
Мы научились читать ответ целиком. Теперь разберём случай, когда сервер отвечает не данными, а указанием идти по другому адресу.
Перенаправления: коды 3xx
Адреса меняются: сайт переехал на другое имя, страница получила новый путь, http заменили на https. Чтобы старые ссылки не сломались, сервер отвечает не страницей, а указанием «иди туда». Это перенаправление (redirect).
Перенаправление похоже на табличку на закрытом магазине: «Мы переехали, новый адрес такой-то». Ты не получил товар, но знаешь, куда идти. Оговорка: табличка бывает «навсегда» и «на время ремонта», и от этого зависит, запомнишь ли ты новый адрес.
Сервер отвечает кодом 3xx и заголовком Location с новым адресом. Клиент (браузер, curl -L) повторяет запрос по новому адресу. Самые частые:
| Код | Смысл | Что делает клиент |
|---|---|---|
| 301 | переехали навсегда | запоминает новый адрес; для GET обычно так и оставляет метод |
| 302 | переехали на время | идёт по новому адресу, но старый не забывает |
| 304 | не изменилось (см. кэш ниже) | берёт свою сохранённую копию |
| 307, 308 | как 302 и 301, но метод и тело сохраняются | повторяет тот же POST |
Допустим, ты открыл http://example.com/, а сервер хочет, чтобы всё шло по https. Он отвечает:
HTTP/1.1 301 Moved Permanently
Location: https://example.com/
Content-Length: 0
Без ключа -L curl покажет этот ответ и остановится. С -L он сделает второй запрос по адресу из Location и покажет уже итоговую страницу. Между ними два запроса и два кода, поэтому в логах сервера ты увидишь и 301, и потом 200.
Осторожно: «301 и 302 одно и то же». Для человека почти да, для поисковиков и кэшей нет: 301 запоминается надолго, и неверно выданный 301 потом сложно «забыть» даже после исправления. Ещё ловушка: цикл перенаправлений (A ведёт на B, B ведёт на A) даёт ошибку curl: (47) Maximum (50) redirects followed.
Прикинь сам: чем 301 отличается от 307?
301 навсегда, при переходе метод могут сменить на GET. 307 временный и сохраняет метод, 308 то же для постоянного.
Главное: адрес нового места лежит в
Location,curlидёт по нему только с ключом-L.
Проверь понимание: ты поменял адрес сервиса и хочешь, чтобы клиенты перешли на новый навсегда. Какой код отдашь и в каком заголовке передашь новый адрес?
Ответ
Код 301 (или 308, если важно сохранить метод и тело запроса), новый адрес в заголовке Location.
Перенаправление говорит, куда идти. Но иногда можно вообще не ходить за тем, что уже есть: так работает кэш.
Кэш и условные запросы: как не качать одно и то же
Картинка логотипа не меняется месяцами. Если браузер будет скачивать её на каждой странице, будут тратиться трафик и время. Придумали кэш (cache): временную копию ответа, которую используют, пока она свежая.
Ты записал телефон соседа в записную книжку, а не звонишь в справочную каждый раз. Оговорка: если сосед сменил номер, книжка устарела, и нужно правило, когда проверять снова.
Сервер в ответе даёт заголовок Cache-Control с правилом хранения, например max-age=3600 («копия свежая один час») или no-store («вообще не сохранять»). Когда срок вышел, клиент не обязан скачивать всё заново: он задаёт вопрос «изменилось ли с последнего раза?». В прошлом ответе был заголовок ETag (ETag, entity tag: короткая метка версии содержимого, например хэш). Клиент присылает её обратно в If-None-Match. Если метка совпала, сервер отвечает 304 Not Modified без тела (всего несколько байт), и клиент берёт копию.
1-й раз: клиент --- GET /logo.png ----------------> сервер
<-- 200, ETag: "v7", тело 50 КБ ---
2-й раз: клиент --- GET /logo.png, If-None-Match: "v7" --> сервер
<-- 304 Not Modified, тела нет ----------- (экономия 50 КБ)
Осторожно: что 304 это ошибка. Нет: это успех, «ничего нового». Второе заблуждение: кэш опасен только для картинок. На деле кэшировать данные API (например, ответ с курсами валют) тоже можно, но нужно точно подумать, насколько устаревшим ответ допустим. Про нашего notes: приложение не задаёт Cache-Control, потому что список заметок должен быть свежим.
Прикинь сам: метка на сервере поменялась с
v7наv8, клиент прислалIf-None-Match: "v7". Какой код?
200 с новым телом и новой меткой. 304 был бы, только если бы метки совпали.
Главное: кэш живёт по
Cache-Control, а условный запрос сETagиIf-None-Matchэкономит трафик через 304.
Проверь понимание: клиент прислал
If-None-Match: "v7", а на сервере файл уже другой версии. Какой код вернёт сервер?
Ответ
Метка не совпала, значит содержимое изменилось: сервер вернёт 200 с новым телом и новым ETag.
Кэш помогает не повторять запросы. Но раз сервер между запросами ничего не помнит, как он узнаёт тебя при следующем обращении?
Состояние: как сервер узнаёт, что ты уже заходил
Раньше сказано, что HTTP не помнит прошлых запросов. Но в интернет-магазине ты залогинился один раз и потом ходишь по страницам без повторного входа. Значит, «память» откуда-то берётся.
Сессия работает как гардероб: ты сдал куртку и получил номерок. Гардеробщик тебя в лицо не помнит, но по номерку находит куртку. Оговорка: номерок кто-то может украсть, и тогда чужой заберёт твою куртку.
Клиент один раз доказывает, кто он (логином и паролем). Сервер выдаёт токен или сессионный идентификатор: длинную случайную строку, номерок. Дальше клиент прикладывает её к каждому запросу: в заголовке Authorization: Bearer <токен> (так делают API) или в cookie (куки: небольшая запись, которую сервер просит браузер сохранить заголовком Set-Cookie, а браузер сам присылает обратно заголовком Cookie). Сервер по строке находит, чей это запрос.
1. клиент -> сервер: POST /login {логин, пароль}
2. сервер -> клиент: 200, Set-Cookie: session=9f3a... <- номерок
3. клиент -> сервер: GET /profile, Cookie: session=9f3a...
4. сервер: «9f3a это Аня» -> отдаёт профиль Ани
Заметь: сам HTTP по-прежнему ничего не помнит, «память» реализована поверх него, через номерок в каждом запросе. Поэтому токены нельзя показывать в скриншотах, логах и чатах: кто предъявил токен, тот и «залогинился».
Осторожно: «Cookie это программа» или «cookie следят за мной». Cookie это просто текст в заголовке. Как его используют, зависит от сервера. Ещё путают, где хранить секрет: пароль передаётся один раз при входе, а потом только токен, поэтому украденный токен можно отозвать, не меняя пароль.
Прикинь сам: как сервер узнаёт тебя, если HTTP ничего не помнит между запросами?
Клиент сам присылает «номерок» (cookie или токен) в каждом запросе.
Главное: HTTP без состояния, а «память» это токен или cookie в каждом запросе.
Проверь понимание: почему протокол, который «не помнит» клиента, позволяет держать пользователя залогиненным?
Ответ
Клиент в каждом запросе присылает номерок (токен или cookie), а сервер по нему находит, чей это запрос. Память живёт не в протоколе, а в этой строке и в данных сервера.
Мы выяснили, как сервер узнаёт тебя между запросами. Теперь посмотрим, в какую упаковку заворачивают те же запросы в реальной сети.
HTTPS, HTTP/2 и HTTP/3: то же самое в другой упаковке
В работе ты встретишь не только http:// и HTTP/1.1. Нужно понимать, что меняется, а что нет, чтобы не пугаться.
Одно и то же письмо можно отправить обычной почтой, заказным в запечатанном конверте или курьером с ускоренной доставкой: содержимое то же, меняются упаковка и скорость. Оговорка: в отличие от почты, программы сами договариваются, каким способом ехать.
Способов «ехать» несколько:
- HTTPS это тот же HTTP, но внутри зашифрованного канала TLS (урок 2.6). Методы, коды и заголовки те же. Обычный порт 443 вместо 80. Отличие для тебя: подслушать и подменить запрос по дороге нельзя, а
curl -vдобавляет строки про рукопожатие TLS. - HTTP/2 упаковывает запросы и ответы в двоичные кадры и позволяет отправлять много запросов по одному соединению одновременно (у HTTP/1.1 запросы идут по очереди). Для приложения и для тебя видимая разница мала:
curl -vпокажетHTTP/2 200, а заголовки по-прежнемуИмя: значение(имена в нижнем регистре). - HTTP/3 то же самое, но поверх протокола QUIC (он основан на UDP, а не на TCP, урок 2.2). Он быстрее восстанавливается при потерях пакетов.
Почему курс учит на HTTP/1.1: он текстовый, его можно прочитать глазами и набрать руками через nc. Понимая его, ты понимаешь смысл остальных.
Осторожно: «HTTP/2 это другие коды». Нет: 404 остаётся 404. Другое заблуждение: «HTTPS шифрует всё». Он шифрует содержимое запроса (путь, заголовки, тело), но сам факт соединения с таким-то IP-адресом виден по дороге.
Прикинь сам: изменится ли смысл кода 404 при переходе на HTTP/3?
Нет. Меняется упаковка (QUIC поверх UDP), а методы, коды и заголовки остаются теми же.
Главное: HTTP/2 мультиплексирует запросы по одному соединению, HTTP/3 работает на QUIC. Смысл протокола не меняется.
Проверь понимание: что общего у запроса по HTTP/1.1 и HTTP/2?
Ответ
Смысл: те же методы, коды статуса, заголовки и тело. Различается только способ упаковки этих данных при передаче.
До сих пор мы смотрели на запрос снаружи. Теперь заглянем внутрь приложения и посмотрим, как оно его разбирает.
Как сервер разбирает запрос: что происходит внутри приложения
Когда curl получает 400, 404 или 500, полезно представлять, где именно в приложении принято решение. Тогда лог и код читаются осмысленно.
Сервер действует как регистратура в поликлинике: сначала проверяют, есть ли такой кабинет (путь), потом можно ли туда с такой целью (метод), потом смотрят, все ли документы принесены (тело), и только потом пропускают к врачу (обработчик).
Обработчик в app.py по шагам делает то же, что регистратура:
flowchart TD
Q["Пришёл запрос"] --> S1["1. Разобрать стартовую строку:<br>метод, путь, версия"]
S1 --> S2{"2. Такой путь есть?"}
S2 -->|"нет"| E404["404 Not Found"]
S2 -->|"да"| S3{"3. Метод для пути разрешён?"}
S3 -->|"нет"| E405["405 Method Not Allowed<br>(+ Allow)"]
S3 -->|"да"| S4{"4. Тело и параметры годятся?"}
S4 -->|"нет"| E400["400 Bad Request"]
S4 -->|"да"| S5["5. Выполнить действие<br>(прочитать или записать данные)"]
S5 -->|"исключение в коде"| E500["500 Internal Server Error"]
S5 --> S6["6. Ответить: код, заголовки<br>(с Content-Length), тело"]
Порядок проверок не случаен. Сначала самое дешёвое и общее (путь, метод), потом дорогое (разбор тела, работа с данными). Поэтому запрос DELETE /nope получит 404, а не 405: пути нет, и вопрос про метод не возникает. А POST /notes с пустым телом дойдёт до шага 4 и получит 400.
Осторожно: «500 значит, что в запросе ошибка». Нет: запрос мог быть безупречным, сломался код. Отсюда диагностическое правило: 4xx ищем в запросе, 5xx в логах приложения. Если 4xx выпадает на «правильный» запрос, стоит проверить, что клиент и сервер понимают контракт (какие пути, методы и поля нужны) одинаково.
Прикинь сам: в каком порядке обработчик проверяет путь, метод и тело?
Сначала путь (404), потом метод (405), потом тело (400), и только затем работа с данными.
Главное: сначала дешёвые общие проверки, потом дорогие. Исключение внутри кода даёт 500.
Проверь понимание: ты отправил
DELETE /nope. Какой код придёт: 404 или 405? Почему?
Ответ
404: приложение сначала проверяет путь, и /nope не существует. До проверки метода дело не доходит.
Приложение разобрало путь и метод, но ему ещё нужно прочитать тело. Для этого стороны договариваются о формате данных.
JSON и Content-Type: как договориться о формате данных
Тело запроса это просто набор байт. Сервер должен знать, что в них: текст, картинка или структура данных. Без явной договорённости он будет угадывать и ошибаться. Поэтому формат называют в заголовке Content-Type, а сами данные веб-сервисов обычно пишут в JSON.
Заголовок типа похож на надпись на посылке: «хрупкое», «документы», «продукты». Получатель по ней понимает, как обращаться с содержимым. Оговорка: надпись никак не проверяет содержимое, и коробка с надписью «документы» может быть пустой.
JSON состоит из нескольких элементов: объект в фигурных скобках {"ключ": значение}, список в квадратных [1, 2, 3], строки только в двойных кавычках, числа без кавычек, слова true, false, null. Значением может быть другой объект или список. Значение заголовка Content-Type: application/json говорит серверу: «в теле JSON, разбирай его как JSON». Есть и другие типы: text/plain (обычный текст), text/html (страница), application/x-www-form-urlencoded (данные веб-формы вида a=1&b=2).
Наше приложение принимает заметку в виде объекта с одним ключом text:
{"text": "купить хлеб"}
| |
| значение: строка в двойных кавычках
ключ: тоже строка в двойных кавычках
Чаще всего ошибаются так: используют одинарные кавычки ({'text': 'a'} не JSON), ставят запятую после последнего элемента или забывают экранировать кавычки внутри строки. Тогда сервер не может разобрать тело и возвращает 400 с подсказкой. В нашем приложении для такого случая ответ такой: {"error": "нужен JSON {\"text\": \"...\"}"}. Обрати внимание, что кавычки внутри строки в этом ответе записаны как \": обратная косая черта «экранирует» кавычку, показывает, что она часть строки, а не её конец.
Осторожно: что заголовок Content-Type сам преобразует данные. Он лишь заявляет формат. Если ты отправил через curl -d строку JSON, но не указал -H 'Content-Type: application/json', curl объявит application/x-www-form-urlencoded, и строгий сервер откажется такое принимать (наше приложение прощает и читает тело как JSON независимо от заголовка, поэтому в задании 2 это не сломается, но в чужих сервисах сломается).
Прикинь сам: заголовок
Content-Type: application/jsonгарантирует, что тело корректный JSON?
Нет, он лишь заявляет формат. Корректность проверяет разбор тела.
Главное: в JSON только двойные кавычки и нет запятой в конце.
Проверь понимание: какое из тел корректный JSON:
{'a': 1},{"a": 1,}или{"a": 1}?
Ответ
Только третье. В первом одинарные кавычки, во втором лишняя запятая в конце.
Теперь у нас есть все части: запрос, ответ, заголовки, формат. Соберём их в порядок действий на случай, когда что-то сломалось.
Как искать проблему: чек-лист от ошибки до причины
Из этого урока полезнее всего вынести не список кодов, а порядок действий. Тогда при любой жалобе ты сразу знаешь, что проверить первым.
Представь врача. Врач спрашивает по порядку: температура, горло, кашель. Он не назначает лекарство, пока не выяснил симптомы. Оговорка: в отличие от врача, ты можешь воспроизвести «симптом» сколько угодно раз одной командой.
Ответ на любую жалобу строится по шагам, от общего к частному. Каждый шаг отвечает на свой вопрос:
flowchart TD
A["Шаг 1. curl -v: есть соединение?"] -->|"ошибка (7) или (28) до HTTP"| N["Сеть, порт, сервис (урок 2.2)"]
A -->|"имя не найдено (6)"| D["DNS (урок 2.3)"]
A -->|"соединение есть"| B["Шаг 2. Какой код?"]
B -->|"2xx"| B2["Запрос прошёл: смотри содержимое<br>и ожидания клиента"]
B -->|"4xx"| B4["Смотри запрос: путь, метод,<br>заголовки, тело"]
B -->|"5xx"| B5["Смотри заголовок Server:<br>приложение или прокси"]
B --> C["Шаг 3. Код есть, но странно?<br>Content-Length против длины тела,<br>Content-Type, плавающая ли ошибка"]
C --> T["Шаг 4. Тормозит? curl -w:<br>dns, connect, ttfb, total"]
T --> L["Шаг 5. Лог приложения по времени запроса (journalctl)"]
Первый шаг делит картину на мир «до HTTP» (сеть, порт, DNS) и мир «внутри HTTP» (коды и заголовки).
Первый шаг разделяет всю картину на два мира: «до HTTP» (сеть, порт, DNS) и «внутри HTTP» (коды и заголовки). Ошибки первого мира ты ещё не видишь в виде кодов: у curl они выглядят как curl: (7), curl: (6), curl: (28). Ошибки второго мира приходят как строка HTTP/1.1 ....
Разберём три жалобы и что покажет curl -v в каждой:
| Жалоба | Что покажет curl |
Куда идти |
|---|---|---|
| «сайт не открывается» | curl: (7) Failed to connect ... port 8080 |
сервис не запущен или порт закрыт: systemctl status, ss -tlnp |
| «выдаёт ошибку» | HTTP/1.1 502 Bad Gateway, Server: nginx |
приложение за прокси недоступно: лог прокси, статус приложения |
| «долго грузится» | код 200, но ttfb=8s |
медленное приложение: лог, зависимости, запросы к базе |
Обрати внимание: три жалобы звучат похоже («не работает»), а причины лежат в трёх разных местах. Именно curl -v делает эту разницу видимой за пять секунд.
Осторожно: «Если браузер показывает страницу, то всё работает». Браузер может показать закэшированную копию, а curl при этом получит 500. Поэтому воспроизводить жалобу надо curl-ом, а не браузером: он ничего не прячет и не кэширует.
Прикинь сам:
curlответил ошибкой(7). Смотреть коды HTTP?
Нет, до HTTP дело не дошло: соединения нет. Идём в сеть, порт и сервис.
Главное:
curl -vделит мир на «до HTTP» (ошибки 6, 7, 28) и «внутри HTTP» (коды).
Проверь понимание:
curlпечатаетcurl: (28) Operation timed out after 3001 milliseconds with 0 bytes received. Где искать, в сети или в приложении, и что уточнить в первую очередь?
Ответ
Таймаут значит, что ответа нет вообще. Надо уточнить фазу: если time_connect тоже большое, соединение не устанавливается (сеть, файрвол, порт), если connect быстрый, а ttfb нет, то соединились, но приложение не отвечает (зависло или долго считает). Различает эти случаи curl -w с фазами.
Чек-лист подсказывает, где искать причину. Если жалоба на медленную работу, нужно ещё понять, из чего складывается время запроса.
Из чего складывается время запроса
Жалоба «тормозит» бесполезна, пока не известно, где именно тормозит. Запрос проходит несколько этапов, и на каждом можно потерять время. Если знаешь этапы, замер сразу показывает виновника.
Задержка складывается как при заказе пиццы: сначала ищешь номер (DNS), потом дозваниваешься (соединение), потом ждёшь, пока пиццу приготовят и она поедет (обработка на сервере), потом принимаешь коробку (передача тела). Общее время «от желания до еды» складывается из всех, и «долго» может быть где угодно.
curl умеет печатать метки времени каждого этапа. Они отсчитываются от начала запроса, поэтому каждая следующая больше предыдущей:
время от начала запроса:
0 ------ namelookup ------ connect ------ (TLS) ------ starttransfer ------ total
| | | | |
| DNS: имя | TCP: соединение| | первый байт | последний байт
| -> адрес | установлено | (для https: TLS) | ответа пришёл | ответа пришёл
приложение обрабатывает скачивание тела
time_namelookup: сколько ушло на превращение имени в адрес (урок 2.3).time_connect: до момента, когда TCP-соединение установлено (урок 2.2).time_starttransfer: до прихода первого байта ответа. Его называют TTFB (time to first byte). Он показывает, сколько приложение «думало».time_total: до прихода последнего байта.
Проверим на нашем приложении. Эндпоинт /slow?sec=2 из этого урока спит две секунды и только потом отвечает. Замер даст примерно такое (значения в секундах):
connect ~ 0.0001 // соединение на своей же машине мгновенно
ttfb ~ 2.0033 // почти всё время ушло на «раздумья» приложения
total ~ 2.0034 // тело короткое, скачалось мгновенно
Читаем: большая разница между connect и ttfb значит, что медленно работает приложение или то, от чего оно зависит (например, база данных). Большая разница между ttfb и total значит, что тело тяжёлое или сеть медленная. Большое namelookup значит проблему с DNS. Так разные причины «тормозит» разделяются одной командой.
Осторожно: «Сайт тормозит, значит, надо больше серверов». Сначала замер: если ttfb 2 секунды из-за медленного запроса к базе, добавление серверов ничего не даст.
Прикинь сам:
connect=0.02,ttfb=4.1. Кто медлит?
Приложение: соединение быстрое, а первый байт ждали 4 секунды.
Главное: время делится на DNS, connect, TTFB и total. Большой разрыв между
connectиttfbзначит медленное приложение.
Проверь понимание: у запроса
connect=0.02s,ttfb=4.1s,total=4.2s. Где искать проблему?
Ответ
В приложении или в том, от чего оно зависит: соединение быстрое (0,02 с), тело быстрое (0,1 с между ttfb и total), а почти все 4 секунды ушли на ожидание первого байта. Смотри лог приложения и запросы к базе.
Чтобы увидеть всё это своими глазами, нужен инструмент. Это curl.
curl: инструмент на каждый день
Браузер скрывает запрос и ответ, а нужно их видеть и собирать руками. curl (client URL) это программа командной строки, которая отправляет HTTP-запрос и показывает результат. Она на каждом сервере, в каждом контейнере и в каждом скрипте проверки.
Браузер это готовый автомобиль с закрытым капотом, curl это ключи и диагностический разъём: показывает, что происходит внутри, и позволяет сделать то, чего кнопки не умеют.
Обычная команда: curl [ключи] URL. Ключи, нужные в работе:
| Ключ | Что делает |
|---|---|
-v |
подробно: строки со * (работа самого curl), > (что ушло на сервер), < (что пришло) |
-i |
показать заголовки ответа вместе с телом |
-I |
отправить HEAD и показать только заголовки |
-s |
тихий режим: без полосы прогресса (silent) |
-S |
вместе с -s всё же показывать ошибки |
-X МЕТОД |
задать метод |
-H 'Имя: значение' |
добавить заголовок |
-d 'тело' |
тело запроса; метод сам станет POST |
-o файл |
сохранить тело в файл (-o /dev/null значит «выбросить»: /dev/null это «чёрная дыра» Linux) |
-w 'формат' |
напечатать после запроса метрики: код, время (переменные вида %{http_code}) |
--max-time N |
общий лимит времени в секундах |
--resolve имя:порт:IP |
подставить адрес для имени, не трогая DNS и /etc/hosts |
-f |
вернуть код выхода 22, если код ответа 400 и выше (для скриптов) |
-L |
идти за перенаправлениями (3xx) |
Код выхода (exit code) это число, которое команда отдаёт оболочке после завершения: 0 значит успех, любое другое ошибку. Его видно в переменной $? сразу после команды. curl возвращает разные коды для разных ошибок: 6 «не нашёл имя», 7 «не смог соединиться», 28 «таймаут». Без -f код ответа 404 или 500 для curl не ошибка: запрос выполнен и ответ получен, поэтому код выхода 0. Для скриптов мониторинга это ловушка, и от неё защищает -f.
Соберём запрос из частей, каждая отвечает за свою деталь:
curl -s -X POST -H 'Content-Type: application/json' -d '{"text":"a"}' http://127.0.0.1:8080/notes
| | | | | |
| | | | | URL
| | | | тело запроса
| | | заголовок формата тела
| | метод (с -d он и так POST, -X можно опустить)
| без прогресса
программа
Осторожно: «-X POST нужно всегда». Нет: -d уже делает запрос POST, и curl даже пишет предупреждение Note: Unnecessary use of -X or --request, POST is already inferred. А ещё -d без явного Content-Type отправляет application/x-www-form-urlencoded (формат веб-форм), поэтому для JSON заголовок указывай сам.
Прикинь сам: почему в мониторинге нужен
-f?
Без него 500 тоже даст код выхода 0. С -f при коде от 400 выход будет 22.
Главное: для скриптов
curl -fsS --max-time 2: ошибки видны, зависания нет.
Проверь понимание: скрипт делает
curl -s http://host/healthzи проверяет, что код выхода 0. Сервис при этом отвечает 500. Скрипт скажет «жив». Что исправить?
Ответ
Добавить -f: curl -fsS --max-time 2 http://host/healthz. Тогда ответ 500 даст код выхода 22. -S покажет причину ошибки, --max-time не даст скрипту повиснуть.
Практика
Все задания выполняются на сервере или ВМ из предыдущих уроков, в одном терминале. Приложение работает как сервис notes (урок 1.8), отдельный запуск python3 app.py не нужен.
Подготовка: проверить стенд
Проверим, что всё на месте, и поставим nc, если его нет.
Команды: systemctl is-active notes печатает active, если сервис работает. getent hosts notes.lab показывает, во что превращается имя (урок 2.3). nc -h печатает справку и доказывает, что nc установлен.
systemctl is-active notes
curl -sS http://127.0.0.1:8080/healthz; echo
getent hosts notes.lab
nc -h 2>&1 | head -2
Что должно получиться:
active
ok
127.0.0.1 notes.lab
OpenBSD netcat (Debian patchlevel 1.226-1ubuntu2)
usage: nc [-46CDdFhklNnrStUuvZz] [-I length] [-i interval] [-M ttl]
Если nc не найден, поставь его: sudo apt install -y netcat-openbsd. Если нет записи notes.lab, добавь её (урок 2.3): echo '127.0.0.1 notes.lab' | sudo tee -a /etc/hosts. Если сервис не активен, запусти: sudo systemctl start notes.
Задание 1. Читаем запрос и ответ построчно
Цель: прочитать каждую строку curl -v и понять, откуда она.
Предскажи: приложение пока в версии v2.2. Какую версию протокола покажет ответ? Будет ли в ответе Content-Length?
Ответ
Ответ начнётся с HTTP/1.0 200 OK: v2.2 говорит на HTTP/1.0 и закрывает соединение. Content-Length в ответе есть: приложение считает длину и в v2.2. Разница v3 не в наличии этого заголовка, а в том, что сервер станет держать соединение открытым, и точность длины начнёт иметь значение.
Шаги: команда curl -v http://127.0.0.1:8080/healthz: curl это программа, -v включает подробный режим, дальше URL с адресом 127.0.0.1 (наша машина), портом 8080 и путём /healthz (эндпоинт «жив ли сервис»).
curl -v http://127.0.0.1:8080/healthz
Что должно получиться: примерно такое (версия Python, дата и время у тебя другие).
* Trying 127.0.0.1:8080...
* Connected to 127.0.0.1 (127.0.0.1) port 8080
> GET /healthz HTTP/1.1
> Host: 127.0.0.1:8080
> User-Agent: curl/8.5.0
> Accept: */*
>
* HTTP 1.0, assume close after body
< HTTP/1.0 200 OK
< Server: BaseHTTP/0.6 Python/3.12.3
< Date: Wed, 30 Sep 2026 12:00:22 GMT
< Content-Type: text/plain; charset=utf-8
< Content-Length: 2
<
* Closing connection
ok
Как читать вывод:
- Строки со
*это комментарии самогоcurl:Trying(пробует адрес и порт),Connected(TCP-соединение установлено, урок 2.2),Closing connection(соединение закрыто). - Строки с
>это то, чтоcurlотправил.GET /healthz HTTP/1.1стартовая строка: метод, путь, версия.Hostcurlподставил сам из URL.User-Agentпредставляется,Accept: */*значит «принимаю любой формат». Пустая строка>без текста означает конец заголовков. - Строка
* HTTP 1.0, assume close after bodycurlпонял, что сервер ответил по HTTP/1.0, и приготовился к закрытию соединения после тела. - Строки с
<это ответ.HTTP/1.0 200 OKстатус: версия, код, пояснение.ServerиDateсервер добавил сам (BaseHTTPэто стандартный обработчик Python).Content-Typeформат тела,Content-Length: 2длина тела: словоokэто 2 байта. - После пустой строки
<идёт тело:ok. Оно печатается без перевода строки, поэтому в конце вывода нет пустой строки.
Объясни себе:
- Какую версию протокола отправил
curlв запросе, а какой ответил сервер? Почему они разные? - Откуда взялся заголовок
Host, если ты его не писал?
Как проверить отказ: остановим сервис и посмотрим, как выглядит ошибка до всякого HTTP. Здесь sudo systemctl stop notes останавливает сервис, $? даёт код выхода curl, а sudo systemctl start notes возвращает всё обратно. Между командами sleep 1 даёт приложению секунду на запуск.
sudo systemctl stop notes
curl -sS http://127.0.0.1:8080/healthz; echo "код выхода: $?"
sudo systemctl start notes; sleep 1
Вывод:
curl: (7) Failed to connect to 127.0.0.1 port 8080 after 0 ms: Couldn't connect to server
код выхода: 7
Это не HTTP-ошибка: до запроса дело не дошло, TCP-соединение не установилось, и curl не получил кода ответа. Так выглядит «сервис не запущен» (урок 2.2). Обрати внимание: в curl 8.5 (Ubuntu 24.04) написано Couldn't connect to server, а в curl 8.18 (Ubuntu 26.04) Could not connect to server; смысл тот же, а Connection refused в этих версиях виден только с ключом -v.
Типичные ошибки:
curl: (7) Failed to connect to 127.0.0.1 port 8080 ... Couldn't connect to server: приложение не запущено или слушает другой порт. Проверьsystemctl is-active notesиss -tlnp | grep 8080.curl: (52) Empty reply from server: сервер принял соединение, но закрыл его, ничего не ответив. Смотри лог:sudo journalctl -u notes -n 20; скорее всего, исключение в обработчике.curl: (6) Could not resolve host: ...: имя не превратилось в адрес, вернись к уроку 2.3.
Отличия в Ubuntu 26.04. В curl 8.18 вывод -v немного другой: вместо строки Connected to ... идёт Established connection to 127.0.0.1 (127.0.0.1 port 8080) from 127.0.0.1 port <порт>, добавляются строки * using HTTP/1.x и * Request completely sent off. Заголовки (> и <) те же, а Server содержит Python/3.14.4.
Задание 2. Методы и коды: создаём заметку, ловим 404, 405 и 400
Цель: увидеть, как приложение отвечает на неверный путь, неверный метод и неверное тело.
Предскажи: что вернут (а) GET /nope, (б) DELETE /notes, (в) POST /notes с пустым телом? Назови код для каждого.
Ответ
(а) 404: пути нет. (б) 405: путь /notes есть, метод DELETE для него не разрешён. (в) 400: метод и путь верные, но тело не прошло проверку (пустое, а нужен JSON).
Шаги. Разберём команды. -i показывает заголовки ответа вместе с телом. -H 'Content-Type: application/json' добавляет заголовок формата, -d '{"text":"купить хлеб"}' задаёт тело (и делает метод POST). В команде с -o /dev/null -w '%{http_code}\n' тело выбрасывается, а после запроса печатается только код: %{http_code} это переменная curl («код ответа»), \n перевод строки. -s убирает полосу прогресса. В URL есть кириллица только в теле, в самом URL её нет.
# создаём заметку
curl -i -H 'Content-Type: application/json' -d '{"text":"купить хлеб"}' http://127.0.0.1:8080/notes
curl -s http://127.0.0.1:8080/notes
# только код ответа для трёх неверных запросов
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/nope
curl -s -o /dev/null -w '%{http_code}\n' -X DELETE http://127.0.0.1:8080/notes
curl -s -o /dev/null -w '%{http_code}\n' -X POST http://127.0.0.1:8080/notes
Что должно получиться:
HTTP/1.0 201 Created
Server: BaseHTTP/0.6 Python/3.12.3
Date: Wed, 30 Sep 2026 12:00:24 GMT
Content-Type: application/json; charset=utf-8
Content-Length: 9
{"id": 1}
[{"id": 1, "text": "купить хлеб", "created_at": "2026-09-30T12:00:24+00:00"}]
404
405
400
Как читать вывод:
201 Created: заметка создана (2xx). В теле{"id": 1}номер новой заметки. Если у тебя в списке уже были заметки, номер будет больше.- Вторая команда читает список: массив из одной заметки с текстом и временем создания (у тебя время другое).
- Три числа в конце это коды на неверные запросы, в том порядке, в котором мы их отправляли: 404, 405, 400.
Посмотрим на 405 и 404 целиком, а не только на код:
curl -i -X DELETE http://127.0.0.1:8080/notes
curl -i http://127.0.0.1:8080/nope
HTTP/1.0 405 Method Not Allowed
Server: BaseHTTP/0.6 Python/3.12.3
Date: Wed, 30 Sep 2026 12:00:24 GMT
Content-Type: application/json; charset=utf-8
Content-Length: 31
{"error": "method not allowed"}
HTTP/1.0 404 Not Found
Server: BaseHTTP/0.6 Python/3.12.3
Date: Wed, 30 Sep 2026 12:00:24 GMT
Content-Type: application/json; charset=utf-8
Content-Length: 22
{"error": "not found"}
Обрати внимание: в 405 нет заголовка Allow, хотя по правилам HTTP он обязателен. Это недоработка v2.2, в шаге проекта (задание 3) она исправится.
И ещё одно различие v2.2 и v3. Запрос HEAD (curl -I) v2.2 не понимает:
curl -I http://127.0.0.1:8080/healthz
HTTP/1.0 501 Unsupported method ('HEAD')
Server: BaseHTTP/0.6 Python/3.12.3
Date: Wed, 30 Sep 2026 12:00:24 GMT
Connection: close
Content-Type: text/html;charset=utf-8
Content-Length: 357
Код 501 (Not Implemented) значит «сервер не умеет такой метод». Запомни этот вывод: после задания 3 та же команда даст 200.
Объясни себе: чем 404 отличается от 405 для тебя как для человека, который разбирает жалобу? Что ты подскажешь клиенту в каждом случае?
Типичные ошибки:
curl: (3) URL rejected: Malformed input to a URL function: в URL попал пробел или кавычка. Заключай URL в кавычки целиком и кодируй пробел как%20.{"error": "нужен JSON {\"text\": \"...\"}"}при верном на вид теле: оболочка съела кавычки. Тело JSON бери в одинарные кавычки целиком:-d '{"text":"..."}'.Note: Unnecessary use of -X or --request, POST is already inferred.: не ошибка,curlсообщает, что-X POSTвместе с-dлишний.
Задание 3. Шаг проекта: app.py v3
Цель: перевести «Заметки» на HTTP/1.1 с корректным Content-Length и добавить /headers, /slow, /error.
Предскажи: после включения protocol_version = "HTTP/1.1" ты забудешь Content-Length в одном ответе. Что произойдёт при запросе именно к нему?
Ответ
Клиент не поймёт, где конец тела. Соединение остаётся открытым, и запрос будет висеть, пока не сработает таймаут (у curl это --max-time). Поэтому в коде все ответы идут через один вспомогательный метод, и забыть заголовок негде.
Шаги.
- Сделай копию рабочего файла перед правкой. Команда
cpкопирует файл, суффикс.before-v3подскажет, что это за версия:
cp ~/notes/app.py ~/notes/app.py.before-v3
- Чтобы не ошибиться в правках, возьми эталонную версию v3. Команда
curl -fsSL -o ~/notes/app.py <URL>:-fне сохранит страницу с ошибкой вместо файла,-sи-Sтихий режим с показом ошибок,-Lидёт за перенаправлениями,-oзаписывает ответ в файл.
curl -fsSL -o ~/notes/app.py https://raw.githubusercontent.com/distinguished-sre/learning/main/devops/project/notes/versions/v3.py
- Посмотри, что изменилось.
diff старый новыйпоказывает различия:<строки только в старом файле,>только в новом,2c2значит «строка 2 заменена».| head -12оставляет первые 12 строк.
diff ~/notes/app.py.before-v3 ~/notes/app.py | head -12
2c2
< """Заметки v2.2: файловое хранилище, сигналы, /leak и /burn."""
---
> """Заметки v3: HTTP/1.1, диагностика HTTP и файловое хранилище."""
60,62c60,66
< def send(self, code, body, ctype="application/json; charset=utf-8"):
< data = body.encode("utf-8")
< self.send_response(code)
---
> # HTTP/1.1: соединение остаётся открытым, поэтому Content-Length обязателен
> protocol_version = "HTTP/1.1"
>
Разберём ключевые куски нового кода. Никакого шаблона копировать не нужно, всё уже в файле, но полезно понимать, что и зачем там написано.
Единый метод ответа _send. Класс Handler (обработчик запросов) в app.py наследует стандартный BaseHTTPRequestHandler, а метод это функция внутри класса:
class Handler(BaseHTTPRequestHandler):
# HTTP/1.1: соединение остаётся открытым, поэтому Content-Length обязателен
protocol_version = "HTTP/1.1"
def _send(self, status, body, ctype="application/json; charset=utf-8", extra=None):
"""Единственное место, где пишется ответ: всегда с Content-Length."""
data = body if isinstance(body, bytes) else body.encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(data)))
for name, value in (extra or {}).items():
self.send_header(name, value)
self.end_headers()
if self.command != "HEAD":
self.wfile.write(data)
protocol_version = "HTTP/1.1"включает keep-alive: обработчик перестаёт закрывать соединение после каждого ответа.data = ... body.encode("utf-8")превращает текст в байты, если пришёл текст. Это ключевая строка: дальше все длины считаются поdata.send_response(status)пишет строку статуса и заголовкиServerиDate.send_headerдобавляет заголовок.end_headers()пишет пустую строку.str(len(data))длина байтов, а не символов: то самое расхождение 22 и 32 из теории.extraсловарь дополнительных заголовков (для 405 в него кладутAllow).if self.command != "HEAD": на запросHEADтело отправлять нельзя (только заголовки, в том числеContent-Lengthс длиной, которую имело бы тело).self.commandэто метод запроса.
Разбор пути и метода. Общий метод _handle сначала проверяет путь, потом метод:
routes = {"/", "/notes", "/healthz", "/readyz", "/headers", "/slow",
"/error", "/leak", "/burn"}
if path not in routes:
self.close_connection = True # тело неизвестного запроса не читаем
return self._json(404, {"error": "not found"})
allowed = "GET, POST" if path == "/notes" else "GET"
if self.command not in allowed.split(", ") and self.command != "HEAD":
self.close_connection = True # непрочитанное тело не станет следующим запросом
return self._json(405, {"error": "method not allowed"}, {"Allow": allowed})
Порядок важен: сначала «есть ли такой путь» (нет: 404), потом «разрешён ли метод» (нет: 405 с заголовком Allow). Строка self.close_connection = True говорит обработчику закрыть соединение после ответа: раз мы не прочитали тело запроса, оно осталось бы в соединении и было бы принято за начало следующего запроса.
Три новых эндпоинта.
if path == "/headers":
# демонстрационный эндпоинт: приложение показывает, что оно получило
self._send(200, json.dumps(dict(self.headers.items()), ensure_ascii=False))
return
if path == "/slow":
try:
sec = int(parse_qs(parsed.query).get("sec", ["0"])[0])
except ValueError:
sec = -1
if not 0 <= sec <= 120:
self._send(400, '{"error":"sec must be 0..120"}')
return
time.sleep(sec)
self._send(200, "slept %d" % sec, "text/plain; charset=utf-8")
return
if path == "/error":
# всегда 500: для тренировки алертов и разбора логов
self._send(500, '{"error":"synthetic"}')
return
/headersпревращает заголовки запроса в JSON и возвращает: приложение «показывает», что оно получило.self.headersэто заголовки запроса,json.dumpsпревращает словарь в текст JSON./slow?sec=N:parse_qsразбирает параметры запроса (sec=2превращается в число 2),time.sleep(sec)усыпляет ответ на N секунд. Значения вне диапазона 0-120 и не числа отвергаются кодом 400: без такой проверки любой клиент мог бы занять поток приложения на сутки./errorвсегда отвечает 500: понадобится, чтобы тренироваться на алертах и логах.
Метод do_* и 405 для любых методов. Внизу класса стоят строки
def do_GET(self):
self._handle()
do_POST = do_HEAD = do_PUT = do_DELETE = do_PATCH = do_OPTIONS = do_GET
def __getattr__(self, name):
# BaseHTTPRequestHandler ищет do_<метод>; неизвестные методы тоже дают 405.
if name.startswith("do_"):
return self._handle
raise AttributeError(name)
Стандартный обработчик по имени метода ищет функцию do_<МЕТОД>. Все известные методы направлены в один _handle, а __getattr__ перехватывает любые незнакомые (do_PROPFIND): они тоже дойдут до проверки и получат 405, а не стандартный 501. Так появилась поддержка HEAD: раньше метода do_HEAD не было, отсюда 501.
Полный файл лежит в эталоне: versions/v3.py. Ты уже скачал его командой выше.
- Выкати новую версию на сервер.
sudo install -o root -g root -m 755 источник назначениекопирует файл и сразу задаёт владельца и права (урок 1.3): root владеет кодом, права 755 позволяют всем читать и выполнять, но менять только root.systemctl restartперезапускает сервис (урок 1.8),sleep 1даёт секунду на запуск,cmpсравнивает два файла побайтно и молчит, если они одинаковые.
sudo install -o root -g root -m 755 ~/notes/app.py /opt/notes/app.py
sudo systemctl restart notes; sleep 1
systemctl is-active notes
cmp ~/notes/app.py /opt/notes/app.py && echo одинаковые
active
одинаковые
- Проверь, что изменилось. Сначала повтори
curl -vиз задания 1:
curl -v http://127.0.0.1:8080/healthz
* Trying 127.0.0.1:8080...
* Connected to 127.0.0.1 (127.0.0.1) port 8080
> GET /healthz HTTP/1.1
> Host: 127.0.0.1:8080
> User-Agent: curl/8.5.0
> Accept: */*
>
< HTTP/1.1 200 OK
< Server: BaseHTTP/0.6 Python/3.12.3
< Date: Wed, 30 Sep 2026 12:00:25 GMT
< Content-Type: text/plain; charset=utf-8
< Content-Length: 2
<
* Connection #0 to host 127.0.0.1 left intact
ok
Два отличия от задания 1: в ответе теперь HTTP/1.1 и вместо Closing connection стоит left intact («оставлено нетронутым»): curl не закрыл соединение, потому что сервер обещал держать его открытым. Строки * HTTP 1.0, assume close after body больше нет. Теперь HEAD, который в задании 2 давал 501:
curl -I http://127.0.0.1:8080/healthz
HTTP/1.1 200 OK
Server: BaseHTTP/0.6 Python/3.12.3
Date: Wed, 30 Sep 2026 12:00:25 GMT
Content-Type: text/plain; charset=utf-8
Content-Length: 2
Заголовки те же, тела нет, при этом Content-Length: 2 честно указывает, какой длины оно было бы. Дальше три новых эндпоинта и 405:
curl -si http://127.0.0.1:8080/healthz | head -1
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/error
curl -s -o /dev/null -w '%{http_code}\n' 'http://127.0.0.1:8080/slow?sec=999'
curl -s -w '\n%{time_total}s\n' 'http://127.0.0.1:8080/slow?sec=1'
curl -i -X DELETE http://127.0.0.1:8080/notes
Разбор: curl -si ... | head -1 показывает только первую строку ответа, то есть строку статуса. В последней curl -s -w '\n%{time_total}s\n' перед метрикой стоит \n: тело slept 1 печатается без перевода строки, и \n переносит время на следующую строку. Адреса с ? берём в кавычки, иначе оболочка попытается раскрыть ? как шаблон.
HTTP/1.1 200 OK
500
400
slept 1
1.006269s
HTTP/1.1 405 Method Not Allowed
Server: BaseHTTP/0.6 Python/3.12.3
Date: Wed, 30 Sep 2026 12:00:26 GMT
Content-Type: application/json; charset=utf-8
Content-Length: 31
Allow: GET, POST
{"error": "method not allowed"}
Как читать вывод: первая строка это HTTP/1.1 вместо 1.0. 500 даёт /error, 400 даёт /slow?sec=999 (значение вне диапазона), slept 1 приходит через секунду (время 1.006269s у тебя будет чуть другим). И главное: в 405 появился заголовок Allow: GET, POST со списком методов, которые /notes принимает.
Посмотри лог приложения. journalctl -u notes показывает записи сервиса, -n 6 последние шесть, -o cat без служебных префиксов:
sudo journalctl -u notes -n 6 --no-pager -o cat
2026-09-30 12:00:24,860 INFO started host=127.0.0.1 port=8080
2026-09-30 12:00:25,861 INFO 127.0.0.1 "GET /error HTTP/1.1" 500 -
2026-09-30 12:00:25,864 INFO 127.0.0.1 "GET /slow?sec=999 HTTP/1.1" 400 -
2026-09-30 12:00:26,873 INFO 127.0.0.1 "GET /slow?sec=1 HTTP/1.1" 200 -
2026-09-30 12:00:26,880 INFO 127.0.0.1 "DELETE /notes HTTP/1.1" 405 -
2026-09-30 12:00:26,885 INFO 127.0.0.1 "GET /error HTTP/1.1" 500 -
Каждая строка это один запрос: адрес клиента, метод и путь в кавычках, код ответа. Так по логу видно, какие коды приложение выдавало. Запросы к /healthz и /readyz в лог не пишутся (проверки живости не должны его засорять), поэтому строки про них ты не увидишь.
Объясни себе:
- Почему
/slowсsec=999возвращает 400, а не спит? - Зачем в
_sendпроверкаself.command != "HEAD"?
Типичные ошибки:
curl: (22) The requested URL returned error: 404при скачиванииv3.py: файла нет по этому адресу (опечатка в URL или он ещё не опубликован). Проверь адрес; на крайний случай возьми файлproject/notes/versions/v3.pyиз репозитория курса.install: cannot remove '/opt/notes/app.py': Permission denied: забылsudoпередinstall.curl: (7) Failed to connect ...после перезапуска: сервис не поднялся. Смотриsudo systemctl status notesиsudo journalctl -u notes -n 20: скорее всего, синтаксическая ошибка вapp.py(например, потеряна строка при ручной правке).curl: (18) transfer closed with 60 bytes remaining to read: заявленныйContent-Lengthбольше реально отданных байт. Такое бывает, если считать длину по символам, а не по байтам (задание 6).
Задание 4. Заголовки и Host: --resolve вместо правки hosts
Цель: увидеть, что приложение получает от клиента, и понять роль Host.
Предскажи: ты запросишь http://notes.lab:8080/headers, где notes.lab берётся из /etc/hosts (урок 2.3). Какое значение Host увидит приложение? А если запросить http://127.0.0.1:8080/headers с ключом -H 'Host: shop.lab'?
Ответ
В первом случае notes.lab:8080 (имя из URL и порт, если он нестандартный). Во втором shop.lab: явный -H заменяет заголовок, при этом TCP-соединение по-прежнему идёт на 127.0.0.1. Так проверяют виртуальные хосты, не трогая DNS.
Шаги. Три способа задать имя. Первый берёт notes.lab из /etc/hosts. Второй меняет только заголовок Host: -H 'Host: shop.lab'. Третий, --resolve demo.lab:8080:127.0.0.1, сообщает curl: «для имени demo.lab и порта 8080 используй адрес 127.0.0.1», то есть подменяет DNS только для этой команды (формат имя:порт:IP), и Host получается из URL.
# имя из /etc/hosts (запись notes.lab из урока 2.3)
curl -s http://notes.lab:8080/headers
# подмена Host на лету
curl -s -H 'Host: shop.lab' http://127.0.0.1:8080/headers
# то же через --resolve: имя и IP, без правки /etc/hosts
curl -s --resolve demo.lab:8080:127.0.0.1 http://demo.lab:8080/headers
Что должно получиться: три строки JSON, различаются только значением Host.
{"Host": "notes.lab:8080", "User-Agent": "curl/8.5.0", "Accept": "*/*"}
{"Host": "shop.lab", "User-Agent": "curl/8.5.0", "Accept": "*/*"}
{"Host": "demo.lab:8080", "User-Agent": "curl/8.5.0", "Accept": "*/*"}
Как читать вывод: JSON это словарь «имя заголовка: значение». Host первым, потому что curl ставит его первым; User-Agent и Accept curl добавил сам. Приложение ничего не додумывает: оно показывает ровно то, что пришло. Можно передать и собственные заголовки. Authorization и X-Forwarded-For приложение примет как есть, никак не проверяя:
curl -s -H 'Authorization: Bearer abc123' -H 'X-Forwarded-For: 203.0.113.7' http://127.0.0.1:8080/headers
{"Host": "127.0.0.1:8080", "User-Agent": "curl/8.5.0", "Accept": "*/*", "Authorization": "Bearer abc123", "X-Forwarded-For": "203.0.113.7"}
Этот пример показывает, что клиент может выдумать X-Forwarded-For (адрес 203.0.113.7 ты только что придумал сам), поэтому за такими заголовками нужно доверять только известному прокси. Чтобы JSON читался глазами, пропусти его через python3 -m json.tool (стандартный форматер JSON): curl -s http://notes.lab:8080/headers | python3 -m json.tool печатает каждый ключ на своей строке.
Сравни: имя без записи в /etc/hosts и без --resolve не работает.
curl -sS http://demo.lab:8080/headers; echo "код выхода: $?"
curl: (6) Could not resolve host: demo.lab
код выхода: 6
Объясни себе:
- В чём разница между
-H 'Host: ...'и--resolve? Что уходит в DNS в каждом случае? (Подсказка: в третьем случаеcurlобращается к DNS за именем, но получает готовый ответ, а в втором имени в URL нет вовсе.) - Почему приложение получило
Host: notes.lab:8080с портом, аshop.labбез порта?
Типичные ошибки:
curl: (6) Could not resolve host: notes.lab: записи нет в/etc/hosts(вернись к уроку 2.3) или опечатка в имени.{"error": "not found"}вместо JSON с заголовками: приложение ещё в версии v2.2, эндпоинта/headersнет. Выполни задание 3 и проверьcmp ~/notes/app.py /opt/notes/app.py.
Нейросеть может разобрать вывод
curl -vпо строкам, но не видит твой сервер. Вставляй вывод, убрав токены и cookie, и сверяй названные ею заголовки с тем, что реально напечатано.
Задание 5. Время ответа и HTTP руками через nc
Цель: измерить фазы запроса и отправить настоящий HTTP без curl.
Предскажи: /slow?sec=2 спит две секунды. Какие из времён time_connect, time_starttransfer, time_total будут около двух секунд?
Ответ
time_connect близко к нулю: TCP-соединение на своей машине устанавливается мгновенно. time_starttransfer (первый байт ответа) и time_total около двух секунд: приложение спит до того, как отправить хоть что-то.
Шаги. Команда с -w печатает метрики после запроса. -o /dev/null выбрасывает тело, --max-time 10 даёт запросу максимум 10 секунд. В строке формата %{http_code}, %{time_connect}, %{time_starttransfer}, %{time_total} это переменные curl, значения подставляются в текст. Обратная косая \ в конце строки продолжает команду на следующей строке.
# сводка времён по фазам
curl -s -o /dev/null --max-time 10 \
-w 'код=%{http_code} connect=%{time_connect}s ttfb=%{time_starttransfer}s total=%{time_total}s\n' \
'http://127.0.0.1:8080/slow?sec=2'
# то же, но имя берётся из /etc/hosts: добавляется этап DNS
curl -s -o /dev/null --max-time 10 \
-w 'dns=%{time_namelookup}s connect=%{time_connect}s ttfb=%{time_starttransfer}s total=%{time_total}s\n' \
'http://notes.lab:8080/slow?sec=2'
код=200 connect=0.000084s ttfb=2.003310s total=2.003369s
dns=0.000238s connect=0.000323s ttfb=2.006482s total=2.006570s
Как читать вывод: connect около нуля, а ttfb и total около двух секунд: время ушло на «раздумья» приложения, а не на сеть и не на передачу. dns в миллисекундных долях, потому что имя нашлось в локальном файле /etc/hosts; с настоящим DNS-сервером здесь могло быть заметно больше. Числа у тебя будут другими, порядок тот же.
Теперь HTTP руками. printf печатает текст с управляющими символами: \r\n превращается в пару символов CRLF. | (конвейер) передаёт этот текст на вход nc, а nc 127.0.0.1 8080 открывает TCP-соединение к порту 8080 и отправляет полученное. Строка Connection: close просит сервер закрыть соединение после ответа, иначе nc будет ждать дальше.
printf 'GET /healthz HTTP/1.1\r\nHost: notes.lab\r\nConnection: close\r\n\r\n' | nc 127.0.0.1 8080
HTTP/1.1 200 OK
Server: BaseHTTP/0.6 Python/3.12.3
Date: Wed, 30 Sep 2026 12:00:31 GMT
Content-Type: text/plain; charset=utf-8
Content-Length: 2
ok
Это тот же ответ, что видел curl, только без всякой обёртки: обычный текст. Чтобы убедиться, что разделители это \r\n, а не просто перевод строки, передай вывод в cat -A (показывает невидимые символы: ^M это \r, $ конец строки):
printf 'GET /healthz HTTP/1.1\r\nHost: notes.lab\r\nConnection: close\r\n\r\n' | nc 127.0.0.1 8080 | cat -A
HTTP/1.1 200 OK^M$
Server: BaseHTTP/0.6 Python/3.12.3^M$
Date: Wed, 30 Sep 2026 12:00:31 GMT^M$
Content-Type: text/plain; charset=utf-8^M$
Content-Length: 2^M$
^M$
ok
Теперь POST с телом. Тело в переменной BODY. Длину берём не вручную, а командой: printf '%s' "$BODY" | wc -c считает байты (wc -c это подсчёт байт). Для английского тела длина в символах совпадает с байтами, поэтому тело оставим ASCII: с русским пришлось бы считать именно wc -c, а не ${#BODY}, который считает символы.
BODY='{"text":"from nc"}'
LEN=$(printf '%s' "$BODY" | wc -c); echo "LEN=$LEN"
printf 'POST /notes HTTP/1.1\r\nHost: notes.lab\r\nContent-Type: application/json\r\nContent-Length: %s\r\nConnection: close\r\n\r\n%s' "$LEN" "$BODY" | nc 127.0.0.1 8080
LEN=18
HTTP/1.1 201 Created
Server: BaseHTTP/0.6 Python/3.12.3
Date: Wed, 30 Sep 2026 12:00:31 GMT
Content-Type: application/json; charset=utf-8
Content-Length: 9
{"id": 2}
Здесь $(...) подставляет вывод внутренней команды в переменную, а %s в printf вставляет значения, которые идут после формата. Ответ 201: ты только что создал заметку без единого HTTP-клиента.
Наконец, keep-alive воочию. Отправим два запроса по одному соединению: второй с Connection: close, чтобы nc завершился.
printf 'GET /healthz HTTP/1.1\r\nHost: notes.lab\r\n\r\nGET / HTTP/1.1\r\nHost: notes.lab\r\nConnection: close\r\n\r\n' | nc 127.0.0.1 8080
HTTP/1.1 200 OK
Server: BaseHTTP/0.6 Python/3.12.3
Date: Wed, 30 Sep 2026 12:00:31 GMT
Content-Type: text/plain; charset=utf-8
Content-Length: 2
okHTTP/1.1 200 OK
Server: BaseHTTP/0.6 Python/3.12.3
Date: Wed, 30 Sep 2026 12:00:31 GMT
Content-Type: text/plain; charset=utf-8
Content-Length: 19
Notes service vdev
Два ответа подряд, «склеенные»: тело первого (ok) кончилось ровно там, где обещал Content-Length: 2, и сразу начался второй ответ. Между запросами не было нового TCP-рукопожатия. Теперь уберём Connection: close из первого запроса и не пошлём второго:
time (printf 'GET /healthz HTTP/1.1\r\nHost: notes.lab\r\n\r\n' | nc -w 2 127.0.0.1 8080)
HTTP/1.1 200 OK
...
ok
real 0m2.009s
Ответ пришёл сразу, но nc завершился только через 2 секунды: ключ -w 2 (wait) велит ему закрыть соединение после двух секунд простоя. Сервер соединение не закрывает, он ждёт следующего запроса. Это и есть keep-alive: соединение остаётся, пока кто-то не закроет его или не сработает таймаут.
Типичные ошибки:
curl: (28) Operation timed out after 10001 milliseconds with 0 bytes received: приложение не ответило за--max-time. Для/slow?sec=2так быть не должно, проверь, что сервис жив.nc: command not foundилиnc: invalid option: на Ubuntu нуженnetcat-openbsd. Поставь:sudo apt install -y netcat-openbsd.ncвывел ответ, но не завершился: в запросе нетConnection: close, и сервер держит соединение. ПрерватьCtrl+Cили добавь-w 2.ncничего не вывел: в запросе нет\r\n\r\n(пустой строки в конце), сервер ждёт продолжения заголовков.
Задание 6. Content-Length своими руками: фальшивый сервер на nc
Цель: увидеть на живом примере, что происходит, когда длина в заголовке и настоящая длина тела не совпадают. Это нужно для диагностики в разделе «Сломай и почини».
Предскажи: nc притворится сервером и ответит Content-Length: 100, но отдаст всего 40 байт. Что покажет curl, если nc после этого закроет соединение? А если оставит открытым?
Ответ
Если закроет: curl поймёт, что данных не хватило, и сообщит об ошибке curl: (18) transfer closed with 60 bytes remaining to read. Если оставит открытым: curl будет ждать оставшиеся 60 байт до --max-time и завершится ошибкой таймаута.
Шаги. Команда nc -l 127.0.0.1 9090 (-l, listen) слушает порт 9090 и отвечает на первое подключение тем, что ей передали на вход. Ключ -N закрывает соединение после отправки. Внешние скобки ( ... & ) запускают всё в фоне, чтобы терминал остался свободным, а >/dev/null 2>&1 прячет вывод (то, что пришло от curl), sleep 0.5 даёт nc время начать слушать.
# сервер закрывает соединение после 40 байт
( (printf 'HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\n0123456789012345678901234567890123456789') | nc -N -l 127.0.0.1 9090 >/dev/null 2>&1 & )
sleep 0.5
curl -sS --max-time 3 http://127.0.0.1:9090/; echo; echo "код выхода: $?"
curl: (18) transfer closed with 60 bytes remaining to read
0123456789012345678901234567890123456789
код выхода: 0
Обрати внимание: 40 байт тела curl всё же напечатал, а сообщение об ошибке идёт в отдельный поток. «Код выхода: 0» тут обманка: $? показал код команды echo, а не curl. Настоящий код curl при этой ошибке 18 (его видно, если не ставить echo между командами).
Теперь соединение остаётся открытым: сервер после 40 байт молчит 8 секунд (sleep 8), пока curl ждёт.
( (printf 'HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\n0123456789012345678901234567890123456789'; sleep 8) | nc -l 127.0.0.1 9092 >/dev/null 2>&1 & )
sleep 0.5
curl -sS --max-time 3 http://127.0.0.1:9092/
0123456789012345678901234567890123456789curl: (28) Operation timed out after 3008 milliseconds with 40 out of 100 bytes received
И в обратную сторону: заявлено 5 байт, отдано 10.
( (printf 'HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\n0123456789') | nc -N -l 127.0.0.1 9091 >/dev/null 2>&1 & )
sleep 0.5
curl -sS --max-time 3 http://127.0.0.1:9091/; echo
01234
Как читать вывод: при слишком большой длине curl пишет, сколько байт получено и сколько ждал (40 out of 100 bytes received) или сколько осталось прочитать (60 bytes remaining to read). При слишком малой длине ошибки нет вовсе: curl тихо обрезал тело по заявленной длине (напечатал 01234), а остальное молча выбросил. Это самый коварный вариант: сервис «работает», а данные обрезаны.
Объясни себе: почему обрезанный ответ опаснее висящего запроса?
Типичные ошибки:
nc: Address already in use: порт 9090 (или 9091, 9092) уже занят прошлым запускомnc. Подожди пару секунд или возьми другой порт.curl: (7) Failed to connect:ncне успел начать слушать. Увеличьsleepили проверь, что порт указан верно.
Сломай и почини
Скачай скрипт и запусти один из сценариев. Это как реальная неудачная выкладка: подменяется код /opt/notes/app.py, сервис перезапускается, и он «ведёт себя странно».
curl -fsSL -o /tmp/break-2.4.sh https://raw.githubusercontent.com/distinguished-sre/learning/main/devops/project/notes/break/2.4/break.sh
sudo bash /tmp/break-2.4.sh 1
Номер сценария 1, 2 или 3 задай сам или попроси кого-нибудь запустить сценарий за тебя, чтобы не знать заранее. Скрипт перед поломкой сохраняет рабочий файл как /opt/notes/app.py.good, а команда sudo bash /tmp/break-2.4.sh fix возвращает всё обратно (её можно запускать повторно, вреда не будет). Пока ищешь причину, не читай сам скрипт: его текст выдаст ответ. Твоя рабочая копия ~/notes/app.py не тронута, с ней удобно сравнивать.
Скрипту нужны выполненное задание 3 (версия v3) и работающий сервис notes. Если их нет, он скажет об этом и ничего не изменит.
Симптом
Ты получаешь жалобу: «сервис ведёт себя странно». Три возможных проявления, по одному на сценарий:
- Сценарий 1: ответы приложения приходят, но коды не совпадают с тем, что мы изучили: запрос на неизвестный путь возвращает 405, а неверный метод возвращает 500 и не сообщает список допустимых методов.
- Сценарий 2: запрос проходит с кодом 200, но
curlпечатает обрезанный список заметок, и JSON не разбирается. Ни одной ошибки при этом не видно. - Сценарий 3: первый запрос по соединению проходит, а второй по тому же соединению не получает ответа.
curlтихо переоткрывает соединение.
Гипотезы
Составь список причин до любой проверки. Подсказка: что можно узнать по коду (класс 4xx и 5xx, есть ли Allow), по заголовкам (Content-Length и Connection) и по поведению на втором запросе.
Проверки
Общий порядок: сначала код ответа и заголовки, потом сравнение с эталоном.
# сценарий 1: сравни коды с тем, что ты знаешь о 404 и 405
curl -i http://127.0.0.1:8080/nope
curl -i -X DELETE http://127.0.0.1:8080/notes
sudo journalctl -u notes -n 5 --no-pager -o cat # traceback в логе есть?
# сценарий 2: длина заявленная и длина настоящая
curl -s http://127.0.0.1:8080/notes | python3 -m json.tool
curl -s -D - -o /tmp/body.bin http://127.0.0.1:8080/notes | grep -i content-length; wc -c < /tmp/body.bin
printf 'GET /notes HTTP/1.1\r\nHost: notes.lab\r\nConnection: close\r\n\r\n' | nc 127.0.0.1 8080 | tail -n 1 | wc -c
# сценарий 3: два запроса подряд по одному соединению
curl -sv http://127.0.0.1:8080/healthz http://127.0.0.1:8080/ 2>&1 | grep -i 'dead\|Re-using\|left intact'
printf 'GET /healthz HTTP/1.1\r\nHost: notes.lab\r\n\r\nGET / HTTP/1.1\r\nHost: notes.lab\r\nConnection: close\r\n\r\n' | nc 127.0.0.1 8080 | grep -c '^HTTP'
# для любого сценария: чем /opt/notes/app.py отличается от твоей рабочей копии
diff ~/notes/app.py /opt/notes/app.py
Пояснения. В сценарии 2 curl -D - печатает заголовки в поток вывода, -o /tmp/body.bin кладёт тело в файл, wc -c < файл считает байты в файле. Третья строка отправляет запрос через nc и берёт последнюю строку вывода (tail -n 1): это тело ответа, wc -c считает его настоящую длину. Сравни её с Content-Length. Ключ -sv в сценарии 3: -s убирает полосу прогресса, -v показывает диалог. grep -c '^HTTP' считает, сколько ответов пришло: должно быть 2, а не 1.
Исправление
Разбор трёх сценариев
Сценарий 1: неверные коды. Запрос на несуществующий путь /nope получает 405 вместо 404, а DELETE /notes получает 500 вместо 405 и без заголовка Allow. Признаки: в теле {"error": "not found"} сказано «не найден», а код 405 говорит о методе; и наоборот, код 500 обещал бы внутреннюю ошибку, но в журнале journalctl нет ни traceback, ни сообщения об ошибке, а в теле честно написано method not allowed. То есть приложение здорово, но выбирает не тот код. Диагностика: сопоставь ситуацию с таблицей кодов (нет пути 404, метод не тот 405 с Allow, ошибка внутри кода 500). diff ~/notes/app.py /opt/notes/app.py показывает две изменённые строки: в них 404 подменён на 405, а 405 на 500. Починка: sudo bash /tmp/break-2.4.sh fix или вручную sudo install -o root -g root -m 755 ~/notes/app.py /opt/notes/app.py && sudo systemctl restart notes. Урок: код статуса это контракт, клиенты и мониторинг принимают решения по нему (повторить запрос, вызвать дежурного), и неверный код ломает всё вокруг, хотя «сервис отвечает».
Сценарий 2: Content-Length посчитан в символах. В коде вместо len(data) (байты) стало len(body) (символы строки). Для ASCII разницы нет, поэтому /healthz и остальное работает. Но в списке заметок есть русский текст: каждая русская буква два байта и один символ, и заявленная длина получается меньше настоящей. Клиент читает ровно заявленное число байт и отбрасывает остальное: JSON обрывается посреди слова (в выводе виден символ �), python3 -m json.tool пишет что-то вроде Expecting value: line 1 column 209. Проверка: Content-Length из -D - (в нашем прогоне 237) против настоящей длины тела через nc (266). Разница в байтах и есть «недостающая» часть. Скрипт дописал заметку с русским текстом, чтобы поломка проявилась; в обычной жизни она проявляется, как только в данных появляется не-ASCII. Починка: fix, либо вручную вернуть str(len(data)). Урок: длину всегда считают по байтам, а один общий метод _send защищает от ошибки в каждом месте отдельно.
Сценарий 3: keep-alive и закрытое соединение. Сервис объявил HTTP/1.1 и в ответе нет Connection: close, но после отправки ответа закрывает сокет (в коде добавлено self.close_connection = True). Клиент считает соединение живым и пытается использовать его снова. В выводе curl -sv виден шаг Connection 0 seems to be dead (соединение 0 похоже на мёртвое), после чего curl открывает новое. Обычно клиенты так тихо повторяют, и поломку не заметить, но в другой ситуации (nc, самописный клиент, прокси под нагрузкой) второй запрос по соединению остаётся без ответа: в проверке через nc пришёл только один ответ из двух. Диагностика: два запроса по одному соединению, сравнение с эталоном через diff (в новом файле лишняя строка self.close_connection = True). Починка: fix. Честный вариант объявить закрытие: слать Connection: close; либо, как правило, оставлять соединение открытым. Урок: если сервер закрывает соединение, он обязан это объявить.
Общий метод: код ответа, потом заголовки Content-Length и Connection, потом поведение на втором запросе, потом diff с эталоном. Три проверки, три причины. После всех сценариев обязательно выполни sudo bash /tmp/break-2.4.sh fix и убедись: cmp ~/notes/app.py /opt/notes/app.py && echo одинаковые.
ИИ в помощь
Нейросеть хорошо читает заголовки и коды, но не видит твоего сервера и логов. Общие правила работы с ней: ИИ-помощник.
Задача: разобрать ответ сервера.
Я учу HTTP. Вот вывод curl -v к моему сервису (токены и cookie я убрал):
<вставь вывод целиком>.
Разбери по строкам: что отправил клиент, что ответил сервер, какой код и что он значит,
какие заголовки важны. Скажи, чья это проблема: клиента, прокси или приложения.
Проверь ответ: найди каждый названный заголовок в выводе: если его нет, нейросеть его придумала. Типичная ошибка: путает 502, 503 и 504 или называет 500 «ошибкой в запросе».
Задача: найти, где теряется время.
Вот результат curl -w для медленного запроса:
<вставь time_namelookup, time_connect, time_starttransfer, time_total>.
Объясни, на каком этапе уходит время и в какую сторону копать. Ничего не меняй, только предлагай проверки.
Проверь ответ: сверь с правилом: большой разрыв между connect и ttfb значит медленное приложение, а не сеть. Типичная ошибка: советует «добавить серверов» без замера.
Задача: составить запрос для проверки метода.
Мне нужна команда curl, которая отправляет POST с JSON {"text": "хлеб"} на http://localhost:8080/notes
и показывает код ответа и заголовки. Объясни каждый ключ.
Проверь ответ: выполни и сравни код с ожидаемым. Типичная ошибка: забывает -H "Content-Type: application/json" или ставит одинарные кавычки внутри JSON.
Словарик урока
| Термин | Простыми словами |
|---|---|
| HTTP | Правила, по которым клиент просит, а сервер отвечает; текстовый формат «запрос-ответ» поверх TCP |
| Протокол | Набор правил общения: кто пишет первым, в каком порядке и виде |
| Код ответа (status code) | Трёхзначное число в начале ответа: 2xx успех, 4xx ошибка клиента, 5xx ошибка сервера |
| Заголовок (header) | Служебная строка запроса или ответа: кто ты, что принимаешь, сколько байтов в теле |
| Прокси | Посредник: принимает запрос от клиента и пересылает настоящему серверу |
curl |
Консольная программа, которая отправляет HTTP-запрос и печатает ответ |
nc (netcat) |
Утилита, которая открывает TCP-соединение и позволяет печатать в него вручную |
| Клиент, сервер | Клиент отправляет запрос (браузер, curl), сервер отвечает (наше приложение) |
| URL | Адрес ресурса: схема, хост, порт, путь и параметры |
| Эндпоинт (endpoint) | Путь внутри сервиса, который отвечает за одно действие: /healthz, /notes |
| Параметры запроса (query string) | Часть URL после ?: пары имя=значение через & |
| Метод (method) | Слово в начале запроса, которое говорит, что сделать: GET, POST, DELETE |
| Идемпотентность | Повтор запроса даёт то же состояние, что одно выполнение (GET, PUT, DELETE; POST нет) |
| Заголовок (header) | Строка Имя: значение со служебными сведениями о запросе или ответе |
| Тело (body) | Данные запроса или ответа после пустой строки |
| CRLF | Пара символов \r\n, которой заканчивается каждая строка HTTP |
| Код статуса | Трёхзначное число в ответе; первая цифра показывает класс: 2xx успех, 4xx клиент, 5xx сервер |
| 404 и 405 | 404: такого пути нет; 405: путь есть, метод не разрешён (в ответе Allow) |
| 401 и 403 | 401: ты не представился; 403: представился, но прав нет |
| 502, 503, 504 | Отвечает прокси: приложение не ответило как надо, не готово или не успело |
| SLO | Целевой уровень надёжности, например «99,9% запросов успешны»; 5xx его «съедают» |
| Прокси (proxy) | Программа-посредник между клиентом и приложением (в курсе nginx, урок 2.5) |
| Балансировщик | Программа, которая раздаёт запросы между несколькими копиями приложения |
| JSON | Текстовый формат данных с фигурными скобками и ключами в кавычках |
| Токен, Bearer | Выданная сервером длинная строка-пропуск; Authorization: Bearer <токен> |
| Кэш (cache) | Временная копия ответа, чтобы не спрашивать сервер повторно |
| Виртуальные хосты | Несколько сайтов на одном IP и порту; сервер выбирает нужный по заголовку Host |
| Keep-alive | Соединение остаётся открытым для следующих запросов (по умолчанию в HTTP/1.1) |
Content-Length |
Длина тела в байтах; по нему клиент понимает, где кончился ответ |
| Chunked | Передача тела кусками с длиной каждого, когда общий размер заранее неизвестен |
| TTFB | Время до первого байта ответа; показывает, сколько думало приложение |
| Backoff | Растущая пауза между повторами запроса (1, 2, 4, 8 секунд) |
| Код выхода | Число, которое команда отдаёт после завершения: 0 успех, иначе ошибка ($?) |
nc (netcat) |
Утилита, открывающая TCP-соединение и передающая текст; позволяет отправить HTTP руками |
Вопросы с собеседований
Раздел для повторения: ответь вслух, потом открой ответ. Короткие вопросы с пометкой [на скорость] тренируй на время: ответ за 30 секунд.
1. [junior] [часто] Какие бывают классы кодов ответа HTTP и что ты ждёшь от каждого?
Ответ
1xx информационные, 2xx успех (200 OK, 201 Created, 204 No Content), 3xx перенаправление (301 постоянное, 302 временное, 304 Not Modified), 4xx ошибка клиента (400, 401, 403, 404, 429), 5xx ошибка сервера (500, 502, 503, 504). Для меня главное различие такое: 5xx обычно наша проблема, 4xx чаще запрос клиента, хотя массовые 4xx после релиза тоже сигнал. Долю 5xx считаю как основу SLI. Код быстро смотрю так: curl -sS -o /dev/null -w '%{http_code}\n' URL.
Что хотят услышать: пять классов с примерами, кто виноват при 4xx и 5xx, 5xx как метрика надёжности
Красный флаг: отвечать, что «200 - хорошо, остальное плохо», и не различать 4xx и 5xx
2. [junior] [часто] [на скорость] Чем 401 отличается от 403?
Ответ
401: клиент не аутентифицирован (не назвался или учётные данные неверные). 403: личность известна, но прав на ресурс нет. Пример: без токена в заголовке Authorization получишь 401, с токеном обычного пользователя на админский путь 403.
Что хотят услышать: аутентификация («кто ты») против авторизации («что тебе можно»), пример, упоминание Authorization.
Красный флаг: «это одно и то же».
3. [middle] [часто] Повтор какого запроса безопасен при таймауте, а какого нет? Как быть с оплатой?
Ответ
Безопасно повторять идемпотентные: GET, PUT, DELETE. POST может создать дубль. Для оплаты клиент шлёт заголовок Idempotency-Key, а сервер по нему возвращает результат первого вызова и второй платёж не создаёт. Повторы делают с растущей паузой и ограничением числа попыток (backoff), иначе получится шторм запросов, который добьёт и без того больной сервис.
Что хотят услышать: идемпотентность, ключ идемпотентности, backoff, 429 и заголовок Retry-After.
Красный флаг: «ретраи (повторы) включим везде».
4. [junior] Сервис отвечает 500, а через минуту 200 на тот же запрос. С чего начнёшь?
Ответ
Сначала смотрю, кто отвечает: приложение (500) или прокси перед ним (были бы 502-504). Затем воспроизвожу запрос curl -i несколько раз и смотрю заголовки, потом лог приложения по времени ответов с 500. Проверяю, есть ли несколько копий (реплик) приложения: возможно, сломана только одна из них, и балансировщик то попадает в неё, то нет.
Что хотят услышать: различие 500 и 502 (отвечает приложение или прокси), время в логе, гипотеза про одну плохую реплику, повторяемость запроса.
Красный флаг: «перезапустил бы сервис» без единой проверки.
5. [junior] Прод отвечает 502. Что делаешь?
Ответ
502 приходит от прокси, значит, проверяю, достучался ли он до приложения. Иду на сервер: systemctl status приложения, ss -tlnp (слушает ли нужный порт), curl на приложение напрямую, минуя прокси, и error.log прокси. Обычно приложение лежит, слушает другой порт или упирается в лимиты.
Что хотят услышать: 502 это не ответ приложения, проверка по слоям, прямой запрос к бэкенду (приложению за прокси), лог прокси.
Красный флаг: искать баг в коде, не проверив, что приложение вообще живо.
6. [junior] [на скорость] Как проверить, что сервис жив, из скрипта мониторинга?
Ответ
curl -fsS --max-time 2 http://host/healthz: -f даёт код выхода 22 при ответах 4xx и 5xx (без него curl считает любой полученный ответ успехом), -S показывает ошибку, --max-time не даёт скрипту повиснуть. Проверяю код выхода, а не текст ответа.
Что хотят услышать: код выхода, таймаут, разница между «жив» (/healthz) и «готов принимать трафик» (/readyz).
Красный флаг: curl без таймаута в cron (планировщике, который запускает команды по расписанию).
7. [middle] Пользователи жалуются на тормоза, а CPU и память в норме. Как локализуешь, где задержка?
Ответ
Снимаю curl -w с фазами: time_namelookup, time_connect, time_starttransfer, time_total. Большая доля в DNS означает проблему с резолвером, в connect сеть или порт, в ttfb приложение или его зависимость (например, база данных), между ttfb и total тяжёлое тело. Затем сравниваю запрос напрямую к приложению и через прокси, чтобы понять, какой слой добавляет время.
Что хотят услышать: разбор по фазам, сравнение слоёв, догадка про медленную БД, перцентили (p95: время, дольше которого отвечают только 5% запросов), а не среднее.
Красный флаг: «добавлю ресурсов» без измерения.
8. [middle] Запрос зависает, а curl пишет transfer closed with N bytes remaining to read. Что могло произойти?
Ответ
Сервер заявил Content-Length больше фактически отданного, либо оборвал соединение посередине ответа (упал процесс, сработал таймаут прокси). Сравниваю заявленную длину и полученные байты, смотрю лог сервера и прокси на обрыв. Если ответ содержит не-ASCII текст, проверяю, не считается ли длина в символах вместо байтов.
Что хотят услышать: роль Content-Length в keep-alive, длина в байтах, а не в символах, chunked, обрыв на прокси.
Красный флаг: «сеть глючит» без проверки.
9. [middle] Ты за прокси, и в логах приложения у всех клиентов один и тот же IP. Как исправить?
Ответ
Приложение видит адрес прокси, потому что соединение приходит от него. Прокси должен передавать настоящий адрес клиента в заголовке X-Forwarded-For, а приложение его читать, но доверять только заголовку от известного прокси, иначе клиент подделает адрес.
Что хотят услышать: X-Forwarded-For, X-Forwarded-Proto (по какой схеме, http или https, пришёл клиент), доверенные прокси, риск подделки.
Красный флаг: «просто прочитаю заголовок у любого клиента и поверю».
10. [junior] Сервис отвечает 504 за прокси, но приложение на сервере живо. Что проверишь?
Ответ
504 значит, что прокси соединился, но не дождался ответа. Измеряю время ответа приложения напрямую: curl -w с ttfb. Если оно больше таймаута прокси, ищу медленный запрос (база данных, внешний вызов) в логах, а не поднимаю таймаут вслепую.
Что хотят услышать: 502 против 504, прямой запрос к бэкенду, ttfb, таймаут как симптом, а не лекарство.
Красный флаг: «увеличу proxy_read_timeout (время ожидания ответа в nginx) до часа».
11. [middle] После релиза клиенты жалуются на 400, а в тестах всё зелёное. Как разбираешься?
Ответ
Прошу точный запрос клиента (метод, заголовки, тело), воспроизвожу его через curl -v. Чаще всего причина в Content-Type, кодировке тела или новом обязательном поле. Сравниваю с логом приложения, смотрю, какая проверка вернула 400.
Что хотят услышать: воспроизвести запрос руками, заголовки и тело, лог валидации (проверки входных данных), совместимость API (не сломали ли формат для старых клиентов).
Красный флаг: «у меня работает».
12. [junior] [на скорость] Чем редирект 301 отличается от 302, и что такое 307 и 308?
Ответ
301 - постоянный редирект: клиенты и поисковики запоминают новый адрес. 302 - временный, адрес обычно не запоминается (кеш возможен, если сервер явно разрешил через Cache-Control). Клиенты в ответ на 301 и 302 вправе поменять POST на GET, и многие так делают. 307 (временный) и 308 (постоянный) сохраняют метод и тело запроса, поэтому их берут для редиректов API. Новый адрес приходит в заголовке Location. Проверяю командами curl -I http://host и curl -IL, чтобы пройти цепочку целиком. Зацикленный редирект (например, HTTP на HTTPS за прокси) виден как ошибка Too many redirects.
Что хотят услышать: постоянный и временный, смена метода, 307/308 сохраняют метод, Location, curl -IL, петля редиректов.
Красный флаг: «301 и 302 - это одно и то же».
13. [middle] Чем HTTP/1.1, HTTP/2 и HTTP/3 отличаются и что это значит для тебя как для инженера?
Ответ
HTTP/1.1 - текстовый протокол: на соединении запросы идут по очереди, поэтому браузер открывает несколько соединений. HTTP/2 - двоичный, мультиплексирует много запросов в одном TCP-соединении и сжимает заголовки, но при потере пакета задерживается весь TCP. HTTP/3 работает поверх QUIC (на UDP) и лишён этой проблемы, плюс быстрее устанавливает соединение. На практике новую версию часто держит балансировщик или nginx, а до приложения может доходить HTTP/1.1. Проверяю командой curl -v --http2 https://host. Для HTTP/3 нужен открытый UDP 443, иначе клиент откатится на TCP.
Что хотят услышать: очередь против мультиплексирования, HTTP/2 на TCP, HTTP/3 на QUIC/UDP, терминация на прокси, UDP 443.
Красный флаг: «HTTP/2 и HTTP/3 - это другие порты и другие методы».
Проверено на версиях
Прогонялось на стенде в Docker (образ devops-lab:24.04, Ubuntu 24.04 с systemd, сервис notes от пользователя notes):
- Ubuntu 24.04 LTS:
curl8.5.0, Python 3.12.3,netcat-openbsd1.226-1ubuntu2. Все задания 1-6 выполнены, вывод в уроке настоящий (изменяющиеся значения: даты, время, номера портов клиента). - Приложение:
app.pyv2.2 в заданиях 1 и 2, v3 (эталонproject/notes/versions/v3.py) начиная с задания 3. - Ubuntu 26.04 LTS (контейнер без systemd):
curl8.18.0, Python 3.14.4. Провереныcurl -v, HEAD,-f, таймауты, фальшивый сервер наnc: отличия описаны в заданиях (текст ошибок и строки-v), поведение приложения то же. - Сценарии
break.sh1, 2, 3 иfixпрогнаны на стенде 24.04,shellcheckбез замечаний. Скачивание скриптов иv3.pyпо адресамraw.githubusercontent.comне проверялось: файлы станут доступны после публикации в веткеdevops.
Итог урока: ты умеешь
- умею разобрать вывод
curl -vпострочно: запрос, заголовки, ответ - умею по классу кода сказать, чья проблема: клиента, приложения или прокси
- умею отличать 404 от 405, 401 от 403, 500 от 502, 503 и 504
- умею собрать запрос с методом, заголовками и телом через
curl - умею измерить фазы запроса через
curl -wи найти, где задержка - умею отправить HTTP-запрос руками через
nc - умею объяснить, зачем нужен
Content-Lengthпри keep-alive и почему он считается в байтах - умею обновить
app.pyдо v3 и проверить/headers,/slow,/error
Дальше: Урок 2.5: nginx как reverse proxy для «Заметок»
Проверь себя
Короткий тест по уроку: 5 вопросов из банка в 30. Засчитывается только полностью правильный ответ, порог 60%. Каждая новая попытка даёт другие вопросы, пока банк не закончится. Ответы видны после проверки.
Тест работает с включённым JavaScript.