Files
nunuchek c1d64f6a79 chore(deploy): адреса серверов из deploy/.env.local, а не из репозитория
DEPLOY_HOST, RELAY_HOST и TELEGRAM_BOT_ID обязательны и читаются из
неотслеживаемого deploy/.env.local (образец — .env.local.example).
2026-10-17 17:53:18 +03:00

236 lines
9.8 KiB
Bash
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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=<tag> / previous=<tag>
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" <deploy/api.env.example)
if [[ $env_state == created ]]; then
warn "api.env не было — положен пустой шаблон $REMOTE_DIR/api.env (chmod 600)."
warn "Заполните его на сервере и перезапустите api: cd $REMOTE_DIR && docker-compose -f $COMPOSE_FILE -p $PROJECT up -d api"
else
info "api.env уже есть — не трогаем"
fi
local state previous
state=$(read_deployed)
previous=$(deployed_field current "$state")
# повторный деплой того же коммита не должен затирать предыдущий тег
if [[ $previous == "$sha" ]]; then
previous=$(deployed_field previous "$state")
fi
compose_up "$sha"
retag_latest "$sha"
step "Запись .deployed"
write_deployed "$sha" "$previous"
step "Удаление старых образов kvarta-landing/kvarta-api (кроме $sha${previous:+ и $previous})"
# ssh склеивает аргументы в строку — пустой previous пропал бы, поэтому заглушка "-"
remote bash -s -- "$sha" "${previous:--}" "${REPOS[@]}" <<'REMOTE'
set -u
keep_current=$1 keep_previous=$2
shift 2
for repo in "$@"; do
docker image ls "$repo" --format '{{.Tag}}' | while read -r tag; do
case $tag in
latest|"$keep_current"|"$keep_previous"|"<none>") 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