231 lines
9.4 KiB
Bash
Executable File
231 lines
9.4 KiB
Bash
Executable File
#!/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
|