Оформить опыт с 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.
This commit is contained in:
Tot Maxim
2026-07-18 15:06:57 +03:00
parent 8f431e4798
commit 40ff73f647
8 changed files with 907 additions and 4 deletions

View File

@@ -0,0 +1,643 @@
# Журнал команд: что запускалось и зачем
Построчный разбор каждой команды, которую я выполнял по SSH при наладке курса на сервере — с разбором каждого аргумента и объяснением, что значит полученный ответ. Цель файла — чтобы ты мог повторить любой шаг сам, точно понимая, что делает каждый флаг, а не копируя команду вслепую.
Формат каждой записи:
- **Задача** — зачем вообще эта команда.
- **Команда** — целиком, можно копировать.
- **Разбор аргументов** — что делает каждая часть.
- **Получен ответ** — реальный фрагмент вывода из этой сессии.
- **Значит** — какой вывод/решение делаем на основе этого ответа.
Общее для всех команд ниже, кроме отмеченных отдельно: они выполняются в моей SSH-сессии как `ssh -o BatchMode=yes [-o ConnectTimeout=N] totserver@192.168.31.163 'команда'`. Разбор этой обвязки — в самом первом пункте, дальше не повторяю.
---
## 2026-07-18
### 0. SSH-обвязка (используется почти во всех командах ниже)
**Команда:**
```bash
ssh -o BatchMode=yes -o ConnectTimeout=5 totserver@192.168.31.163 'команда'
```
**Разбор аргументов:**
- `-o BatchMode=yes` — запрещает SSH задавать любые интерактивные вопросы (в первую очередь пароль). Если аутентификация по ключу вдруг не сработает, соединение сразу упадёт с ошибкой вместо того, чтобы зависнуть в ожидании ввода, которого никто не даст — критично для автоматических/неинтерактивных вызовов.
- `-o ConnectTimeout=5` — сколько секунд ждать установления TCP-соединения, прежде чем сдаться. Защита от зависания, если сервер недоступен по сети.
- `'команда'` — то, что выполняется на сервере одной неинтерактивной **non-login shell**-сессией (важно, см. пункт 18 — от этого зависит, виден ли `~/.local/bin` в `PATH`).
---
### 1. Проверка, что уже стоит на сервере
**Команда:**
```bash
ssh -o BatchMode=yes -o ConnectTimeout=5 totserver@192.168.31.163 'echo SSH_OK; docker --version 2>&1; kind --version 2>&1; kubectl version --client 2>&1 | head -2; helm version 2>&1; echo ---; docker ps --format "{{.Names}}\t{{.Image}}\t{{.Status}}" 2>&1; echo ---; sysctl fs.inotify.max_user_watches fs.inotify.max_user_instances; stat -fc %T /sys/fs/cgroup; echo ---; snap list 2>/dev/null | grep -Ei "kubectl|go|helm|microk8s"'
```
**Разбор аргументов:**
- `echo SSH_OK` — простой маркер в начале вывода, чтобы сразу видеть, что соединение вообще установилось (до того, как читать остальное).
- `docker --version`, `kind --version`, `kubectl version --client`, `helm version`у каждого инструмента своя команда проверки версии; `2>&1` перенаправляет stderr в stdout, чтобы в одном потоке видеть и версию (если стоит), и ошибку `command not found` (если не стоит) — иначе ошибка могла бы потеряться.
- `kubectl version --client | head -2``--client` просит показать только версию локального `kubectl`, не пытаясь достучаться до кластера (которого ещё нет); `head -2` обрезает вывод до первых двух строк, там уже есть номер версии.
- `docker ps --format "{{.Names}}\t{{.Image}}\t{{.Status}}"``--format` с Go-шаблоном вместо стандартной широкой таблицы Docker печатает только три нужные колонки (имя контейнера, образ, статус) через табуляцию — компактно видно, что уже крутится на сервере.
- `sysctl fs.inotify.max_user_watches fs.inotify.max_user_instances` — без `-w` и без `=значение` `sysctl` работает в режиме чтения: показывает текущие значения этих двух лимитов ядра на число файлов/директорий, за которыми можно следить через inotify (механизм Linux для отслеживания изменений файлов) — важно для K8s, где kubelet и служебные процессы следят за множеством файлов конфигурации.
- `stat -fc %T /sys/fs/cgroup``-f` заставляет `stat` показывать информацию не о самом файле, а о файловой системе, на которой он лежит; `-c %T` — вывести только тип этой файловой системы. Так проверяется версия cgroup (`cgroup2fs` = cgroup v2, нужен для нормальной работы современного `kind`/`containerd`).
- `snap list | grep -Ei "kubectl|go|helm|microk8s"``snap list` без фильтров печатает все установленные snap-пакеты; `grep -E` (расширенные регулярки, `|` как «или») `-i` (без учёта регистра) сужает список до тех, что могут относиться к K8s-инструментам.
**Получен ответ:**
```
Docker version 28.5.1, build e180ab8
bash: line 1: kind: command not found
Client Version: v1.35.6
bash: line 1: helm: command not found
---
gitea_runner docker.gitea.com/act_runner:0.2.10 Up 4 weeks
gitea docker.gitea.com/gitea:1.24.7 Up 4 weeks (healthy)
---
fs.inotify.max_user_watches = 122407
fs.inotify.max_user_instances = 128
cgroup2fs
```
**Значит:** Docker и kubectl уже есть (kubectl — через snap), kind и helm нужно ставить с нуля. На сервере уже крутится рабочий стек Gitea — его нельзя случайно задеть. cgroup v2 — `kind` будет работать без дополнительных танцев с конфигурацией. inotify-лимиты низкие (122407/128) — стандартные для Ubuntu, для многонодового кластера может не хватить, стоит поднять (см. шаг с sudo-блоком).
---
### 2. Проверка sudo, занятых портов, памяти и ядра
**Команда:**
```bash
ssh -o BatchMode=yes totserver@192.168.31.163 'sudo -n true 2>/dev/null && echo SUDO_NOPASSWD_OK || echo SUDO_NEEDS_PASSWORD; echo ---; ss -tlnp 2>/dev/null | awk "{print \$4}" | grep -E ":(80|443|8080|8443|3000|9090|6443)$" ; echo ---; free -m | head -2; nproc; uname -r'
```
**Разбор аргументов:**
- `sudo -n true 2>/dev/null && echo ... || echo ...``-n` («non-interactive») запрещает sudo самому спрашивать пароль: если пароль нужен, команда сразу проваливается вместо зависания в ожидании ввода, которого некому дать по неинтерактивному SSH. `true` — команда-пустышка, которая просто успешно завершается; нужна только как повод проверить, сработает ли sudo без пароля. `&&`/`||` — печатают разный текст в зависимости от того, удался ли `sudo -n true`.
- `ss -tlnp``-t` только TCP-сокеты, `-l` только слушающие (LISTEN), `-n` показывать порты числом, а не именами сервисов, `-p` пытаться показать владеющий процесс (без root может быть пусто, но порты всё равно видны).
- `awk '{print $4}'` — берёт 4-е поле по пробелам из вывода `ss` — это колонка «локальный адрес:порт».
- `grep -E ":(80|443|8080|8443|3000|9090|6443)$"` — оставляет только строки, заканчивающиеся на один из этих портов — это как раз порты, важные для курса (HTTP/HTTPS, стандартные альтернативы, порт API-сервера K8s).
- `free -m | head -2` — память в мегабайтах, `head -2` берёт только заголовок и строку `Mem:` (без `Swap:`) для краткости.
- `nproc` — сколько логических процессоров видно текущему пользователю.
- `uname -r` — версия ядра.
**Получен ответ:**
```
SUDO_NEEDS_PASSWORD
---
0.0.0.0:443
0.0.0.0:80
*:3000
*:9090
---
4
6.8.0-124-generic
```
**Значит:** sudo требует пароль → все мои дальнейшие команды должны обходиться без sudo (root-права я предоставить неинтерактивно не могу). Порты 80, 443, 3000, 9090 заняты чем-то другим (Gitea/файл-сервер) — при настройке Ingress в модуле 6 нельзя занимать их напрямую, нужны другие порты (8080/8443 через `extraPortMappings`, см. SERVER.md). 4 ядра — используется дальше при выборе `--threads=4` в бенчмарках.
---
### 3. Блок для пользователя — обновление системы, sysctl, reboot
Эту команду выполнял **ты сам** в своей интерактивной SSH-сессии (пароль sudo знаешь только ты) — но она часть той же последовательности и нужна, чтобы повторить сетап с нуля.
**Команда:**
```bash
sudo apt update && sudo apt upgrade -y
echo -e "fs.inotify.max_user_watches=524288\nfs.inotify.max_user_instances=512" | sudo tee /etc/sysctl.d/99-kind.conf && sudo sysctl --system
sudo reboot
```
**Разбор аргументов:**
- `apt update` — обновляет локальный список доступных версий пакетов из репозиториев (не устанавливает ничего сам по себе).
- `apt upgrade -y` — устанавливает все доступные обновления пакетов; `-y` отвечает «да» на все подтверждения автоматически.
- `echo -e "...\n..."``-e` включает интерпретацию `\n` как перевода строки (без `-e` строка выведется буквально с `\n` внутри) — формирует две строки с нужными sysctl-параметрами.
- `| sudo tee /etc/sysctl.d/99-kind.conf``tee` одновременно пишет полученный со входа текст в файл и печатает его на экран; используется вместо `sudo echo ... > file`, потому что перенаправление `>` само по себе выполняется от имени твоего обычного пользователя (а не sudo) и не имело бы прав писать в `/etc`, а `tee`, запущенный через `sudo`, эти права имеет.
- `sudo sysctl --system` — перечитывает все конфигурационные файлы sysctl (включая только что созданный) и применяет их немедленно, без перезагрузки.
- `sudo reboot` — полная перезагрузка сервера (нужна, чтобы применились обновления ядра/пакетов, помеченные `*** System restart required ***`).
**Получен ответ (после перезагрузки, проверено отдельной командой ниже):**
```
fs.inotify.max_user_watches = 524288
fs.inotify.max_user_instances = 512
```
**Значит:** лимиты применились и сохранятся после будущих перезагрузок (файл в `/etc/sysctl.d/` — постоянный, не временный). Пакеты обновлены, дальнейшая работа идёт на актуальной системе.
---
### 4. Проверка сервера после перезагрузки
**Команда:**
```bash
ssh -o BatchMode=yes -o ConnectTimeout=8 totserver@192.168.31.163 'echo UPTIME:; uptime; echo ---DOCKER---; docker ps --format "{{.Names}}\t{{.Status}}"; echo ---SYSCTL---; sysctl fs.inotify.max_user_watches fs.inotify.max_user_instances; echo ---FREE---; free -h; echo ---APT---; cat /var/run/reboot-required 2>/dev/null || echo "no reboot required"'
```
**Разбор аргументов:**
- `uptime` — печатает, сколько времени сервер работает без перезагрузки, плюс load average; маленькое значение здесь — прямое доказательство, что reboot реально произошёл.
- `sysctl ...` (без `-w`) — снова чтение значений, теперь чтобы убедиться, что лимиты из sysctl.d применились и после ребута.
- `free -h``-h` («human-readable») показывает память в удобных единицах (Gi/Mi) вместо голых байт/килобайт.
- `cat /var/run/reboot-required 2>/dev/null || echo "no reboot required"` — этот файл существует на диске только когда система считает, что нужна ещё одна перезагрузка (обычно после обновления ядра). `cat` на несуществующий файл вернёт ошибку (подавленную через `2>/dev/null`) и ненулевой код — тогда сработает `||` и выведется «no reboot required».
**Получен ответ:**
```
UPTIME:
13:23:58 up 2 min, 2 users, load average: 0.25, 0.22, 0.09
---DOCKER---
gitea_runner Restarting (1) 44 seconds ago
gitea Up About a minute (healthy)
gitea_db Up About a minute (healthy)
---SYSCTL---
fs.inotify.max_user_watches = 524288
fs.inotify.max_user_instances = 512
---APT---
no reboot required
```
**Значит:** сервер поднялся 2 минуты назад — ребут прошёл успешно, повторной перезагрузки система не требует, лимиты применились. `gitea` и `gitea_db` — здоровы. `gitea_runner` в `Restarting` — насторожило, проверено следующей командой.
---
### 5. Диагностика gitea_runner
**Команда:**
```bash
ssh -o BatchMode=yes totserver@192.168.31.163 'docker logs --tail 20 gitea_runner 2>&1'
```
**Разбор аргументов:**
- `docker logs <container>` — показывает stdout/stderr процесса внутри контейнера.
- `--tail 20` — только последние 20 строк вместо всей истории логов с момента старта контейнера (которая может быть огромной при постоянном restart-loop).
**Получен ответ:**
```
level=error msg="fail to invoke Declare" error="unknown: rpc error: code = Unauthenticated desc = unregistered runner"
```
**Значит:** это ошибка регистрации act_runner в Gitea (runner потерял/не имеет токена), не связана с перезагрузкой или настройкой K8s. Отдельная проблема вне периметра курса, зафиксирована в SERVER.md/PROGRESS.md, не трогал.
---
### 6. Подготовка папки для бенчмарков + базовая температура
**Команда:**
```bash
ssh -o BatchMode=yes totserver@192.168.31.163 'mkdir -p ~/bench && cd ~/bench && sensors 2>/dev/null | grep -E "Package|Composite"'
```
**Разбор аргументов:**
- `mkdir -p ~/bench` — создаёт папку для временных файлов бенчмарков; `-p` не выдаёт ошибку, если папка уже есть (и заодно создала бы промежуточные каталоги, если бы путь был вложенным).
- `sensors` — печатает показания всех датчиков, которые видит `lm-sensors`.
- `grep -E "Package|Composite"` — оставляет только строки с температурой пакета CPU (`Package id 0`) и NVMe (`Composite`) — самые показательные точки для проверки перегрева.
**Получен ответ:**
```
Composite: +55.9°C
Package id 0: +49.0°C
```
**Значит:** стартовая температура в норме (крит. отметки — 89.8°C у NVMe, 105°C у CPU), есть с чем сравнить температуру после нагрузки бенчмарками.
---
### 7. Скачивание образа для sysbench (фоновая задача)
**Команда:**
```bash
ssh -o BatchMode=yes totserver@192.168.31.163 'docker pull severalnines/sysbench'
```
**Разбор аргументов:**
- `docker pull <образ>` — без тега по умолчанию тянет тег `latest`; просто скачивает образ в локальный кэш Docker, ничего не запускает. Вынесено отдельной командой (а не как часть `docker run`), чтобы сетевая задержка скачивания не искажала время самого CPU-бенчмарка.
**Получен ответ:**
```
Status: Downloaded newer image for severalnines/sysbench:latest
```
**Значит:** образ в кэше, следующий `docker run` с этим образом стартует мгновенно, без сетевых задержек — можно измерять чистую производительность CPU.
---
### 8. Бенчмарк CPU, 4 потока
**Команда:**
```bash
ssh -o BatchMode=yes totserver@192.168.31.163 'docker run --rm severalnines/sysbench sysbench cpu --threads=4 --time=15 run 2>&1'
```
**Разбор аргументов:**
- `docker run --rm <образ> <команда...>` — запускает контейнер и сразу после завершения процесса удаляет его (`--rm`) — не оставляет мусора от одноразовых тестовых прогонов.
- `sysbench cpu ... run` — подкоманда `cpu` запускает тест производительности CPU (вычисление простых чисел), `run` — команда «выполнить тест» (в отличие от `prepare`/`cleanup`, которые нужны только для тестов БД/файловой системы).
- `--threads=4` — количество параллельных рабочих потоков; выставлено равным числу ядер сервера (см. шаг 2, `nproc` = 4), чтобы измерить производительность при полной загрузке всех ядер.
- `--time=15` — тест длится фиксированные 15 секунд вместо фиксированного числа операций, дальше замеряется, сколько «событий» (вычислений) успело пройти за это время.
**Получен ответ:**
```
CPU speed:
events per second: 11283.72
total time: 15.0003s
```
**Значит:** ~11.3k событий/сек при полной загрузке всех 4 ядер — точка отсчёта для сравнения с однопоточным результатом ниже и ориентир, что CPU не является узким местом для стандартных подов курса.
---
### 9. Бенчмарк CPU (1 поток) и RAM
**Команда:**
```bash
ssh -o BatchMode=yes totserver@192.168.31.163 'docker run --rm severalnines/sysbench sysbench cpu --threads=1 --time=10 run 2>&1 | grep -E "events per second|total time"; echo "---MEMORY---"; docker run --rm severalnines/sysbench sysbench memory --threads=4 --time=10 run 2>&1 | grep -E "MiB/sec|MB/sec|transferred|Total operations"'
```
**Разбор аргументов:**
- `--threads=1` — та же CPU-нагрузка, но только на одном ядре. Важно отдельно от 4-поточного теста: многие ключевые компоненты K8s (например, fsync у etcd, обработка одного запроса kube-apiserver) упираются в производительность одного ядра, а не в суммарную многопоточную мощность.
- `sysbench memory ... run` — тест пропускной способности памяти (сколько данных можно прочитать/записать в RAM в единицу времени); без явного `--memory-oper` использует режим по умолчанию (запись).
- `grep -E "events per second|total time"` / `grep -E "MiB/sec|...|Total operations"` — сжимают многословный вывод sysbench до одной-двух строк с самими цифрами.
**Получен ответ:**
```
events per second: 3301.05
---MEMORY---
Total operations: 101389750 (10137580.18 per second)
99013.43 MiB transferred (9899.98 MiB/sec)
```
**Значит:** однопоточная производительность (3.3k events/sec) заметно ниже многопоточной (11.3k) — ожидаемо для N100 (это энергоэффективный, а не высокочастотный процессор), но всё ещё достаточно для K8s control-plane в pet-масштабе. Память гоняется на ~9.9 ГБ/с — не узкое место.
---
### 10. Бенчмарк диска — неудачная попытка (обучающий момент)
**Команда (не сработала):**
```bash
docker run --rm -v ~/bench:/bench ljishen/fio fio --name=seqwrite --directory=/bench --rw=write --bs=1M --size=1G --direct=1 --numjobs=1 --group_reporting
```
**Разбор аргументов:**
- `-v ~/bench:/bench` — монтирует папку `~/bench` с сервера (на реальном NVMe) внутрь контейнера по пути `/bench`. Принципиально важно: без volume fio писал бы во внутренний слой контейнера, а не на настоящий диск, и результат был бы не про физический NVMe.
- `ljishen/fio fio --name=...` — так я по ошибке продублировал `fio` как первый аргумент.
**Получен ответ:**
```
fio: unable to open 'fio' job file
```
**Значит:** ошибка. Проверил причину отдельной командой ниже — образ `ljishen/fio` уже сам является обёрткой над `fio` (`ENTRYPOINT=["fio"]`), поэтому переданное мной слово `fio` было воспринято не как команда, а как имя job-файла, который fio пытался (и не смог) открыть.
---
### 11. Проверка entrypoint образа
**Команда:**
```bash
ssh -o BatchMode=yes totserver@192.168.31.163 'docker run --rm ljishen/fio --version 2>&1; echo "---"; docker inspect ljishen/fio --format "{{.Config.Entrypoint}} {{.Config.Cmd}}"'
```
**Разбор аргументов:**
- `docker run --rm ljishen/fio --version` — без слова `fio` в начале: если `--version` действительно долетает до самой программы fio, значит entrypoint уже подставляет `fio` сам.
- `docker inspect <образ> --format "{{.Config.Entrypoint}} {{.Config.Cmd}}"``inspect` печатает полные метаданные образа в JSON; `--format` с Go-шаблоном вытаскивает из этого JSON только два конкретных поля — `Entrypoint` (неизменяемая часть команды запуска) и `Cmd` (аргументы по умолчанию, которые можно переопределить).
**Получен ответ:**
```
fio-3.6
---
[fio] []
```
**Значит:** подтверждено — `Entrypoint` уже `["fio"]`, а `Cmd` пустой. Значит все аргументы, которые я передаю после имени образа в `docker run`, должны быть аргументами **для fio напрямую**, без повторения слова `fio`.
---
### 12. Бенчмарк диска — последовательная запись (исправлено)
**Команда:**
```bash
ssh -o BatchMode=yes totserver@192.168.31.163 'docker run --rm -v ~/bench:/bench ljishen/fio --name=seqwrite --directory=/bench --rw=write --bs=1M --size=1G --direct=1 --numjobs=1 --group_reporting 2>&1 | tail -25'
```
**Разбор аргументов:**
- `--name=seqwrite` — произвольная метка этого job'а fio, используется только в заголовках вывода.
- `--directory=/bench` — куда писать тестовый файл (внутрь смонтированного volume — то есть реально на NVMe хоста).
- `--rw=write` — паттерн доступа: чисто последовательная запись (эмулирует, например, запись большого лога/бэкапа целиком).
- `--bs=1M` — размер одного блока ввода-вывода — 1 мегабайт; крупные блоки типичны для последовательных операций и дают максимальную пропускную способность.
- `--size=1G` — сколько всего данных записать за тест.
- `--direct=1` — включает `O_DIRECT`: запись идёт мимо кэша страниц ОС напрямую на диск. Без этого флага тест мог бы измерить скорость RAM-кэша, а не реального диска.
- `--numjobs=1` — один параллельный процесс fio (для последовательного теста больше не нужно — параллельность как раз мешает последовательности).
- `--group_reporting` — объединяет статистику всех `numjobs` в одну сводку вместо вывода по каждому job'у отдельно (при `numjobs=1` эффекта почти нет, но привычка нужна для следующего теста).
**Получен ответ:**
```
write: IOPS=1024, BW=1024MiB/s (1074MB/s)(1024MiB/1000msec)
```
**Значит:** ~1 ГБ/с на последовательной записи — быстрый NVMe, загрузка образов контейнеров и запись больших файлов не будет узким местом.
---
### 13. Бенчмарк диска — случайный доступ 4k (IOPS)
**Команда:**
```bash
ssh -o BatchMode=yes totserver@192.168.31.163 'docker run --rm -v ~/bench:/bench ljishen/fio --name=randrw --directory=/bench --rw=randrw --rwmixread=70 --bs=4k --size=512M --direct=1 --numjobs=4 --iodepth=16 --group_reporting --runtime=15 --time_based 2>&1 | grep -E "read:|write:|IOPS|clat"'
```
**Разбор аргументов (отличия от предыдущего теста):**
- `--rw=randrw --rwmixread=70` — случайный доступ, смешанный на чтение/запись, где 70% операций — чтение, 30% — запись; это ближе к реальной нагрузке БД/etcd, чем чистая последовательная запись.
- `--bs=4k` — маленький блок 4 КБ — именно мелкие случайные операции сильнее всего нагружают диск и определяют IOPS (операций в секунду), а не мегабайты в секунду. Это ключевая метрика для etcd, который делает частые маленькие fsync.
- `--size=512M` — меньше, чем в первом тесте, потому что при `--time_based` (см. ниже) размер — это просто верхняя граница, реальная длительность определяется временем.
- `--numjobs=4` — 4 параллельных процесса fio, по числу ядер CPU — эмулирует одновременную нагрузку от нескольких подов/процессов.
- `--iodepth=16` — сколько запросов ввода-вывода держится «в полёте» одновременно на каждый job, до получения ответа от диска — имитирует не единичный запрос-ответ, а очередь, как под реальной многопользовательской нагрузкой.
- `--runtime=15 --time_based` — тест идёт ровно 15 секунд (`--time_based` заставляет fio перезапускать проход по данным заново, если `--size` кончился раньше времени), а не «пока не запишется 512 МБ» — нужно для честного измерения именно IOPS/сек, а не общего времени на фиксированный объём.
**Получен ответ:**
```
read: IOPS=12.6k, BW=49.3MiB/s
write: IOPS=5435, BW=21.2MiB/s
clat avg=224.59us (read) / 208.86us (write)
```
**Значит:** ~12.6k IOPS на чтение и ~5.4k на запись при латентности <0.25мс с большим запасом достаточно для etcd (которому обычно достаточно однозначных десятков IOPS с задержкой fsync <10мс) и для любых баз данных в подах курса.
---
### 14. Уборка временных файлов бенчмарка
**Команда:**
```bash
ssh -o BatchMode=yes totserver@192.168.31.163 'rm -rf ~/bench/*.0.0 ~/bench/seqwrite* ~/bench/randrw*; ls -la ~/bench/'
```
**Разбор аргументов:**
- `rm -rf <шаблоны>` удаляет файлы, оставшиеся от fio (тестовые файлы данных и *.0.0 вспомогательные файлы состояния fio); `-r` рекурсивно (на случай, если что-то создалось как директория), `-f` без запроса подтверждения и без ошибки, если файла с таким именем нет.
- `ls -la ~/bench/` проверка, что папка действительно пуста после уборки; `-l` подробный формат (размеры, права), `-a` показывает и скрытые файлы (начинающиеся с точки), если fio такие оставил.
**Получен ответ:**
```
total 8
(только . и .. — папка пустая)
```
**Значит:** временные файлы бенчмарков убраны, диск не засорён тестовыми артефактами.
---
### 15. Установка kind без sudo
**Команда:**
```bash
ssh -o BatchMode=yes totserver@192.168.31.163 '
set -e
mkdir -p ~/.local/bin
ARCH=$(dpkg --print-architecture)
KIND_VER=$(curl -s https://api.github.com/repos/kubernetes-sigs/kind/releases/latest | grep -oP "\"tag_name\": \"\K[^\"]+")
curl -Lo ~/.local/bin/kind "https://kind.sigs.k8s.io/dl/${KIND_VER}/kind-linux-${ARCH}"
chmod +x ~/.local/bin/kind
~/.local/bin/kind --version
'
```
**Разбор аргументов:**
- `set -e` заставляет весь скрипт немедленно остановиться при первой же команде, завершившейся с ошибкой, вместо того чтобы продолжать выполнение с уже сломанным состоянием (например, пытаться `chmod +x` файл, который не скачался).
- `mkdir -p ~/.local/bin` целевая папка для бинарников без прав root; на Ubuntu она по умолчанию входит в `PATH` для интерактивных сессий (см. пункт 18).
- `dpkg --print-architecture` узнаёт архитектуру процессора этой системы (`amd64`), чтобы не хардкодить её в URL скрипт остаётся рабочим и на ARM-сервере.
- `curl -s https://api.github.com/.../releases/latest` `-s` (silent) запрашивает у GitHub API JSON с данными о последнем релизе kind без индикатора прогресса, засоряющего вывод.
- `grep -oP '"tag_name": "\K[^"]+'` `-o` печатает только совпавший фрагмент текста (не всю строку); `-P` включает Perl-регулярки, нужные для `\K` этот спецсимвол «сбрасывает» начало совпадения, то есть в захват попадает только то, что идёт **после** `tag_name": "`, так извлекается чистый номер версии (`v0.32.0`) без лишних кавычек и ключа.
- `curl -Lo ~/.local/bin/kind "URL"` `-L` заставляет curl следовать HTTP-редиректам (GitHub Releases часто отдаёт 302 на CDN); `-o <путь>` сохраняет скачанное в файл вместо печати в терминал.
- `chmod +x` добавляет право на исполнение без этого бинарник нельзя будет запустить напрямую.
**Получен ответ:**
```
kind version 0.32.0
```
**Значит:** kind установлен и работает, версия 0.32.0 (актуальная на момент установки). Это прямая замена шага `choco install kind` из Модуля 0 курса только без прав администратора и без Windows-пакетного менеджера.
---
### 16. Установка helm без sudo
**Команда:**
```bash
ssh -o BatchMode=yes totserver@192.168.31.163 '
cd /tmp
curl -fsSL -o get_helm.sh https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3
chmod +x get_helm.sh
HELM_INSTALL_DIR=$HOME/.local/bin USE_SUDO=false ./get_helm.sh
rm -f get_helm.sh
~/.local/bin/helm version
'
```
**Разбор аргументов:**
- `curl -fsSL -o get_helm.sh <url>` `-f` fail») заставляет curl вернуть ошибку и не сохранять файл, если сервер ответил HTTP-ошибкой (например, 404) без этого флага в файл сохранилась бы страница с текстом ошибки, и скрипт попытался бы её выполнить как bash; `-s` тихий режим (без прогресс-бара); `-S` но при этом всё равно показывать текст ошибки, если она возникла (иначе `-s` подавил бы и её); `-L` следовать редиректам.
- `HELM_INSTALL_DIR=$HOME/.local/bin USE_SUDO=false ./get_helm.sh` это официальный установочный скрипт Helm, который сам умеет определять ОС/архитектуру, скачивать нужный архив и распаковывать бинарник. Две переменные окружения меняют его поведение: `HELM_INSTALL_DIR` куда класть бинарник (по умолчанию `/usr/local/bin`, для чего нужен root); `USE_SUDO=false` явно запрещает скрипту самому подставлять `sudo` перед своими внутренними командами создания папок/копирования файлов.
**Получен ответ:**
```
Downloading https://get.helm.sh/helm-v3.21.3-linux-amd64.tar.gz
Verifying checksum... Done.
helm installed into /home/totserver/.local/bin/helm
helm not found. Is /home/totserver/.local/bin on your $PATH?
Failed to install helm
```
(команда завершилась с exit code 1, несмотря на текст "helm installed into...")
**Значит:** это ложная тревога, не реальная ошибка установки. Сам скрипт в конце **тоже** пытается вызвать `helm` напрямую (полагаясь на `PATH`) для самопроверки но моя SSH-команда выполняется как неинтерактивная non-login shell, в которой `~/.local/bin` ещё не добавлен в `PATH` (эта строчка подключается только в `~/.profile`, который читается лишь при **login shell** см. пункт 18). Бинарник при этом реально записан на диск корректно проверено следующим шагом прямым обращением по полному пути.
---
### 17. Проверка, что helm реально установился
**Команда:**
```bash
ssh -o BatchMode=yes totserver@192.168.31.163 'ls -la ~/.local/bin/; ~/.local/bin/helm version; echo "---PATH---"; echo $PATH'
```
**Разбор аргументов:**
- `~/.local/bin/helm version` вызов бинарника по **полному пути**, минуя `PATH` целиком если он и так работает, ошибка из шага 16 точно была только про видимость в `PATH`, а не про сам бинарник.
- `echo $PATH` печатает список директорий, в которых шелл ищет команды, чтобы наглядно убедиться в отсутствии `~/.local/bin`.
**Получен ответ:**
```
-rwxr-xr-x 1 totserver totserver 58654882 ... helm
-rwxrwxr-x 1 totserver totserver 10522750 ... kind
version.BuildInfo{Version:"v3.21.3", ...}
---PATH---
/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin
```
**Значит:** оба бинарника на месте и рабочие; в `PATH` этой non-login-сессии `~/.local/bin` действительно нет подтверждена гипотеза из пункта 16, дальше нужно проверить именно login shell.
---
### 18. Проверка PATH в login shell (как в реальной SSH-сессии)
**Команда:**
```bash
ssh -o BatchMode=yes totserver@192.168.31.163 'bash -lc "which kind helm kubectl docker; kind --version; helm version --short; kubectl version --client -o json | grep gitVersion; docker --version"'
```
**Разбор аргументов:**
- `bash -lc "..."` `-l` login») запускает bash так, будто ты только что залогинился интерактивно: он читает `/etc/profile` и `~/.profile`, где как раз и лежит стандартный для Ubuntu кусок, добавляющий `~/.local/bin` в `PATH`, если папка существует; `-c "..."` выполняет переданную строку как единственную команду вместо запуска интерактивного приглашения. Обычный `ssh host 'команда'` (как во всех предыдущих шагах) login shell не создаёт поэтому там `.profile` не читался.
- `which kind helm kubectl docker` для каждого имени печатает полный путь к найденному в `PATH` исполняемому файлу (или ничего, если не найден) быстрый способ проверить видимость сразу всех инструментов.
- `helm version --short` `--short` печатает только номер версии одной строкой вместо полной структуры `BuildInfo{...}`.
- `kubectl version --client -o json | grep gitVersion` `-o json` выводит версию в формате JSON вместо человекочитаемого текста, `grep gitVersion` вытаскивает из этого JSON только строку с номером версии.
**Получен ответ:**
```
/home/totserver/.local/bin/kind
/home/totserver/.local/bin/helm
/snap/bin/kubectl
/usr/bin/docker
kind version 0.32.0
v3.21.3+g1ad6e68
"gitVersion": "v1.35.6",
Docker version 29.6.2, build dfc4efb
```
**Значит:** в login shell (то есть в твоей обычной интерактивной SSH-сессии) все четыре инструмента находятся сами, без ручных полных путей установка полностью рабочая, дополнительных действий с `PATH` не требуется. (Заодно видно, что Docker подтянулся до 29.6.2 during `apt upgrade` в шаге 3.)
---
### 19. Создание кластера kind (замер времени)
**Команда:**
```bash
ssh -o BatchMode=yes totserver@192.168.31.163 'bash -lc "time kind create cluster --name lab"'
```
**Разбор аргументов:**
- `time <команда>` встроенная в bash обёртка: выполняет команду как обычно, а после завершения печатает, сколько заняло `real` (реальное время по часам), `user` и `sys` (CPU-время самого процесса и ядра). Используется только для замера, на результат самой команды не влияет.
- `kind create cluster --name lab` `--name lab` задаёт имя кластера оно используется как префикс для Docker-контейнеров нод (`lab-control-plane`) и как способ отличить этот кластер от других, если их будет несколько. Имя `lab` то же самое, что использует курс во всех модулях, что важно для совместимости всех последующих команд курса без изменений.
**Получен ответ:**
```
✓ Ensuring node image
✓ Preparing nodes
✓ Starting control-plane
✓ Installing CNI
✓ Installing StorageClass
real 0m52.836s
```
**Значит:** кластер с нуля поднимается за ~53 секунды практически всё это время уходит на скачивание образа ноды `kindest/node` при первом запуске (при повторных `kind create cluster` будет заметно быстрее, образ уже в кэше).
---
### 20. Проверка нод и системных подов
**Команда:**
```bash
ssh -o BatchMode=yes totserver@192.168.31.163 'bash -lc "kubectl get nodes -o wide; echo ---; kubectl get pods -n kube-system"'
```
**Разбор аргументов:**
- `kubectl get nodes -o wide` `-o wide` добавляет к стандартному выводу (имя, статус) дополнительные колонки: внутренний IP, версия ОС, версия ядра, container runtime.
- `kubectl get pods -n kube-system` `-n kube-system` указывает конкретный namespace для запроса вместо намерения по умолчанию (`default`) именно в `kube-system` живут поды control-plane-компонентов (это ровно то, о чём Модуль 2 курса).
**Получен ответ (сразу после создания, ещё стартует):**
```
lab-control-plane NotReady ...
coredns-... 0/1 Pending
kube-apiserver-lab-control-plane 0/1 Running
```
**Значит:** сразу после `kind create cluster` нода и часть подов ещё не готовы (control-plane только инициализируется) это ожидаемо, не ошибка. Нужно дождаться готовности отдельной командой (следующий шаг).
---
### 21. Замер времени до полной готовности
**Команда:**
```bash
ssh -o BatchMode=yes totserver@192.168.31.163 'bash -lc "time kubectl wait --for=condition=Ready nodes --all --timeout=120s; time kubectl wait --for=condition=Ready pods --all -n kube-system --timeout=120s"'
```
**Разбор аргументов:**
- `kubectl wait --for=condition=Ready <объекты> --timeout=Ns` вместо разового снимка состояния (`get`) эта команда **блокируется** и ждёт, пока указанное условие не станет истинным для всех выбранных объектов, либо не истечёт таймаут.
- `--for=condition=Ready` конкретное условие ожидания: статус `Ready=True` в списке conditionов объекта.
- `nodes --all` в первом вызове применить ко всем объектам типа Node (а не к одной по имени).
- `pods --all -n kube-system` во втором ко всем подам именно в этом namespace.
- `--timeout=120s` если условие не выполнится за 2 минуты, команда вернёт ошибку вместо вечного ожидания.
**Получен ответ:**
```
node/lab-control-plane condition met
real 0m4.100s
pod/coredns-... condition met
(и так все поды)
real 0m2.389s
```
**Значит:** после создания кластера потребовалось ещё ~6.5 секунды (4.1 + 2.4), чтобы нода и все системные поды перешли в `Ready` итоговое время от нуля до полностью рабочего кластера 53 + 6.5 59-60 секунд.
---
### 22. Смоук-тест self-healing (практика Модуля 1)
**Команда:**
```bash
ssh -o BatchMode=yes totserver@192.168.31.163 'bash -lc "
kubectl create deployment demo --image=nginxdemos/hello
kubectl wait --for=condition=Ready pod -l app=demo --timeout=60s
kubectl get pods -o wide
POD=\$(kubectl get pods -l app=demo -o jsonpath=\"{.items[0].metadata.name}\")
echo \"Killing pod: \$POD\"
kubectl delete pod \$POD
sleep 2
kubectl get pods -o wide
kubectl delete deployment demo
"'
```
**Разбор аргументов:**
- `kubectl create deployment demo --image=nginxdemos/hello` создаёт Deployment по имени `demo`; `--image=` задаёт образ, который будет запущен в подах тот же образ, что использует курс в Модуле 1.
- `kubectl wait --for=condition=Ready pod -l app=demo --timeout=60s` `-l app=demo` это **label selector**: отбирает поды с меткой `app=demo`, которую `kubectl create deployment` проставляет автоматически (по умолчанию равна имени деплоймента) так не нужно заранее знать случайно сгенерированное имя пода.
- `POD=$(kubectl get pods -l app=demo -o jsonpath="{.items[0].metadata.name}")` `-o jsonpath="..."` вытаскивает из JSON-ответа API конкретное поле по JSONPath-выражению: `.items[0]` первый объект в списке подов, `.metadata.name` его имя. Результат сохраняется в shell-переменную `POD`, чтобы не копировать имя пода вручную в следующую команду.
- `kubectl delete pod $POD` удаляет конкретный под по имени это и есть намеренная «поломка», которую предлагает сделать Модуль 1, чтобы увидеть self-healing.
- `sleep 2` пауза в 2 секунды, чтобы дать контроллеру ReplicaSet время заметить пропажу пода и создать замену, прежде чем смотреть результат.
- `kubectl delete deployment demo` уборка за собой в конце (курс явно требует убрать Deployment после демонстрации, чтобы дальше начинать модули с чистого состояния).
**Получен ответ:**
```
pod/demo-6484fc4fb6-m5fmv condition met
Killing pod: demo-6484fc4fb6-m5fmv
pod "demo-6484fc4fb6-m5fmv" deleted
demo-6484fc4fb6-vtkrd 1/1 Running 0 4s
```
**Значит:** после удаления пода `demo-6484fc4fb6-m5fmv` контроллер ReplicaSet тут же создал новый под `demo-6484fc4fb6-vtkrd` другое имя (случайный суффикс), тот же Deployment. Это и есть self-healing: желаемое состояние 1 реплика демо-приложения») поддерживается автоматически, без вмешательства человека.
---
### 23. Финальная проверка — ресурсы и здоровье Gitea
**Команда:**
```bash
ssh -o BatchMode=yes totserver@192.168.31.163 'echo ---DOCKER-STATS---; docker stats --no-stream --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}"; echo ---FREE---; free -h; echo ---GITEA---; docker ps --format "{{.Names}}\t{{.Status}}" | grep -E "gitea|file-server"; echo ---TEMP---; sensors 2>/dev/null | grep -E "Package|Composite"; echo ---CURL-GITEA---; curl -s -o /dev/null -w "gitea http: %{http_code}\n" http://localhost:3000'
```
**Разбор аргументов:**
- `docker stats --no-stream --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}"` `docker stats` по умолчанию это живой, постоянно обновляющийся вывод (как `top`), что зависло бы неинтерактивную SSH-команду навсегда; `--no-stream` берёт один снимок текущих значений и сразу завершается. `--format "table ..."` задаёт три нужные колонки (имя контейнера, % CPU, использование памяти) вместо полного набора столбцов Docker по умолчанию.
- `docker ps ... | grep -E "gitea|file-server"` сужает список контейнеров до тех, что относятся к прод-стеку, который нельзя было задевать.
- `curl -s -o /dev/null -w "gitea http: %{http_code}\n" http://localhost:3000` `-s` без индикатора прогресса; `-o /dev/null` выбрасывает тело ответа (страницу логина Gitea) интересен не HTML, а сам факт и код ответа; `-w "...%{http_code}..."` печатает после запроса кастомную строку, подставляя туда код HTTP-ответа минимальная проверка «жив ли сервис», без вывода целой страницы.
**Получен ответ:**
```
lab-control-plane 20.83% 516.1MiB / 15.4GiB
gitea 0.05% 99.2MiB / 15.4GiB
gitea Up 8 minutes (healthy)
gitea_db Up 9 minutes (healthy)
Composite: +59.9°C
Package id 0: +50.0°C
gitea http: 302
```
**Значит:** kind-кластер в простое ест ~516 МБ RAM и часть одного ядра на сервере с 16 ГБ это несущественно. Gitea и Postgres по-прежнему `healthy`, отвечают на запросы (302 это редирект на страницу логина, ожидаемый и корректный ответ живого сервиса, а не ошибка). Температура поднялась всего на ~5-10°C от базовой до критических отметок (89.8°C/105°C) очень далеко, троттлинга нет. Вывод: кластер можно держать поднятым параллельно с рабочим стеком без риска для него.
---
## Соглашение на будущее
Начиная с этой сессии, каждая команда, которую я выполняю в рамках курса (не только сегодня), добавляется в этот файл новой датированной секцией в том же формате: задача команда разбор аргументов полученный ответ что он значит. `PROGRESS.md` при этом остаётся коротким журналом-указателем со ссылкой на соответствующий раздел здесь.