111. Контейнеры и инфраструктура: Compose, образы, обратный прокси
Прибор редко живёт один: данные уходят на сервер, где их принимают, хранят и показывают. Этот сервер — тоже часть изделия, и собирать его руками на каждой машине так же плохо, как собирать прошивку без конвейера (раздел 110). Раздел строит маленькую, но настоящую инфраструктуру: приёмник телеметрии на C в контейнере, обратный прокси перед ним и файл Compose, который поднимает всё одной командой. Каждое свойство проверено опытом: стенд поднимается, его роняют, пересоздают и проверяют, что видно снаружи.
Как это называется в документации
Оригинал Перевод image, layer образ, слой образа container контейнер: запущенный образ multi-stage build многоступенчатая сборка образа volume / bind mount том / подключённый каталог хоста Compose file, service файл Compose, сервис healthcheck проверка здоровья контейнера restart policy правило перезапуска reverse proxy обратный прокси rate limiting ограничение частоты запросов internal network внутренняя сеть без выхода наружу
111.1 Что строим
Приборы шлют показания запросом HTTP POST /api/telemetry (раздел 107),
приёмник дописывает строку в файл и по запросу говорит, сколько строк
принято. Приёмник — небольшая программа на C без библиотек: так видно, что
контейнеру всё равно, на чём написана программа.
Наружу открыт только прокси nginx. Он принимает запросы, отсекает лишнее и передаёт остальное приёмнику во внутреннюю сеть. Три свойства, ради которых всё затевается:
- стенд описан файлами и поднимается одной командой на любой машине;
- падение программы и перезапуск сервера не теряют данные;
- снаружи видно только то, что задумано.
111.2 Образ: две ступени
Образ собирается из Dockerfile. Компилятор нужен только чтобы собрать
программу, в работающем образе ему делать нечего:
FROM debian:12-slim AS build
COPY receiver.c /src/receiver.c
RUN gcc -std=gnu11 -O2 -Wall -Wextra -static -o /receiver /src/receiver.c \
&& mkdir /data
FROM scratch
COPY --from=build /receiver /receiver
ENTRYPOINT ["/receiver"]
Первая ступень — Debian с gcc, вторая — FROM scratch, пустой образ, куда
копируется одна статически собранная программа. Итог опыта: образ приёмника —
351 346 байт, ступень с компилятором — 98 МБ, в 279 раз больше. Меньше образ —
быстрее выкладка по медленному каналу и меньше программ, в которых может
найтись уязвимость: в scratch нет даже оболочки.
Номер версии базового образа закрепляют так же, как версию компилятора
в конвейере: nginx:1.31.3-alpine, а не nginx:latest, — иначе завтрашняя
сборка выйдет не такой, как сегодняшняя. Но и тег с версией
(nginx:1.31.3-alpine, debian:12-slim) пересобирают с обновлениями
безопасности: содержимое меняется, имя — нет. Неизменен только хэш образа
@sha256:….
111.3 Не от root
Процесс в контейнере по умолчанию работает от root. Приёмнику это не нужно:
COPY --from=build --chown=65534:65534 /data /data
USER 65534:65534
Опыт подтвердил: контейнер работает от пользователя 65534. Каталог /data
создаётся в образе сразу с этим владельцем — новый том получает те же
права, и программа может писать в свой файл, но больше никуда.
111.4 Compose: весь стенд в одном файле
Файл compose.yaml описывает сервисы, тома и сети:
services:
receiver:
build: ./receiver
restart: on-failure
volumes:
- telemetry:/data
networks:
- back
proxy:
image: nginx:1.31.3-alpine
restart: unless-stopped
ports:
- "127.0.0.1:18111:80"
volumes:
- ./proxy/nginx.conf:/etc/nginx/conf.d/default.conf:ro
networks:
- front
- back
volumes:
telemetry:
networks:
front:
back:
internal: true
Прокси подключён к обеим сетям, приёмник — только к back. Именованный
том объявляют в корне файла: без раздела volumes Compose откажется
запускаться («refers to undefined volume»). Проверка здоровья и порядок
запуска — в 111.5.
Команды, которыми пользуются каждый день:
docker compose up -d --build собрать и поднять в фоне
docker compose ps что запущено и в каком состоянии
docker compose logs -f receiver журнал сервиса
docker compose down остановить и удалить контейнеры
docker compose down -v то же вместе с томами — и данными
Порт прокси опубликован только на 127.0.0.1: на сервере наружу его
выпускает сетевой экран или внешний прокси с шифрованием, а не Docker сам.
Порт, опубликованный на всех адресах ("18111:80"), Docker пробрасывает
своими правилами в iptables ещё до того, как пакет дойдёт до правил ufw
(и зон firewalld): порт виден из сети, даже если сетевой экран его
«запрещает». Ограничения для опубликованных портов ставят в цепочку
DOCKER-USER — её Docker проверяет раньше своих правил.
111.5 Проверка здоровья и порядок запуска
«Контейнер запущен» не значит «программа готова отвечать». Проверка здоровья
спрашивает саму программу. В образе scratch нет ни curl, ни оболочки —
поэтому приёмник умеет проверять себя сам:
if (argc > 1 && strcmp(argv[1], "--check") == 0)
return self_check();
self_check() делает запрос GET /api/count к себе и возвращает 0 при
ответе 200. В Compose:
receiver:
healthcheck:
test: ["CMD", "/receiver", "--check"]
interval: 2s
proxy:
depends_on:
receiver:
condition: service_healthy
Прокси запускается только после того, как приёмник стал healthy: первые
запросы приборов не получат ошибку «сервис недоступен».
111.6 Падение и перезапуск
Правило restart: on-failure поднимает контейнер, если процесс завершился
с ошибкой. Опыт: служебный запрос изнутри сети заставил приёмник упасть.
Docker перезапустил его сам — RestartCount стал 1, — проверка здоровья
снова зелёная, а счётчик принятых строк остался 3: они лежат в файле
на томе, а не в памяти процесса.
unless-stopped у прокси поднимает его и после перезагрузки сервера.
Перезапуск — страховка, а не лечение: частые перезапуски видны
в RestartCount и должны превращаться в оповещение (раздел 112).
111.7 Тома: данные отдельно от контейнера
Контейнер одноразовый: его удаляют при каждом обновлении образа. Данные
живут в томе telemetry, подключённом к /data. Опыт:
docker compose downудалил контейнеры и сети, том остался; послеupсчётчик — снова 3;docker compose down -vудалил и том — вместе со всеми показаниями.
Поэтому down -v на рабочем сервере — команда, после которой нужна
резервная копия. Том для важных данных копируют по расписанию — например,
отдельным контейнером с tar, подключённым к тому же тому.
111.8 Сети: наружу только прокси
Приёмник не публикует ни одного порта и подключён только к сети back
с internal: true — у неё нет выхода наружу. Прокси подключён к обеим
сетям. Опыт: контейнер во внешней сети front до приёмника
не достучался, а контейнер в сети back — достучался. Внутри сети
сервисы находят друг друга по имени: прокси обращается к receiver:8080,
адресов никто не пишет.
Если приёмник взломают, из внутренней сети ему некуда отправить данные; если сломают прокси — он видит только приёмник, а не всё остальное на сервере.
111.9 Обратный прокси
limit_req_zone $binary_remote_addr zone=tele:1m rate=5r/s;
location = /api/crash {
return 403;
}
location /api/ {
limit_req zone=tele burst=5 nodelay;
limit_req_status 429;
client_max_body_size 1k;
proxy_set_header X-Real-IP $remote_addr;
proxy_pass http://receiver:8080;
}
Что прокси сделал в опыте:
| Запрос | Ответ |
|---|---|
три строки на /api/telemetry |
200, приёмник насчитал 3 |
служебный /api/crash |
403 — наружу не выпущен |
всё вне /api/ |
404 |
| тело больше 1 КБ | 413 |
| 20 запросов подряд | прошли 6–7, остальные — 429 |
Ограничение — 5 запросов в секунду с запасом 5: сломанный прибор,
который шлёт без остановки, не положит приёмник. Приёмник видит соединение
от прокси, а настоящий адрес клиента получает в заголовке X-Real-IP:
через прокси /api/whoami вернул адрес клиента, напрямую — прочерк.
На рабочем сервере прокси же держит сертификат HTTPS — приёмник
о шифровании не знает.
111.10 Грабли
Контейнер «работает», а сервис не отвечает. Нет проверки здоровья,
зависимые сервисы стартуют раньше. healthcheck и condition: service_healthy.
После обновления пропали данные. Данные писались внутрь контейнера,
а не в том. Всё, что должно пережить контейнер, — в volumes.
permission denied при записи в том. Программа работает не от root,
а каталог принадлежит root. Создать каталог в образе с нужным владельцем.
Сборка вчера и сегодня дала разные образы. Базовый образ latest.
Закреплять версии.
Приёмник доступен из интернета в обход прокси. Опубликован его порт. Публиковать только прокси, приёмник — во внутренней сети.
В журнале приёмника у всех один и тот же адрес. Это адрес прокси.
Брать X-Real-IP, а прокси — ставить его.
down -v на сервере. Удаляет тома с данными. Резервная копия — до.
111.11 Итоги
- Двухступенчатая сборка: образ приёмника — 351 346 байт против 98 МБ
у ступени с компилятором;
FROM scratch, пользователь 65534. - Compose описывает сервисы, тома и сети;
up -d --build— весь стенд одной командой. - Проверка здоровья делает сама программа (
--check), прокси ждётservice_healthy. - Упавший приёмник Docker поднял сам (
RestartCount1), данные в томе целы;downих не трогает,down -v— удаляет. - Наружу — только прокси: 403 на служебный адрес, 404 вне
/api/, 413 на большое тело, 429 при частых запросах, адрес клиента — вX-Real-IP.
Дальше: раздел 112 «Наблюдаемость» — журналы, метрики и оповещения для этого стенда.