chore(deploy): Docker-образы, compose, deploy.sh, setup-nginx.sh

This commit is contained in:
2026-10-17 15:48:19 +03:00
parent e2d85a28ef
commit 79ab146b1d
8 changed files with 565 additions and 0 deletions
+230
View File
@@ -0,0 +1,230 @@
#!/usr/bin/env bash
# Деплой «Кварты» на VPS. Запускать локально из корня репозитория:
# deploy/deploy.sh — собрать HEAD и выкатить
# deploy/deploy.sh --rollback — вернуть предыдущий тег из /root/kvarta_site/.deployed
# Хост: DEPLOY_HOST (по умолчанию root@185.5.249.52).
#
# На сервере рядом чужие боевые сайты: скрипт трогает только /root/kvarta_site,
# контейнеры проекта kvarta и образы kvarta-landing/kvarta-api. Никаких prune.
set -euo pipefail
DEPLOY_HOST="${DEPLOY_HOST:-root@185.5.249.52}"
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"
}
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=$(sed -n 's/^current=//p' <<<"$state")
previous=$(sed -n 's/^previous=//p' <<<"$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"
local name
for name in landing api; do
info "kvarta-$name"
docker buildx build --platform linux/amd64 --load \
--provenance=false --sbom=false \
-f "deploy/Dockerfile.$name" \
--build-arg "APP_VERSION=$sha" \
-t "kvarta-$name:$sha" -t "kvarta-$name:latest" \
. || die "сборка kvarta-$name упала; $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 "kvarta-landing:$sha" "kvarta-api:$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=$(sed -n 's/^current=//p' <<<"$state")
# повторный деплой того же коммита не должен затирать предыдущий тег
if [[ $previous == "$sha" ]]; then
previous=$(sed -n 's/^previous=//p' <<<"$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