chore(deploy): Docker-образы, compose, deploy.sh, setup-nginx.sh
This commit is contained in:
@@ -0,0 +1,38 @@
|
|||||||
|
# зависимости и артефакты сборки — пересобираются внутри образа
|
||||||
|
**/node_modules
|
||||||
|
**/.next
|
||||||
|
**/dist
|
||||||
|
**/out
|
||||||
|
**/coverage
|
||||||
|
**/*.tsbuildinfo
|
||||||
|
**/next-env.d.ts
|
||||||
|
.pnpm-store
|
||||||
|
|
||||||
|
# vcs, служебное агентов и редакторов
|
||||||
|
.git
|
||||||
|
.claude
|
||||||
|
.worktrees
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
**/.DS_Store
|
||||||
|
|
||||||
|
# секреты и данные
|
||||||
|
**/.env
|
||||||
|
**/.env.*
|
||||||
|
**/*.env
|
||||||
|
data
|
||||||
|
**/data
|
||||||
|
**/*.sqlite
|
||||||
|
**/*.sqlite-*
|
||||||
|
**/*.sqlite.gz
|
||||||
|
|
||||||
|
# артефакты проверок
|
||||||
|
.screenshots
|
||||||
|
.lighthouse
|
||||||
|
test-results
|
||||||
|
playwright-report
|
||||||
|
|
||||||
|
# не нужно для сборки образов
|
||||||
|
docs
|
||||||
|
deploy
|
||||||
|
**/*.md
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
# Сборка из корня репо:
|
||||||
|
# docker buildx build --platform linux/amd64 -f deploy/Dockerfile.api \
|
||||||
|
# --build-arg APP_VERSION=$(git rev-parse --short HEAD) -t kvarta-api .
|
||||||
|
|
||||||
|
# builder на том же node:22-slim (glibc), что и рантайм: нативный better-sqlite3,
|
||||||
|
# скачанный/собранный здесь под целевую платформу, гарантированно совместим
|
||||||
|
FROM node:22-slim AS build
|
||||||
|
# toolchain нужен только если prebuilt better-sqlite3 не скачается; в рантайм не попадает
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends python3 make g++ ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
# corepack из node 22 может не знать ключи свежего pnpm — обновляем его
|
||||||
|
RUN npm install -g corepack@latest >/dev/null && corepack enable
|
||||||
|
WORKDIR /repo
|
||||||
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.base.json ./
|
||||||
|
COPY apps/api/package.json apps/api/
|
||||||
|
COPY packages/shared/package.json packages/shared/
|
||||||
|
RUN corepack install
|
||||||
|
RUN --mount=type=cache,id=kvarta-pnpm-slim,target=/pnpm-store \
|
||||||
|
pnpm install --frozen-lockfile --store-dir /pnpm-store \
|
||||||
|
--filter kvarta --filter "@kvarta/api..."
|
||||||
|
COPY apps/api apps/api
|
||||||
|
COPY packages/shared packages/shared
|
||||||
|
RUN pnpm --filter @kvarta/api build
|
||||||
|
# отдельный каталог только с production-зависимостями api (shared уже вшит tsup'ом)
|
||||||
|
RUN --mount=type=cache,id=kvarta-pnpm-slim,target=/pnpm-store \
|
||||||
|
pnpm --filter @kvarta/api deploy --prod --legacy --store-dir /pnpm-store /out \
|
||||||
|
&& rm -rf /out/src /out/tsconfig.json /out/tsup.config.ts /out/vitest.config.ts
|
||||||
|
# better-sqlite3 13 везёт prebuilt-бинарники всех платформ (~17 МБ) и исходники SQLite (~10 МБ):
|
||||||
|
# оставляем только glibc-бинарник своей архитектуры (linux-x64 для linux/amd64)
|
||||||
|
RUN set -eu; \
|
||||||
|
bs=$(readlink -f /out/node_modules/better-sqlite3); \
|
||||||
|
keep="linux-$(node -p process.arch).node"; \
|
||||||
|
test -f "$bs/prebuilds/$keep"; \
|
||||||
|
find "$bs/prebuilds" -type f ! -name "$keep" -delete; \
|
||||||
|
rm -rf "$bs/deps" "$bs/src" "$bs/build" "$bs/binding.gyp" \
|
||||||
|
/out/node_modules/.pnpm/node-addon-api@*; \
|
||||||
|
find /out/node_modules -name '*.md' -type f -delete; \
|
||||||
|
cd /out && node -e "const D=require('better-sqlite3'); const db=new D(':memory:'); console.log('better-sqlite3 ok, sqlite', db.prepare('select sqlite_version() v').get().v); db.close()"
|
||||||
|
|
||||||
|
# рантайму не нужны npm/corepack/yarn и документация (~25 МБ); удалить их слоем поверх базы
|
||||||
|
# нельзя — размер не уменьшится, поэтому чистим и сплющиваем базу в один слой через scratch
|
||||||
|
FROM node:22-slim AS node-base
|
||||||
|
RUN rm -rf /usr/local/lib/node_modules /usr/local/bin/npm /usr/local/bin/npx \
|
||||||
|
/usr/local/bin/corepack /usr/local/bin/yarn /usr/local/bin/yarnpkg /opt/yarn-* \
|
||||||
|
/usr/local/include /usr/local/share/doc /usr/local/share/man \
|
||||||
|
/usr/share/doc /usr/share/man /usr/share/info /usr/share/locale \
|
||||||
|
/var/lib/apt/lists/* /var/cache/* /var/log/* /root/.npm /tmp/* \
|
||||||
|
&& groupadd -r -g 1001 kvarta \
|
||||||
|
&& useradd -r -u 1001 -g kvarta -d /app -s /usr/sbin/nologin kvarta \
|
||||||
|
&& mkdir -p /app /data && chown kvarta:kvarta /data
|
||||||
|
|
||||||
|
FROM scratch AS runtime
|
||||||
|
COPY --from=node-base / /
|
||||||
|
ARG APP_VERSION=dev
|
||||||
|
ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \
|
||||||
|
NODE_ENV=production \
|
||||||
|
PORT=3000 \
|
||||||
|
DB_PATH=/data/kvarta.sqlite \
|
||||||
|
APP_VERSION=${APP_VERSION}
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=build --chown=kvarta:kvarta /out ./
|
||||||
|
USER kvarta
|
||||||
|
VOLUME ["/data"]
|
||||||
|
EXPOSE 3000
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||||
|
CMD ["node", "-e", "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/api/health').then(r=>process.exit(r.ok?0:1),()=>process.exit(1))"]
|
||||||
|
CMD ["node", "dist/index.js"]
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
# Сборка из корня репо:
|
||||||
|
# docker buildx build --platform linux/amd64 -f deploy/Dockerfile.landing -t kvarta-landing .
|
||||||
|
|
||||||
|
FROM node:22-alpine AS build
|
||||||
|
# corepack из node 22 может не знать ключи свежего pnpm — обновляем его
|
||||||
|
RUN npm install -g corepack@latest >/dev/null && corepack enable
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
WORKDIR /repo
|
||||||
|
# сначала только манифесты — слой с зависимостями кешируется
|
||||||
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.base.json ./
|
||||||
|
COPY apps/landing/package.json apps/landing/
|
||||||
|
COPY packages/shared/package.json packages/shared/
|
||||||
|
RUN corepack install
|
||||||
|
RUN --mount=type=cache,id=kvarta-pnpm-alpine,target=/pnpm-store \
|
||||||
|
pnpm install --frozen-lockfile --store-dir /pnpm-store \
|
||||||
|
--filter kvarta --filter "@kvarta/landing..."
|
||||||
|
COPY apps/landing apps/landing
|
||||||
|
COPY packages/shared packages/shared
|
||||||
|
RUN pnpm --filter @kvarta/landing build \
|
||||||
|
&& mkdir -p apps/landing/public
|
||||||
|
|
||||||
|
# рантайму не нужны npm/corepack/yarn (~23 МБ); удалить их слоем поверх базы нельзя —
|
||||||
|
# размер не уменьшится, поэтому чистим и сплющиваем базу в один слой через scratch
|
||||||
|
FROM node:22-alpine AS node-base
|
||||||
|
RUN rm -rf /usr/local/lib/node_modules /usr/local/bin/npm /usr/local/bin/npx \
|
||||||
|
/usr/local/bin/corepack /usr/local/bin/yarn /usr/local/bin/yarnpkg /opt/yarn-* \
|
||||||
|
/usr/local/include /root/.npm /tmp/* \
|
||||||
|
&& addgroup -S -g 1001 kvarta && adduser -S -u 1001 -G kvarta kvarta
|
||||||
|
|
||||||
|
FROM scratch AS runtime
|
||||||
|
COPY --from=node-base / /
|
||||||
|
ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \
|
||||||
|
NODE_ENV=production \
|
||||||
|
NEXT_TELEMETRY_DISABLED=1 \
|
||||||
|
PORT=3000 \
|
||||||
|
HOSTNAME=0.0.0.0
|
||||||
|
WORKDIR /app
|
||||||
|
# outputFileTracingRoot = корень репо, поэтому server.js лежит в apps/landing/ внутри standalone
|
||||||
|
COPY --from=build --chown=kvarta:kvarta /repo/apps/landing/.next/standalone ./
|
||||||
|
COPY --from=build --chown=kvarta:kvarta /repo/apps/landing/.next/static ./apps/landing/.next/static
|
||||||
|
COPY --from=build --chown=kvarta:kvarta /repo/apps/landing/public ./apps/landing/public
|
||||||
|
USER kvarta
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["node", "apps/landing/server.js"]
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Секреты api. На сервере: /root/kvarta_site/api.env, chmod 600, заполняет автор.
|
||||||
|
# deploy.sh кладёт этот шаблон, только если api.env на сервере ещё нет, и дальше его не трогает.
|
||||||
|
# PORT, DB_PATH и APP_VERSION задаёт образ — здесь их не указывать.
|
||||||
|
|
||||||
|
# Токен бота от @BotFather
|
||||||
|
TELEGRAM_BOT_TOKEN=
|
||||||
|
# ID группы с темами, куда бот пишет (отрицательное число вида -100…)
|
||||||
|
TELEGRAM_CHAT_ID=
|
||||||
|
# ID темы «Заявки» (message_thread_id) — сюда приходят новые заявки
|
||||||
|
TELEGRAM_LEADS_THREAD_ID=
|
||||||
|
# ID темы «Система» — ошибки, бэкапы, служебные сообщения
|
||||||
|
TELEGRAM_SYSTEM_THREAD_ID=
|
||||||
|
# Telegram user ID автора: только ему бот отвечает на команды администрирования
|
||||||
|
TELEGRAM_ADMIN_ID=
|
||||||
Executable
+230
@@ -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
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Прод на VPS: /root/kvarta_site/docker-compose_vps.yml (кладёт deploy/deploy.sh).
|
||||||
|
# На сервере только docker-compose v2.23 (без плагина `docker compose`):
|
||||||
|
# KVARTA_TAG=<sha> docker-compose -f docker-compose_vps.yml -p kvarta up -d
|
||||||
|
# Наружу порты не торчат — только 127.0.0.1, снаружи ходит nginx хоста.
|
||||||
|
name: kvarta
|
||||||
|
|
||||||
|
x-logging: &logging
|
||||||
|
# диск сервера почти полный — логи ротируются: не больше 3 × 10 МБ на контейнер
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
services:
|
||||||
|
landing:
|
||||||
|
image: kvarta-landing:${KVARTA_TAG:-latest}
|
||||||
|
container_name: kvarta-landing
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:13120:3000"
|
||||||
|
logging: *logging
|
||||||
|
|
||||||
|
api:
|
||||||
|
image: kvarta-api:${KVARTA_TAG:-latest}
|
||||||
|
container_name: kvarta-api
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:13121:3000"
|
||||||
|
env_file: api.env
|
||||||
|
volumes:
|
||||||
|
- kvarta-data:/data
|
||||||
|
logging: *logging
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
kvarta-data:
|
||||||
|
# явное имя: не зависит от project name, его ищут бэкапы и восстановление
|
||||||
|
name: kvarta-data
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# /etc/nginx/sites-available/kvarta.unuchak.com (ставит deploy/setup-nginx.sh)
|
||||||
|
# Только 80-й порт: certbot --nginx сам допишет 443-блок и редирект.
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name kvarta.unuchak.com;
|
||||||
|
|
||||||
|
# заявка — это небольшой JSON; всё крупнее отсекаем ещё на nginx
|
||||||
|
client_max_body_size 32k;
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_proxied any;
|
||||||
|
gzip_comp_level 5;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
gzip_types text/plain text/css text/xml application/javascript application/json
|
||||||
|
application/xml application/rss+xml image/svg+xml application/manifest+json;
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://127.0.0.1:13121;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_connect_timeout 60s;
|
||||||
|
proxy_send_timeout 60s;
|
||||||
|
proxy_read_timeout 60s;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:13120;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_connect_timeout 60s;
|
||||||
|
proxy_send_timeout 60s;
|
||||||
|
proxy_read_timeout 60s;
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+90
@@ -0,0 +1,90 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Однократная настройка nginx и HTTPS для kvarta.unuchak.com. Запускать локально из корня:
|
||||||
|
# CERTBOT_EMAIL=you@example.com deploy/setup-nginx.sh
|
||||||
|
# Хост: DEPLOY_HOST (по умолчанию root@185.5.249.52). Повторный запуск безопасен.
|
||||||
|
#
|
||||||
|
# На сервере чужие боевые сайты: при ошибке `nginx -t` наш симлинк убирается
|
||||||
|
# и reload не делается — общий nginx не должен упасть из-за нашего конфига.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
DEPLOY_HOST="${DEPLOY_HOST:-root@185.5.249.52}"
|
||||||
|
DOMAIN=kvarta.unuchak.com
|
||||||
|
CONF_SRC="deploy/nginx/$DOMAIN.conf"
|
||||||
|
AVAILABLE="/etc/nginx/sites-available/$DOMAIN"
|
||||||
|
ENABLED="/etc/nginx/sites-enabled/$DOMAIN"
|
||||||
|
|
||||||
|
SSH_OPTS=(-o BatchMode=yes -o ConnectTimeout=10
|
||||||
|
-o ControlMaster=auto -o "ControlPath=$HOME/.ssh/kvarta-nginx-%C" -o ControlPersist=60)
|
||||||
|
|
||||||
|
STEP=0
|
||||||
|
step() { STEP=$((STEP + 1)); printf '\n\033[1m[%d] %s\033[0m\n' "$STEP" "$*"; }
|
||||||
|
info() { printf ' %s\n' "$*"; }
|
||||||
|
die() { printf '\033[31mОШИБКА: %s\033[0m\n' "$*" >&2; exit 1; }
|
||||||
|
# shellcheck disable=SC2029 # команды для сервера собираются локально намеренно
|
||||||
|
remote() { ssh "${SSH_OPTS[@]}" "$DEPLOY_HOST" "$@"; }
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
step "Проверка параметров"
|
||||||
|
if [[ -z ${CERTBOT_EMAIL:-} ]]; then
|
||||||
|
die "задайте CERTBOT_EMAIL — на него Let's Encrypt шлёт предупреждения об истечении сертификата:
|
||||||
|
CERTBOT_EMAIL=you@example.com deploy/setup-nginx.sh"
|
||||||
|
fi
|
||||||
|
[[ $CERTBOT_EMAIL =~ ^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$ ]] \
|
||||||
|
|| die "CERTBOT_EMAIL не похож на email: $CERTBOT_EMAIL"
|
||||||
|
[[ -f $CONF_SRC ]] || die "нет $CONF_SRC"
|
||||||
|
info "хост $DEPLOY_HOST, домен $DOMAIN, email $CERTBOT_EMAIL"
|
||||||
|
|
||||||
|
step "Проверка сервера"
|
||||||
|
remote true || die "нет ssh-доступа к $DEPLOY_HOST"
|
||||||
|
remote 'command -v nginx >/dev/null && command -v certbot >/dev/null' \
|
||||||
|
|| die "на сервере нет nginx или certbot"
|
||||||
|
# общий nginx должен быть исправен до нас — иначе мы не отличим свою ошибку от чужой
|
||||||
|
remote 'nginx -t 2>&1' >/dev/null || die "nginx -t падает ещё до наших изменений — разберитесь вручную, скрипт ничего не трогал"
|
||||||
|
|
||||||
|
step "Конфиг в $AVAILABLE"
|
||||||
|
# если certbot уже дописал в конфиг 443-блок — не затираем его исходным 80-м
|
||||||
|
if remote "grep -q 'managed by Certbot' $AVAILABLE 2>/dev/null"; then
|
||||||
|
info "конфиг уже обработан certbot — оставляю как есть"
|
||||||
|
else
|
||||||
|
# прежняя версия нашего конфига — чтобы вернуть её, если новая не пройдёт nginx -t
|
||||||
|
remote "[ ! -e $AVAILABLE ] || cp -a $AVAILABLE $AVAILABLE.bak"
|
||||||
|
scp -q "${SSH_OPTS[@]}" "$CONF_SRC" "$DEPLOY_HOST:$AVAILABLE"
|
||||||
|
info "скопирован"
|
||||||
|
fi
|
||||||
|
|
||||||
|
step "Симлинк в sites-enabled"
|
||||||
|
created_link=0
|
||||||
|
if remote "[ -e $ENABLED ] || [ -L $ENABLED ]"; then
|
||||||
|
info "уже есть — не трогаю"
|
||||||
|
else
|
||||||
|
remote "ln -s $AVAILABLE $ENABLED"
|
||||||
|
created_link=1
|
||||||
|
info "создан $ENABLED"
|
||||||
|
fi
|
||||||
|
|
||||||
|
step "nginx -t"
|
||||||
|
if ! remote 'nginx -t'; then
|
||||||
|
if ((created_link)); then
|
||||||
|
remote "rm -f $ENABLED"
|
||||||
|
info "наш симлинк удалён"
|
||||||
|
fi
|
||||||
|
if remote "[ -e $AVAILABLE.bak ]"; then
|
||||||
|
remote "mv -f $AVAILABLE.bak $AVAILABLE"
|
||||||
|
info "восстановлена прежняя версия $AVAILABLE"
|
||||||
|
fi
|
||||||
|
die "конфиг не прошёл nginx -t — reload не выполнен"
|
||||||
|
fi
|
||||||
|
|
||||||
|
remote "rm -f $AVAILABLE.bak"
|
||||||
|
|
||||||
|
step "systemctl reload nginx"
|
||||||
|
remote 'systemctl reload nginx'
|
||||||
|
|
||||||
|
step "Сертификат Let's Encrypt (certbot --nginx)"
|
||||||
|
remote "certbot --nginx -d $DOMAIN --non-interactive --agree-tos --redirect -m '$CERTBOT_EMAIL'"
|
||||||
|
|
||||||
|
step "Итоговая проверка"
|
||||||
|
remote 'nginx -t' && remote "curl -fsS -o /dev/null -w 'https://$DOMAIN/ → %{http_code}\n' https://$DOMAIN/ || true"
|
||||||
|
|
||||||
|
printf '\nГотово: https://%s\n' "$DOMAIN"
|
||||||
Reference in New Issue
Block a user