✻ Урок 4.6 · Тема 4: Docker и Compose
Compose: nginx и TLS перед «Заметками»
Содержание урока
Зачем это нужно
В уроке 4.5 стек из «Заметок» и PostgreSQL описан одним compose.yml, но приложение всё ещё опубликовано на 127.0.0.1:8080, а HTTPS нет. В проде так не бывает: наружу смотрит только обратный прокси (reverse proxy). Это программа-посредник на входе, как охрана в бизнес-центре: клиент разговаривает только с ней, а она передаёт запросы приложению внутри. Ты уже настраивал такой в уроке 2.5, теперь он станет одним из контейнеров стека. Он принимает шифрованное соединение (TLS, протокол шифрования, благодаря которому в адресной строке появляется замочек, урок 2.6), а приложение и база (PostgreSQL, хранилище данных из урока 4.4) доступны лишь внутри сети Docker, которую контейнеры стека видят между собой, а остальной мир нет.
Две типичные аварии именно здесь. Первая: после пересоздания приложения прокси отвечает 502 (Bad Gateway: «прокси не дозвонился до приложения»), хотя приложение живо. Вторая: база случайно оказывается доступна из интернета из-за одной строки ports: "5432:5432" (она говорит Docker «открой порт контейнера на хосте»), причём файрвол (ufw, программа, которая решает, какие соединения пускать на сервер) молчит. Обе разберём на настоящих логах.
Шаг проекта: в compose.yml появляется сервис proxy (nginx 1.30) на портах 80 (обычный HTTP) и 443 (HTTPS), порт 8080 больше не публикуется, сертификат (цифровой документ, которым сервер доказывает, что он тот, за кого себя выдаёт) делает скрипт scripts/gen-tls.sh, логи контейнеров ротируются (старые записи стираются, чтобы не заполнить диск), а контейнеры сами поднимаются после сбоя. Стек (stack) здесь значит просто «набор контейнеров, которые работают вместе».
Что нужно знать
- Урок 4.5: Compose и PostgreSQL:
compose.yml, сервисыnotesиdb,.env, healthcheck (проверка здоровья контейнера), томpgdata(именованный том Docker: место на диске, где база хранит данные и переживает пересоздание контейнера), сетьnotes-net(внутренняя сеть стека). - Урок 4.3: тома и сети Docker: встроенный DNS по имени контейнера, публикация порта
-p 127.0.0.1:...(проброс порта контейнера на порт хоста, чтобы достучаться до него снаружи). - Урок 4.2: Dockerfile: образ «Заметок» и его healthcheck.
- Урок 2.5: nginx:
proxy_pass, заголовкиX-Forwarded-*,nginx -t, чтение[emerg]и 502. - Урок 2.6: TLS: сертификат, SAN (список имён, для которых сертификат действителен),
openssl s_client, редирект 80 на 443 (автоматическая переадресация с HTTP на HTTPS), самоподписанный сертификат (выданный самим себе, подробнее ниже). - Урок 2.3: DNS: как имя превращается в адрес и что такое кэш.
- Урок 2.7: файрвол: ufw и его границы (что он умеет закрыть, а что нет).
Картина целиком
Представь бизнес-центр. Снаружи одна входная дверь с охраной: она проверяет пропуск, не даёт пройти посторонним и решает, в какой офис вести посетителя. Внутри сотрудники ходят друг к другу по внутренним номерам, снаружи эти номера никому не известны. Если офис переехал в другую комнату, охрана должна узнать новый номер, иначе она будет вести людей в пустую комнату. Аналогия перестаёт работать в одном: охрана бизнес-центра не шифрует разговор посетителя, а наш прокси шифрует и расшифровывает весь трафик.
Наш стек устроен так же: охрана это nginx (proxy), офисы это «Заметки» (notes) и база (db).
flowchart TB
NET["интернет / твой ноутбук"] -->|"порты 80 (HTTP) и 443 (HTTPS): единственные открытые двери"| P
subgraph HOST["Хост (твоя ВМ), Docker"]
subgraph NN["сеть notes-net: внутри, снаружи не видна"]
P["proxy<br>nginx :80 :443"] -->|"http, порт 8080, имя notes"| N["notes<br>app.py"]
N -->|"порт 5432, имя db"| D[("db")]
end
T["./deploy/tls<br>сертификат и ключ, только чтение"] -.-> P
C["./deploy/nginx/compose.conf<br>конфиг, только чтение"] -.-> P
end
Дальше по порядку: что вообще можно открывать наружу и почему Docker обходит ufw; как запустить nginx в контейнере; почему nginx «застревает» на старом адресе приложения; как работает шифрование на прокси; как не дать логам забить диск; как контейнеры поднимаются после сбоя.
Теория
Обратный прокси: зачем нужна «охрана на входе»
Приложение «Заметки» (app.py) умеет отвечать на запросы, но не умеет шифровать трафик, отдавать сертификаты, отличать запросы разных сайтов, отбиваться от медленных клиентов и писать аккуратный журнал доступа. Можно научить этому каждое приложение, но тогда то же придётся делать в каждом следующем. Обратный прокси (reverse proxy) берёт эти задачи на себя один раз. Слово «обратный» отличает его от обычного (прямого) прокси: обычный прокси стоит на стороне клиента и ходит в интернет вместо него (корпоративный выход в сеть). Обратный стоит на стороне сервера и принимает запросы вместо приложений.
Администратор на ресепшене клиники: пациенты приходят к нему, а не в кабинеты. Он проверяет запись, направляет к нужному врачу, а врачи могут спокойно работать, не отвлекаясь на общий поток. Аналогия перестаёт работать в том, что администратор не меняет то, что говорит врач, а прокси может: добавляет заголовки, сжимает, переписывает адреса.
Устроено это так.
- Клиент подключается к адресу сайта. Этот адрес ведёт на прокси, а не на приложение.
- Прокси смотрит в запрос (какой
Host, какой путь) и выбирает, кому передать. Та группа адресов, куда он передаёт, называется upstream («вверх по течению», то есть приложение за прокси). - Прокси открывает второе соединение к приложению и пересылает запрос.
- Ответ идёт обратно тем же путём, и клиент уверен, что общался с одним сервером.
Посмотрим на примере. У нас один upstream, notes:8080. Завтра появится второе приложение, например admin:9000. Клиенты по-прежнему ходят на порты 80 и 443, а в конфиг nginx добавляется ещё одно правило. Открывать новые порты на хосте и объяснять всем пользователям, что теперь нужно :9000, не надо. Другой плюс: если приложение упало, клиент получает осмысленный ответ 502 от прокси, а не «соединение отклонено» от ядра.
Прикинь сам: Сейчас у тебя один upstream
notes:8080, завтра добавитсяadmin:9000. Сколько адресов нужно знать клиенту?
Один: адрес прокси. Какому upstream передать запрос, решает прокси по Host и пути, а клиент ничего не меняет.
Осторожно, частое заблуждение: что прокси ускоряет приложение. Сам по себе нет, он добавляет ещё один шаг. Выигрыш в другом: безопасность, единая точка входа, шифрование в одном месте, возможность менять приложения за ним.
Главное: обратный прокси стоит перед приложениями: принимает клиентов, шифрует, выбирает upstream, но сам приложение не ускоряет.
Теперь решим, какие порты открывать наружу.
Проверь понимание: чем обратный прокси отличается от корпоративного, через который сотрудники ходят в интернет?
Ответ
Корпоративный (прямой) прокси представляет клиентов перед интернетом: сервер видит адрес прокси вместо адресов сотрудников. Обратный представляет серверы перед клиентами: клиент видит адрес прокси вместо адресов приложений. Направление «за кого говорит прокси» разное.
Что публиковать наружу
Контейнер живёт в своей сети с адресами вроде 172.20.0.5, извне эти адреса недоступны. Чтобы клиент с другой машины достучался до контейнера, порт нужно опубликовать (publish): попросить Docker пробросить порт хоста в порт контейнера. Без публикации контейнер изолирован, и это хорошо: чем меньше открыто, тем меньше шансов, что чем-то воспользуются лишним. Плохо, когда публикуют всё подряд «на всякий случай».
Публикация порта это дверь, пробитая в стене здания. Каждая дверь это ещё одно место, куда может зайти посторонний. Аналогия перестаёт работать в том, что дверь Docker пробивает мимо охраны здания (ufw), и об этом ниже.
Устроено это так.
- В
compose.ymlу сервиса пишутports: ["80:80"]: «порт 80 хоста ведёт в порт 80 контейнера». - Docker при старте контейнера сам добавляет правила в iptables. iptables это встроенный в ядро Linux фильтр сетевых пакетов (урок 2.7); ufw просто удобная обёртка над ним.
- Пакет из интернета на порт 80 хоста попадает в правило Docker, которое подменяет адрес назначения на адрес контейнера (например,
172.20.0.4:80) и пересылает пакет дальше. - ufw охраняет вход «на сам хост» (цепочка INPUT). Пакет к контейнеру идёт не на хост, а через хост, и до правил ufw не доходит.
flowchart LR
CL["клиент"] --> PORT["порт хоста"]
PORT -->|"пакеты на хост: ssh 22, nginx на хосте"| UFW["правила ufw<br>цепочка INPUT"]
PORT -->|"пакеты через хост"| DK["правила Docker<br>DNAT + FORWARD"]
DK --> CT["контейнер 172.20.0.4:80<br>ufw эти пакеты не видит"]
Вот как это выглядит на деле. В compose.yml у db написано ports: ["5432:5432"]. Это короткая запись "порт_хоста:порт_контейнера", адрес не указан, значит порт слушает на всех сетевых интерфейсах хоста (0.0.0.0, урок 2.1). Ты закрыл 5432 в ufw командой sudo ufw deny 5432. Но пакеты к базе идут через правила Docker, а не через INPUT, поэтому база доступна любому, кто достанет до хоста. Формы записи и что они значат:
Запись в ports |
Кто может подключиться |
|---|---|
"5432:5432" |
любой, кто дойдёт до хоста, ufw не мешает |
"127.0.0.1:5432:5432" |
только процессы на самом хосте (loopback, адрес «я сам») |
ключа ports нет |
никто снаружи; контейнеры в той же сети Docker достают по имени |
Ключ expose: ["5432"] ничего не публикует: это только пометка для читателя файла. Порт 5432/tcp в колонке PORTS у docker compose ps без стрелки -> значит ровно это: образ объявил порт, наружу он не открыт.
Поэтому правило одно: наружу публикуется только то, что должно быть видно клиентам. Для «Заметок» это 80 и 443 у proxy. notes и db не имеют ключа ports вообще: друг друга они находят по имени в сети notes-net, этого достаточно. Для отладки приложения без прокси можно временно опубликовать порт на loopback: 127.0.0.1:8080:8080.
Прикинь сам: В стеке три сервиса:
proxy,notes,db. Сколько из них должны иметь ключports?
Один: proxy (80 и 443). notes и db доступны по имени внутри сети, и публиковать их не нужно.
Осторожно, частое заблуждение: «Я закрыл порт в ufw, значит он закрыт». Для портов, опубликованных Docker, это неправда. Проверять нужно снаружи (с другой машины nc -zv адрес порт) или смотреть колонку PORTS в docker compose ps: любой 0.0.0.0:...-> это открытая дверь.
Главное: наружу публикуют только то, что видят клиенты, а ufw не защищает опубликованные Docker порты: пакеты идут через правила Docker.
Дальше посмотрим, как nginx запускается в контейнере.
Проверь понимание: в
compose.ymlуdbстоитports: ["5432:5432"], а ufw закрывает 5432. Доступна ли база из интернета?
Ответ
Да, скорее всего доступна: пакеты к опубликованному порту идут через правила Docker и до правил ufw не доходят. Правильно убрать ports у db совсем или написать 127.0.0.1:5432:5432. Если контейнеру нужен доступ откуда-то ещё, делают отдельную защищённую схему (VPN, туннель), а не открытый порт.
nginx в контейнере: конфиг, тома и порядок старта
В уроке 2.5 nginx стоял на хосте, а его конфиг лежал в /etc/nginx/sites-enabled/. Теперь весь стек в Compose, и прокси тоже контейнер: тогда стек поднимается одной командой на любом сервере, и версия nginx закреплена тегом образа (nginx:1.30). Проблема одна: внутри контейнера свои файлы, а нам нужно подложить туда свой конфиг и сертификат, не пересобирая образ.
Образ nginx это стандартный кухонный комбайн из магазина. Свой конфиг это сменная насадка: мы не переделываем комбайн, а вставляем насадку снаружи. Аналогия перестаёт работать в том, что насадка (файл на хосте) и то, что видит комбайн, это один и тот же файл, а не копия: правка на хосте меняет то, что читает nginx.
Устроено это так.
- Официальный образ nginx читает файлы
/etc/nginx/conf.d/*.conf: главный конфигnginx.confвнутри образа содержит строкуinclude /etc/nginx/conf.d/*.conf;внутри блокаhttp. Поэтому в наших файлах нет обёрткиhttp { ... }: они уже лежат внутри неё. - В образе уже есть файл
conf.d/default.conf(страница «Welcome to nginx!»). Мы монтируем свой файл поверх него:./deploy/nginx/compose.conf:/etc/nginx/conf.d/default.conf:ro. Слева путь на хосте, справа путь в контейнере,:ro(read-only) значит «только чтение»: даже взломанный nginx не перепишет ни конфиг, ни ключ. - Такое монтирование файла или каталога с хоста называют bind mount, в отличие от именованного тома
pgdata(урок 4.3), которым управляет Docker. Bind mount нужен, когда файлы принадлежат нам и лежат в git. - Сертификат и ключ монтируем отдельным каталогом
./deploy/tls:/etc/nginx/tls:ro. В образ они не попадают никогда: образ можно выложить в реестр, а ключ нельзя. depends_on: [notes]уproxyговорит только «сначала создай и запустиnotes». Это порядок запуска, а не готовность:notesможет ещё стартовать, когдаproxyуже принимает запросы. Для нашего конфига это не страшно (ниже видно почему: имя приложения nginx разрешает при запросе, а не при старте).restart: unless-stopped(было уnotesиdbв 4.5) значит: если контейнер упал, Docker перезапустит его, а после перезагрузки хоста поднимет снова, кроме случая, когда ты остановил его сам (docker compose stop). Без этой строки упавшийproxyпросто остаётся в состоянииExited.
Теперь на числах. Файл deploy/nginx/compose.conf в контейнере становится /etc/nginx/conf.d/default.conf. Изменил файл на хосте, выполнил docker compose up -d --force-recreate proxy (или restart), nginx прочитал новый конфиг. Пересборка образа не нужна. Заметь: restart контейнера перечитывает смонтированные файлы, но не применяет новые настройки из самого compose.yml (порты, logging): для них нужно пересоздание, up -d.
Прикинь сам: Ты изменил
deploy/nginx/compose.confна хосте, но nginx отвечает по-старому. Сколько раз nginx сам перечитывал файл?
Ни одного: nginx читает конфиг при старте и по команде. Нужен docker compose restart proxy или перечитывание конфига.
Осторожно: путь слева и справа. Левый путь считается от каталога с compose.yml на хосте, правый существует только внутри контейнера. Если справа написать не тот каталог, nginx не найдёт файл, а на хосте ничего не сломается: именно так устроен сценарий 3 в «Сломай и почини».
Главное: свой конфиг монтируют поверх
default.conf(bind mount,:ro), а сертификаты кладут в каталог на хосте, а не в образ.
Следующая проблема подкрадывается незаметно: nginx может застрять на старом адресе приложения.
Проверь понимание: ты изменил
deploy/nginx/compose.confна хосте, но в контейнере nginx отвечает по-старому. Что ты сделаешь и почему?
Ответ
nginx читает конфиг при старте и при команде перечитывания, сам за файлом не следит. Нужно docker compose restart proxy (или up -d --force-recreate proxy), а до этого проверить синтаксис командой nginx -t. Пересобирать образ не нужно: файл смонтирован, а не скопирован в образ.
Почему nginx застревает на старом адресе приложения
В Compose контейнеры находят друг друга по имени: внутренний DNS-сервер Docker (адрес 127.0.0.11 внутри каждого контейнера, урок 4.3) отвечает на имя notes текущим IP контейнера. IP контейнера при пересоздании может смениться. Поэтому вопрос «когда nginx спрашивает DNS» определяет, переживёт ли прокси обновление приложения.
Ты записал в блокнот номер офиса «Заметки: комната 3». Офис переехал в комнату 5, а ты продолжаешь водить посетителей в комнату 3. Разумный вариант: не полагаться на блокнот, а каждый раз спрашивать у справочной «где сейчас Заметки?». Аналогия перестаёт работать в том, что справочную нельзя дёргать бесконечно: nginx запоминает ответ на несколько секунд (кэш).
Устроено это так.
- Когда в
proxy_passстоит готовое имя, напримерproxy_pass http://notes:8080;, nginx один раз, при чтении конфига (при старте илиreload), спрашивает DNS, получает IP и запоминает его до следующего перечитывания. Он не спрашивает снова. - Контейнер
notesпересоздали, у него новый IP. nginx продолжает стучаться по старому. Клиент получает 502 Bad Gateway (урок 2.5), а в логе nginx видноconnect() failed ... upstream: "http://<старый IP>:8080/...". - Лекарство из двух частей. Первая: директива
resolver 127.0.0.11 valid=10s;: «для динамических имён спрашивай DNS Docker, ответ храни 10 секунд». Вторая: адрес кладётся в переменную,set $upstream http://notes:8080;иproxy_pass $upstream;. - Когда в
proxy_passпеременная, nginx не может разрешить имя заранее (значение переменной известно только во время запроса). Поэтому он разрешает имя на каждый запрос черезresolver, с кэшемvalid=10s. Через 10 секунд после пересоздания nginx сам узнает новый IP, перезапускать его не нужно. - Побочные эффекты: при старте nginx уже не падает, если
notesещё не существует (имя не проверяется при загрузке), а в момент запуска приложения клиент может получить короткий 502.
Разберём пример. Схема времени для статического proxy_pass (проверено на стенде: IP до пересоздания 172.20.0.5, после 172.20.0.6):
sequenceDiagram
participant C as клиент
participant X as nginx
participant D as DNS Docker
participant N as notes
Note over X,D: 0 с: nginx стартует
X->>D: notes?
D-->>X: 172.20.0.5 (помнит навсегда)
Note over N: 60 с: notes пересоздан, теперь 172.20.0.6
C->>X: 61 с: GET /healthz
X->>N: идёт на 172.20.0.5, там никто не слушает
X-->>C: 502 Bad Gateway
И для переменной с resolver:
sequenceDiagram
participant C as клиент
participant X as nginx (переменная и resolver)
participant D as DNS Docker
C->>X: 0 с: GET /healthz
X->>D: notes?
D-->>X: 172.20.0.5 (кэш на 10 с)
Note over D: 60 с: notes пересоздан, теперь 172.20.0.6, кэш истёк
C->>X: 61 с: GET /healthz
X->>D: notes?
D-->>X: 172.20.0.6
X-->>C: ответ 200
Ещё одна тонкость: proxy_pass $upstream; с переменной, в которой нет пути после порта, передаёт приложению исходный путь запроса как есть (/notes?x=1 остаётся /notes?x=1), как в уроке 2.5.
Прикинь сам:
notesпересоздан: адрес был172.20.0.5, стал172.20.0.6. Сколько раз nginx со статическимproxy_pass http://notes:8080спросит DNS после старта?
Один: при загрузке конфига. После пересоздания он идёт на старый адрес и отдаёт 502.
Осторожно, частое заблуждение: что одного resolver 127.0.0.11; достаточно. Без переменной в proxy_pass директива resolver для этого имени вообще не используется: имя разрешается при загрузке конфига. Второе заблуждение: «valid=0, чтобы всегда было свежо». Это заставит nginx обращаться к DNS на каждый запрос без кэша, лишняя нагрузка ради секунд выигрыша. Третье: что depends_on следит за IP. Он о порядке старта и про адреса ничего не знает.
Главное: чтобы nginx переспрашивал DNS, нужны переменная в
proxy_passиresolver 127.0.0.11; одногоresolverбез переменной недостаточно.
Дальше добавим шифрование: TLS на прокси.
Проверь понимание: почему
resolver 127.0.0.11без переменной вproxy_passне решает проблему?
Ответ
Статическое имя в proxy_pass разрешается один раз при загрузке конфига, а resolver для него не используется. Динамическое разрешение включается только тогда, когда адрес задан через переменную.
TLS на прокси: терминация, SAN и самоподписанный сертификат
Без шифрования пароли и заметки идут по сети открытым текстом (урок 2.6). Шифровать можно в каждом приложении, но тогда каждое надо учить работать с сертификатами и обновлять их. Проще один раз сделать это в прокси: он принимает шифрованное соединение, расшифровывает и передаёт приложению обычный HTTP. Так устроена терминация TLS (TLS termination, от «termination»: завершение): шифрованный участок «клиент до прокси» заканчивается на прокси.
Охранник на входе бизнес-центра проверяет паспорт посетителя (сертификат подтверждает, что сайт тот, за кого себя выдаёт), а внутри здания посетителя сопровождают без повторных проверок. Аналогия перестаёт работать в том, что внутри сети Compose трафик действительно не шифруется, и это осознанный выбор: сеть notes-net видна только контейнерам стека.
Устроено это так.
- Клиент подключается к порту 443 и в первом сообщении рукопожатия называет имя, к которому идёт:
notes.lab(это SNI, Server Name Indication). - nginx отдаёт сертификат из файла
ssl_certificateи доказывает владение ключом изssl_certificate_key. - Клиент проверяет три вещи: подпись сертификата (кто выдал), срок действия и совпадение имени. Имя сверяется со списком SAN (Subject Alternative Name, «альтернативные имена субъекта») внутри сертификата. Старое поле CN (Common Name) современные клиенты не смотрят.
- Если всё сошлось, шифрованный канал готов. nginx расшифровывает запрос и шлёт его в
notesпо HTTP. - Приложение не знает, что снаружи был HTTPS. Поэтому nginx добавляет заголовок
X-Forwarded-Proto: https($schemeиз урока 2.5), и приложение может строить правильные ссылки.
sequenceDiagram
participant C as клиент
participant P as proxy (nginx)
participant N as notes
C->>P: HTTPS, ClientHello, SNI notes.lab (шифровано)
P-->>C: сертификат notes.crt
P->>N: HTTP (открыто, notes-net), Host, X-Forwarded-Proto: https
N-->>P: ответ
P-->>C: ответ, зашифрован
Посмотрим на примере. Учебный сертификат самоподписанный (self-signed): его подписал тот же ключ, что в нём указан, а не удостоверяющий центр. Браузер и curl ему по умолчанию не доверяют, потому что подписавшего нет в их списке доверенных, зато шифрование работает так же, как с настоящим. Проверка покажет код 18 (self-signed certificate). Если сказать клиенту «доверяй именно этому файлу» (curl --cacert deploy/tls/notes.crt), код будет 0 (ok).
Чтобы не править /etc/hosts ради имени notes.lab, используют curl --resolve notes.lab:443:127.0.0.1: «для этой команды считай, что notes.lab на порту 443 это 127.0.0.1». Имя остаётся настоящим и для проверки сертификата, и для заголовка Host, а систему править не нужно.
Сертификат выпускает скрипт scripts/gen-tls.sh, а каталог deploy/tls вносится в .gitignore: приватный ключ в git не попадает (коммит ключа означает, что он скомпрометирован навсегда: история git хранит его даже после удаления). Настоящий сертификат Let’s Encrypt (бесплатный удостоверяющий центр, о нём в уроке 2.6) появится в теме 6.
Прикинь сам: Клиент идёт на
https://notes.lab. Сколько соединений открывается на пути клиент, прокси, приложение и какие из них шифрованы?
Два: клиент и прокси по HTTPS (шифровано), прокси и приложение по HTTP внутри notes-net. Приложение узнаёт о HTTPS из заголовка X-Forwarded-Proto.
Осторожно, частое заблуждение: «Самоподписанный значит не шифрует». Шифрует, просто клиент не знает, кому верить. Ещё путают ключ и сертификат: сертификат публичный, его отдают каждому клиенту, а ключ секретный и не покидает сервер. И третье: curl -k («не проверять сертификат») кажется быстрым лекарством, но в скриптах он отключает защиту целиком: подмена сервера становится незаметной.
Главное: nginx принимает HTTPS и шифрованное соединение завершает на себе (терминация TLS), а в приложение идёт обычный HTTP.
Разберёмся, кому клиент доверяет и зачем нужен сертификат.
Проверь понимание: зачем
curl --resolve notes.lab:443:127.0.0.1вместо правки/etc/hosts?
Ответ
Флаг подставляет адрес только для одной команды: имя notes.lab остаётся настоящим для TLS и заголовка Host, а систему править не нужно. Запрос по https://127.0.0.1/ дал бы ошибку имени: в SAN сертификата есть только notes.lab.
Сертификат, ключ и удостоверяющий центр: кто кому верит
Шифрование само по себе не отвечает на вопрос «с кем я разговариваю». Можно отлично зашифровать переписку, но с мошенником, который притворился банком. Поэтому браузеру нужен способ проверить, что сайт настоящий. Для этого есть сертификат: документ, в котором написано «этот открытый ключ принадлежит сайту notes.lab», и под ним чья-то подпись.
Сертификат это паспорт, а удостоверяющий центр (certificate authority, CA) это паспортный стол: паспорту верят, потому что знают и доверяют тем, кто его выдал. Аналогия перестаёт работать в том, что паспорт один на всю жизнь, а сертификат действует недолго (от недель до года) и его приходится обновлять.
Устроено это так.
- Сервер создаёт пару ключей: закрытый (private key, хранится только на сервере) и открытый (public key, его можно показывать всем). Они связаны математически: то, что зашифровано одним, расшифровывается только вторым.
- Открытый ключ вместе с именем сайта оформляется как сертификат и подписывается. Подпись это «печать» CA: по ней можно проверить, что содержимое не подделано.
- Браузер хранит список CA, которым верит (он поставляется вместе с системой). Если подпись сделана одним из них, сертификат принимается.
- Самоподписанный сертификат подписан тем же закрытым ключом, который в нём указан. Подписывающего в списке доверенных нет, поэтому браузер предупреждает: «не знаю, кому верить».
Вот как это выглядит на деле. Файлы, которые делает scripts/gen-tls.sh: notes.key это закрытый ключ, notes.crt это сертификат с открытым ключом. nginx читает оба: ssl_certificate указывает на .crt, ssl_certificate_key на .key. Первый можно отдавать всем, второй нельзя копировать никуда. Посмотреть содержимое сертификата можно командой openssl x509 -in deploy/tls/notes.crt -noout -subject -dates -ext subjectAltName: увидишь имя, даты «с» и «по» и список SAN.
Прикинь сам: Сколько ключей нужно серверу для TLS и какой из них нельзя пересылать?
Два: открытый (его показывают всем) и закрытый. Закрытый ключ нельзя отправлять никому, иначе его владелец сможет выдавать себя за сервер.
Осторожно, частое заблуждение: что сертификат «зашифровывает» трафик. Сертификат только доказывает владение ключом и имя, а шифрует сессионный ключ, о котором клиент и сервер договариваются при рукопожатии (handshake, обмен первыми сообщениями перед передачей данных). Ещё путают срок: истёкший сертификат ломает доступ так же, как чужой.
Главное: сертификат доказывает владение ключом и имя сайта, а шифрует сессию сам TLS; самоподписанному сертификату клиент не доверяет без добавления в доверенные.
Теперь посмотрим, что делать с обычным HTTP на порту 80.
Проверь понимание: коллега прислал тебе в мессенджер
notes.key, «чтобы настроить на другом сервере». Что не так?
Ответ
Закрытый ключ нельзя пересылать: любой, кто его получил, может выдавать себя за сервер. Правильно выпустить отдельный ключ и сертификат для каждого сервера, а пересланный ключ считать скомпрометированным и заменить.
Порты 80 и 443 и редирект с HTTP на HTTPS
Порт (port) это номер «двери» на машине: одному адресу соответствуют тысячи портов, и каждая программа слушает свой. По договорённости браузер без явного порта идёт на 80 для http:// и на 443 для https://. Поэтому у прокси открыты именно они: пользователь не должен вводить номера. Но люди часто набирают адрес без схемы, и браузер сначала пробует 80. Если там пусто, сайт «не открывается», хотя на 443 всё работает.
У здания два входа: парадный (с охраной и проверкой пропусков, 443) и старый (80, без проверки). Старый вход не закрывают наглухо, а ставят табличку «вход с другой стороны» и сразу переводят посетителя на парадный. Аналогия перестаёт работать в том, что табличка читается автоматически: браузер сам переходит на новый адрес, человек не замечает.
Устроено это так.
- nginx слушает порт 80 в отдельном блоке
server { listen 80; ... }. - На любой запрос этот блок отвечает кодом 301 (Moved Permanently, «переехал навсегда») и заголовком
Location: https://<то же имя>/<тот же путь>. - Браузер получает 301, читает
Locationи сам делает новый запрос уже по HTTPS. - Настоящая работа (передача приложению) идёт только в блоке
listen 443 ssl.
Теперь на числах. Запрос curl -i http://notes.lab/notes?x=1 вернёт HTTP/1.1 301 Moved Permanently и Location: https://notes.lab/notes?x=1. Путь и параметры (/notes?x=1) сохранены, поэтому ссылка из старой закладки работает. Флаг -i у curl печатает заголовки ответа вместе с телом: без него Location не увидеть.
Прикинь сам: Пользователь открыл
http://notes.lab/login. Сколько запросов отправит браузер до страницы по HTTPS?
Два: первый по HTTP получает 301 с Location: https://notes.lab/login, второй уже по HTTPS. Пароль в первом запросе уходить не должен.
Осторожно: код 301 и 302: 301 запоминается браузером надолго (если ошибся, исправлять придётся и в кэшах клиентов), 302 временный. Ещё думают, что редирект защищает первый запрос: он ещё идёт по HTTP открытым текстом, поэтому пароли на порт 80 отправлять нельзя.
Главное: порт 80 только перенаправляет кодом 301 на HTTPS, а вся работа идёт в блоке
listen 443 ssl.
Прокси работает. Позаботимся о логах, чтобы они не съели диск.
Проверь понимание: пользователь ввёл
http://notes.lab/loginи отправил форму с паролем. Защищён ли пароль?
Ответ
Зависит от момента. Если форма открылась уже по HTTPS после редиректа, то да: отправка идёт по шифрованному каналу. Если форму отправили прямо на http://, первый запрос ушёл открытым текстом и пароль мог быть перехвачен. Поэтому формы входа всегда должны открываться и отправляться только по HTTPS.
Логи контейнеров и их ротация
Всё, что приложение пишет в stdout и stderr (стандартные потоки вывода и ошибок), Docker сохраняет и показывает командой docker logs (docker compose logs). Сохраняет он в обычный файл на хосте, и по умолчанию этот файл растёт без предела. Шумный сервис за месяц забивает диск. Это классическая причина «диск заполнен, а du по каталогу приложения показывает мало»: файл лежит не в каталоге приложения, а в /var/lib/docker/containers/.
Журнал посещений на проходной. Если никогда не менять страницы и не выбрасывать старые тетради, шкаф однажды не закроется. Ротация это правило «когда тетрадь заполнена, заводим новую, а самую старую выбрасываем». Аналогия перестаёт работать в том, что журнал пишется постоянно и никто не листает его вручную.
Устроено это так.
- Драйвер логов по умолчанию называется
json-file: каждая строка вывода превращается в JSON-запись и дописывается в файл/var/lib/docker/containers/<id>/<id>-json.log. - Опция
max-size: "10m"говорит: когда файл достиг 10 мегабайт, начни новый. - Опция
max-file: "3"говорит: храни не больше трёх файлов, самый старый удаляй. Итого на контейнер максимум 30 МБ логов. - Эти настройки принадлежат контейнеру, а не Compose «на лету». Изменил их в
compose.yml,restartне поможет: нужноdocker compose up -d, который заметит изменение и пересоздаст контейнер. - Чтобы не повторять три строки трижды, в
compose.ymlих пишут один раз под именемx-logging(ключи верхнего уровня, начинающиеся сx-, Compose пропускает как «расширения») и подключают в сервисах якорем YAML.&loggingдаёт блоку имя,*loggingвставляет блок целиком.
Разберём пример. Проверить, что настройка применилась. Команда docker inspect выводит всё, что Docker знает о контейнере, в виде большого JSON (формат записи данных в виде вложенных «ключ: значение»). Флаг -f (от format) с шаблоном достаёт из него одно поле, здесь HostConfig.LogConfig (настройки логов из «конфигурации хоста» контейнера). notes-proxy-1 это имя контейнера: Compose собирает его из имени проекта, имени сервиса и номера.
docker inspect -f '{{.HostConfig.LogConfig}}' notes-proxy-1
Ответ {json-file map[max-file:3 max-size:10m]}: драйвер json-file, три файла по 10 МБ. Если у контейнера, созданного раньше, там map[], значит он создан до правки и ещё не пересоздан.
Прикинь сам:
max-size: 10mиmax-file: 3. Сколько мегабайт логов максимум хранит один контейнер, и сколько у трёх сервисов?
30 МБ на контейнер (3 × 10), у трёх сервисов 90 МБ. Старые файлы удаляются по размеру и числу, не по возрасту.
Осторожно, частое заблуждение: что ротация «чистит» логи по возрасту. Нет, только по размеру и числу файлов. И что при ротации логи пропадают из docker logs сразу: старые строки исчезают только когда самый старый файл удалён.
Главное: ротацию задают в
compose.ymlчерезmax-sizeиmax-file, а применяются они при создании контейнера (up -d), а не приrestart.
Приложению за прокси нужно знать, кто на самом деле клиент.
Проверь понимание: ты добавил
max-size: 10mвcompose.ymlи сделалdocker compose restart proxy. Ротация включилась?
Ответ
Нет. restart перезапускает тот же контейнер с прежними настройками, а логирование задаётся при создании контейнера. Нужен docker compose up -d, который заметит изменение и пересоздаст контейнер.
Заголовки X-Forwarded-*: как приложение узнаёт о клиенте
Когда между клиентом и приложением стоит прокси, приложение видит соединение только от прокси. Без подсказки оно решит, что все запросы приходят с одного адреса 172.20.0.4 по обычному HTTP. Из-за этого в логах пропадают настоящие адреса клиентов, счётчики «запросов с одного IP» бесполезны, а ссылки, которые приложение строит само, получаются с http:// вместо https://.
Курьер приносит в офис посылки от многих отправителей, но на всех посылках стоит штамп курьерской службы. Чтобы получатель знал настоящего отправителя, курьер приклеивает к каждой посылке записку: «от кого» и «как отправлено». Аналогия перестаёт работать в том, что записку можно подделать: если посылка пришла не от курьера, а от постороннего, записка тоже может быть поддельной.
Устроено это так.
- Клиент шлёт запрос на прокси. В запросе есть строка
Host: notes.lab: имя, к которому обращались. - Прокси открывает второе, отдельное соединение к приложению. Для приложения это новый запрос, и
Host, адрес клиента и схему оно из первого соединения само не получит. - Поэтому прокси дописывает заголовки директивами
proxy_set_header:Host $host(то же имя, что просил клиент),X-Real-IP $remote_addr(адрес клиента),X-Forwarded-For $proxy_add_x_forwarded_for(цепочка адресов: то, что уже было в заголовке, плюс адрес клиента) иX-Forwarded-Proto $scheme(httpилиhttps). - Приложение читает эти заголовки. Наш
app.pyумеет отдать их на/headers, поэтому проверка простая: смотрим, что до него дошло. - Доверять заголовкам можно только тогда, когда приложение достижимо лишь через ваш прокси. Если оставить
ports: "8080:8080"уnotes, любой клиент придёт в обход прокси и сам напишетX-Forwarded-For: 1.2.3.4.
клиент 203.0.113.7 --HTTPS--> proxy --HTTP--> notes
|
+-- записывает: X-Real-IP: 203.0.113.7
X-Forwarded-For: 203.0.113.7
X-Forwarded-Proto: https
Host: notes.lab
Посмотрим на примере. Если перед proxy есть ещё один прокси (например, облачный балансировщик с адресом 198.51.100.9), запрос придёт в наш nginx уже с заголовком X-Forwarded-For: 203.0.113.7. Значение $proxy_add_x_forwarded_for дополнит его адресом того, кто подключился к нам: получится 203.0.113.7, 198.51.100.9. Левый адрес самый ранний, то есть настоящий клиент, правый добавлен последним. В нашем стенде прокси один, поэтому в /headers виден один адрес.
Прикинь сам: В логе приложения все запросы приходят с
172.20.0.4. Сколько разных клиентов видит приложение без заголовков?
Один: это адрес контейнера proxy. Настоящий адрес клиента лежит в X-Forwarded-For и X-Real-IP, которые прокси добавляет сам.
Осторожно: X-Real-IP и X-Forwarded-For принимают за одно и то же. Первый обычно один адрес, второй список. Ещё путают «заголовок пришёл» и «заголовку можно верить»: это разные вещи, верить можно только заголовкам, которые поставил ваш собственный прокси.
Главное: прокси передаёт приложению настоящие
Host, адрес клиента и схему черезproxy_set_header.
Теперь соберём правильный порядок старта всего стека.
Проверь понимание: в логе приложения все запросы приходят с адреса
172.20.0.4. Что это за адрес и где искать настоящий?
Ответ
Это адрес контейнера proxy в сети notes-net: приложение соединяется именно с ним. Настоящий адрес клиента в заголовках X-Forwarded-For и X-Real-IP, если прокси их выставляет и приложение их читает.
Порядок старта: depends_on, healthcheck и restart
Три контейнера стартуют одновременно, но зависят друг от друга: приложению нужна готовая база, прокси нужно приложение. Если запустить всё разом, приложение упадёт на первой попытке подключиться к ещё не готовой базе. Кроме того, любой контейнер рано или поздно падает (нехватка памяти, ошибка, перезагрузка хоста), и вручную поднимать его по ночам не хочется.
Запуск ресторана: повара должны прийти до открытия зала, а зал открывается, когда кухня сказала «готово». «Повар пришёл» и «кухня готова к заказам» это разные события. Аналогия перестаёт работать в том, что у Compose «сказать готово» умеет только контейнер с проверкой (healthcheck), а у остальных «готов» означает лишь «процесс запущен».
Устроено это так.
depends_onв короткой форме (- notes) означает: сначала создай и запустиnotes, потомproxy. Готовность приложения при этом не проверяется.depends_onв длинной форме (db: condition: service_healthy) ждёт, пока проверка контейнера станетhealthy. Такnotesстартует только после того, как база отвечает.- Проверка (
healthcheck) это команда, которую Docker периодически запускает внутри контейнера. Уdbэтоpg_isready: она успешна, если PostgreSQL принимает соединения.intervalпериод,timeoutсколько ждать ответ,retriesсколько неудач подряд считать провалом,start_periodльготное время на разогрев, в которое провалы не считаются. restart: unless-stoppedработает уже после старта: если процесс контейнера завершился с ошибкой, Docker запускает его снова, с растущей паузой между попытками. Ручная остановка (docker compose stop) политику отключает до следующегоup.- Итог:
depends_onдаёт порядок при запуске, healthcheck даёт признак готовности,restartдаёт самовосстановление. Ни одна из трёх вещей не заменяет остальные.
flowchart LR
DB["db"] -->|"healthy: ждём проверку"| NT["notes"]
NT -->|"порядок только"| PX["proxy"]
NT -.->|"падение notes: процесс завершился, Docker ждёт и запускает снова, пока не остановлен вручную"| NT
Вот как это выглядит на деле. Сценарий 1 и 3 из раздела «Сломай и почини» иллюстрируют политику restart: nginx не может стартовать из-за ошибки конфига, падает сразу, Docker перезапускает, и docker compose ps показывает Restarting (1) .... Число в скобках это код завершения процесса: 0 штатно, ненулевой значит ошибка. Контейнер при этом не «завис», он честно пытается снова и снова, а причина лежит в docker compose logs.
Прикинь сам: Из трёх механизмов (
depends_on,healthcheck,restart) сколько проверяют готовность сервиса и сколько чинят упавший?
Готовность отражает один healthcheck (через service_healthy в depends_on), а чинит упавший один restart. depends_on без условия только задаёт порядок запуска.
Осторожно, частое заблуждение: что depends_on без condition подождёт готовности. Не подождёт. Ещё путают restart и up -d: первое про самовосстановление процесса, второе про применение нового описания.
Главное:
depends_onдаёт порядок,healthcheckдаёт признак готовности,restartдаёт самовосстановление, и ни одно не заменяет другие.
Остаётся научиться читать главную команду проверки стенда.
Проверь понимание:
proxyстартовал раньше, чем приложение начало принимать соединения. Почему nginx при этом не упал?
Ответ
Потому что в proxy_pass переменная и resolver: имя notes разрешается при запросе, а не при загрузке конфига. Клиент в этот момент получил бы 502, но сам nginx работает и подхватит приложение, как только оно ответит.
Как читать docker compose ps: состояния и колонки
Почти все проверки этого урока начинаются с одной команды, и если читать её вывод по диагонали, можно пропустить главное: контейнер «поднят», но перезапускается каждые пять секунд. Без привычки читать состояния диагностика превращается в угадывание.
Табло вылетов в аэропорту: «вылетел», «задерживается», «отменён». Нужно знать не только название рейса, но и статус. Аналогия перестаёт работать в том, что у рейса нет состояния «садится и снова взлетает», а у контейнера есть: Restarting.
Устроено это так.
- Колонка
STATUSначинается с состояния:Up(работает),Exited (1)(завершился с кодом 1),Restarting (1)(упал и Docker пытается снова),Created(создан, не запущен). - Рядом в скобках может быть здоровье:
(healthy)(проверка проходит),(unhealthy)(проходит с ошибкой),(health: starting)(льготный периодstart_period, вердикта ещё нет). - Колонка
PORTSпоказывает, что опубликовано:0.0.0.0:443->443/tcpзначит «порт 443 на всех адресах хоста ведёт в порт 443 контейнера». - Вместо точки внутри имени контейнера стоит дефис и номер:
notes-proxy-1. Это имя проекта, сервиса и экземпляра.
Теперь на числах. Строка notes-proxy-1 ... Up 3 minutes ... 0.0.0.0:80->80/tcp, 0.0.0.0:443->443/tcp читается так: контейнер прокси работает три минуты, снаружи открыты два порта. Строка notes-db-1 ... Up 3 minutes (healthy) 5432/tcp говорит, что база здорова, а порт 5432/tcp без стрелки -> наружу не открыт.
Прикинь сам: В
STATUSнаписаноRestarting (1) 4 seconds ago. Сколько раз контейнер уже упал и где искать причину?
Минимум один раз, и Docker запускает его по кругу. Причина в docker compose logs proxy, а Up само по себе о здоровье не говорит.
Осторожно: Up с «всё хорошо». Up значит только «процесс запущен»: приложение внутри может не отвечать. Смотри ещё на healthy и реальный запрос через curl.
Главное:
Upзначит только «процесс запущен»: смотри наhealthy,Restartingи реальный запрос черезcurl.
Проверь понимание: в
STATUSнаписаноRestarting (1) 4 seconds ago. Что это значит и где смотреть причину?
Ответ
Процесс контейнера завершается с кодом 1 (ошибка), Docker по политике restart запускает его снова, и так по кругу. Причина в логах: docker compose logs proxy, первая строка с ошибкой (для nginx это [emerg]).
Практика
Работай в каталоге ~/notes, где после 4.5 есть compose.yml, .env, Dockerfile и .gitignore. Порты 80 и 443 на хосте должны быть свободны: хостовые nginx и notes остановлены в уроке 4.1. Разбор команды: ss -ltnp показывает слушающие TCP-порты (-l слушающие, -t TCP, -n числами, -p с процессами), grep -E ':(80|443)\s' оставляет строки с портами 80 или 443, а || echo печатает фразу, если grep ничего не нашёл.
cd ~/notes
sudo ss -ltnp | grep -E ':(80|443)\s' || echo "80 и 443 свободны"
docker compose ps
80 и 443 свободны
NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS
notes-db-1 postgres:18 "docker-entrypoint.s…" db 5 minutes ago Up 5 minutes (healthy) 5432/tcp
notes-notes-1 notes-notes "python app.py" notes 5 minutes ago Up 5 minutes (healthy) 127.0.0.1:8080->8080/tcp
Как читать вывод: первая строка от ss: если вместо неё видишь строки с LISTEN, порт занят и в последнем столбце указан процесс. В docker compose ps столбец STATUS показывает (healthy): пробы из healthcheck прошли. У notes порт опубликован на loopback (127.0.0.1:8080->8080/tcp), у db стрелки нет: наружу не опубликован.
Задание 1. Самоподписанный сертификат скриптом
Цель: получить deploy/tls/notes.crt и notes.key с SAN notes.lab и не дать ключу попасть в git.
Предскажи: если в сертификате будет только CN=notes.lab без SAN, примет ли его curl с флагом --cacert?
Ответ
Нет. Современные версии OpenSSL и curl проверяют имя по SAN, поле CN игнорируется. Будет ошибка проверки имени.
Шаги:
- Создай скрипт.
cat > файл <<'SCRIPT'записывает в файл всё до строкиSCRIPT(кавычки вокруг метки нужны, чтобы bash не подставил$OUTраньше времени). Что делает сам скрипт:set -euo pipefailостанавливает его при любой ошибке (урок 1.6);${1:-deploy/tls}берёт первый аргумент или значение по умолчанию;openssl req -x509сразу выпускает самоподписанный сертификат;-newkey rsa:2048 -nodesсоздаёт новый ключ RSA на 2048 бит без пароля на ключе (-nodes: иначе nginx не сможет стартовать без человека);-days 365срок;-subj "/CN=notes.lab"поле CN;-addext "subjectAltName=DNS:notes.lab"добавляет SAN;-keyoutи-outкуда положить ключ и сертификат;chmod 600оставляет ключ читаемым только владельцу,chmod 644даёт всем читать сертификат.
mkdir -p scripts
cat > scripts/gen-tls.sh <<'SCRIPT'
#!/usr/bin/env bash
# Генерация самоподписанного сертификата для notes.lab (365 дней).
set -euo pipefail
OUT="${1:-deploy/tls}"
mkdir -p "$OUT"
openssl req -x509 -newkey rsa:2048 -nodes -days 365 \
-subj "/CN=notes.lab" \
-addext "subjectAltName=DNS:notes.lab" \
-keyout "$OUT/notes.key" -out "$OUT/notes.crt"
# ключ читает только владелец, сертификат публичный
chmod 600 "$OUT/notes.key"
chmod 644 "$OUT/notes.crt"
echo "готово: $OUT/notes.crt"
SCRIPT
chmod +x scripts/gen-tls.sh
shellcheck scripts/gen-tls.sh
shellcheck ничего не печатает, если замечаний нет (он ставился в уроке 1.7).
- Запусти скрипт, закрой каталог от git и проверь сертификат.
grep -qx 'deploy/tls/' .gitignore || echo 'deploy/tls/' >> .gitignoreчитается так: если точной строкиdeploy/tls/в файле нет (-xцелая строка,-qмолча), допиши её. Вopenssl x509 -nooutфлаги-subject,-ext subjectAltName,-enddateпечатают только нужные поля.git check-ignore -vобъясняет, какое правило скрывает файл.
./scripts/gen-tls.sh
grep -qx 'deploy/tls/' .gitignore || echo 'deploy/tls/' >> .gitignore
openssl x509 -in deploy/tls/notes.crt -noout -subject -ext subjectAltName -enddate
git check-ignore -v deploy/tls/notes.key
Что должно получиться:
.+....+++++++++++++++++*.....+.....+++++++++++++++++*.......+..+....+.....
..+...+..+++++++++++++++++*.+.....+........+++++++++++++++++*....+.....+.
-----
готово: deploy/tls/notes.crt
subject=CN = notes.lab
X509v3 Subject Alternative Name:
DNS:notes.lab
notAfter=Sep 30 12:13:46 2027 GMT
.gitignore:7:deploy/tls/ deploy/tls/notes.key
Первые строки из точек и плюсов (здесь сокращены) это индикатор генерации ключа: у тебя они другие и длиннее. Дата в notAfter будет через 365 дней от сегодня, номер строки в .gitignore тоже свой. Файла deploy/tls/ в .gitignore из урока 3.1 нет: ты добавил его сейчас.
Как читать вывод: subject=CN = notes.lab это поле CN; X509v3 Subject Alternative Name с DNS:notes.lab это то, по чему клиент сверяет имя; notAfter конец срока. Последняя строка .gitignore:7:deploy/tls/ deploy/tls/notes.key значит «файл скрыт правилом из строки 7 файла .gitignore». Если команда молчит, правила нет и ключ может попасть в коммит.
Объясни себе:
- Что случится, если закоммитить
notes.key, и как узнать, что это уже произошло? - Почему срок сертификата 365 дней, а не 10 лет?
Типичные ошибки:
-addext: unknown option(илиreq: Unrecognized flag addext): слишком старый OpenSSL. На Ubuntu 24.04 и 26.04 флаг есть; обнови систему.Can't open "deploy/tls/notes.key" for writing, Permission denied: каталогdeploy/tlsсоздан раньше черезsudoи принадлежит root. Исправление:sudo chown -R "$USER" deploy/tls.
Задание 2. Конфиг nginx: редирект и динамический upstream
Цель: написать deploy/nginx/compose.conf, который переживает пересоздание notes.
Предскажи: в конфиге будет set $upstream http://notes:8080; и proxy_pass $upstream;. Изменится ли URI запроса /notes?x=1, который получит приложение?
Ответ
Не изменится. Если в proxy_pass нет части URI, nginx передаёт исходный URI целиком, в том числе и при использовании переменной.
Шаги:
- Создай файл. Отличие от
notes.confиз 2.5 и 2.6: файл лежит вconf.dконтейнера (без обёрткиhttp), сертификат берётся из/etc/nginx/tls/(так мы смонтируем каталог), а вместо адреса127.0.0.1:8080имяnotesиз сети Compose. Директивыlisten,server_name,return 301,ssl_*,proxy_set_headerте же, что в уроках 2.5 и 2.6.
mkdir -p deploy/nginx
cat > deploy/nginx/compose.conf <<'CONF'
# Внутренний DNS Docker; ответы кэшируются на 10 секунд
resolver 127.0.0.11 valid=10s;
server {
listen 80;
server_name notes.lab;
# весь HTTP уходит на HTTPS
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name notes.lab;
ssl_certificate /etc/nginx/tls/notes.crt;
ssl_certificate_key /etc/nginx/tls/notes.key;
ssl_protocols TLSv1.2 TLSv1.3;
location / {
# переменная заставляет nginx разрешать имя на каждый запрос
set $upstream http://notes:8080;
proxy_pass $upstream;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 30s;
}
}
CONF
Разбор по строкам. resolver 127.0.0.11 valid=10s;: спрашивать DNS Docker и держать ответ 10 секунд. Первый server слушает порт 80 и отвечает всем 301 на тот же адрес с https. Второй слушает 443 с TLS, берёт сертификат и ключ и разрешает только TLS 1.2 и 1.3. В location / сначала переменная $upstream, потом proxy_pass $upstream;. Четыре proxy_set_header передают приложению имя из запроса, адрес клиента, цепочку адресов и схему http или https. proxy_read_timeout 30s ждёт ответ приложения не дольше 30 секунд, иначе клиент получит 504.
- Проверь синтаксис одноразовым контейнером, не трогая стек. Разбор:
docker run --rmзапускает контейнер и удаляет по завершении; два-vмонтируют конфиг и сертификаты так же, как это сделает Compose;nginx -tпроверяет конфиг;2>&1 | tail -n 2соединяет ошибки с обычным выводом и оставляет две последние строки (образ перед проверкой печатает восемь строк про свой запуск, они нам не нужны).
docker run --rm \
-v "$PWD/deploy/nginx/compose.conf:/etc/nginx/conf.d/default.conf:ro" \
-v "$PWD/deploy/tls:/etc/nginx/tls:ro" \
nginx:1.30 nginx -t 2>&1 | tail -n 2
Что должно получиться:
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
Как читать вывод: syntax is ok конфиг разобран, test is successful nginx смог открыть всё, что нужно (сертификат и ключ найдены). Проверка проходит, хотя сервиса notes в этой сети нет: как раз потому, что имя стоит в переменной. Если убрать tail, увидишь строки /docker-entrypoint.sh: ... и can not modify /etc/nginx/conf.d/default.conf (read-only file system?): скрипт образа пробует включить IPv6 в default.conf, а файл смонтирован только для чтения. Это безвредно.
Если
nginx -tругается на конфиг, вставь нейросети текст ошибки и конфиг (без ключей и паролей). Проверь ответ повторнымnginx -t: нейросеть любит советовать директивы из старых версий nginx.
Объясни себе:
- Что произойдёт при
nginx -t, если заменить переменную наproxy_pass http://notes:8080;? - Зачем
valid=10s, а неvalid=0?
Типичные ошибки:
nginx: [emerg] host not found in upstream "notes" in /etc/nginx/conf.d/default.conf:22: ответ на первый вопрос выше. Статическийproxy_pass, а имяnotesв этой (разовой) сети не существует.no resolver defined to resolve notesвdocker compose logs proxyпри запросе (а не при старте): вproxy_passпеременная, а строкиresolverнет.nginx -tэто не ловит, ошибка вылезает как 502 при первом запросе. Добавьresolverна уровеньserverили выше.nginx: [emerg] "resolver" directive is not allowed here in /etc/nginx/nginx.conf:2: файл смонтирован какnginx.conf, а не какconf.d/default.conf. Вnginx.confтакие директивы должны стоять внутри блокаhttp.
Задание 3. Сервис proxy в compose.yml
Цель: добавить proxy, убрать публикацию порта у notes, включить ротацию логов.
Предскажи: после правки docker compose ps покажет для notes порт 8080/tcp без стрелки ->. Что это значит для запроса curl http://127.0.0.1:8080/healthz с хоста?
Ответ
Запрос завершится ошибкой соединения: порт хоста 8080 больше никем не слушается. Приложение доступно только контейнерам в сети notes-net.
Шаги:
- Приведи
compose.ymlк такому виду. Изменения относительно 4.5: блокx-loggingсверху, уnotesнетports, у всех сервисовlogging: *logging, новый сервисproxy. Строкиrestart: unless-stoppedостаются уnotesиdbкак в 4.5 и появляются уproxy.
# Общие настройки ротации логов, подключаются якорем
x-logging: &logging
driver: json-file
options:
max-size: "10m"
max-file: "3"
services:
notes:
build: .
environment:
STORE: postgres
DATABASE_URL: ${DATABASE_URL}
APP_VERSION: ${APP_VERSION:-dev}
# ports убран: приложение видно только внутри сети notes-net
depends_on:
db:
condition: service_healthy
restart: unless-stopped
logging: *logging
db:
image: postgres:18
environment:
POSTGRES_DB: notes
POSTGRES_USER: notes
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U notes -d notes"]
interval: 5s
timeout: 3s
retries: 10
start_period: 10s
restart: unless-stopped
logging: *logging
proxy:
image: nginx:1.30
ports:
# единственные опубликованные порты стека
- "80:80"
- "443:443"
volumes:
# конфиг и сертификат монтируются только для чтения
- ./deploy/nginx/compose.conf:/etc/nginx/conf.d/default.conf:ro
- ./deploy/tls:/etc/nginx/tls:ro
depends_on:
- notes
restart: unless-stopped
logging: *logging
volumes:
pgdata:
networks:
default:
name: notes-net
Пароль и строка подключения по-прежнему берутся из .env (урок 4.5), в файле их нет. depends_on: - notes у proxy это короткая форма: только порядок запуска.
- Проверь итоговый файл и подними стек.
docker compose config --quietразбирает файл и молчит, если он корректен.up -dпересоздаст изменившиеся сервисы:notes(изменилисьportsиlogging) иdb(изменилсяlogging), аproxyсоздастся впервые.--format 'table ...'задаёт колонки таблицы.
docker compose config --quiet && echo "compose.yml корректен"
docker compose up -d
docker compose ps --format 'table {{.Service}}\t{{.Status}}\t{{.Ports}}'
Что должно получиться (сначала строка проверки, потом сокращённый вывод up, потом таблица):
compose.yml корректен
Container notes-notes-1 Recreate
Container notes-proxy-1 Created
Container notes-db-1 Healthy
Container notes-notes-1 Started
Container notes-proxy-1 Started
SERVICE STATUS PORTS
db Up 23 seconds (healthy) 5432/tcp
notes Up 18 seconds (healthy) 8080/tcp
proxy Up 18 seconds 0.0.0.0:80->80/tcp, [::]:80->80/tcp, 0.0.0.0:443->443/tcp, [::]:443->443/tcp
Полный вывод up -d длиннее (по две строки на каждую операцию), а твои секунды другие. Сразу после запуска у notes может быть (health: starting): healthcheck образа ещё не отработал, через 10 секунд станет healthy.
Как читать вывод: стрелка -> есть только у proxy. 0.0.0.0:80->80/tcp значит «порт 80 хоста на всех адресах ведёт в порт 80 контейнера», [::] то же для IPv6. Порты 5432/tcp и 8080/tcp без стрелки означают «объявлены образом, но не опубликованы». Первая строка Recreate значит, что Compose увидел изменение в описании notes и пересоздал контейнер: том pgdata и данные при этом целы.
Не уверен, что опубликовано наружу, а что нет? Вставь нейросети вывод
docker compose psи спроси, какие порты видны снаружи. Но сверь с адресом слева (0.0.0.0или127.0.0.1): нейросеть часто забывает, что Docker обходит ufw.
Объясни себе:
- Почему
depends_on: notesуproxyне гарантирует, что приложение уже принимает запросы, и почему для нашего конфига это не страшно? - Что делает
*loggingи чем якорь лучше копирования?
Типичные ошибки:
Bind for 0.0.0.0:80 failed: port is already allocated(илиaddress already in use): порт занят хостовым nginx или другим контейнером. Найди владельцаsudo ss -ltnp | grep ':80 'и останови его.yaml: unmarshal errors: line 1: field x-logging not found: у старой версии Compose нет расширений. Проверьdocker compose version, нужна v2 и новее.
Задание 4. Проверка снаружи: HTTP, HTTPS и заголовки
Цель: убедиться, что редирект и TLS работают, а внутренние порты закрыты.
Предскажи: какой код вернёт curl -sI http://notes.lab/ и в каком заголовке будет адрес назначения?
Ответ
Код 301 Moved Permanently, адрес в Location: https://notes.lab/.
Шаги:
- Все запросы идут с подменой адреса через
--resolveи доверием нашему сертификату (--cacert). СтрокаR='...'кладёт два флага--resolveв переменную,$Rбез кавычек подставляет их в команду (bash разделит по пробелам). Флагиcurl:-sбез индикатора загрузки,-Iтолько заголовки,-X POSTметод,-dтело запроса. Для «Заметок» тело должно быть JSON вида{"text":"..."}(так устроенapp.py, урок 4.4); без JSON приложение вернёт 400.grep -E '^(HTTP|Location)'оставляет строку статуса иLocation.
R='--resolve notes.lab:80:127.0.0.1 --resolve notes.lab:443:127.0.0.1'
curl -sI $R http://notes.lab/ | grep -E '^(HTTP|Location)'
curl -s $R --cacert deploy/tls/notes.crt https://notes.lab/healthz; echo
curl -s $R --cacert deploy/tls/notes.crt -X POST -d '{"text":"через прокси"}' https://notes.lab/notes; echo
curl -s $R --cacert deploy/tls/notes.crt https://notes.lab/notes; echo
curl -s $R --cacert deploy/tls/notes.crt https://notes.lab/headers; echo
- Убедись, что напрямую приложение и база недоступны.
--max-time 3ограничивает ожидание тремя секундами,nc -zv -w 2проверяет порт без отправки данных (-z), подробно (-v) и с таймаутом 2 секунды (урок 2.7);|| trueне даёт команде считаться ошибкой, если отказ ожидаем.
curl -sS --max-time 3 http://127.0.0.1:8080/healthz || true
nc -zv -w 2 127.0.0.1 5432 || true
- Посмотри, что видит клиент в рукопожатии.
echo |подаёт пустой ввод, чтобыs_clientне ждал ввода с клавиатуры,-servernameзадаёт SNI,2>/dev/nullпрячет служебные сообщения,grepоставляет три нужные строки.
echo | openssl s_client -connect 127.0.0.1:443 -servername notes.lab 2>/dev/null \
| grep -E 'subject=|New,|Verify return code'
Что должно получиться: результат первых четырёх запросов:
HTTP/1.1 301 Moved Permanently
Location: https://notes.lab/
ok
{"id": 1}
[{"id": 1, "text": "через прокси", "created_at": "2026-09-30T12:14:56.914459+00:00"}]
{"Host": "notes.lab", "X-Real-IP": "127.0.0.1", "X-Forwarded-For": "127.0.0.1", "X-Forwarded-Proto": "https", "User-Agent": "curl/8.5.0", "Accept": "*/*"}
Затем шаги 2 и 3:
curl: (7) Failed to connect to 127.0.0.1 port 8080 after 0 ms: Couldn't connect to server
nc: connect to 127.0.0.1 port 5432 (tcp) failed: Connection refused
subject=CN = notes.lab
New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384
Verify return code: 18 (self-signed certificate)
Время в created_at у тебя своё. На Ubuntu 26.04 (curl 8.18) первая строка ошибки звучит Could not connect to server, смысл тот же.
Как читать вывод: 301 и Location подтверждают редирект порта 80 на HTTPS. ok от /healthz пришёл через шифрованный канал и прокси. Строка /headers показывает, что видит приложение: X-Forwarded-Proto: https (схема клиента) и X-Real-IP (адрес клиента, здесь 127.0.0.1, потому что запрос с самого хоста). Прямые запросы к 8080 и 5432 отказывают: наружу опубликованы только 80 и 443. Verify return code: 18 ожидаем: сертификат самоподписанный, клиент ему не доверяет без -CAfile. New, TLSv1.3 показывает версию протокола и шифр.
Объясни себе:
- Что означает
Verify return code: 18и как он изменится, если передать-CAfile deploy/tls/notes.crt? (В нашем прогоне стало0 (ok).) - Какой заголовок увидит приложение в
X-Forwarded-Proto?
Типичные ошибки:
curl: (60) SSL certificate problem: self-signed certificate: не передан--cacert deploy/tls/notes.crt. Не лечи флагом-kв скриптах: он отключает проверку целиком.curl: (35) OpenSSL SSL_connect: SSL_ERROR_SYSCALL: nginx не поднялся. Смотриdocker compose logs proxy.curl: (60) SSL: no alternative certificate subject name matches target host name '127.0.0.1': запрос без--resolveк имени, которого нет в SAN (в нашем прогоне запрос был кhttps://127.0.0.1/).400и{"error": ...}наPOST /notes: тело не JSON. Нужен вид{"text":"..."}в одинарных кавычках оболочки.
Задание 5. Шаг проекта: полный стек и живучесть
Цель: зафиксировать в проекте стек proxy, notes, db и доказать, что смена адреса приложения не ломает прокси и что логи ротируются.
Предскажи: ты пересоздаёшь notes, и у него появляется другой IP. Получит ли клиент 502 в ближайшие 10 секунд и почему?
Ответ
Возможен короткий 502 или ошибка, пока приложение стартует или пока в кэше nginx старый адрес (до 10 секунд). Но после этого запросы пойдут на новый IP без перезапуска nginx: имя разрешается заново.
Шаги:
- При обычном
--force-recreateDocker часто отдаёт новому контейнеру тот же IP (освободившийся). Чтобы увидеть смену адреса, займи освободившийся IP временным контейнеромalpine:3.22(командаsleep 300просто держит его живым 5 минут), а потом запустиnotes.docker inspect -fс шаблоном достаёт нужное поле из JSON (rangeпроходит по сетям контейнера,.IPAddressпечатает адрес). Затемsleep 12ждёт истечения кэшаvalid=10s.
docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' notes-notes-1
docker compose stop notes
docker run -d --rm --name notes-holder --network notes-net alpine:3.22 sleep 300
docker compose up -d notes
docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' notes-notes-1
sleep 12
curl -s --resolve notes.lab:443:127.0.0.1 --cacert deploy/tls/notes.crt https://notes.lab/notes; echo
docker rm -f notes-holder
- Проверь ротацию логов:
docker inspect -f '{{.HostConfig.LogConfig}}' notes-proxy-1
- Сохрани состояние проекта в git.
git addперечисляет файлы поимённо (неgit add ., чтобы случайно не захватить лишнее),git status --shortпоказывает подготовленное (Aдобавлен,Mизменён).
git add compose.yml deploy/nginx/compose.conf scripts/gen-tls.sh .gitignore
git status --short
git commit -m "compose: proxy nginx 1.30 с TLS, порт 8080 не публикуется"
Что должно получиться:
172.20.0.3
172.20.0.5
[{"id": 1, "text": "через прокси", "created_at": "2026-09-30T12:14:56.914459+00:00"}]
{json-file map[max-file:3 max-size:10m]}
M .gitignore
M compose.yml
A deploy/nginx/compose.conf
A scripts/gen-tls.sh
Адреса у тебя будут другими, важно, что они отличаются. Вывод git status не содержит deploy/tls/ и .env: оба закрыты .gitignore. Эталон файлов: project/notes на GitHub.
Как читать вывод: два IP это адрес notes до и после; заметка вернулась через прокси, значит nginx нашёл приложение по новому адресу, а данные лежат в базе, а не в контейнере notes. Если сделать curl сразу после up -d, а не через 12 секунд, возможен 502: приложение стартует, а в кэше nginx ещё старый адрес.
Объясни себе:
- Почему заметка не пропала после пересоздания
notes, хотя контейнер новый? - Что бы изменилось, если убрать
set $upstreamи вернуть статическийproxy_pass? (Ответ разбирает сценарий 2 ниже.)
Типичные ошибки:
Error response from daemon: No such container: notes-notes-1: имя контейнера зависит от названия каталога проекта. Возьми точное имя изdocker compose ps.error: pathspec 'deploy/nginx/compose.conf' did not match any files: файл не создан или ты вне~/notes.Conflict. The container name "/notes-holder" is already in use: временный контейнер остался от прошлой попытки,docker rm -f notes-holder.
Сломай и почини
Скрипт поломки ломает твой рабочий стек. Читать его не нужно: смысл в диагностике. Перед началом убедись, что стек из задания 5 работает, а изменения закоммичены (откат: git checkout -- . и docker compose up -d). Скачай скрипт и запусти нужный сценарий из ~/notes (без sudo: файлы проекта твои):
cd ~/notes
curl -fsSL -o /tmp/break-4.6.sh https://raw.githubusercontent.com/distinguished-sre/learning/main/devops/project/notes/break/4.6/break.sh
bash /tmp/break-4.6.sh 1 # номер сценария: 1, 2 или 3
Вернуть рабочее состояние можно в любой момент: bash /tmp/break-4.6.sh fix.
Симптом
После запуска сайт https://notes.lab не отвечает или отвечает ошибкой. Ты знаешь только это. Для каждого номера симптом свой: контейнер proxy постоянно перезапускается (сценарии 1 и 3) либо клиент получает 502 или вообще не получает ответа (сценарий 2).
Гипотезы
Выпиши минимум три, прежде чем что-то менять:
- Ошибка в конфиге nginx (имя upstream, синтаксис).
- Приложение недоступно или сменило адрес.
- Файлы сертификата отсутствуют или смонтированы не туда.
- Порт занят другим процессом.
Проверки
docker compose ps -a
docker compose logs --tail=20 proxy
docker network inspect notes-net
docker compose config | grep -A8 'proxy:'
docker exec (выполнить команду внутри уже работающего контейнера, как зайти в комнату и посмотреть своими глазами) по возможности тоже пригодится, но на перезапускающемся контейнере он срабатывает лишь в короткие моменты между падениями: docker exec notes-proxy-1 nginx -t, docker exec notes-proxy-1 ls -l /etc/nginx/tls, docker exec notes-proxy-1 getent hosts notes. Начинай с ps -a и логов proxy: первая же строка [emerg] обычно называет причину и файл.
Исправление
Разбор сценариев
Сценарий 1. host not found in upstream. docker compose ps -a показывает proxy в состоянии Restarting (1), в логе:
nginx: [emerg] host not found in upstream "notes-app" in /etc/nginx/conf.d/default.conf:21
Статический proxy_pass разрешается при старте, а имя notes-app в сети notes-net не находится: сервис называется notes. Отсюда цикл: nginx падает при загрузке, restart: unless-stopped поднимает его снова, и так по кругу. Исправление: привести конфиг к виду из задания 2 (resolver и set $upstream http://notes:8080;), проверить имя сервиса в compose.yml, затем docker compose up -d --force-recreate proxy.
Сценарий 2. 502 после смены адреса notes. В конфиге снова статический proxy_pass http://notes:8080;, nginx стартует нормально, notes в порядке (healthy), а curl даёт 502 Bad Gateway. В логе proxy:
[error] 22#22: *3 connect() failed (111: Connection refused) while connecting to upstream, client: 192.168.65.1, server: notes.lab, request: "GET /healthz HTTP/1.1", upstream: "http://172.20.0.5:8080/healthz", host: "notes.lab"
В логе IP, которого у notes больше нет. Проверка: docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' notes-notes-1 даёт другой адрес, а docker exec notes-proxy-1 getent hosts notes показывает тот же новый адрес: DNS Docker знает правду, а nginx закэшировал старый ответ при старте. В сети виден лишний контейнер break-4.6-holder-1: это инструмент поломки, он держит старый адрес, чтобы отказ был мгновенным (без него запрос завис бы на минуту и закончился 504). Исправление постоянное: переменная в proxy_pass вместе с resolver 127.0.0.11 valid=10s (задание 2). Временное: docker compose restart proxy, но авария повторится при следующем обновлении приложения.
Сценарий 3. TLS-файлы не смонтированы. Контейнер proxy снова в цикле перезапусков, в логе:
nginx: [emerg] cannot load certificate "/etc/nginx/tls/notes.crt": BIO_new_file() failed (SSL: error:80000002:system library::No such file or directory:calling fopen(/etc/nginx/tls/notes.crt, r) error:10000080:BIO routines::no such file)
Конфиг просит /etc/nginx/tls/notes.crt, а в контейнере этого пути нет. На хосте с файлами всё в порядке (ls deploy/tls), значит, ошибка в правой части монтирования. Проверка: docker compose config, секция volumes у proxy: том смонтирован не в /etc/nginx/tls. Причины на практике: опечатка в пути контейнера, не запущен gen-tls.sh, монтирование не того каталога. Исправление: верный том ./deploy/tls:/etc/nginx/tls:ro (и ./scripts/gen-tls.sh, если файлов нет), затем docker compose up -d, потому что смена томов применяется только пересозданием контейнера.
ИИ в помощь
Нейросеть хорошо объясняет директивы nginx и разбирает вывод curl -v, но конфиг и сертификаты твоего стенда она не видит. Общие правила: ИИ-помощник.
Задача: разобрать конфиг nginx.
Вот мой конфиг nginx для обратного прокси: <вставь без ключей и паролей>.
Объясни каждую директиву. Скажи, почему при пересоздании приложения может появиться 502, и покажи, что поменять.
Не придумывай директивы, которых нет в документации nginx.
Проверь ответ: выполни nginx -t внутри контейнера, затем проверь поведение командой curl. Типичная ошибка нейросети: предложить resolver без переменной в proxy_pass, это не решает проблему.
Задача: понять вывод curl -v для HTTPS.
Вот вывод curl -v https://notes.lab --resolve notes.lab:443:127.0.0.1: <вставь>.
Объясни, где проходит TLS-рукопожатие, что значит ошибка о сертификате и какой флаг нужен для самоподписанного.
Проверь ответ: сверь с разделом про TLS: самоподписанному сертификату нужен --cacert (проверка по своему сертификату), а -k только отключает проверку. Типичная ошибка нейросети: советовать отключить проверку сертификата навсегда.
Задача: проверить, что открыто наружу.
Вот мой compose.yml (без паролей) и вывод ss -tlnp на хосте: <вставь>.
Какие порты доступны из интернета, и что стоит закрыть? Учти, что Docker обходит ufw.
Проверь ответ: сверь каждый порт с записью ports. Типичная ошибка нейросети: считать, что правило ufw закрывает порт, опубликованный Docker.
Словарик урока
| Термин | Простыми словами |
|---|---|
| Обратный прокси (reverse proxy) | Программа на входе, которая принимает запросы клиентов и передаёт их приложениям внутри; клиент приложение не видит |
Публикация порта (ports) |
Проброс порта хоста в порт контейнера; после него контейнер доступен снаружи |
loopback, 127.0.0.1 |
Адрес «сам компьютер»: порт на нём виден только с этой машины |
| iptables | Встроенный в ядро Linux фильтр пакетов; правила Docker для портов пишутся в него |
| bind mount | Файл или каталог с хоста, подключённый в контейнер как есть, без копирования |
:ro (read-only) |
Пометка монтирования «только чтение»: контейнер не может изменить файл |
conf.d |
Каталог, файлы которого образ nginx подключает внутрь блока http |
| Терминация TLS | Шифрование заканчивается на прокси, дальше до приложения идёт обычный HTTP |
| SAN (Subject Alternative Name) | Список имён внутри сертификата, по которому клиент проверяет, что попал куда хотел |
| Самоподписанный сертификат | Сертификат, подписанный своим же ключом; шифрует, но клиент ему без подсказки не доверяет |
resolver |
Директива nginx: какой DNS-сервер спрашивать для имён, разрешаемых во время запроса |
| Кэш DNS в nginx | Запомненный ответ «имя равно IP»; valid=10s держит его 10 секунд |
| 502 Bad Gateway | Ответ прокси: он не смог получить ответ от приложения |
restart: unless-stopped |
Политика Docker: перезапускать контейнер после сбоя и перезагрузки хоста, кроме ручной остановки |
json-file |
Драйвер логов по умолчанию: вывод контейнера пишется в JSON-файл на хосте |
| Ротация логов | Замена заполненного файла новым и удаление самых старых (max-size, max-file) |
Якорь YAML (&имя, *имя) |
Способ один раз описать блок и вставить его в нескольких местах файла |
| Редирект 301 | Ответ «страница переехала навсегда» с новым адресом в Location; браузер идёт по нему сам |
| Upstream | Приложение или группа адресов за прокси, куда он передаёт запросы |
| Прямой и обратный прокси | Прямой ходит в интернет за клиентов, обратный принимает запросы за серверы |
| Закрытый и открытый ключ | Пара связанных ключей: закрытый хранится только на сервере, открытый входит в сертификат |
| Удостоверяющий центр (CA) | Организация, чья подпись под сертификатом заставляет клиентов ему верить |
| Рукопожатие (handshake) | Обмен первыми сообщениями, в котором клиент и сервер договариваются о шифровании |
docker inspect -f |
Показывает одно поле из подробных данных контейнера по шаблону |
docker exec |
Запускает команду внутри работающего контейнера |
Restarting (1) |
Контейнер падает с кодом 1, и Docker снова его запускает |
healthy / unhealthy |
Результат проверки healthcheck: сервис отвечает или нет |
--resolve (curl) |
Флаг «для этого запроса считай, что имя указывает на такой-то адрес» |
Вопросы с собеседований
Раздел для повторения: ответь вслух, потом открой ответ. Вопросы с пометкой «часто» задают почти на каждом собеседовании по теме урока: начни с них. Короткие вопросы с пометкой «на скорость» тренируй на время: ответ за 30 секунд.
1. [junior] [часто] Что такое reverse proxy и зачем он стоит перед приложением?
Ответ
Обратный прокси принимает запросы клиентов и передаёт их приложению. Он даёт единую точку входа на портах 80 и 443, терминирует TLS в одном месте, скрывает приложение и базу от интернета и умеет балансировать, сжимать и ограничивать запросы. В nginx это proxy_pass http://notes:8080;. Заголовки X-Forwarded-For и X-Forwarded-Proto передаю, чтобы приложение видело реальный IP клиента и схему. Обычный (прямой) прокси, наоборот, работает на стороне клиента.
Что хотят услышать: единая точка входа, TLS на прокси, приложение не публикуется наружу, балансировка, proxy_pass, X-Forwarded-заголовки.
Красный флаг: Путает обратный прокси с VPN или публикует и прокси, и приложение, и базу.
2. [middle] [часто] Как работает HTTPS: что клиент проверяет в сертификате сервера?
Ответ
Клиент и сервер договариваются о версии TLS и шифрах и сразу обмениваются половинками ключа, из которых обе стороны получают общий сеансовый ключ. Сервер присылает сертификат, клиент проверяет цепочку до доверенного центра сертификации, срок действия и что имя хоста есть в сертификате (поле SAN). Только после проверки идут данные, зашифрованные сеансовым ключом. Ошибки обычно такие: сертификат просрочен, выписан на другое имя, нет промежуточного сертификата в цепочке или он самоподписанный. Смотрю через curl -v и openssl s_client -connect host:443 -servername host -verify_hostname host -verify_return_error: само -servername только задаёт SNI, имя проверяет -verify_hostname.
Что хотят услышать: цепочка доверия до CA, срок, имя в SAN, сеансовый ключ, типовые причины ошибок, openssl s_client.
Красный флаг: «Сертификат шифрует трафик сам по себе»; лечит ошибку флагом curl -k.
3. [junior] [часто] Клиент зашёл на http://, а нужен только HTTPS. Как настроишь?
Ответ
Добавляю server на порту 80 с return 301 https://$host$request_uri; и server на 443 с сертификатом. Проверяю curl -sI: должен быть 301 и заголовок Location. Позже добавляют HSTS (заголовок, по которому браузер сам ходит только на https), как в уроке 2.6.
Что хотят услышать: return 301 вместо rewrite, $request_uri, проверка curl -I, позже HSTS.
Красный флаг: дублирование всех location в обоих серверах.
4. [junior] Прод отвечает 502 за nginx в Compose, приложение только что задеплоили. Твои действия?
Ответ
Смотрю docker compose ps: живо ли приложение и не перезапускается ли. Читаю docker compose logs proxy: там видно, к какому адресу nginx не смог подключиться. Сравниваю этот IP с реальным у контейнера notes через docker inspect. Если адрес старый, значит nginx закэшировал IP при старте: временно перезапускаю прокси, потом чиню конфиг (переменная в proxy_pass и resolver 127.0.0.11).
Что хотят услышать: порядок от статуса к логам, знание про кэш DNS в nginx, resolver 127.0.0.11 и переменную в proxy_pass.
Красный флаг: сразу «перезапущу всё» без чтения логов.
5. [middle] Nginx в Compose не стартует: host not found in upstream "notes". Почему и как починить?
Ответ
При статическом proxy_pass nginx разрешает имя при загрузке конфига. Имя не находится: опечатка в названии сервиса, notes в другой сети или ещё не создан. Проверяю имена в compose.yml и getent hosts notes внутри сети (getent hosts спрашивает систему, во что разрешается имя). Постоянное решение: resolver и переменная, чтобы старт не зависел от порядка.
Что хотят услышать: статическое и динамическое разрешение, встроенный DNS 127.0.0.11, что depends_on не про DNS.
Красный флаг: «добавлю sleep перед стартом nginx».
6. [junior] [на скорость] Что публикуешь наружу в стеке nginx, приложение, база и почему?
Ответ
Только 80 и 443 у прокси. Приложение и база общаются по имени внутри сети Compose. Для отладки публикую порт на 127.0.0.1. Публикация обходит ufw (правила Docker стоят в другом месте пути пакета), поэтому лишний ports у базы открывает её всему интернету.
Что хотят услышать: обход ufw через iptables Docker, 127.0.0.1: в публикации, минимальная поверхность атаки.
Красный флаг: «открою 5432, чтобы удобно ходить DBeaver’ом» (DBeaver это программа с графическим интерфейсом для работы с базами).
7. [middle] Порт 5432 базы оказался доступен из интернета, хотя ufw его закрывает. Что произошло?
Ответ
Docker сам пишет правила iptables для опубликованных портов, и трафик к контейнеру идёт мимо правил ufw. Ищу в compose.yml ports: "5432:5432" и убираю публикацию или привязываю к 127.0.0.1. Проверяю снаружи nc -zv, меняю пароль базы на случай, если её уже сканировали или подбирали.
Что хотят услышать: цепочка DOCKER и что для своих правил Docker оставляет цепочку DOCKER-USER, смена пароля как часть реакции.
Красный флаг: «ufw закрыт, значит всё закрыто».
8. [middle] Диск заполнился, а du по каталогу приложения показывает мало. Что проверишь?
Ответ
Смотрю df -h, затем sudo du -xh /var/lib/docker | sort -h | tail. Часто виновата логи контейнеров json-file без ограничения: файлы *-json.log. Решение: max-size и max-file в logging и пересоздание контейнеров через up -d. Разово оценить размеры образов, томов и кэша: docker system df.
Что хотят услышать: /var/lib/docker/containers, ротация, что restart настройки не применяет.
Красный флаг: truncate в чужом файле без понимания причины и без ротации.
9. [middle] Браузер ругается на сертификат на новом стенде, а curl без -k тоже падает. Как разберёшься?
Ответ
Смотрю openssl s_client -connect host:443 -servername имя и строку Verify return code. Проверяю, что имя в SAN совпадает с запрошенным, срок не истёк и цепочка полная. Для самоподписанного сертификата код 18 ожидаем, для настоящего (Let’s Encrypt) должен быть 0.
Что хотят услышать: SAN вместо CN, -servername (SNI), проверка срока и цепочки.
Красный флаг: «отключу проверку сертификата в клиенте».
10. [junior] [на скорость] Чем отличаются docker compose restart и docker compose up -d, когда ты поменял compose.yml?
Ответ
restart перезапускает те же контейнеры с прежней конфигурацией. up -d сравнивает описание с существующим и пересоздаёт изменившиеся сервисы. Поэтому новые порты, тома и logging применяются только через up -d. Правку смонтированного файла конфига nginx достаточно применить перезапуском.
Что хотят услышать: пересоздание контейнера, что перезапуск не читает новый compose-файл.
Красный флаг: «это одно и то же».
11. [middle] Как обновить приложение за nginx в Compose без простоя?
Ответ
На одном узле Compose это ограничено: up -d пересоздаёт контейнер, и есть короткое окно. Уменьшаю его через healthcheck и resolver, чтобы прокси быстро увидел новый адрес. Настоящее плавное обновление (rolling update) и проверки готовности решаются в Kubernetes (тема 5). Второй вариант: два экземпляра приложения и выкатывать по одному.
Что хотят услышать: честное ограничение Compose, роль healthcheck, упоминание Kubernetes.
Красный флаг: «Compose умеет zero-downtime из коробки».
12. [middle] Приложение в логах видит IP прокси вместо IP клиента. Почему и что изменить?
Ответ
Соединение к приложению идёт от контейнера proxy. Настоящий адрес передаётся заголовками X-Forwarded-For и X-Real-IP, а приложение или его фреймворк должно доверять им только от известного прокси (иначе клиент подделает заголовок). Проверяю proxy_set_header в конфиге и как приложение читает заголовок.
Что хотят услышать: X-Forwarded-For, доверие только своему прокси, риск подделки заголовка клиентом.
Красный флаг: «включу network_mode: host для приложения» (так контейнер использует сеть хоста напрямую, теряя изоляцию).
Проверено на версиях
Всё, что ниже, прогонялось на Docker Desktop (Mac, Apple Silicon): Docker Engine 29.6.2, Docker Compose v5.3.1. Стек поднимался по-настоящему с образами nginx:1.30 (в прогоне nginx 1.30.5), postgres:18 и образом «Заметок» из Dockerfile урока 4.2 с app.py v4. Клиентские проверки (curl, openssl s_client, nc) выполнялись в Ubuntu 24.04 (curl 8.5.0, OpenSSL 3.0.13) внутри сетевого пространства контейнера proxy, чтобы 127.0.0.1 вёл на опубликованные порты, как на хосте. Сертификат выпущен gen-tls.sh в Ubuntu 24.04. Скрипт break/4.6/break.sh: shellcheck без замечаний, прогнаны сценарии 1, 2, 3 и fix, повторные запуски каждого.
Не прогонялось: сам Docker Engine на Linux (правила iptables и обход ufw описаны по документации и уроку 2.7), Ubuntu 26.04, ss на хосте с реальными занятыми портами, git status и коммит из задания 5 в настоящем репозитории ученика (вывод восстановлен по правилам git). Версии OpenSSL и Compose на Ubuntu-хосте ученика зависят от установки: проверь актуальную версию на странице проекта.
Итог урока: ты умеешь
- умею выпустить самоподписанный сертификат с SAN скриптом и не коммитить ключ
- умею добавить в
compose.ymlсервис nginx с конфигом и сертификатом, смонтированными:ro - умею опубликовать наружу только 80 и 443 и объяснить, почему
portsу базы опасен - умею настроить редирект 80 на 443 и проверить его через
curl --resolve - умею объяснить и починить 502 после пересоздания приложения (
resolverи переменная) - умею читать
[emerg]в логах nginx и проверять конфиг черезnginx -t - умею включить ротацию логов
json-fileи знаю, что она применяется черезup -d
Дальше: Урок 4.7: Образы: multi-stage, теги и реестр ghcr.io
Проверь себя
Короткий тест по уроку: 5 вопросов из банка в 30. Засчитывается только полностью правильный ответ, порог 60%. Каждая новая попытка даёт другие вопросы, пока банк не закончится. Ответы видны после проверки.
Тест работает с включённым JavaScript.