#!/usr/bin/env bash # Деплой «Кварты» на VPS. Запускать локально из корня репозитория: # deploy/deploy.sh — собрать HEAD и выкатить # deploy/deploy.sh --rollback — вернуть предыдущий тег из /root/kvarta_site/.deployed # Хост: DEPLOY_HOST из deploy/.env.local или окружения. # # На сервере рядом чужие боевые сайты: скрипт трогает только /root/kvarta_site, # контейнеры проекта kvarta и образы kvarta-landing/kvarta-api. Никаких prune. set -euo pipefail # адреса серверов в репо не храним: deploy/.env.local (образец — deploy/.env.local.example) # shellcheck source=/dev/null [[ -f "$(dirname "$0")/.env.local" ]] && source "$(dirname "$0")/.env.local" DEPLOY_HOST="${DEPLOY_HOST:?задайте DEPLOY_HOST в deploy/.env.local (см. deploy/.env.local.example)}" REMOTE_DIR=/root/kvarta_site COMPOSE_FILE=docker-compose_vps.yml PROJECT=kvarta REPOS=(kvarta-landing kvarta-api) MIN_FREE_MB=1024 HEALTH_TIMEOUT=30 SSH_OPTS=(-o BatchMode=yes -o ConnectTimeout=10 -o ControlMaster=auto -o "ControlPath=$HOME/.ssh/kvarta-deploy-%C" -o ControlPersist=60) STEP=0 step() { STEP=$((STEP + 1)); printf '\n\033[1m[%d] %s\033[0m\n' "$STEP" "$*"; } info() { printf ' %s\n' "$*"; } warn() { printf '\033[33m ВНИМАНИЕ: %s\033[0m\n' "$*" >&2; } die() { printf '\033[31mОШИБКА: %s\033[0m\n' "$*" >&2; exit 1; } # shellcheck disable=SC2029 # команды для сервера собираются локально намеренно remote() { ssh "${SSH_OPTS[@]}" "$DEPLOY_HOST" "$@"; } usage() { sed -n '2,5p' "$0" | sed 's/^# \{0,1\}//' } cd "$(dirname "$0")/.." # --- общие шаги на сервере --------------------------------------------------- check_server() { step "Проверка сервера $DEPLOY_HOST" remote true || die "нет ssh-доступа к $DEPLOY_HOST (нужен ключ, BatchMode)" remote 'command -v docker-compose >/dev/null' \ || die "на сервере нет docker-compose (ожидается v2 в /usr/local/bin)" info "$(remote 'docker-compose version --short 2>/dev/null || docker-compose version' | head -1)" } # compose up с указанным тегом, затем ждём health обоих сервисов compose_up() { local tag=$1 step "Запуск контейнеров с тегом $tag" remote "cd $REMOTE_DIR && KVARTA_TAG=$tag docker-compose -f $COMPOSE_FILE -p $PROJECT up -d --remove-orphans" step "Ожидание здоровья (до ${HEALTH_TIMEOUT} с)" if remote bash -s -- "$HEALTH_TIMEOUT" <<'REMOTE' set -u for _ in $(seq 1 "$1"); do if curl -fs -o /dev/null http://127.0.0.1:13121/api/health \ && curl -fs -o /dev/null http://127.0.0.1:13120/; then curl -fsS http://127.0.0.1:13121/api/health; echo exit 0 fi sleep 1 done exit 1 REMOTE then info "api и landing отвечают" else warn "сервисы не ответили за ${HEALTH_TIMEOUT} с; последние логи:" remote "cd $REMOTE_DIR && docker-compose -f $COMPOSE_FILE -p $PROJECT logs --tail 40" || true die "деплой $tag не поднялся. Откат: deploy/deploy.sh --rollback" fi } # latest на сервере — всегда то, что сейчас запущено (для ручного docker-compose up) retag_latest() { local tag=$1 repo for repo in "${REPOS[@]}"; do remote "docker tag $repo:$tag $repo:latest" done } # .deployed: current= / previous= read_deployed() { remote "cat $REMOTE_DIR/.deployed 2>/dev/null || true" } # значение поля из вывода read_deployed: deployed_field current "$state" deployed_field() { sed -n "s/^$1=//p" <<<"$2"; } write_deployed() { local current=$1 previous=$2 remote "printf 'current=%s\nprevious=%s\n' '$current' '$previous' > $REMOTE_DIR/.deployed" info "current=$current previous=${previous:-—}" } # --- откат ------------------------------------------------------------------- rollback() { check_server step "Чтение $REMOTE_DIR/.deployed" local state current previous repo state=$(read_deployed) current=$(deployed_field current "$state") previous=$(deployed_field previous "$state") [[ -n $previous ]] || die "в .deployed нет предыдущего тега — откатываться некуда" info "сейчас $current, откатываемся на $previous" for repo in "${REPOS[@]}"; do remote "docker image inspect $repo:$previous >/dev/null 2>&1" \ || die "на сервере нет образа $repo:$previous" done compose_up "$previous" retag_latest "$previous" step "Запись .deployed" # меняем местами: повторный --rollback вернёт обратно write_deployed "$previous" "$current" printf '\nГотово: откат на %s.\n' "$previous" } # --- деплой ------------------------------------------------------------------ deploy() { step "Проверка рабочего дерева" if [[ -n $(git status --porcelain) ]]; then git status --short die "есть незакоммиченные или неотслеживаемые файлы — закоммитьте или уберите их" fi local sha sha=$(git rev-parse --short HEAD) info "коммит $sha: $(git log -1 --format=%s)" check_server step "Свободное место на сервере" local free_mb # меряем раздел, где docker хранит образы (туда ляжет docker load) free_mb=$(remote bash -s <<'REMOTE' dir=$(docker info -f '{{.DockerRootDir}}' 2>/dev/null) [ -d "$dir" ] || dir=/ echo "$dir $(df -Pm "$dir" | awk 'NR==2 {print $4}')" REMOTE ) local docker_root=${free_mb% *} free_mb=${free_mb##* } [[ $free_mb =~ ^[0-9]+$ ]] || die "не удалось узнать свободное место на сервере (получено: '$free_mb')" info "свободно ${free_mb} МБ на разделе с $docker_root" if ((free_mb < MIN_FREE_MB)); then die "на сервере меньше ${MIN_FREE_MB} МБ свободно (${free_mb} МБ). Освободите место (своё, не чужое) и повторите" fi local need_upload=0 repo for repo in "${REPOS[@]}"; do remote "docker image inspect $repo:$sha >/dev/null 2>&1" || need_upload=1 done if ((need_upload)); then step "Сборка образов linux/amd64 ($sha)" local hint="если в логе 'exec format error' — на этой машине нет эмуляции amd64:" hint+=" Docker Desktop → Settings → General → «Use Rosetta for x86_64/amd64 emulation»" hint+=" или docker run --privileged --rm tonistiigi/binfmt --install amd64" for repo in "${REPOS[@]}"; do info "$repo" docker buildx build --platform linux/amd64 --load \ --provenance=false --sbom=false \ -f "deploy/Dockerfile.${repo#kvarta-}" \ --build-arg "APP_VERSION=$sha" \ -t "$repo:$sha" -t "$repo:latest" \ . || die "сборка $repo упала; $hint" done docker image ls --format ' {{.Repository}}:{{.Tag}} {{.Size}}' | grep -E "^ kvarta-(landing|api):$sha " || true step "Передача образов на сервер (docker save | gzip | ssh docker load)" docker save "${REPOS[@]/%/:$sha}" | gzip | remote 'gunzip | docker load' else step "Образы $sha уже есть на сервере — сборка и передача пропущены" fi step "Файлы в $REMOTE_DIR" remote "mkdir -p $REMOTE_DIR" scp -q "${SSH_OPTS[@]}" "deploy/$COMPOSE_FILE" "$DEPLOY_HOST:$REMOTE_DIR/$COMPOSE_FILE" info "$COMPOSE_FILE обновлён" local env_state env_state=$(remote "cd $REMOTE_DIR && if [ -e api.env ]; then echo exists; else umask 077 && cat > api.env && chmod 600 api.env && echo created; fi" ") continue ;; esac if docker rmi "$repo:$tag" >/dev/null; then echo " удалён $repo:$tag" else echo " не удалось удалить $repo:$tag (используется?) — пропускаю" fi done done REMOTE printf '\nГотово: %s выкачен на %s.\n' "$sha" "$DEPLOY_HOST" } case "${1:-}" in "") deploy ;; --rollback) rollback ;; -h | --help) usage ;; *) usage; die "неизвестный аргумент: $1" ;; esac