Деплой на k0s через shell-скрипт вместо CI/CD

Published: 2026-06-05

Этот сайт работает на single-node кластере k0s. Здесь нет GitHub Actions, нет ArgoCD, нет FluxCD. Пайплайн деплоя — один shell-скрипт k0s-setup/06-deploy-site.sh, который запускается с ноутбука. Вот почему это нормально и как это работает.


Кластер

k0s — дистрибутив Kubernetes из одного бинарника. Весь control plane и воркер работают на одном VPS (2 vCPU, 4 GB RAM) в Праге. k0s выбран вместо k3s или microk8s из-за минимального footprint, отсутствия встроенных дополнений и прозрачного самостоятельного управления. Скрипт установки (01-prepare-vm.sh) разворачивает его примерно за две минуты.

На одной ноде работают две реплики пода сайта. Не ради высокой доступности — чтобы выкатки проходили без простоя: Kubernetes сначала дренирует один под и только потом запускает новый. Для этого нужен PodDisruptionBudget:

yamlapiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: antonnovikov-site-pdb
spec:
  minAvailable: 1
  selector:
    matchLabels:
      app: antonnovikov-site

Self-hosted Docker registry

DockerHub и GHCR не используются. В кластере работает plain Docker Registry v2 как NodePort-сервис на порту 30500. Образы пушатся напрямую на 91.184.248.13:30500/antonnovikov-site:<tag>.

Registry не требует аутентификации на внутреннем порту (закрыт файрволом, недоступен из интернета). Образы никогда не покидают VPS.


Скрипт деплоя

Скрипт выполняет четыре шага по порядку:

1. Сборка образа

bashIMAGE_TAG="${IMAGE_TAG:-$(git rev-parse --short HEAD)}"
docker buildx build --platform linux/amd64 \
    -t "${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}" --push .

Тег по умолчанию — короткий SHA git-коммита. --push отправляет образ прямо в registry за один шаг. На машинах без buildx используется fallback на docker build + docker push.

Перед сборкой в имя кэша service worker подставляется текущее Unix-время:

bashBUILD_TIME=$(date +%s)
sed -i.bak "s/self.__BUILD_TIME__/'${BUILD_TIME}'/g" assets/js/sw.js
# ...происходит сборка...
git checkout assets/js/sw.js   # восстановить

Так service worker обновляет кэш при каждом деплое, а JS-файл в репозитории остаётся неизменным.

2. Синхронизация .env → Kubernetes Secret

bashkubectl create secret generic antonnovikov-site-secrets \
    --from-env-file=".env" \
    --dry-run=client -o yaml | kubectl apply -f -

Локальный .env-файл (не закоммиченный в репозиторий) при каждом деплое превращается в Kubernetes Secret. --dry-run=client -o yaml | kubectl apply — идиоматичный способ создать или обновить Secret без жалоб kubectl на уже существующий ресурс.

3. Применение манифестов

bashfor manifest in middleware service ingressroute certificate deployment vpn-redirect; do
    envsubst < "${SCRIPT_DIR}/manifests/${manifest}.yaml" | kubectl apply -f -
done

Манифесты используют плейсхолдеры ${VARIABLE}. envsubst подставляет их из окружения shell перед передачей в kubectl. Манифест Deployment ссылается на образ с текущим тегом:

yamlimage: ${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}

Изменение тега — вот что запускает rolling update. Никаких kubectl set image, никаких патчей — просто повторное применение манифеста с новым значением.

4. Очистка старых образов

После завершения выкатки скрипт удаляет все теги из registry, кроме текущего:

bashTAGS=$(curl -s "http://${REGISTRY}/v2/${IMAGE_NAME}/tags/list" | jq -r '.tags[]?')
for tag in $TAGS; do
    if [ "$tag" != "$IMAGE_TAG" ]; then
        DIGEST=$(curl -s -I \
            -H "Accept: application/vnd.docker.distribution.manifest.v2+json" \
            "http://${REGISTRY}/v2/${IMAGE_NAME}/manifests/${tag}" \
            | grep -i "Docker-Content-Digest" | awk '{print $2}' | tr -d '\r')
        curl -s -X DELETE \
            "http://${REGISTRY}/v2/${IMAGE_NAME}/manifests/${DIGEST}" || true
    fi
done

Проверка выкатки

После применения манифеста Deployment скрипт ожидает завершения выкатки:

bashkubectl rollout status deployment/antonnovikov-site --timeout=120s

Команда блокируется до готовности всех подов. Если новый под не запускается (битый образ, crashloop, провал liveness probe) — скрипт завершается с ненулевым кодом, а старые поды продолжают работать. Стратегия rolling update гарантирует, что старая реплика удаляется только после того, как новая прошла readiness-проверку.


Почему не CI/CD?

Для персонального сайта с одним контрибьютором CI-пайплайн создаёт накладные расходы без особой пользы:

  • Нужны секреты в CI-системе (те же, что уже есть в .env).
  • Нужен раннер с настроенными Docker и kubectl.
  • Появляется ещё один веб-интерфейс, в котором придётся разбираться при проблемах.
  • Появляется зависимость от внешнего сервиса.

Shell-скрипт — это ~120 строк: они читаются, запускаются локально и легко проходят аудит. Деплой занимает 90 секунд. При ошибке — сообщение прямо в терминале.

Компромисс: нет автоматических деплоев при push. Это намеренно. Сайт меняется редко, и «деплой на каждый коммит» означал бы случайные деплои во время экспериментальной разработки.


Запуск деплоя

bash# Стандартный деплой (текущий git HEAD)
./k0s-setup/06-deploy-site.sh

# Деплой конкретного тега
IMAGE_TAG=abc1234 ./k0s-setup/06-deploy-site.sh

# Dry run: только собрать и запушить, не применять манифесты
DRY_RUN=1 ./k0s-setup/06-deploy-site.sh

Требования:

  • kubectl настроен на k0s-кластер
  • Docker с поддержкой buildx и настроенным insecure registry
  • .env файл с секретами сайта

Сравнение: shell-скрипт vs GitLab CI

Shell-скрипт GitLab CI
Запуск Вручную (с ноутбука) Автоматически при push
Секреты Локальный .env CI-переменные
Видимость Вывод в терминале Веб-интерфейс CI
Зависимость kubectl + Docker локально CI-раннер
Отладка Сразу в терминале Чтение логов задачи
Откат Повторный запуск со старым тегом Перезапуск задачи или вручную

Для команды или нескольких окружений CI/CD — правильный выбор. Для персонального сайта, который деплоит один человек, shell-скрипт действительно проще.