# Журнал команд: что запускалось и зачем Построчный разбор каждой команды, которую я выполнял по SSH при наладке курса на сервере — с разбором каждого аргумента и объяснением, что значит полученный ответ. Цель файла — чтобы ты мог повторить любой шаг сам, точно понимая, что делает каждый флаг, а не копируя команду вслепую. Формат каждой записи: - **Задача** — зачем вообще эта команда. - **Команда** — целиком, можно копировать. - **Разбор аргументов** — что делает каждая часть. - **Получен ответ** — реальный фрагмент вывода из этой сессии. - **Значит** — какой вывод/решение делаем на основе этого ответа. Общее для всех команд ниже, кроме отмеченных отдельно: они выполняются в моей SSH-сессии как `ssh -o BatchMode=yes [-o ConnectTimeout=N] totserver@192.168.31.163 'команда'`. Разбор этой обвязки — в самом первом пункте, дальше не повторяю. --- ## 18-07-2026 ### 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 ` — показывает 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 ` — `-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) очень далеко, троттлинга нет. Вывод: кластер можно держать поднятым параллельно с рабочим стеком без риска для него. --- ### 24. Модуль 2 — проверка CI-стека + `kind-config.yaml` новой топологии Начиная с этой записи, команды выполнял **ты сам** напрямую в интерактивной SSH-сессии на сервере (login shell, `~/.local/bin` уже в `PATH`), а мне присылал готовый лог — поэтому команды ниже идут без SSH-обвязки из пункта 0. **Команда:** ```bash docker ps --filter name=gitea --format '{{.Names}}\t{{.Status}}' cat > ~/k8s/kind-config.yaml << 'EOF' kind: Cluster apiVersion: kind.x-k8s.io/v1alpha4 nodes: - role: control-plane extraPortMappings: - containerPort: 80 hostPort: 8080 - containerPort: 443 hostPort: 8443 - role: worker - role: worker EOF cat ~/k8s/kind-config.yaml ``` **Разбор аргументов:** - `docker ps --filter name=gitea --format '{{.Names}}\t{{.Status}}'` — сужает список контейнеров до тех, чьё имя содержит `gitea`, и печатает только имя и статус — быстрая проверка перед началом работы, что боевой CI-стек жив (чек-лист из SERVER.md). - `cat > файл << 'EOF' ... EOF` — heredoc: всё, что между `<< 'EOF'` и завершающим `EOF`, построчно записывается в файл через `cat`; кавычки вокруг `'EOF'` отключают подстановку переменных/спецсимволов внутри блока (не нужна здесь, но привычка на будущее для блоков с `$`). - `role: control-plane` / `role: worker` — три записи в списке `nodes` = kind создаст три Docker-контейнера, один control-plane и два worker (топология модуля 2 из LEARNING.md). - `extraPortMappings` внутри control-plane-ноды — пробрасывает порты хоста на порты внутри этого контейнера-ноды: `hostPort: 8080` → `containerPort: 80` и `hostPort: 8443` → `containerPort: 443`. Сделано сразу в модуле 2 (адаптация из SERVER.md), а не только в модуле 6 (когда понадобится Ingress) — иначе пришлось бы пересоздавать кластер повторно. Именно 8080/8443, а не 80/443, потому что порты 80/443 хоста уже заняты Gitea/nginx (см. `ss -tulpn` в сессии — 80/443/3000/3030/222/4400/9090 заняты, 8080/8443 свободны). **Получен ответ:** ``` gitea_runner Up 7 hours gitea Up 7 hours (healthy) gitea_db Up 7 hours (healthy) gitea_runner_armhf Up 7 hours kind: Cluster apiVersion: kind.x-k8s.io/v1alpha4 nodes: - role: control-plane extraPortMappings: - containerPort: 80 hostPort: 8080 - containerPort: 443 hostPort: 8443 - role: worker - role: worker ``` **Значит:** CI-стек в порядке, можно продолжать. Файл `kind-config.yaml` записан корректно — готов к использованию в `kind create cluster --config`. --- ### 25. Модуль 2 — пересоздание кластера с топологией control-plane + 2 worker **Команда:** ```bash kind delete cluster --name lab kind create cluster --name lab --config kind-config.yaml kubectl cluster-info --context kind-lab ``` **Разбор аргументов:** - `kind delete cluster --name lab` — удаляет старый однонодовый кластер (созданный в модуле 0-1 без конфига) вместе с его Docker-контейнером, чтобы освободить имя `lab` под новую топологию. - `kind create cluster --name lab --config kind-config.yaml` — `--config` вместо голого `--name` заставляет kind читать топологию и extraPortMappings из файла, а не создавать кластер дефолтной однонодовой конфигурацией. - `kubectl cluster-info --context kind-lab` — печатает адрес API-сервера и CoreDNS для явно указанного контекста `kind-lab` (kind сам создаёт и переключает kubectl-контекст с таким именем при каждом `create cluster`). **Получен ответ:** ``` Deleting cluster "lab" ... Deleted nodes: ["lab-control-plane"] Creating cluster "lab" ... ✓ Ensuring node image (kindest/node:v1.36.1) 🖼 ✓ Preparing nodes 📦 📦 📦 ✓ Writing configuration 📜 ✓ Starting control-plane 🕹️ ✓ Installing CNI 🔌 ✓ Installing StorageClass 💾 ✓ Joining worker nodes 🚜 Set kubectl context to "kind-lab" Kubernetes control plane is running at https://127.0.0.1:36761 CoreDNS is running at https://127.0.0.1:36761/api/v1/namespaces/kube-system/services/kube-dns:dns/proxy ``` **Значит:** пересоздание прошло без ошибок и быстро (образ ноды `kindest/node:v1.36.1` уже был в кэше Docker с прошлого раза — не пришлось скачивать заново). Обрати внимание на новый шаг в выводе, которого не было при однонодовом создании: `✓ Joining worker nodes` — именно он добавляет `lab-worker`/`lab-worker2` к кластеру после того, как control-plane уже поднят. kubectl-контекст переключён на `kind-lab` автоматически. --- ### 26. Модуль 2 — проверка нод **Команда:** ```bash kubectl get nodes -o wide ``` **Получен ответ:** ``` NAME STATUS ROLES AGE VERSION INTERNAL-IP EXTERNAL-IP OS-IMAGE KERNEL-VERSION CONTAINER-RUNTIME lab-control-plane Ready control-plane 57s v1.36.1 172.22.0.4 Debian GNU/Linux 13 (trixie) 6.8.0-136-generic (amd64) containerd://2.3.1 lab-worker Ready 46s v1.36.1 172.22.0.2 Debian GNU/Linux 13 (trixie) 6.8.0-136-generic (amd64) containerd://2.3.1 lab-worker2 Ready 47s v1.36.1 172.22.0.3 Debian GNU/Linux 13 (trixie) 6.8.0-136-generic (amd64) containerd://2.3.1 ``` **Значит:** все 3 ноды поднялись и перешли в `Ready` за 46-57 секунд — быстрее, чем однонодовый кластер с нуля (модуль 0, ~53с только на control-plane), потому что образ ноды уже был в кэше. Колонка `ROLES` подтверждает топологию: одна `control-plane`, две `` (worker — kind не проставляет им явную роль-метку по умолчанию, в отличие от control-plane). `KERNEL-VERSION` (6.8.0-136) новее, чем было зафиксировано в SERVER.md на момент установки (6.8.0-124) — сервер обновлялся ядром между сессиями, это не связано с kind и не требует действий. --- ### 27. Модуль 2 — компоненты control-plane и DaemonSet'ы вживую **Команда:** ```bash kubectl get pods -n kube-system -o wide ``` **Получен ответ:** ``` NAME READY STATUS RESTARTS AGE NODE coredns-589f44dc88-s8m6z 1/1 Running 0 86s lab-control-plane coredns-589f44dc88-xlkk7 1/1 Running 0 86s lab-control-plane etcd-lab-control-plane 1/1 Running 0 93s lab-control-plane kindnet-62npg 1/1 Running 0 86s lab-control-plane kindnet-9lsrg 1/1 Running 0 86s lab-worker2 kindnet-xwjqx 1/1 Running 0 85s lab-worker kube-apiserver-lab-control-plane 1/1 Running 0 93s lab-control-plane kube-controller-manager-lab-control-plane 1/1 Running 0 93s lab-control-plane kube-proxy-6ltl9 1/1 Running 0 85s lab-worker kube-proxy-9z5x9 1/1 Running 0 86s lab-control-plane kube-proxy-h7xtp 1/1 Running 0 86s lab-worker2 kube-scheduler-lab-control-plane 1/1 Running 0 93s lab-control-plane ``` **Значит:** ровно та картина, которую описывает теория модуля 2. Четыре компонента control-plane (`kube-apiserver`, `etcd`, `kube-controller-manager`, `kube-scheduler`) — все с суффиксом `-lab-control-plane` в имени и все в колонке `NODE` только на `lab-control-plane`, других экземпляров нет. Два DaemonSet'а — `kindnet-*` (CNI, сетевая связность между подами на разных нодах) и `kube-proxy-*` (сетевые правила для Service, разбирается в модуле 5) — у каждого ровно по одному поду на каждую из 3 нод (control-plane и оба worker), что и есть определение DaemonSet: «один под на каждую подходящую ноду». `coredns-*` — не DaemonSet, а обычный Deployment на 2 реплики; обе оказались на `lab-control-plane`, потому что worker-ноды пока полностью пустые, и scheduler разместил их там, где выгоднее (это никак не привязано к ролям нод — просто текущее решение scheduler'а по ресурсам). Устная самопроверка модуля 2 (3 вопроса из LEARNING.md — компоненты control-plane, компоненты worker-ноды, почему etcd в проде выносят отдельно с нечётным числом нод) пройдена развёрнуто и верно. Дополнительно самостоятельно разобрана разница iptables/nftables применительно к двум режимам работы `kube-proxy` — не входит в буквальную программу модуля 2, но напрямую относится к роли `kube-proxy` и пригодится в модуле 5 (Service и сеть). --- ### 28. Модуль 3 — `pod.yaml` (Namespace + Pod) и создание **Команда:** ```bash cat > ~/k8s/pod.yaml << 'EOF' apiVersion: v1 kind: Namespace metadata: name: demo --- apiVersion: v1 kind: Pod metadata: name: demo-pod namespace: demo labels: app: demo spec: containers: - name: hello image: nginxdemos/hello:latest ports: - containerPort: 80 EOF kubectl apply -f ~/k8s/pod.yaml ``` **Разбор аргументов:** - Один файл, два объекта через разделитель `---` — YAML позволяет описать несколько документов в одном файле, `kubectl apply -f` применяет их по порядку сверху вниз. Namespace идёт первым не случайно: Pod ссылается на `namespace: demo` во втором документе, а этот namespace должен уже существовать (или быть создан в той же команде apply) к моменту создания Pod. - Четыре ключа верхнего уровня из теории модуля 3: `apiVersion` (версия API — `v1` для обоих базовых типов), `kind` (тип объекта), `metadata` (имя/namespace/labels — «паспорт»), `spec` (только у Pod — желаемое состояние: какой контейнер запускать). - `labels: app: demo` — метка на Pod, пока не используется явно, но именно так Service/Deployment в следующих модулях будут находить нужные поды через selector. **Получен ответ:** ``` namespace/demo created pod/demo-pod created ``` **Значит:** оба объекта созданы с первого раза, ошибок в манифесте нет. --- ### 29. Модуль 3 — базовый набор команд: get, describe, logs, exec **Команда:** ```bash kubectl -n demo get pods kubectl -n demo get pods -o wide kubectl -n demo describe pod demo-pod kubectl -n demo logs demo-pod kubectl -n demo exec -it demo-pod -- sh ``` **Разбор аргументов:** - `-n demo` — на каждой команде явно указывает namespace (без него kubectl смотрит в `default`, где ничего нет). - `get pods -o wide` — `-o wide` добавляет колонки `IP` и `NODE` к базовому выводу. - `describe pod` — в отличие от `get`, разворачивает объект полностью: условия (`Conditions`), примонтированные volume, и главное — секцию `Events` (хронологический лог того, что control-plane и kubelet делали с этим подом: `Scheduled` → `Pulling`/`Pulled` → `Created` → `Started`). - `logs demo-pod` — без указания контейнера (работает, потому что в поде он один); печатает stdout/stderr процесса внутри контейнера. - `exec -it ... -- sh` — `-i` (interactive) + `-t` (tty) дают полноценный интерактивный терминал внутри контейнера; `sh`, а не `bash`, потому что образ `nginxdemos/hello` собран на Alpine (slim-образ без bash). **Получен ответ:** ``` NAME READY STATUS RESTARTS AGE demo-pod 1/1 Running 0 25s NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES demo-pod 1/1 Running 0 31s 10.244.2.2 lab-worker Node: lab-worker/172.22.0.2 Status: Running ... Events: Type Reason Age From Message ---- ------ ---- ---- ------- Normal Scheduled 89s default-scheduler Successfully assigned demo/demo-pod to lab-worker Normal Pulling 88s kubelet Pulling image "nginxdemos/hello:latest" Normal Pulled 82s kubelet Successfully pulled image ... in 6.311s Normal Created 82s kubelet Container created Normal Started 82s kubelet Container started 2026/07/18 19:54:20 [notice] 1#1: nginx/1.29.1 2026/07/18 19:54:20 [notice] 1#1: start worker process 27..30 / # exit ``` **Значит:** под запланирован планировщиком на `lab-worker` (control-plane исключён taint'ом), образ скачался за 6.3с, контейнер стартовал и поднял 4 worker-процесса nginx. `logs` показал именно вывод приложения (nginx), `describe`/`Events` — действия самого Kubernetes вокруг пода как объекта; это и есть разница между ними. `exec` подтвердил, что контейнер полноценно интерактивен. --- ### 30. Модуль 3 — полный манифест объекта (`-o yaml`) **Команда:** ```bash kubectl -n demo get pod demo-pod -o yaml ``` **Разбор аргументов:** - `-o yaml` — просит API-сервер отдать объект целиком в том виде, в каком он реально хранится в etcd, а не только колонки таблицы `get pods`. **Получен ответ (сокращённо, полный лог — в сессии):** ``` metadata: annotations: kubectl.kubernetes.io/last-applied-configuration: | {...исходный JSON манифеста...} resourceVersion: "2565" uid: ea8061a9-eca7-4cc0-91d0-86b6e87af510 spec: containers: - imagePullPolicy: Always ... nodeName: lab-worker restartPolicy: Always schedulerName: default-scheduler serviceAccountName: default terminationGracePeriodSeconds: 30 tolerations: [...] volumes: - name: kube-api-access-p5g68 projected: {...} status: conditions: [...] containerStatuses: [...] phase: Running podIP: 10.244.2.2 hostIP: 172.22.0.2 ``` **Значит:** написанные 12 строк манифеста развернулись примерно в 120. Сверх исходного `pod.yaml` появилось: служебные поля `metadata` (`uid`, `resourceVersion`, аннотация с сохранённым исходным манифестом — на ней основан three-way merge при следующих `apply`); дефолты в `spec`, которые Kubernetes подставил сам (`imagePullPolicy`, `restartPolicy`, `schedulerName`, `serviceAccountName`, `tolerations`, автомонтированный volume с токеном сервис-аккаунта, `nodeName` — результат работы планировщика); и целиком раздел `status`, которого в манифесте нет вообще — это runtime-состояние, которое пишет kubelet, наблюдая за реальным контейнером. Итог: `spec` — желаемое состояние (то, что попросили, плюс дефолты), `status` — фактическое состояние (что control loop реально наблюдает) — основа declarative-модели Kubernetes. --- ### 31. Модуль 3 — удаление голого Pod (без self-healing) **Команда:** ```bash kubectl -n demo delete pod demo-pod kubectl -n demo get pods ``` **Разбор аргументов:** - В отличие от модуля 1 (Deployment), здесь Pod создан напрямую, без контроллера сверху. **Получен ответ:** ``` pod "demo-pod" deleted from demo namespace No resources found in demo namespace. ``` **Значит:** под удалился и не пересоздался — namespace `demo` пуст. Это и есть отличие голого Pod от Pod'а под управлением Deployment/ReplicaSet: `restartPolicy: Always` перезапускает контейнер *внутри* пода при его падении, но если исчезает сам Pod (удалён вручную или нода, на которой он жил, выходит из строя), пересоздать его некому — за это отвечает контроллер (ReplicaSet), которого здесь нет. Отсюда правило курса: голые Pod'ы в проде почти не используют, кроме короткоживущих задач. Устная самопроверка модуля 3 (роли ключей манифеста, `logs` vs `describe`, что добавляет `-o yaml`, плюс доп. вопрос про поведение при падении ноды) пройдена развёрнуто и верно. --- ## Соглашение на будущее Начиная с этой сессии, каждая команда, которую я выполняю в рамках курса (не только сегодня), добавляется в этот файл новой датированной секцией в том же формате: задача → команда → разбор аргументов → полученный ответ → что он значит. `PROGRESS.md` при этом остаётся коротким журналом-указателем — со ссылкой на соответствующий раздел здесь.