Files
resume/stack/docker/CASE.md
Tot Maxim 40ff73f647 Оформить опыт с Gitea CI-раннерами в легенду и docker-кейс
Реальная настройка self-hosted CI Gitea Actions (починка раннера из
restart-loop, кастомный cross-builder под ARM) вписана в опыт АО ТНИИС:
LEGEND.md/STORY.md — расширены строки Gitea/Docker и раздел про два
CI-инструмента отдела, docker/CASE.md — новый раздел 9 с
воспроизводимыми фрагментами, ci-cd/CASE.md — ссылка на реальный кейс.

SERVER.md/PROGRESS.md актуализированы: исправлена ошибочная запись
про порт 3000 (это Grafana, не Gitea) и добавлен чек-лист, что нельзя
ломать в CI-инфраструктуре сервера при прохождении курса Kubernetes.
2026-07-18 15:06:57 +03:00

17 KiB
Raw Permalink Blame History

Кейс: Docker

Связь с легендой: ../../legend/LEGEND.md — раздел «АО ТНИИС», контейнеризация сервисов мониторинга и вспомогательных инструментов. Практика опирается на уже готовые стенды ../monitoring/CASE.md и ../nginx/CASE.mdоба используют Docker Compose.

Что нужно реально сделать

1. Dockerfile для собственного простого сервиса (multi-stage build)

# build stage
FROM golang:1.22-alpine AS build
WORKDIR /src
COPY . .
RUN go build -o /app ./...

# итоговый образ — только бинарник, без всей тулчейна сборки
FROM alpine:3.19
COPY --from=build /app /app
ENTRYPOINT ["/app"]
# .dockerignore
.git
*.md
node_modules

Multi-stage build: первый этап содержит весь тяжёлый тулчейн сборки (компилятор, зависимости), второй — только финальный артефакт. Итоговый образ в разы меньше и не тащит в прод лишние инструменты сборки. Слои кешируются построчно — COPY . . перед RUN build означает, что при изменении любого файла кеш слоя сборки инвалидируется; на реальных проектах порядок команд специально выстраивают так, чтобы редко меняющиеся зависимости копировались раньше исходного кода.

2. Volumes: bind mount vs named volume

# bind mount — конкретный путь на хосте, удобно для конфигов и разработки
docker run -v /host/path/nginx.conf:/etc/nginx/nginx.conf:ro nginx

# named volume — управляется самим Docker, удобно для данных (БД, персистентное состояние)
docker volume create pgdata
docker run -v pgdata:/var/lib/postgresql/data postgres

Bind mount используется в кейсах ../monitoring/CASE.md и ../nginx/CASE.md для конфигов (prometheus.yml, nginx.conf) — удобно редактировать файл на хосте и сразу видеть изменения в контейнере. Named volume лучше подходит для данных, которые не нужно редактировать руками с хоста и которые должны переживать пересоздание контейнера (в ../databases/CASE.md для этого пригодился бы именно named volume под /var/lib/postgresql/data).

3. Сети Docker: как контейнеры находят друг друга

docker network ls
docker network inspect <compose-project>_default

В Docker Compose по умолчанию создаётся отдельная bridge-сеть на проект, и все сервисы внутри неё резолвят друг друга по имени сервиса через встроенный DNS Docker — именно поэтому в ../monitoring/CASE.md Prometheus обращается к node-exporter:9100, а не по IP: имя сервиса из docker-compose.yml работает как hostname внутри этой сети.

4. Ограничение ресурсов и что происходит при превышении

services:
  app:
    image: myapp
    deploy:
      resources:
        limits:
          cpus: "0.5"
          memory: 256M
docker run --memory=256m --cpus=0.5 myapp

При превышении лимита памяти ядро (через cgroups, см. ../linux-bash/QUESTIONS.md) убивает процесс через OOM killer — контейнер завершается с кодом 137 (128 + сигнал 9 SIGKILL). Воспроизвести: запустить в контейнере с --memory=50m процесс, который выделяет память сверх лимита (stress --vm 1 --vm-bytes 100M), и увидеть код выхода 137 через docker inspect <container> --format '{{.State.ExitCode}}'.

5. Диагностика упавшего контейнера

docker logs <container>
docker logs --tail 50 -f <container>
docker exec -it <container> sh
docker inspect <container>
docker inspect <container> --format '{{.State.ExitCode}} {{.State.OOMKilled}}'

6. Внутренности: виртуализация vs контейнеризация, containerd, PID контейнера

Виртуализация (VM) — эмулирует полное отдельное «железо» с собственным ядром ОС через гипервизор (VMware/KVM/Hyper-V) — тяжелее, но полная изоляция вплоть до ядра. Контейнеризация — все контейнеры на хосте используют одно и то же ядро хоста, изоляция обеспечивается namespaces (что процесс видит) и ограничение ресурсов — cgroups (сколько он может использовать), без отдельного гостевого ядра — отсюда контейнеры легче и стартуют быстрее VM.

containerd — низкоуровневый контейнерный рантайм, которым Docker Engine пользуется под капотом для реального управления жизненным циклом контейнеров (запуск, остановка, управление образами) — сам Docker CLI/демон — более высокоуровневая обвязка поверх containerd с удобным UX (docker build, docker compose и т.п.). Docker понимает, что контейнер «умер», потому что containerd отслеживает главный процесс контейнера (PID 1 внутри контейнера) — как только этот процесс завершается (сам или из-за ошибки), containerd фиксирует это событие и помечает контейнер как Exited.

docker inspect <container> --format '{{.State.Pid}}'   # PID процесса контейнера на хосте
ps aux | grep <тот же PID>                               # виден и на хосте — контейнер это просто изолированный процесс хоста

7. Entrypoint vs CMD

ENTRYPOINT ["myapp"]
CMD ["--config", "/etc/myapp/default.yml"]

ENTRYPOINT задаёт неизменяемую (без явного --entrypoint при запуске) главную команду контейнера. CMD задаёт аргументы по умолчанию к ней, которые легко переопределить при docker run myapp --config /other.yml — эта строка заменит именно CMD, не трогая ENTRYPOINT. Если задан только CMD без ENTRYPOINT — вся строка CMD целиком заменяется аргументами docker run.

8. Копирование файлов в работающий контейнер

docker cp ./local-file.txt <container>:/app/file.txt
docker cp <container>:/app/output.log ./output.log

9. Реальный кейс: self-hosted CI-раннеры Gitea Actions

В отличие от разделов 18 (демонстрационная практика), это реально работающая инфраструктура: собственный инстанс Gitea с двумя CI-раннерами Gitea Actions под кросс-сборку встроенного C-кода для ARM-плат (проект на базе SDR-платформы Pluto — сигнальная обработка на Cortex-A9). Все конфиги ниже — не иллюстрация, а рабочие файлы сервера.

9.1. Диагностика контейнера в restart-loop

docker ps -a                                          # статус Restarting — контейнер падает и рестартует по кругу
docker logs --tail 50 gitea_runner                    # видно, на каком шаге падает
docker inspect -f '{{.State.Status}}' gitea_runner     # быстрая проверка без полного inspect

Симптом в логах — раннер не может подтвердить свою регистрацию на сервере (unregistered runner). Причина — файл состояния регистрации лежит на bind mount и пережил пересоздание контейнера, но сервер эту регистрацию (токен) больше не признаёт. Урок: персистентный state на volume — это одновременно удобство (не нужно перерегистрироваться при каждом пересоздании контейнера) и риск (протухшее состояние переживает контейнер и требует ручной диагностики, а не просто docker restart). Лечение — остановить контейнер, удалить устаревший файл состояния, поднять заново с актуальным токеном регистрации.

9.2. Кастомный образ cross-builder'а

FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
      build-essential gcc-arm-linux-gnueabihf libc6-dev-armhf-cross \
      autoconf automake libtool pkg-config cmake git wget curl socat
COPY build_deps.sh /root/build_deps.sh
RUN bash /root/build_deps.sh          # кросс-сборка статических ARM-библиотек прямо на этапе сборки образа
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]

Ключевой момент, в котором легко ошибиться: кросс-компиляция не требует эмуляции ARM. arm-linux-gnueabihf-gcc — обычный x86-бинарь, который на входе берёт .c и на выходе даёт ARM-машинный код, выполняясь на полной скорости x86-ядра. Первая наивная попытка задать в compose platform: linux/arm/v7 привела к exec /sbin/tini: exec format error — Docker пытался выполнить ARM-бинарь на x86-ядре без qemu/binfmt-регистрации. Рабочее решение — нативный linux/amd64-образ, внутрь которого установлен ARM-кросс-тулчейн как обычный пакет.

9.3. Сети контейнеров: коллизия портов и host-gateway

Реальная ситуация: CI-джоба клонирует репозиторий по http://.../localhost:3000/..., но порт 3000 на хосте занят другим сервисом (не Gitea), а сама Gitea слушает другой порт. Контейнер раннера работает не в network_mode: host, а в обычной bridge-сети — значит его localhost изолирован от хостового. Решение:

services:
  runner-armhf:
    extra_hosts:
      - "gitea-host:host-gateway"    # спец-имя host-gateway — резолвится в реальный IP хоста
# entrypoint.sh — проброс localhost:3000 контейнера на реальный порт Gitea на хосте
socat TCP-LISTEN:3000,fork,reuseaddr,bind=127.0.0.1 TCP:gitea-host:3030 &

Это тот же принцип DNS-резолвинга сервисов по имени, что и в разделе 3, только в обратную сторону — контейнеру нужно достучаться не до соседнего сервиса в той же сети, а до порта на самом хосте, при этом не отдавая ему network_mode: host целиком (это сломало бы остальную изоляцию раннера).

9.4. Маршрутизация job'ов по меткам

# entrypoint.sh — регистрация только при первом старте (файл состояния ещё не существует)
if [ ! -f /data/.runner ]; then
  act_runner register --no-interactive --instance "$GITEA_INSTANCE_URL" \
    --token "$GITEA_RUNNER_REGISTRATION_TOKEN" --labels "$GITEA_RUNNER_LABELS"
fi
exec act_runner daemon --config /data/config.yaml

Два раннера с разными метками (ubuntu-latest/общие задачи и armhf/кросс-сборка) разбирают джобы по runs-on: в workflow — маршрутизация нагрузки на уровне меток, аналогично тому, как в Kubernetes джобы распределяются по нодам через nodeSelector. restart: unless-stopped в compose — минимальная альтернатива systemd-юниту там, где не нужен сложный порядок запуска.

9.5. Проверка результата сборки: ELF-заголовок

Файл лежит на диске и даже отвечает на command -v — это ещё не значит, что он собран под нужную архитектуру. Финальная проверка в pipeline — чтение байта e_machine прямо из ELF-заголовка:

od -An -tx1 -j 18 -N 2 build/binary | tr -d ' \n'   # ждём 2800 = EM_ARM в little-endian

Что это даёт в разговоре с интервьюером

  • Понимание, что контейнер технически — обычный процесс хоста с изоляцией через namespaces/cgroups, а не мини-VM.
  • Практика multi-stage build и осознанного слоёного кеширования, а не просто «Dockerfile работает».
  • Знание разницы bind mount/named volume не абстрактно, а с привязкой к тому, где какой тип реально применён в других кейсах репозитория.
  • Воспроизведённый вживую OOM kill (код 137) — понимание на практике, а не только в теории.
  • Реальная диагностика упавшего в продакшене контейнера по логам и статусу, а не смоделированная ситуация.
  • Сборка кастомного образа под нестандартную задачу (кросс-компилятор + прекомпилированные зависимости внутри образа), а не использование готового образа из Docker Hub.
  • Сетевая коллизия портов, решённая без изменения архитектуры (host-gateway + проброс), — практика того, что сети Docker не всегда «просто работают из коробки».

Как это ложится в легенду

В реальной работе (АО ТНИИС) Docker упоминается в стеке отдела как средство контейнеризации сервисов сопровождения. Разделы 18 — практика поверх уже готовых стендов мониторинга и nginx. Раздел 9 — реальный рабочий кейс: администрирование Gitea и её CI-раннеров, включая сборку кастомного образа cross-builder'а для кросс-компиляции встроенного C-кода под ARM (проект отдела на SDR-платформе, см. ../../legend/LEGEND.md → «АО ТНИИС» → Gitea).