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

190 lines
17 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Кейс: Docker
Связь с легендой: [../../legend/LEGEND.md](../../legend/LEGEND.md) — раздел «АО ТНИИС», контейнеризация сервисов мониторинга и вспомогательных инструментов. Практика опирается на уже готовые стенды [../monitoring/CASE.md](../monitoring/CASE.md) и [../nginx/CASE.md](../nginx/CASE.md) — оба используют Docker Compose.
## Что нужно реально сделать
### 1. Dockerfile для собственного простого сервиса (multi-stage build)
```dockerfile
# 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
```bash
# 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](../monitoring/CASE.md) и [../nginx/CASE.md](../nginx/CASE.md) для конфигов (`prometheus.yml`, `nginx.conf`) — удобно редактировать файл на хосте и сразу видеть изменения в контейнере. Named volume лучше подходит для данных, которые не нужно редактировать руками с хоста и которые должны переживать пересоздание контейнера (в [../databases/CASE.md](../databases/CASE.md) для этого пригодился бы именно named volume под `/var/lib/postgresql/data`).
### 3. Сети Docker: как контейнеры находят друг друга
```bash
docker network ls
docker network inspect <compose-project>_default
```
В Docker Compose по умолчанию создаётся отдельная bridge-сеть на проект, и все сервисы внутри неё резолвят друг друга по имени сервиса через встроенный DNS Docker — именно поэтому в [../monitoring/CASE.md](../monitoring/CASE.md) Prometheus обращается к `node-exporter:9100`, а не по IP: имя сервиса из `docker-compose.yml` работает как hostname внутри этой сети.
### 4. Ограничение ресурсов и что происходит при превышении
```yaml
services:
app:
image: myapp
deploy:
resources:
limits:
cpus: "0.5"
memory: 256M
```
```bash
docker run --memory=256m --cpus=0.5 myapp
```
При превышении лимита памяти ядро (через cgroups, см. [../linux-bash/QUESTIONS.md](../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. Диагностика упавшего контейнера
```bash
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`.
```bash
docker inspect <container> --format '{{.State.Pid}}' # PID процесса контейнера на хосте
ps aux | grep <тот же PID> # виден и на хосте — контейнер это просто изолированный процесс хоста
```
### 7. Entrypoint vs CMD
```dockerfile
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. Копирование файлов в работающий контейнер
```bash
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
```bash
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'а
```dockerfile
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` изолирован от хостового. Решение:
```yaml
services:
runner-armhf:
extra_hosts:
- "gitea-host:host-gateway" # спец-имя host-gateway — резолвится в реальный IP хоста
```
```bash
# 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'ов по меткам
```bash
# 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-заголовка:
```bash
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](../../legend/LEGEND.md) → «АО ТНИИС» → Gitea).