Анатомия веб-сервиса: прокси, приложение, база, кэш, очередь
Lesson from the course “Курс подготовки DevOps-инженеров”
Хронометраж: занятие 60 минут — теория 30 минут, практика 30 минут. Домашняя работа: 1–2 часа.
Кратко о чём урок: Из каких компонентов состоит типовой сервис, как они взаимодействуют, где узкие места и какие требования каждый компонент выдвигает к инфраструктуре.
1. Что изучаем
- Называть слои типового веб-сервиса и объяснять, что каждый из них хранит: какой компонент можно пересоздать с нуля, а какой нельзя.
- Различать живость и готовность и объяснять, почему проверка живости не должна обращаться к базе.
- Предсказывать поведение сервиса при отказе каждого слоя и проверять предсказание, выключив слой.
- Объяснять, что делает прокси перед приложением и какие запросы до приложения не доходят вовсе.
- Находить случаи, когда клиент получил успешный ответ, а данные разошлись.
2. Зачем это нужно и где применяется
Дальше в курсе всё будет про то, как это разворачивать: контейнеры, Kubernetes, конвейеры, мониторинг. Но развернуть можно только то, устройство чего понимаешь, а с фразой «приложение не работает» в заявке инженер сталкивается раньше, чем с любым инструментом.
Разница между «сервис лежит» и «одна из его частей лежит» — практическая. Отказ кэша чаще всего означает «стало медленнее», отказ базы — «не работает ничего», отказ обработчика очереди — «работает, но письма не приходят, и завтра их придёт три тысячи сразу». Реакция на эти три события разная, и определять её надо до инцидента.
Второе, что даёт этот урок, — умение задавать правильные вопросы про незнакомый сервис. Вам передают систему, которую писали три года без вас. Вопросы «где здесь состояние», «что переживёт перезапуск», «какие таймауты заданы», «что проверяет ваша проверка живости» дают картину за полчаса, а без них разбираться приходится на инциденте.
Третье — понимание, где сервис теряет данные молча. Это самый неприятный класс отказов: клиент
получил успешный ответ, в журнале ошибок нет, а данные разошлись. В практике урока мы этот
случай воспроизведём и увидим код 201 там, где половина работы не сделана.
3. Теория (30 минут)
Пять слоёв и что между ними общего
Типовой веб-сервис почти всегда выглядит так:
клиент → прокси → приложение → база данных
├→ кэш
└→ очередь → обработчик → база данных
Прокси принимает соединения, завершает TLS, отдаёт статику и распределяет запросы. Приложение исполняет логику. База хранит данные, которые обязаны сохраниться. Кэш хранит копии того, что дорого считать. Очередь принимает задания, которые не нужно выполнять прямо во время запроса, а обработчик их выполняет.
Слоёв может быть больше (объектное хранилище, поисковый индекс, отдельный сервис фоновых задач) или меньше, но набор ролей повторяется от сервиса к сервису, и полезно уметь его узнавать.
Ключевое деление внутри этого списка — не «важный или нет», а есть ли у компонента состояние. Прокси, приложение и обработчик состояния не имеют: их можно остановить, удалить, собрать заново из образа и запустить — сервис вернётся к работе. База состояние имеет: потеря её тома означает потерю данных, и никакой пересборкой это не лечится. Кэш и очередь занимают промежуточное положение, и именно поэтому про них чаще всего ошибаются. Кэш можно потерять без последствий — он по определению копия. Очередь потерять нельзя: в ней лежат задания, которые никто больше не помнит.
Это деление определяет всё остальное: что можно перезапускать не думая, что нужно резервировать, где нужен том, а где достаточно образа. Первый вопрос про незнакомый сервис — «где здесь состояние», и ответ на него часто оказывается неожиданным для самих разработчиков.
Прокси делает больше, чем кажется
Проще всего думать, что прокси просто пересылает запросы. На стенде это опровергается за минуту.
Запрос за статическим файлом до приложения не доходит вообще: проверено счётчиком запросов
к базе — до обращения к /static/hello.txt он равен 1, после — тоже 1. Приложение об этом
запросе не узнало. В журнале прокси это видно ещё яснее:
172.18.0.1 "GET /items HTTP/1.1" 200 upstream=0.011 total=0.010 served_by=5a2e8ab7295b
172.18.0.1 "GET /static/hello.txt HTTP/1.1" 200 upstream=- total=0.000 served_by=-
Поле upstream= — время, которое ответ ждали от приложения. У статики оно пустое: приложения
в этом запросе не было. Разделение upstream_response_time и request_time — то, без чего
нельзя ответить на вопрос «медленно у нас или у клиента»: если upstream мал, а total велик,
приложение ответило быстро, а время ушло на передачу данных клиенту.
Второе, что делает прокси, — сообщает приложению, кто клиент. Без заголовков X-Forwarded-For и
X-Real-IP приложение видит адрес прокси и в журнале у всех запросов будет один и тот же адрес.
На стенде это проверяется прямым сравнением: обращение к приложению изнутри сети даёт «запрос
напрямую», через прокси — «да».
Третье — таймауты. По умолчанию nginx ждёт ответа от приложения 60 секунд. При зависшем
приложении это означает, что рабочие процессы прокси заняты ожиданием, и новые клиенты не
получают даже отказа. Поэтому proxy_read_timeout задаётся явно и обычно куда меньше минуты.
Кэш ускоряет, но не хранит
Кэш в этом стенде — Redis, и в нём лежит результат запроса к базе на 30 секунд. Разница измерима: пять прогонов с очисткой кэша перед каждым дали 2,3–5,3 мс на промахе против 0,4–1,3 мс на попадании, то есть примерно в 4–8 раз. Абсолютные числа здесь ничего не значат (это loopback и двадцать строк выборки), но соотношение показательно: обращение к кэшу дешевле обращения к базе на порядок, потому что не требует ни разбора запроса, ни чтения страниц.
Важнее другое: отказ кэша не обязан останавливать сервис. Проверено — при выключенном Redis
запрос /items вернул код 200, источник данных «db (кэш недоступен)», время 7,5 мс. Это
достигается не само: в коде обращение к кэшу обёрнуто в обработчик ошибок, и при отказе
приложение читает базу. Написать иначе легко — и тогда падение кэша, компонента без состояния,
роняет сервис целиком.
Обратная сторона — момент сброса. После записи новой строки кэш очищается, иначе добавленный элемент не будет виден до истечения TTL. Это выбор между свежестью данных и нагрузкой на базу, и делать его надо сознательно: в стенде выбрана свежесть, потому что 30 секунд невидимости новой записи для учебного сервиса заметнее, чем лишние запросы.
Живость и готовность — это разные вопросы
У приложения на стенде две проверки, и они отвечают на разное. /health отвечает «процесс жив»
и не обращается ни к базе, ни к кэшу. /ready отвечает «я могу обслуживать запросы» и проверяет
зависимости.
Разница видна при отказе базы:
до отказа: /health=200 /ready=200
база лежит: /health=200 /ready=503
и в теле отказа готовности:
{"status": "not ready",
"checks": {"db": "PoolTimeout: couldn't get a connection after 5.00 sec", "cache": "ok"}}
Зачем разделение? Реакция на эти два состояния противоположна. «Не жив» означает перезапустить процесс. «Не готов» означает снять с балансировки и подождать. Если проверка живости обращается к базе, то при недоступности базы оркестратор начнёт перезапускать полностью исправное приложение — и вместо одной проблемы вы получите две, причём вторая помешает решать первую. Ошибка распространённая, потому что «проверить всё» интуитивно кажется правильным.
Ответ готовности полезно делать говорящим: строка PoolTimeout: couldn't get a connection after 5.00 sec сразу называет и компонент, и характер отказа. Проверка, отвечающая только кодом 503,
экономит инженеру ноль времени.
Очередь: чем она помогает и чем платит
Очередь нужна, когда работу не обязательно делать во время запроса. Клиент получает ответ сразу, а обработка идёт своим темпом. Плата за это — задержка между «принято» и «сделано», и её надо уметь видеть.
На стенде обработчик останавливается, и восемь запросов на запись дают восемь сообщений в
очереди — rabbitmqctl list_queues показывает items 8. После запуска обработчика очередь
расходится до нуля. Число сообщений в очереди и есть отставание: пока оно около нуля, всё
хорошо, а растущее означает, что обработчики не успевают.
Два решения в коде обработчика стоит разобрать, потому что оба про потерю данных.
Подтверждение после работы, а не до. Сообщение подтверждается только после того, как результат записан в базу. Подтвердить сразу после получения проще, но тогда падение процесса между получением и записью означает потерю задания: брокер уже считает его выполненным.
Отказ без возврата в ту же очередь. Если обработка не удалась, сообщение помечается
requeue=False и уходит в недоставленные. Возврат в исходную очередь при постоянной ошибке
(например, в сообщении неверные данные) даёт бесконечный цикл: сообщение возвращается, снова
падает, снова возвращается — и заодно мешает обрабатывать остальные.
Где сервис теряет данные молча
Это главный практический вывод урока, и он проверяется одним опытом. Выключаем брокер и отправляем запрос на запись:
код ответа: 201
{"id": 5010, "name": "broker-down", "queued": false}
Строка в базе появилась — проверено, количество выросло с 5009 до 5010. Сообщение не отправлено,
и восстановить его невозможно: приложение о нём больше не помнит. Клиент получил успешный
ответ. В журнале приложения есть запись ERROR не удалось опубликовать сообщение, но кто её
читает в момент, когда ошибок нет?
Так выглядит расхождение данных: половина операции выполнена, вторая половина потеряна, и никто не считает это отказом. Правильных вариантов поведения здесь несколько — вернуть клиенту ошибку, складывать неотправленные сообщения в базу и досылать их отдельным процессом, объединять запись и отправку в одну транзакцию, — но выбирать надо сознательно. Стенд намеренно оставлен в неправильном состоянии: это задание домашней работы.
Порядок запуска, сигналы и проверки контейнера
Три вещи из устройства стенда, которые в эксплуатации попадаются постоянно.
Зависимость «запущен» не равна «готов». В compose.yaml у зависимостей указано
condition: service_healthy. Без условия прокси стартует, когда приложение только запущено,
и первые запросы получают 502. То же с базой: проверка её готовности сделана через
pg_isready -h 127.0.0.1, и указание хоста существенно — во время инициализации кластера
PostgreSQL поднимает временный сервер на unix-сокете, и проверка через сокет отвечает «готов»
раньше, чем база начинает принимать соединения по сети.
Сигнал завершения в контейнере ведёт себя не так, как в оболочке. Измерено на стенде:
обработчик очереди с обработчиком SIGTERM останавливается за 2 секунды и оставляет в
журнале «остановлен». А контейнер с sleep 600 первым процессом — за 10 секунд и с кодом
выхода 137, то есть его убили. Причина не в sleep: процесс с номером 1 не получает
действий по умолчанию для сигналов, у которых нет обработчика, поэтому SIGTERM до него просто
не доходит, и через десять секунд docker stop присылает SIGKILL. С флагом --init тот же
контейнер останавливается мгновенно и с кодом 143, потому что первым процессом становится
tini, который сигнал пересылает. Подробно жизненный цикл процесса разбирается в уроке 07.05,
но помнить это стоит сразу: десять секунд на остановку каждого контейнера при выкате из сотни
экземпляров — это почти двадцать минут.
Проверка живости живёт в образе, а не в контейнере. HEALTHCHECK записан в образ, поэтому
достаётся любому контейнеру из него. На стенде приложение и обработчик очереди собраны из одного
образа, и обработчик получил проверку curl http://127.0.0.1:8000/health, которого не слушает.
Результат — unhealthy при полностью исправной работе и curl: (7) Failed to connect в истории
проверок. Пришлось отключить проверку для обработчика явно. Вывод общий: у процесса, который не
принимает запросы, живость проверяется не портом, а признаком, что он делает работу.
Прокси помнит адрес приложения дольше, чем нужно
Этот случай стоит отдельного раздела, потому что выглядит он как «выкатили новую версию и получили 502», а причина совсем не там, где её ищут.
Сначала о том, чего в опыте недостаточно. Просто пересоздать контейнер приложения
(docker compose up -d --force-recreate app) мало: он часто получает тот же адрес, и тогда
ничего не происходит — проверено, код остался 200. Чтобы адрес гарантированно сменился, старый
занимается заглушкой: приложение останавливается, освободившийся адрес забирает посторонний
контейнер, приложение запускается снова. Это не искусственный трюк, а то же, что бывает при
выкате, когда рядом успел подняться другой контейнер.
Результат:
адрес приложения был: 172.18.0.8
освободившийся адрес занят заглушкой: 172.18.0.5
приложение поднялось на: 172.18.0.8
запрос через прокси: код 502
getent hosts app → 172.18.0.8 app
Приложение здорово, DNS внутри прокси отвечает правильно, а через прокси идёт 502. В журнале ошибок прокси видно, куда он стучится на самом деле:
connect() failed (111: Connection refused) while connecting to upstream,
upstream: "http://172.18.0.5:8000/items"
Адрес заглушки, то есть тот, который приложение занимало раньше. nginx разрешает имена
серверов из upstream один раз при запуске и держит полученный адрес, пока конфигурация не
перезагружена. Проверено: nginx -s reload — и код снова 200.
Вывод практический: после пересоздания того, к чему обращается прокси, прокси надо перезагрузить.
Обходные пути есть — задать resolver и подставлять адрес через переменную в proxy_pass, тогда
имя разрешается на каждый запрос, — но и у них своя цена. В настоящей эксплуатации этой проблемы
обычно нет, потому что адрес за прокси стабилен: за ним стоит имя службы Kubernetes или
балансировщик. А вот на стенде и на одной машине с docker compose она встречается постоянно,
и знать её надо, чтобы не искать причину в приложении.
Соединение с базой — дорогой ресурс
Про базу в этом уроке нужно знать одну вещь, из которой потом вырастает половина инцидентов: соединение с PostgreSQL стоит дорого, и его нельзя открывать на каждый запрос.
Измерено на стенде, четыре прогона по 50 запросов SELECT 1:
| Способ | Время на запрос | Соотношение |
|---|---|---|
| Новое соединение на каждый запрос | 15–22 мс | — |
| Через пул соединений | 1,1–2,3 мс | в 7–16 раз быстрее |
Причина в том, что PostgreSQL на каждое соединение запускает отдельный процесс операционной
системы: это не «открыть сокет», а «создать процесс, выделить ему память, выполнить
аутентификацию». Отсюда два следствия. Первое: приложение держит пул и переиспользует
соединения — в стенде это ConnectionPool с max_size=5. Второе: число соединений ограничено,
и предел ниже, чем кажется. SHOW max_connections на стенде отвечает 100, а занято сейчас
восемь. Сто — это значение по умолчанию, и на настоящей базе оно тоже обычно невелико: каждое
соединение потребляет память, поэтому поднимать его бесконечно нельзя.
Арифметика простая и неприятная: десять экземпляров приложения по пулу на двадцать соединений — это двести соединений при пределе сто. База начнёт отказывать в подключении, причём не под нагрузкой, а просто после выката десятого экземпляра. Инструмент против этого — пулер вроде PgBouncer, и подробно это разбирается в уроке 07.06. Здесь достаточно запомнить, что число соединений — ресурс, который надо считать, а не надеяться на него.
3.1. Примеры и варианты реализации
Одна задача — три среды. Если вариант в этом уроке неприменим, это указывается явно с объяснением причины.
| Среда | Как решается | Что нужно иметь |
|---|---|---|
| Локальный стенд (Docker / VM / kind) | пять слоёв поднимаются через docker compose, отказы воспроизводятся остановкой контейнера |
Docker с compose, образы из examples/docker/ |
| VPS / bare-metal | те же роли, но каждая — служба systemd на одной или нескольких машинах | пакеты nginx, postgresql, redis, rabbitmq-server, доступ sudo |
| Yandex Cloud | приложение на ВМ или в контейнерах, база и кэш — управляемые сервисы | Managed Service for PostgreSQL, Managed Service for Redis, Message Queue или Managed Kafka |
Локальный стенд. Полный вариант и лучший для изучения: все пять слоёв видны одновременно,
любой можно выключить одной командой, и всё убирается одной. Именно на нём измерены числа этого
урока. Ограничения стенда названы прямо в
examples/docker/README.md: под нагрузкой числа нереалистичны,
остановка контейнера — не отказ машины, репликация и переключение базы так не проверяются.
VPS / bare-metal. Роли те же, но границы между ними видны хуже: на одной машине прокси, приложение и база живут рядом, и «сервис работает» легко спутать с «машина работает». Зато появляется то, чего нет в стенде: конкуренция за память и диск между слоями. Отсюда правило, которое на этом варианте выучивается быстро, — базу не селят на ту же машину, где приложение, если есть выбор: под нагрузкой они отбирают память друг у друга, и первым от этого страдает кэш страниц PostgreSQL.
Yandex Cloud. Отличие принципиальное: база и кэш перестают быть вашими процессами. Managed Service for PostgreSQL сам занимается резервными копиями, обновлениями и репликами, но и доступа к файловой системе кластера у вас нет — часть приёмов из уроков 07.06 и 07.10 к нему неприменима, и это оговаривается там прямо. Взамен исчезает целый класс задач: не нужно настраивать репликацию руками и следить за местом под WAL. Соединения при этом остаются ограниченным ресурсом, а пулер в управляемом сервисе включается настройкой.
Что выбрать. Разбираться в устройстве — на локальном стенде: только там можно ломать компоненты по одному без последствий. Небольшой сервис с одной командой — VPS, где всё под контролем и стоимость предсказуема. Сервис, для которого потеря данных недопустима, а команда не имеет отдельного администратора баз данных, — управляемая база в облаке: резервные копии и переключение на реплику там сделаны за вас, и это ровно та часть работы, которую тяжелее всего сделать правильно самому.
4. Практика на занятии (30 минут)
Курс проходят самостоятельно, поэтому каждый шаг говорит, что ввести, зачем, что должно получиться и что делать при неудаче.
Что делаем. Поднимаем сервис из пяти слоёв, смотрим на каждый по отдельности и ломаем их по одному. Все числа в теории получены именно этими командами — их можно сверить.
Файлы для работы: blocks/07-applications-and-data/examples/07.01-web-service-anatomy/
Стенд — compose из пяти сервисов, образы описаны в
blocks/07-applications-and-data/examples/docker/. Своего Dockerfile там
только один — Dockerfile.app для учебного приложения: базу,
кэш, брокер и прокси незачем пересобирать, они настраиваются переменными окружения. Состав
сервисов и условия их запуска — в compose.yaml;
зависимости приложения закреплены по версиям в
app/requirements.txt, настройки
прокси — в nginx/proxy.conf, а
обработчик очереди — отдельный процесс из того же образа,
app/worker.py.
cd blocks/07-applications-and-data/examples/07.01-web-service-anatomy
docker compose up -d --build # первый запуск дольше: собирается образ приложения
docker compose ps # ждём healthy у app, cache, db, queue
Шаги.
Шаг 1 (2 минуты). Кто здесь есть.
-
Что ввести:
./lab-anatomy.sh layers -
Зачем: увидеть все слои сразу и то, что о себе сообщает приложение — жива ли база, отзывается ли кэш и пришёл ли запрос через прокси.
-
Ожидаемый результат: приложение отвечает, отзываются ли база и кэш и пришёл ли запрос через прокси. В столбце
STATUSу обработчика очереди проверки живости нет — это сделано намеренно, почему, разбирается в шаге 6. -
Если не так: если контейнеры ещё не
healthy, стенд не дождался готовности — проверьтеdocker compose ps, все лиapp,cache,db,queueв состоянииhealthy, и повторите.
Шаг 2 (3 минуты). Чего прокси не отдаёт приложению.
-
Что ввести:
./lab-anatomy.sh proxy -
Зачем: проверить счётчиком запросов к базе, доходит ли запрос за статикой до приложения.
-
Ожидаемый результат: счётчик не изменился — до приложения запрос не дошёл. Ниже — сравнение того, что видит приложение при прямом обращении и через прокси.
-
Если не так: если счётчик вырос, статику отдаёт приложение, а не прокси — проверьте блок
location /static/вnginx/proxy.conf.
Шаг 3 (3 минуты). Кэш: промах и попадание.
-
Что ввести:
./lab-anatomy.sh cache -
Зачем: измерить разницу между чтением из базы (промах) и из кэша (попадание).
-
Ожидаемый результат: первый запрос идёт в базу, остальные — в кэш, и время отличается в несколько раз. Числа на вашей машине будут другими; сравнивать надо соотношение, а не абсолютные значения. Заодно видно остаток TTL и размер значения в байтах.
-
Если не так: если времена одинаковы, кэш не включился — проверьте, что Redis запущен (
docker compose ps cache).
Шаг 4 (3 минуты). Запись: три действия за один запрос.
-
Что ввести:
./lab-anatomy.sh write -
Зачем: показать, что один
POSTпишет в базу, сбрасывает кэш и отправляет сообщение в очередь. -
Ожидаемый результат: в выводе
queued: true, затем чтение уже из базы (кэш сброшен), затем строка из журнала обработчика: сообщение получено и записано. -
Если не так: если
queued: false, брокер недоступен — проверьтеdocker compose ps queue.
Шаг 5 (5 минут). Очередь копится.
-
Что ввести:
./lab-anatomy.sh queue -
Зачем: увидеть отставание — число сообщений в очереди при остановленном обработчике.
-
Ожидаемый результат:
rabbitmqctl list_queuesпоказывает восемь сообщений — это и есть отставание. После запуска обработчика очередь расходится до нуля. Режим занимает около минуты: остановка и запуск контейнеров не мгновенны. -
Если не так: если очередь пуста при остановленном обработчике, сообщения не публикуются — проверьте, что приложение соединено с брокером (шаг 4 должен давать
queued: true).
Шаг 6 (5 минут). Ломаем по одному.
-
Что ввести:
./lab-anatomy.sh break -
Зачем: увидеть, как по-разному отвечают живость и готовность при отказе слоя (самый важный шаг занятия).
-
Ожидаемый результат: при отказе базы
/healthостаётся 200, а/readyотдаёт 503 и называет причину. При отказе кэша сервис продолжает отвечать 200, но читает базу напрямую. -
Если не так: если при отказе базы
/healthтоже стал 503, значит проверка живости обращается к базе — это как раз тот антипаттерн, которого быть не должно. -
Ответьте: сравните это с тем, что было бы, если бы проверка живости обращалась к базе.
Шаг 7 (5 минут). Пересоздаём приложение.
-
Что ввести:
./lab-anatomy.sh redeploy -
Зачем: воспроизвести самый неочевидный случай занятия — когда прокси держит старый адрес приложения и отдаёт 502.
-
Ожидаемый результат: режим меняет адрес приложения (останавливает его, занимает освободившийся адрес заглушкой, запускает снова), после чего прокси отдаёт 502, а в журнале ошибок виден старый адрес. DNS при этом отвечает правильно. После
nginx -s reloadкод снова 200. Заглушка снимается автоматически. Режим занимает около минуты: контейнер ждёт проверки готовности. -
Если не так: если код сразу 200, адрес не сменился (контейнер получил тот же) — именно поэтому режим занимает адрес заглушкой, а не делает просто
--force-recreate. -
Ответьте: почему в режиме именно так, а не просто
--force-recreate? Пересозданный контейнер часто получает тот же адрес, и тогда опыт ничего не показывает.
Шаг 8 (2 минуты). Журнал прокси.
-
Что ввести:
./lab-anatomy.sh logs -
Зачем: увидеть разделение времени приложения (
upstream) и полного времени запроса (total). -
Ожидаемый результат: у статики
upstream=-. В образе nginx/var/log/nginx/access.log— символьная ссылка на/dev/stdout, поэтому журнал читается черезdocker compose logs, а неtailвнутри контейнера. -
Если не так: если
tailпо журналу внутри контейнера висит и ничего не печатает, это ожидаемо (ссылка на/dev/stdout) — читайтеdocker compose logs.
Проверка результата. Вы увидели, что запрос за статикой не доходит до приложения; что кэш
даёт выигрыш в несколько раз и его отказ сервис переживает; что живость и готовность отвечают
разное при отказе базы; и что очередь при остановленном обработчике накапливает сообщения.
Уберите стенд: docker compose down -v — с ключом -v, иначе останется том с данными базы.
5. Домашняя работа (1–2 часа)
Задание. Три части: разобрать сервис, сломать его по всем слоям и починить один настоящий дефект.
Часть 1. Карточка сервиса. Заполните
anatomy-card.md — по учебному стенду
или по своему рабочему сервису, если он есть. Обязательно заполните столбец «есть ли данные»
для каждого слоя и столбец «таймаут задан» для каждой связи: пустая клетка в нём — находка,
а не пропуск.
Часть 2. Карта отказов. Заполните
failure-map.md, выключив все шесть
компонентов по одному, а не только базу и кэш. Для каждого запишите фактические коды ответов,
восстановился ли сервис сам и за сколько. Отдельно проверьте случай с выключенным брокером:
что происходит с запросом на запись.
Часть 3. Починить потерю сообщения. Стенд намеренно оставлен с дефектом: при выключенном
брокере POST /items возвращает 201, строка в базе появляется, а сообщение теряется без следа.
Исправьте это в app/app.py любым из способов
и обоснуйте выбор одной фразой:
- вернуть клиенту ошибку и не писать в базу (запись и отправка — одно целое);
- писать неотправленные сообщения в таблицу и досылать отдельным процессом;
- вернуть клиенту 202 с честным указанием, что обработка отложена.
Проверьте исправление тем же опытом: остановите брокер, отправьте запрос, посмотрите на код ответа и на содержимое базы.
Критерии приёмки.
- Карточка сервиса заполнена целиком, включая обработчики очереди и объектное хранилище (или явное «нет» с объяснением, почему такого слоя нет).
- В карточке для каждого слоя указано, есть ли у него состояние, и хотя бы одна строка в разделе «что переживёт перезапуск» помечена «проверено: да».
- Карта отказов заполнена по всем шести компонентам, коды ответов — фактические.
- В карте отказов записано, что происходит с записью при выключенном брокере.
- Названы отказ, который клиент не заметит, и отказ, который останавливает сервис.
- Дефект с потерей сообщения исправлен, выбранный способ обоснован одной фразой.
- Исправление проверено воспроизведением: при остановленном брокере видно новое поведение, и в базе нет строки без сообщения (либо есть, но она попала в таблицу для досылки).
-
docker compose config --quietпроходит без ошибок, если вы меняли compose-файл. - После работы стенд убран:
docker compose down -v,docker ps -aне показывает контейнеров стенда.
6. Частые ошибки и подводные камни
Проверка живости обращается к базе. Симптом: база недоступна, и оркестратор начинает перезапускать исправные экземпляры приложения, добавляя к отказу базы отказ приложения. Причина: в проверку живости добавили зависимости, потому что «проверять надо всё». Что делать: живость — только сам процесс, зависимости — в готовность.
depends_on без условия готовности. Симптом: сразу после up часть запросов получает 502,
через полминуты всё работает. Причина: зависимый сервис стартует, когда сосед только запущен.
Что делать: condition: service_healthy и проверка готовности у зависимости.
Проверка готовности базы через unix-сокет. Симптом: приложение стартует, получает отказ
соединения и падает, хотя pg_isready только что ответил «готов». Причина: во время
инициализации кластера PostgreSQL поднимает временный сервер на сокете. Что делать:
pg_isready -h 127.0.0.1, то есть проверять то, к чему подключается приложение.
HEALTHCHECK из образа достался процессу, который не слушает порт. Симптом: обработчик
очереди работает, а docker compose ps показывает unhealthy; в истории проверок
curl: (7) Failed to connect. Причина: проверка записана в образ и применяется ко всем
контейнерам из него. Что делать: отключить или заменить для такого сервиса — у процесса без
приёма запросов живость проверяется не портом.
Процесс с номером 1 не получает SIGTERM. Симптом: остановка контейнера всегда занимает
десять секунд, код выхода 137. Причина: ядро не применяет к процессу с номером 1 действия по
умолчанию для сигналов без обработчика, поэтому SIGTERM игнорируется и docker stop
досылает SIGKILL. Что делать: обрабатывать SIGTERM в приложении либо запускать с --init —
проверено, тот же контейнер тогда останавливается мгновенно с кодом 143.
После выката приложения прокси отдаёт 502. Симптом: контейнер приложения пересоздан,
docker compose ps показывает healthy, приложение отвечает изнутри сети, а через прокси —
502; в журнале ошибок прокси указан адрес, которого больше нет. Причина: nginx разрешил имя
сервера из upstream при запуске и держит адрес до перезагрузки конфигурации. Что делать:
nginx -s reload после пересоздания зависимости, либо resolver с подстановкой адреса через
переменную в proxy_pass. Проверять надо не DNS: getent hosts app внутри прокси отвечает
верно, и это сбивает с толку.
Потребитель очереди не переживает перезапуск брокера. Симптом: брокер перезапустили,
обработчик исчез из docker compose ps с кодом выхода 1, очередь копится. Причина: обрыв
соединения поднимает исключение (StreamLostError, «Connection reset by peer»), и процесс
завершается. Что делать: цикл переподключения в самом обработчике и политика перезапуска
у контейнера — одного из двух недостаточно, потому что есть отказы, которых процесс пережить
не может.
Соединение с базой на каждый запрос. Симптом: под нагрузкой приложение упирается не в
процессор, а в ожидание базы; в pg_stat_activity сотни соединений. Причина: нет пула.
Что делать: пул в приложении, а при нескольких экземплярах — пулер перед базой; и считать
арифметику «экземпляры умножить на размер пула» против max_connections.
Отказ кэша роняет сервис. Симптом: Redis перезапустился, сервис вернул 500. Причина: обращение к кэшу не обёрнуто в обработку ошибок, хотя кэш по определению не обязателен. Что делать: при отказе кэша читать источник данных и записать предупреждение в журнал.
Успешный ответ при половине сделанной работы. Симптом: клиент получил 201, данные в базе есть, а письмо не ушло и никогда не уйдёт. Причина: отправка в очередь выполняется «на удачу» и её отказ не влияет на ответ. Что делать: выбрать поведение сознательно — отказ клиенту, таблица для досылки или честный 202.
tail по журналу nginx в контейнере. Симптом: команда висит и ничего не печатает.
Причина: в официальном образе access.log — ссылка на /dev/stdout. Что делать: читать
docker logs или docker compose logs.
docker compose down без -v. Симптом: следующий запуск стенда поднимается с прежними
данными, и опыты дают не те числа. Причина: тома переживают down. Что делать: down -v,
когда нужно чистое состояние.
7. Проверка усвоения
- Перечислите слои типового сервиса и скажите про каждый, есть ли у него состояние. Какие можно пересоздать из образа, а какие нельзя?
- Кэш и очередь оба находятся «между» приложением и базой. Почему потеря кэша безопасна, а потеря очереди — нет?
- Чем отличается проверка живости от проверки готовности? Что произойдёт, если живость будет обращаться к базе, а база станет недоступна?
- Запрос за статическим файлом пришёл на прокси. Как проверить, дошёл ли он до приложения, не заглядывая в код?
- В журнале прокси у запроса
upstream=0.01, аtotal=2.5. Где ушло время и что это означает? - Приложение получило
POST, записало строку в базу, а отправка сообщения в очередь не удалась. Какой ответ получит клиент в стенде урока и почему это плохо? Назовите два способа исправить. - Остановка контейнера с приложением всегда занимает ровно десять секунд. В чём причина и как проверить догадку?
- У вас десять экземпляров приложения, у каждого пул на двадцать соединений, у базы
max_connections = 100. Что произойдёт и как это решается? - Приложение пересоздали, оно отвечает, а через прокси идёт 502.
getent hosts appвнутри прокси показывает правильный адрес. Где искать причину и как проверить догадку одной командой?
8. Материалы
- Service Best Practices — Site Reliability Engineering (Google) — что считать признаками эксплуатируемого сервиса. Полезно как список вопросов к чужой системе.
- Kubernetes: Liveness, Readiness and Startup Probes — первоисточник по разделению живости и готовности. Формулировки применимы и вне Kubernetes, а сам Kubernetes будет в блоке 13.
- Compose file:
depends_on— описание условийservice_started,service_healthyиservice_completed_successfully. - Dockerfile:
HEALTHCHECK— почему проверка принадлежит образу и как её переопределить. - nginx: модуль
ngx_http_upstream_module—keepalive, таймауты и переменные$upstream_*, включая$upstream_response_time. - nginx: директива
log_format— список переменных журнала; заодно видно, что директива допускается только в контекстеhttp. - PostgreSQL:
pg_isready— коды возврата и почему важно указывать хост. - RabbitMQ: Consumer Acknowledgements and Publisher Confirms — про подтверждения, из которых складываются гарантии доставки; разбирается в уроке 07.09.
- Redis: Key eviction —
политики вытеснения и
maxmemory; понадобится в уроке 07.08. - The Twelve-Factor App: Processes — короткий текст про процессы без состояния; полный разбор факторов — следующий урок.
← Предыдущий: 06.08. Типовые задачи автоматизации на практике · Блок 07 · Следующий: 07.02. Twelve-Factor App с точки зрения эксплуатации →
Practice materials
Scripts, cheat sheets and starter files for the lesson tasks. The lesson text is on this page.
-
Lesson materials 07.01 «Анатомия веб-сервиса прокси, приложение, база, кэш, очередь».zip