Деплой на 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-скрипт действительно проще.