diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..d2cda07c --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +*.sh text eol=lf +/deploy/** text eol=lf diff --git a/.github/workflows/build&test.yml b/.github/workflows/build&test.yml index 2a65b123..5d1d4d5a 100644 --- a/.github/workflows/build&test.yml +++ b/.github/workflows/build&test.yml @@ -12,9 +12,9 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Setup .NET - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v6 with: dotnet-version: 10.0.x - name: Build and test diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 1c2a34ab..5617e375 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -1,29 +1,68 @@ name: Docker publish on: + # Выпуск версии — публикация релиза в GitHub. Имя тега релиза становится + # тегом образа и версией, которую раскатывает rollout.sh на сервере. release: - types: [published] + types: [ published ] + +concurrency: + # Раскатки не должны идти параллельно: две одновременные сломают порядок замены экземпляров + group: deploy-production + cancel-in-progress: false jobs: - build-and-push: + publish: runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.value }} + steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 + + - name: Read version + id: version + run: | + version='${{ github.event.release.tag_name }}' + if [ -z "$version" ]; then + echo "У релиза пустой тег — нечего собирать" >&2 + exit 1 + fi + echo "value=${version}" >> "$GITHUB_OUTPUT" + echo "Version: ${version}" - name: Log in to Docker Hub - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Build and push - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: ./src file: ./src/FillInTheTextBot.Api/Dockerfile push: true tags: | - granstel/fillinthetextbot:${{ github.event.release.tag_name }} + granstel/fillinthetextbot:${{ steps.version.outputs.value }} + granstel/fillinthetextbot:${{ github.sha }} granstel/fillinthetextbot:latest + + deploy: + needs: publish + runs-on: ubuntu-latest + # Шаг пропускается, пока не заданы секреты доступа к серверу + if: ${{ vars.DEPLOY_ENABLED == 'true' }} + + steps: + - name: Rollout on server + uses: appleboy/ssh-action@v1 + with: + host: ${{ secrets.DEPLOY_HOST }} + username: ${{ secrets.DEPLOY_USER }} + key: ${{ secrets.DEPLOY_SSH_KEY }} + script: | + cd ${{ secrets.DEPLOY_PATH }} + ./rollout.sh ${{ needs.publish.outputs.version }} diff --git a/.github/workflows/loadtest.yml b/.github/workflows/loadtest.yml index e9966cb2..7a9f77c9 100644 --- a/.github/workflows/loadtest.yml +++ b/.github/workflows/loadtest.yml @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Run k6 load test env: diff --git a/.gitignore b/.gitignore index 26cfd55f..c4c79921 100644 --- a/.gitignore +++ b/.gitignore @@ -9,7 +9,9 @@ **/.hg* **/.hg **/Keys/ -**/deploy/ +/deploy/.env +/deploy/app.env +/deploy/acme/ **/.config/ **/Properties/ /src/FillInTheTextBot.Api/FillInTheTextBot.Api.csproj.user diff --git a/deploy/app-env.example b/deploy/app-env.example new file mode 100644 index 00000000..4f86c198 --- /dev/null +++ b/deploy/app-env.example @@ -0,0 +1,39 @@ +# Секреты и настройки приложения, передаётся контейнеру как --env-file. +# +# cp app-env.example app.env +# +# В репозиторий app.env не коммитится. Двойное подчёркивание = ':' в конфигурации: +# AppConfiguration__Redis__ConnectionString == AppConfiguration:Redis:ConnectionString + +# Dialogflow: агенты 0..N. JsonPath — путь внутри контейнера, /app/keys смонтирован +# из KEYS_DIR. ScopeId должен быть уникальным и непустым, иначе агент не участвует +# в выборе; он же уезжает клиенту в состоянии сессии, поэтому менять его нельзя — +# у пользователей с активной сессией сохранённое значение перестанет совпадать. +AppConfiguration__Dialogflow__0__ProjectId=your-project-0 +AppConfiguration__Dialogflow__0__ScopeId=scope-0 +AppConfiguration__Dialogflow__0__JsonPath=keys/your-project-0.json +AppConfiguration__Dialogflow__0__Region=europe-west1 + +AppConfiguration__Dialogflow__1__ProjectId=your-project-1 +AppConfiguration__Dialogflow__1__ScopeId=scope-1 +AppConfiguration__Dialogflow__1__JsonPath=keys/your-project-1.json +AppConfiguration__Dialogflow__1__Region=europe-west1 + +# ...остальные агенты по тому же образцу + +# Redis доступен по имени внутри docker-сети; пароль тот же, что REDIS_PASSWORD в .env +AppConfiguration__Redis__ConnectionString=redis:6379,password=CHANGE-ME,abortConnect=false,syncTimeout=2000,asyncTimeout=2000 +AppConfiguration__Redis__KeyPrefix= + +# Пустой Host выключает экспорт трейсов +AppConfiguration__Tracing__Host= +AppConfiguration__Tracing__Port=4317 + +# Плавная остановка; значения по умолчанию подходят +# AppConfiguration__Shutdown__DrainDelaySeconds=10 +# AppConfiguration__Shutdown__TimeoutSeconds=30 + +# Токены проверки входящих запросов от площадок +FITB-YANDEX-INCOMINGTOKEN=CHANGE-ME +FITB-SBER-INCOMINGTOKEN=CHANGE-ME +FITB-MARUSIA-INCOMINGTOKEN=CHANGE-ME diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 00000000..36cc8de3 --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,54 @@ +# Пограничный прокси: TLS, сертификат Let's Encrypt, маршрутизация по docker-меткам. +# Сервис поднимает rollout.sh. +# +# docker compose -f docker-compose.yml up -d + +name: fitb-edge + +networks: + # Создаётся этим стеком; redis и сервис подключаются к ней как к внешней + network: + name: network + +services: + traefik: + image: traefik:v3.7 + container_name: traefik + restart: always + networks: + - network + ports: + - "80:80" + - "443:443" + command: + - --providers.docker=true + # Без явной метки traefik.enable=true наружу ничего не выходит + - --providers.docker.exposedbydefault=false + - --providers.docker.network=network + + - --entrypoints.web.address=:80 + - --entrypoints.websecure.address=:443 + # Наружу не публикуется; API опрашивает rollout.sh изнутри сети + - --entrypoints.traefik.address=:8080 + - --api=true + - --api.insecure=true + + # ACME-проверку Traefik обслуживает до редиректа, выпуск не ломается + - --entrypoints.web.http.redirections.entrypoint.to=websecure + - --entrypoints.web.http.redirections.entrypoint.scheme=https + + - --certificatesresolvers.le.acme.email=${ACME_EMAIL} + - --certificatesresolvers.le.acme.storage=/acme/acme.json + - --certificatesresolvers.le.acme.httpchallenge=true + - --certificatesresolvers.le.acme.httpchallenge.entrypoint=web + + # Иначе клиент может подменить X-Forwarded-* заголовком-алиасом + - --entrypoints.web.http.aliasHeadersStrategy=delete + - --entrypoints.websecure.http.aliasHeadersStrategy=delete + + - --log.level=INFO + - --accesslog=true + volumes: + # Только чтение: Traefik лишь наблюдает за контейнерами + - /var/run/docker.sock:/var/run/docker.sock:ro + - ./acme:/acme diff --git a/deploy/env.example b/deploy/env.example new file mode 100644 index 00000000..ccf1efbe --- /dev/null +++ b/deploy/env.example @@ -0,0 +1,28 @@ +# Параметры раскатки и пограничного прокси. +# +# cp env.example .env +# +# В репозиторий .env не коммитится. + +# Домен сервиса; на него должна быть A-запись на IP сервера +DOMAIN=example.com + +# Почта для регистрации в Let's Encrypt +ACME_EMAIL=admin@example.com + +# Каталоги на сервере, монтируемые в контейнер +KEYS_DIR=/путь/к/ключам/dialogflow +LOGS_DIR=/путь/к/логам/сервиса + +# Пароль Redis; тот же попадает в строку подключения в app.env +REDIS_PASSWORD=CHANGE-ME + +# --- ниже значения по умолчанию, менять не обязательно --- + +# IMAGE_REPO=granstel/fillinthetextbot +# DOCKER_NETWORK=network +# SERVICE_ALIAS=fitb +# APP_ENV_FILE=./app.env +# HEALTH_TIMEOUT=60 # ждём готовности нового экземпляра, сек +# ROTATION_TIMEOUT=30 # ждём, пока Traefik возьмёт его в ротацию, сек +# STOP_TIMEOUT=45 # запас на слив трафика при остановке старого, сек diff --git a/deploy/fitb-deploy b/deploy/fitb-deploy new file mode 100755 index 00000000..a240a9ca --- /dev/null +++ b/deploy/fitb-deploy @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# +# Форсированная команда для ключа раскатки из CI: достаёт из запроса номер версии, +# подтягивает deploy/ с тега этой версии и запускает раскатку. Больше ничего не умеет. +# +# Тег, а не ветка: откат вернёт и старые скрипты раскатки. +# Ставится на сервер один раз под root; себя не обновляет. + +set -euo pipefail + +# shellcheck disable=SC1091 +[ -r /etc/default/fitb-deploy ] && . /etc/default/fitb-deploy + +WORK_DIR="${FITB_WORK_DIR:?задайте FITB_WORK_DIR в /etc/default/fitb-deploy}" +REPO_DIR="${WORK_DIR}/.repo" + +# Остальное в каталоге раскатки — локальная конфигурация с секретами. +SYNCED=(rollout.sh docker-compose.yml redis-compose.yml service-compose.yml env.example app-env.example) + +requested="${SSH_ORIGINAL_COMMAND:-}" + +version="$(printf '%s' "$requested" \ + | grep -oE '(^|[^0-9A-Za-z.-])[0-9]+\.[0-9]+\.[0-9]+([-+.][A-Za-z0-9.-]+)?([^0-9A-Za-z.-]|$)' \ + | grep -oE '[0-9]+\.[0-9]+\.[0-9]+([-+.][A-Za-z0-9.-]+)?' \ + | head -1 || true)" + +if [ -z "$version" ]; then + echo "!! Этому ключу доступна только раскатка версии. Запрошено: ${requested:-<пусто>}" >&2 + exit 2 +fi + +echo "==> Раскатка версии ${version} (запрос от ключа CI)" + +if [ -d "${REPO_DIR}/.git" ]; then + git -C "$REPO_DIR" fetch -q --filter=blob:none origin \ + "refs/tags/${version}:refs/tags/${version}" 2>/dev/null || true + + if git -C "$REPO_DIR" rev-parse -q --verify "refs/tags/${version}^{commit}" >/dev/null; then + git -C "$REPO_DIR" -c advice.detachedHead=false checkout -q --force "refs/tags/${version}" + echo "==> Сверяю deploy/ с тегом ${version}" + + compose_changed=0 + for f in "${SYNCED[@]}"; do + src="${REPO_DIR}/deploy/${f}" + [ -f "$src" ] || continue + if ! cmp -s "$src" "${WORK_DIR}/${f}"; then + cp -f "$src" "${WORK_DIR}/${f}" + echo " обновлён: ${f}" + case "$f" in *compose.yml) compose_changed=1 ;; esac + fi + done + chmod +x "${WORK_DIR}/rollout.sh" + + if [ "$compose_changed" = "1" ]; then + echo "!! compose-файл изменился — конфигурация на сервере устарела." >&2 + echo " Примените вручную: перезапуск прокси рвёт соединения, поэтому не автоматом." >&2 + fi + else + echo "!! Тег ${version} не найден — работаю с текущей копией deploy/." >&2 + fi +else + echo "!! Клона репозитория нет — синхронизация deploy/ пропущена." >&2 +fi + +cd "$WORK_DIR" +exec ./rollout.sh "$version" diff --git a/deploy/redis-compose.yml b/deploy/redis-compose.yml new file mode 100644 index 00000000..1617215a --- /dev/null +++ b/deploy/redis-compose.yml @@ -0,0 +1,39 @@ +# Кэш сессий. Наружу порт не открыт, снаружи — через туннель: +# ssh -L 6379:127.0.0.1:6379 <сервер> +# +# docker compose -f redis-compose.yml up -d + +name: fitb-redis + +networks: + network: + external: true + +services: + redis: + image: redis:8-alpine + container_name: redis + restart: always + networks: + network: + aliases: [redis] + # Без явного адреса docker открыл бы порт наружу в обход ufw + ports: + - "127.0.0.1:6379:6379" + command: > + redis-server + --requirepass ${REDIS_PASSWORD:?задайте REDIS_PASSWORD в .env} + --appendonly yes + --maxmemory 512mb + --maxmemory-policy allkeys-lru + --save 900 1 + volumes: + - redis-data:/data + healthcheck: + test: ["CMD-SHELL", "redis-cli -a '${REDIS_PASSWORD}' --no-auth-warning ping | grep -q PONG"] + interval: 10s + timeout: 3s + retries: 5 + +volumes: + redis-data: diff --git a/deploy/rollout.sh b/deploy/rollout.sh new file mode 100755 index 00000000..68fe62df --- /dev/null +++ b/deploy/rollout.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +# +# Раскатка версии без простоя: ./rollout.sh <версия> +# +# Два проекта compose из одного файла: новая версия поднимается в свободном цвете, +# прежний гасится после подтверждения ротации от прокси. + +set -euo pipefail + +VERSION="${1:?Использование: rollout.sh <версия>}" +export VERSION + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +# Параметры — из .env рядом со скриптом +if [ -f .env ]; then + set -a + # shellcheck disable=SC1091 + . ./.env + set +a +fi + +COMPOSE_FILE="${COMPOSE_FILE:-service-compose.yml}" +NETWORK="${DOCKER_NETWORK:-network}" +APP_ENV_FILE="${APP_ENV_FILE:-${SCRIPT_DIR}/app.env}" +HEALTH_TIMEOUT="${HEALTH_TIMEOUT:-60}" +TRAEFIK_CONTAINER="${TRAEFIK_CONTAINER:-traefik}" +TRAEFIK_SERVICE="${TRAEFIK_SERVICE:-fitb}" +ROTATION_TIMEOUT="${ROTATION_TIMEOUT:-30}" +PROJECT_BLUE="${PROJECT_BLUE:-fitb-blue}" +PROJECT_GREEN="${PROJECT_GREEN:-fitb-green}" + +if [ ! -f "$APP_ENV_FILE" ]; then + echo "!! Не найден файл окружения приложения: $APP_ENV_FILE" >&2 + echo " Скопируйте app-env.example в app.env и заполните секреты." >&2 + exit 1 +fi + +# Проверяем до образа и контейнера, чтобы не тратить цикл ради отказа в конце. +TRAEFIK_IP="$(docker inspect -f "{{ (index .NetworkSettings.Networks \"${NETWORK}\").IPAddress }}" "$TRAEFIK_CONTAINER" 2>/dev/null || true)" + +if [ -z "$TRAEFIK_IP" ]; then + echo "!! Контейнер ${TRAEFIK_CONTAINER} не найден в сети ${NETWORK}." >&2 + echo " Поднимите прокси: docker compose -f docker-compose.yml up -d" >&2 + exit 1 +fi + +compose() { docker compose -p "$1" -f "$COMPOSE_FILE" "${@:2}"; } + +running() { + [ -n "$(docker ps -q --filter "label=com.docker.compose.project=$1" \ + --filter "label=com.docker.compose.service=fitb")" ] +} + +if running "$PROJECT_BLUE"; then + ACTIVE="$PROJECT_BLUE"; TARGET="$PROJECT_GREEN" +elif running "$PROJECT_GREEN"; then + ACTIVE="$PROJECT_GREEN"; TARGET="$PROJECT_BLUE" +else + ACTIVE=""; TARGET="$PROJECT_BLUE" +fi + +echo "==> Активен: ${ACTIVE:-(нет — первый запуск)}; поднимаем в ${TARGET}" + +echo "==> Тянем образ версии ${VERSION}" +compose "$TARGET" pull + +echo "==> Поднимаем ${TARGET}" +compose "$TARGET" up -d + +NEW_ID="$(compose "$TARGET" ps -q fitb)" +NEW_IP="$(docker inspect -f "{{ (index .NetworkSettings.Networks \"${NETWORK}\").IPAddress }}" "$NEW_ID")" + +# Стучимся с хоста, поэтому curl внутри образа не нужен +echo "==> Ждём готовности ${NEW_IP} на /health, до ${HEALTH_TIMEOUT}s" +deadline=$(( $(date +%s) + HEALTH_TIMEOUT )) +until curl -fsS -m 2 "http://${NEW_IP}/health" >/dev/null 2>&1; do + if [ "$(date +%s)" -ge "$deadline" ]; then + echo "!! Новый экземпляр не стал здоровым за ${HEALTH_TIMEOUT}s — откатываемся." >&2 + compose "$TARGET" logs --tail 50 || true + compose "$TARGET" down >/dev/null 2>&1 || true + exit 1 + fi + sleep 2 +done +echo " Новый экземпляр здоров." + +# Гасить старый до подтверждения ротации — оставить пул без живых серверов. +echo "==> Ждём, пока Traefik возьмёт ${NEW_IP} в ротацию, до ${ROTATION_TIMEOUT}s" +deadline=$(( $(date +%s) + ROTATION_TIMEOUT )) +API_URL="http://${TRAEFIK_IP}:8080/api/http/services/${TRAEFIK_SERVICE}@docker" +until curl -fsS -m 2 "$API_URL" 2>/dev/null | grep -q "\"http://${NEW_IP}:80\":\"UP\"" +do + if [ "$(date +%s)" -ge "$deadline" ]; then + echo "!! Traefik не взял новый экземпляр в ротацию за ${ROTATION_TIMEOUT}s — откатываемся." >&2 + compose "$TARGET" logs --tail 50 || true + compose "$TARGET" down >/dev/null 2>&1 || true + exit 1 + fi + sleep 1 +done +echo " Traefik балансирует на новый экземпляр." + +if [ -n "$ACTIVE" ]; then + echo "==> Сливаем и останавливаем ${ACTIVE}" + compose "$ACTIVE" down || true +fi + +echo "==> Чистим повисшие образы" +docker image prune -f >/dev/null 2>&1 || true + +echo "==> Готово: активна версия ${VERSION} в ${TARGET}" diff --git a/deploy/service-compose.yml b/deploy/service-compose.yml new file mode 100644 index 00000000..ffd682fd --- /dev/null +++ b/deploy/service-compose.yml @@ -0,0 +1,36 @@ +# Контейнер сервиса. Имя проекта задаёт rollout.sh — во время раската живут оба цвета. +# +# VERSION=1.27.0 docker compose -p fitb-green -f service-compose.yml up -d --wait + +networks: + network: + external: true + +services: + fitb: + image: ${IMAGE_REPO:-granstel/fillinthetextbot}:${VERSION:?задайте VERSION} + restart: unless-stopped + networks: + network: + # Одинаковый у обоих цветов + aliases: + - ${SERVICE_ALIAS:-fitb} + env_file: + - ${APP_ENV_FILE:-./app.env} + volumes: + - ${KEYS_DIR:?задайте KEYS_DIR в .env}:/app/keys:ro + - ${LOGS_DIR:?задайте LOGS_DIR в .env}:/app/logs + # Больше, чем DrainDelaySeconds + Shutdown.TimeoutSeconds сервиса + stop_grace_period: ${STOP_TIMEOUT:-45}s + labels: + app: fitb + traefik.enable: "true" + traefik.docker.network: ${DOCKER_NETWORK:-network} + traefik.http.routers.fitb.rule: "Host(`${DOMAIN:?задайте DOMAIN в .env}`)" + traefik.http.routers.fitb.entrypoints: websecure + traefik.http.routers.fitb.tls: "true" + traefik.http.routers.fitb.tls.certresolver: le + traefik.http.services.fitb.loadbalancer.server.port: "80" + traefik.http.services.fitb.loadbalancer.healthcheck.path: /health + traefik.http.services.fitb.loadbalancer.healthcheck.interval: 3s + traefik.http.services.fitb.loadbalancer.healthcheck.timeout: 2s diff --git a/src/FillInTheTextBot.Api.IntegrationTests/HappyPathTests.cs b/src/FillInTheTextBot.Api.IntegrationTests/HappyPathTests.cs index 726981e5..e2c793ad 100644 --- a/src/FillInTheTextBot.Api.IntegrationTests/HappyPathTests.cs +++ b/src/FillInTheTextBot.Api.IntegrationTests/HappyPathTests.cs @@ -49,6 +49,8 @@ public async Task OneTimeSetUp() builder.UseSetting("AppConfiguration:Redis:ConnectionString", redisConnectionString); builder.UseSetting("AppConfiguration:Tracing:Port", "0"); + // Пауза вывода из ротации нужна в бою, в тестах она только замедляет остановку + builder.UseSetting("AppConfiguration:Shutdown:DrainDelaySeconds", "0"); builder.UseSetting("Logging:LogLevel:Default", "Warning"); }); diff --git a/src/FillInTheTextBot.Api.IntegrationTests/HealthTests.cs b/src/FillInTheTextBot.Api.IntegrationTests/HealthTests.cs new file mode 100644 index 00000000..06a4b010 --- /dev/null +++ b/src/FillInTheTextBot.Api.IntegrationTests/HealthTests.cs @@ -0,0 +1,61 @@ +using System.Net; +using FillInTheTextBot.Api.Health; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; + +namespace FillInTheTextBot.Api.IntegrationTests; + +/// +/// Проверка готовности — основа бесшовного обновления: балансировщик выводит +/// экземпляр из ротации до того, как тот перестанет слушать порт. +/// +[TestFixture] +public class HealthTests +{ + private WebApplicationFactory _factory = null!; + private HttpClient _client = null!; + + [SetUp] + public void InitTest() + { + _factory = new WebApplicationFactory().WithWebHostBuilder(builder => + { + builder.UseSetting("AppConfiguration:Redis:ConnectionString", "localhost:6379"); + builder.UseSetting("AppConfiguration:Tracing:Port", "0"); + // Пауза вывода из ротации нужна в бою, в тестах она только замедляет остановку + builder.UseSetting("AppConfiguration:Shutdown:DrainDelaySeconds", "0"); + builder.UseSetting("Logging:LogLevel:Default", "Warning"); + }); + + _client = _factory.CreateClient(); + } + + [TearDown] + public async Task CleanUp() + { + _client.Dispose(); + + await _factory.DisposeAsync(); + } + + [Test] + public async Task Health_Running_Healthy() + { + var response = await _client.GetAsync(Startup.HealthPath); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + Assert.That(await response.Content.ReadAsStringAsync(), Is.EqualTo("Healthy")); + } + + [Test] + public async Task Health_ShuttingDown_Unhealthy() + { + // Именно так делает GracefulShutdownService при получении сигнала остановки + _factory.Services.GetRequiredService().BeginShutdown(); + + var response = await _client.GetAsync(Startup.HealthPath); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.ServiceUnavailable), + "Пока приложение ещё принимает запросы, проверка здоровья должна уже краснеть"); + } +} diff --git a/src/FillInTheTextBot.Api/DI/ConfigurationRegistration.cs b/src/FillInTheTextBot.Api/DI/ConfigurationRegistration.cs index a07a2864..4dc74027 100644 --- a/src/FillInTheTextBot.Api/DI/ConfigurationRegistration.cs +++ b/src/FillInTheTextBot.Api/DI/ConfigurationRegistration.cs @@ -13,6 +13,7 @@ internal static void AddAppConfiguration(this IServiceCollection services, AppCo services.AddSingleton(configuration.Dialogflow); services.AddSingleton(configuration.Tracing); services.AddSingleton(configuration.Conversation); + services.AddSingleton(configuration.Shutdown ?? new ShutdownConfiguration()); } } } diff --git a/src/FillInTheTextBot.Api/FillInTheTextBot.Api.csproj b/src/FillInTheTextBot.Api/FillInTheTextBot.Api.csproj index 36d52f09..04316b16 100644 --- a/src/FillInTheTextBot.Api/FillInTheTextBot.Api.csproj +++ b/src/FillInTheTextBot.Api/FillInTheTextBot.Api.csproj @@ -3,7 +3,7 @@ net10.0 Linux - 1.23.1 + 1.26.0 Added new texts and computes diff --git a/src/FillInTheTextBot.Api/Health/GracefulShutdownService.cs b/src/FillInTheTextBot.Api/Health/GracefulShutdownService.cs new file mode 100644 index 00000000..98c3beed --- /dev/null +++ b/src/FillInTheTextBot.Api/Health/GracefulShutdownService.cs @@ -0,0 +1,67 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using FillInTheTextBot.Services.Configuration; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace FillInTheTextBot.Api.Health +{ + /// + /// Даёт балансировщику время вывести экземпляр из ротации до фактической остановки. + /// + /// Без этой паузы порядок такой: приложение перестаёт слушать порт, и только потом + /// балансировщик замечает, что проверка здоровья не проходит — запросы, попавшие + /// в этот промежуток, теряются. С паузой сначала краснеет проверка здоровья, + /// балансировщик уводит трафик, и лишь затем закрывается порт. + /// + public sealed class GracefulShutdownService : IHostedService + { + private readonly IHostApplicationLifetime _lifetime; + private readonly ReadinessState _state; + private readonly ShutdownConfiguration _configuration; + private readonly ILogger _log; + + public GracefulShutdownService( + IHostApplicationLifetime lifetime, + ReadinessState state, + ShutdownConfiguration configuration, + ILogger log) + { + _lifetime = lifetime; + _state = state; + _configuration = configuration; + _log = log; + } + + public Task StartAsync(CancellationToken cancellationToken) + { + _lifetime.ApplicationStopping.Register(OnStopping); + + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + private void OnStopping() + { + _state.BeginShutdown(); + + var delay = TimeSpan.FromSeconds(_configuration.DrainDelaySeconds); + + if (delay <= TimeSpan.Zero) + { + return; + } + + _log.LogInformation("Instance is marked as not ready, draining traffic for {Delay}", delay); + + // Обработчик ApplicationStopping синхронный: хост дожидается его завершения, + // и это ровно то, что нужно — пауза удерживает приложение поднятым + Thread.Sleep(delay); + } + } +} diff --git a/src/FillInTheTextBot.Api/Health/ReadinessHealthCheck.cs b/src/FillInTheTextBot.Api/Health/ReadinessHealthCheck.cs new file mode 100644 index 00000000..44c9ac76 --- /dev/null +++ b/src/FillInTheTextBot.Api/Health/ReadinessHealthCheck.cs @@ -0,0 +1,26 @@ +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace FillInTheTextBot.Api.Health +{ + public sealed class ReadinessHealthCheck : IHealthCheck + { + private readonly ReadinessState _state; + + public ReadinessHealthCheck(ReadinessState state) + { + _state = state; + } + + public Task CheckHealthAsync(HealthCheckContext context, + CancellationToken cancellationToken = default) + { + var result = _state.IsReady + ? HealthCheckResult.Healthy() + : HealthCheckResult.Unhealthy("Экземпляр останавливается"); + + return Task.FromResult(result); + } + } +} diff --git a/src/FillInTheTextBot.Api/Health/ReadinessState.cs b/src/FillInTheTextBot.Api/Health/ReadinessState.cs new file mode 100644 index 00000000..5975dd94 --- /dev/null +++ b/src/FillInTheTextBot.Api/Health/ReadinessState.cs @@ -0,0 +1,19 @@ +namespace FillInTheTextBot.Api.Health +{ + /// + /// Готовность экземпляра принимать новые запросы. Нужна для бесшовного обновления: + /// перед остановкой экземпляр объявляет себя неготовым, балансировщик выводит его + /// из ротации, и только после этого приложение действительно останавливается. + /// + public sealed class ReadinessState + { + private volatile bool _isShuttingDown; + + public bool IsReady => !_isShuttingDown; + + public void BeginShutdown() + { + _isShuttingDown = true; + } + } +} diff --git a/src/FillInTheTextBot.Api/Startup.cs b/src/FillInTheTextBot.Api/Startup.cs index 19d26b99..b4018c6e 100644 --- a/src/FillInTheTextBot.Api/Startup.cs +++ b/src/FillInTheTextBot.Api/Startup.cs @@ -1,4 +1,4 @@ -using FillInTheTextBot.Api.Middleware; +using FillInTheTextBot.Api.Middleware; using FillInTheTextBot.Services.Configuration; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.Configuration; @@ -6,11 +6,19 @@ using System; using System.Linq; using FillInTheTextBot.Api.DI; +using FillInTheTextBot.Api.Health; +using Microsoft.Extensions.Hosting; namespace FillInTheTextBot.Api { public class Startup { + /// + /// Путь проверки здоровья. По нему ходит балансировщик, чтобы понимать, + /// можно ли слать на экземпляр трафик. + /// + public const string HealthPath = "/health"; + private readonly IConfiguration _configuration; public Startup(IConfiguration configuration) @@ -36,10 +44,27 @@ public void ConfigureServices(IServiceCollection services) }); services.AddAppConfiguration(appConfiguration); + + AddHealth(services); + services.AddInternalServices(); services.AddExternalServices(); } + private static void AddHealth(IServiceCollection services) + { + services.AddSingleton(); + services.AddHostedService(); + + services.AddHealthChecks() + .AddCheck("readiness"); + + // Хост должен дождаться и текущих запросов, и разбора очереди фоновых работ + services.AddOptions() + .Configure((options, shutdown) => + options.ShutdownTimeout = TimeSpan.FromSeconds(shutdown.TimeoutSeconds)); + } + // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. // ReSharper disable once UnusedMember.Global public void Configure(IApplicationBuilder app, AppConfiguration configuration) @@ -61,6 +86,7 @@ public void Configure(IApplicationBuilder app, AppConfiguration configuration) { e.MapControllers(); e.MapPrometheusScrapingEndpoint(); + e.MapHealthChecks(HealthPath); }); } } diff --git a/src/FillInTheTextBot.Api/appsettings.json b/src/FillInTheTextBot.Api/appsettings.json index 35b45ecb..293c15ff 100644 --- a/src/FillInTheTextBot.Api/appsettings.json +++ b/src/FillInTheTextBot.Api/appsettings.json @@ -1,9 +1,9 @@ { "Logging": { "LogLevel": { - "Default": "Debug", - "Microsoft": "Debug", - "Microsoft.Hosting.Lifetime": "Debug" + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" } }, "AppConfiguration": { @@ -30,7 +30,11 @@ "Tracing": { "Enabled": false, "Host": "", - "Port": "" + "Port": 0 + }, + "Shutdown": { + "DrainDelaySeconds": 10, + "TimeoutSeconds": 30 }, "Conversation":{ "ResetContextWords": [ diff --git a/src/FillInTheTextBot.Services/Configuration/AppConfiguration.cs b/src/FillInTheTextBot.Services/Configuration/AppConfiguration.cs index 67b05e02..d4f0651a 100644 --- a/src/FillInTheTextBot.Services/Configuration/AppConfiguration.cs +++ b/src/FillInTheTextBot.Services/Configuration/AppConfiguration.cs @@ -11,5 +11,7 @@ public class AppConfiguration public TracingConfiguration Tracing { get; set; } public ConversationConfiguration Conversation { get; set; } + + public ShutdownConfiguration Shutdown { get; set; } } } diff --git a/src/FillInTheTextBot.Services/Configuration/ShutdownConfiguration.cs b/src/FillInTheTextBot.Services/Configuration/ShutdownConfiguration.cs new file mode 100644 index 00000000..9229faca --- /dev/null +++ b/src/FillInTheTextBot.Services/Configuration/ShutdownConfiguration.cs @@ -0,0 +1,18 @@ +namespace FillInTheTextBot.Services.Configuration +{ + public class ShutdownConfiguration + { + /// + /// Сколько секунд экземпляр держится поднятым после объявления себя неготовым, + /// чтобы балансировщик успел увести на него трафик. Должно быть заметно больше + /// интервала проверки здоровья у балансировщика. + /// + public int DrainDelaySeconds { get; set; } = 10; + + /// + /// Сколько секунд хост ждёт завершения текущих запросов и разбора очереди + /// фоновых работ после паузы вывода из ротации. + /// + public int TimeoutSeconds { get; set; } = 30; + } +}