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;
+ }
+}