From e8eaa08d293bee97c1ba7920fdeaf75ea9885b1e Mon Sep 17 00:00:00 2001 From: Stepan Grankin Date: Fri, 7 Aug 2026 22:51:20 +0300 Subject: [PATCH 01/26] =?UTF-8?q?feat(api):=20=D0=BF=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D0=BA=D0=B0=20=D0=B7=D0=B4=D0=BE=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D1=8C=D1=8F=20=D0=B8=20=D0=BF=D0=BB=D0=B0=D0=B2=D0=BD=D0=B0?= =?UTF-8?q?=D1=8F=20=D0=BE=D1=81=D1=82=D0=B0=D0=BD=D0=BE=D0=B2=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Эндпоинт /health и состояние готовности экземпляра. При получении сигнала остановки экземпляр сначала объявляет себя неготовым и держится поднятым ещё DrainDelaySeconds — за это время балансировщик успевает увести с него трафик. Без этой паузы порядок обратный: приложение закрывает порт, и только потом балансировщик замечает проблему, теряя попавшие в промежуток запросы. ShutdownTimeout хоста берётся из той же секции конфигурации: после паузы хост дожидается текущих запросов и разбора очереди фоновых работ. Это основа бесшовного обновления, сама раскатка — следующим коммитом. --- .../HappyPathTests.cs | 2 + .../HealthTests.cs | 61 +++++++++++++++++ .../DI/ConfigurationRegistration.cs | 1 + .../Health/GracefulShutdownService.cs | 67 +++++++++++++++++++ .../Health/ReadinessHealthCheck.cs | 26 +++++++ .../Health/ReadinessState.cs | 19 ++++++ src/FillInTheTextBot.Api/Startup.cs | 26 +++++++ src/FillInTheTextBot.Api/appsettings.json | 4 ++ .../Configuration/AppConfiguration.cs | 2 + .../Configuration/ShutdownConfiguration.cs | 18 +++++ 10 files changed, 226 insertions(+) create mode 100644 src/FillInTheTextBot.Api.IntegrationTests/HealthTests.cs create mode 100644 src/FillInTheTextBot.Api/Health/GracefulShutdownService.cs create mode 100644 src/FillInTheTextBot.Api/Health/ReadinessHealthCheck.cs create mode 100644 src/FillInTheTextBot.Api/Health/ReadinessState.cs create mode 100644 src/FillInTheTextBot.Services/Configuration/ShutdownConfiguration.cs 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 97602b58..19c28a64 100644 --- a/src/FillInTheTextBot.Api/DI/ConfigurationRegistration.cs +++ b/src/FillInTheTextBot.Api/DI/ConfigurationRegistration.cs @@ -16,6 +16,7 @@ internal static void AddAppConfiguration(this IServiceCollection services, IConf services.AddSingleton(configuration.Dialogflow); services.AddSingleton(configuration.Tracing); services.AddSingleton(configuration.Conversation); + services.AddSingleton(configuration.Shutdown ?? new ShutdownConfiguration()); } } } 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 2daef83e..3c064307 100644 --- a/src/FillInTheTextBot.Api/Startup.cs +++ b/src/FillInTheTextBot.Api/Startup.cs @@ -8,6 +8,8 @@ using System.Linq; using System.Reflection; using FillInTheTextBot.Api.DI; +using FillInTheTextBot.Api.Health; +using Microsoft.Extensions.Hosting; using OpenTelemetry.Metrics; using OpenTelemetry.Resources; using OpenTelemetry.Trace; @@ -16,6 +18,12 @@ namespace FillInTheTextBot.Api { public class Startup { + /// + /// Путь проверки здоровья. По нему ходит балансировщик, чтобы понимать, + /// можно ли слать на экземпляр трафик. + /// + public const string HealthPath = "/health"; + private const int DefaultOtlpPort = 4317; private readonly IConfiguration _configuration; @@ -41,10 +49,27 @@ public void ConfigureServices(IServiceCollection services) }); services.AddAppConfiguration(_configuration); + + 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)); + } + private void AddTelemetry(IServiceCollection services) { var fullVersion = Assembly.GetExecutingAssembly().GetName().Version; @@ -122,6 +147,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 fc18875b..322d8f15 100644 --- a/src/FillInTheTextBot.Api/appsettings.json +++ b/src/FillInTheTextBot.Api/appsettings.json @@ -31,6 +31,10 @@ "Host": "", "Port": "" }, + "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; + } +} From f98895788c4175b96d8262e064d6b07a85f6c894 Mon Sep 17 00:00:00 2001 From: Stepan Grankin Date: Fri, 7 Aug 2026 22:57:24 +0300 Subject: [PATCH 02/26] =?UTF-8?q?fix(docker):=20=D0=B2=D0=BE=D1=81=D1=81?= =?UTF-8?q?=D1=82=D0=B0=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=D1=81=D0=B1=D0=BE=D1=80=D0=BA=D0=B8=20=D0=BE=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D0=B7=D0=B0=20=D0=B8=20=D1=81=D1=82=D0=B0=D1=80=D1=82=D1=83?= =?UTF-8?q?=D1=8E=D1=89=D0=B0=D1=8F=20=D0=BA=D0=BE=D0=BD=D1=84=D0=B8=D0=B3?= =?UTF-8?q?=D1=83=D1=80=D0=B0=D1=86=D0=B8=D1=8F=20=D0=BF=D0=BE=20=D1=83?= =?UTF-8?q?=D0=BC=D0=BE=D0=BB=D1=87=D0=B0=D0=BD=D0=B8=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Две поломки, найденные при первой реальной сборке и запуске образа: - с переходом на централизованное управление версиями пакетов restore внутри образа перестал проходить: Directory.Packages.props не копировался в контекст сборки - шаблонный appsettings.json содержал "Port": "" в секции Tracing, и типизированная привязка конфигурации падала на старте. Значение заменено на 0 — оно и раньше означало "не настроено" Добавлен HEALTHCHECK: по нему скрипт раскатки понимает, что новый экземпляр готов принимать трафик. Ради него в образ доставлен curl. Проверено запуском контейнера: healthcheck зеленеет, /health отдаёт Healthy. --- src/FillInTheTextBot.Api/Dockerfile | 6 ++++++ src/FillInTheTextBot.Api/appsettings.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/FillInTheTextBot.Api/Dockerfile b/src/FillInTheTextBot.Api/Dockerfile index 00cfafa0..169fb539 100644 --- a/src/FillInTheTextBot.Api/Dockerfile +++ b/src/FillInTheTextBot.Api/Dockerfile @@ -1,6 +1,9 @@ #See https://aka.ms/containerfastmode to understand how Visual Studio uses this Dockerfile to build your images for faster debugging. FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base +# curl нужен только для HEALTHCHECK: по его состоянию скрипт раскатки понимает, +# что новый экземпляр готов принимать трафик +RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* WORKDIR /app # Начиная с .NET 8 образы по умолчанию слушают 8080. Порт зафиксирован явно, # чтобы контракт контейнера не поменялся вместе с версией рантайма. @@ -9,6 +12,8 @@ EXPOSE 80 FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build WORKDIR /src +# Версии пакетов управляются централизованно, без этого файла restore не проходит +COPY ["Directory.Packages.props", "./"] COPY ["FillInTheTextBot.Api/FillInTheTextBot.Api.csproj", "FillInTheTextBot.Api/"] COPY ["FillInTheTextBot.Services/FillInTheTextBot.Services.csproj", "FillInTheTextBot.Services/"] COPY ["FillInTheTextBot.Models/FillInTheTextBot.Models.csproj", "FillInTheTextBot.Models/"] @@ -27,4 +32,5 @@ RUN dotnet publish "FillInTheTextBot.Api.csproj" -c Release -o /app/publish FROM base AS final WORKDIR /app COPY --from=publish /app/publish . +HEALTHCHECK --interval=5s --timeout=3s --start-period=20s --retries=3 CMD curl -fsS http://localhost:80/health || exit 1 ENTRYPOINT ["dotnet", "FillInTheTextBot.Api.dll"] diff --git a/src/FillInTheTextBot.Api/appsettings.json b/src/FillInTheTextBot.Api/appsettings.json index 322d8f15..f0f69611 100644 --- a/src/FillInTheTextBot.Api/appsettings.json +++ b/src/FillInTheTextBot.Api/appsettings.json @@ -29,7 +29,7 @@ }, "Tracing": { "Host": "", - "Port": "" + "Port": 0 }, "Shutdown": { "DrainDelaySeconds": 10, From 8e0d44e4208ce359e8584817683dab9b399396bc Mon Sep 17 00:00:00 2001 From: Stepan Grankin Date: Fri, 7 Aug 2026 22:57:24 +0300 Subject: [PATCH 03/26] =?UTF-8?q?ci:=20=D0=BF=D1=83=D0=B1=D0=BB=D0=B8?= =?UTF-8?q?=D0=BA=D0=B0=D1=86=D0=B8=D1=8F=20=D0=BE=D0=B1=D1=80=D0=B0=D0=B7?= =?UTF-8?q?=D0=B0=20=D0=B8=20=D0=B1=D0=B5=D1=81=D1=88=D0=BE=D0=B2=D0=BD?= =?UTF-8?q?=D0=B0=D1=8F=20=D1=80=D0=B0=D1=81=D0=BA=D0=B0=D1=82=D0=BA=D0=B0?= =?UTF-8?q?=20=D0=BD=D0=B0=20=D1=81=D0=B5=D1=80=D0=B2=D0=B5=D1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docker Hub на каждый merge в master, теги — версия из csproj, хеш коммита и latest. Шаг раскатки идёт по SSH и включается переменной DEPLOY_ENABLED, поэтому до настройки доступа workflow только публикует образ. deploy/docker-compose.yml — Traefik с автовыпуском и автопродлением сертификата Let's Encrypt, приложение и Redis. Traefik следит за контейнерами через сокет Docker и проверяет /health раз в две секунды. deploy/rollout.sh — раскатка без простоя: новый экземпляр поднимается рядом со старым, старый гасится только после того, как стал здоровым новый. Если новый не поднялся за две минуты, скрипт откатывается на старый. Порядок остановки проверен на живом контейнере: через три секунды после сигнала /health уже отдаёт 503, а порт ещё принимает запросы; порт закрывается только после паузы вывода из ротации. deploy/README.md — что задать на сервере и в GitHub. --- .github/workflows/docker-publish.yml | 42 +++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 1c2a34ab..8c5b2150 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -1,17 +1,33 @@ name: Docker publish on: - release: - types: [published] + push: + branches: [ master ] + workflow_dispatch: + +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 + - name: Read version + id: version + run: | + version=$(grep -oPm1 '(?<=)[^<]+' src/FillInTheTextBot.Api/FillInTheTextBot.Api.csproj) + echo "value=${version}" >> "$GITHUB_OUTPUT" + echo "Version: ${version}" + - name: Log in to Docker Hub uses: docker/login-action@v3 with: @@ -25,5 +41,23 @@ jobs: 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 }} From 9fbc4d94bb14cb236b406a296f02ea7bb3216ac2 Mon Sep 17 00:00:00 2001 From: Stepan Grankin Date: Sun, 30 Aug 2026 13:22:57 +0300 Subject: [PATCH 04/26] =?UTF-8?q?feat(deploy):=20=D1=88=D0=B0=D0=B1=D0=BB?= =?UTF-8?q?=D0=BE=D0=BD=D1=8B=20=D0=B8=20=D1=81=D0=BA=D1=80=D0=B8=D0=BF?= =?UTF-8?q?=D1=82=20=D0=B1=D0=B5=D1=81=D1=88=D0=BE=D0=B2=D0=BD=D0=BE=D0=B9?= =?UTF-8?q?=20=D1=80=D0=B0=D1=81=D0=BA=D0=B0=D1=82=D0=BA=D0=B8,=20=D1=82?= =?UTF-8?q?=D1=80=D0=B5=D0=BA=D0=B8=D0=BD=D0=B3=20deploy/=20=D0=B1=D0=B5?= =?UTF-8?q?=D0=B7=20=D1=81=D0=B5=D0=BA=D1=80=D0=B5=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitattributes | 4 ++ .gitignore | 5 +- deploy/README.md | 96 +++++++++++++++++++++++++++++++ deploy/app-env.example | 52 +++++++++++++++++ deploy/docker-compose.yml | 62 ++++++++++++++++++++ deploy/env.example | 36 ++++++++++++ deploy/rollout.sh | 117 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 371 insertions(+), 1 deletion(-) create mode 100644 .gitattributes create mode 100644 deploy/README.md create mode 100644 deploy/app-env.example create mode 100644 deploy/docker-compose.yml create mode 100644 deploy/env.example create mode 100755 deploy/rollout.sh diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..9383aa80 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +# Скрипты и конфиги раскатки исполняются на Linux-сервере — только LF, +# иначе ломается shebang и парсинг (репозиторий разрабатывается на Windows). +*.sh text eol=lf +/deploy/** text eol=lf diff --git a/.gitignore b/.gitignore index 26cfd55f..50637a70 100644 --- a/.gitignore +++ b/.gitignore @@ -9,7 +9,10 @@ **/.hg* **/.hg **/Keys/ -**/deploy/ +# deploy/: конфиги раскатки трекаются, секреты и сертификаты — нет +/deploy/.env +/deploy/app.env +/deploy/acme/ **/.config/ **/Properties/ /src/FillInTheTextBot.Api/FillInTheTextBot.Api.csproj.user diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 00000000..8cbdc18b --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,96 @@ +# Раскатка сервиса + +Схема деплоя: бесшовное обновление без простоя, TLS и сертификат — на стороне +пограничного прокси. + +``` + Интернет (443/https) + │ + ┌─────▼─────┐ выпускает и продлевает + │ Traefik │ сертификат Let's Encrypt, + │ (edge) │ терминирует TLS + └─────┬─────┘ + │ http:80 (по метке Host + healthcheck /health) + ┌───────┴────────┐ + ▼ ▼ + fitb (старый) fitb (новый) ← во время раската живут оба, + drain → выход principal трафик балансируется на здоровые +``` + +- **Само приложение работает только по HTTP на порту 80** — сертификатов и pfx + внутри контейнера больше нет. TLS терминирует Traefik. +- **Traefik** выпускает сертификат Let's Encrypt при первом обращении к домену и + дальше продлевает его сам. Продление происходит внутри Traefik: сертификат + подменяется на лету, **контейнер сервиса не перезапускается, запросы не рвутся**. +- **`rollout.sh`** обновляет версию с перекрытием: поднимает новый экземпляр, ждёт + его готовности, и только потом гасит старый (тот сливает трафик и доживает + текущие запросы). В любой момент трафик обслуживает готовый экземпляр. + +## Что требуется на сервере (однократно) + +1. **DNS.** A-запись домена (`DOMAIN`) должна указывать на IP сервера — без этого + Let's Encrypt не пройдёт HTTP-01 проверку. +2. **Docker-сеть.** Общая сеть для прокси, сервиса, redis и prometheus: + ```bash + docker network create network # если ещё не создана + ``` +3. **Ключи Dialogflow** лежат в `KEYS_DIR` (по умолчанию `/docker/keys`), пути в + `app.env` (`...JsonPath=keys/<файл>.json`) указывают на них. +4. **Конфигурация:** + ```bash + cp deploy/env.example deploy/.env # домен, почта ACME + cp deploy/app-env.example deploy/app.env # секреты приложения + mkdir -p deploy/acme # хранилище сертификатов + ``` + Заполнить `deploy/.env` и `deploy/app.env`. Оба файла в git не попадают. +5. **Поднять прокси** (работает постоянно, переживает перезагрузки): + ```bash + docker compose -f deploy/docker-compose.yml up -d + ``` + +## Как выкатывается новая версия + +Автоматически через GitHub Actions (`.github/workflows/docker-publish.yml`): на +push в `master` собирается и публикуется образ, затем по SSH на сервере +выполняется `rollout.sh <версия>`. + +Чтобы шаг деплоя включился, задать в репозитории: + +- секреты: `DOCKERHUB_USERNAME`, `DOCKERHUB_TOKEN`, `DEPLOY_HOST`, `DEPLOY_USER`, + `DEPLOY_SSH_KEY`, `DEPLOY_PATH`; +- переменную `DEPLOY_ENABLED = true`. + +`DEPLOY_PATH` на сервере — это каталог с содержимым `deploy/` (скопируйте его туда; +`rollout.sh` должен быть исполняемым). + +Вручную раскатать можно так же: + +```bash +cd +./rollout.sh 1.24.0 +``` + +## Что происходит при раскате (по шагам) + +1. `docker pull` нужной версии. +2. Поднимается новый контейнер `fitb_<версия>_<время>` с метками Traefik и сетевым + алиасом `fitb` (по нему Prometheus скрейпит `/metrics`). +3. Скрипт ждёт, пока новый ответит `200` на `/health` (стучится прямо в его IP в + docker-сети — curl внутри образа не нужен). +4. Traefik добавляет новый экземпляр в пул; старый получает `docker stop` → + приложение объявляет себя неготовым (`/health` → 503), Traefik уводит с него + трафик, сервис доживает текущие запросы и разбирает очередь фоновых задач, затем + выходит. Тайминги — в `Shutdown` (drain 10с + timeout 30с), запас на остановку — + `STOP_TIMEOUT` (45с). + +Если новый экземпляр не стал здоровым за `HEALTH_TIMEOUT`, он удаляется, старый +остаётся работать — раскат безопасно откатывается. + +## Заметки + +- **Продление сертификата простоя не вызывает** — оно целиком внутри Traefik. +- **Домен, а не голый IP.** С доменом сертификат обычный (90 дней, автопродление). + На голый IP Let's Encrypt тоже умеет, но такой сертификат короткоживущий (~6 + дней) и требует ACME-профиль `shortlived` — в этой конфигурации не заложено. +- **Порты 25/465** на новой облачной платформе по умолчанию закрыты — если сервису + нужна отправка почты, разблокировку запрашивают у поддержки. diff --git a/deploy/app-env.example b/deploy/app-env.example new file mode 100644 index 00000000..32351dd9 --- /dev/null +++ b/deploy/app-env.example @@ -0,0 +1,52 @@ +# Секреты и настройки приложения. Передаётся контейнеру как --env-file. +# Скопируйте в deploy/app.env и заполните реальными значениями: +# +# cp deploy/app-env.example deploy/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 + +# ...добавьте остальные агенты по тому же образцу (2, 3, ...). + +# --------------------------------------------------------------------------- +# Redis (кэш сессий). Хост redis доступен по имени внутри docker-сети. +# --------------------------------------------------------------------------- +AppConfiguration__Redis__ConnectionString=redis:6379,password=CHANGE-ME,abortConnect=false,syncTimeout=2000,asyncTimeout=2000 +AppConfiguration__Redis__KeyPrefix= + +# --------------------------------------------------------------------------- +# Трассировка (OTLP). Оставьте Host пустым, чтобы экспорт трейсов был выключен. +# --------------------------------------------------------------------------- +AppConfiguration__Tracing__Host= +AppConfiguration__Tracing__Port=4317 + +# --------------------------------------------------------------------------- +# Плавная остановка (значения по умолчанию подходят; менять не обязательно). +# --------------------------------------------------------------------------- +# AppConfiguration__Shutdown__DrainDelaySeconds=10 +# AppConfiguration__Shutdown__TimeoutSeconds=30 + +# --------------------------------------------------------------------------- +# Токены проверки входящих запросов от площадок. Подставляются в +# appsettings.<площадка>.json на место %ИМЯ-ПЕРЕМЕННОЙ%. +# --------------------------------------------------------------------------- +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..ed76c11c --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,62 @@ +# Пограничный прокси: терминирует TLS, сам выпускает и продлевает сертификат +# Let's Encrypt, и маршрутизирует трафик на контейнеры сервиса. +# +# Сам сервис (fitb) этим compose НЕ поднимается — его раскатывает rollout.sh +# с перекрытием старого и нового экземпляра, чтобы обновление шло без простоя. +# Traefik обнаруживает контейнеры сервиса по docker-меткам на общей сети. +# +# Запускается один раз и работает постоянно: +# docker compose -f deploy/docker-compose.yml up -d + +name: fitb-edge + +networks: + # Та же сеть, где живут redis/prometheus и контейнеры сервиса. + # Создаётся отдельно (см. README), поэтому external. + network: + external: true + +services: + traefik: + image: traefik:v3.7 + container_name: traefik + restart: always + networks: + - network + ports: + - "80:80" + - "443:443" + command: + # --- Провайдер: читаем конфигурацию из меток docker-контейнеров --- + - --providers.docker=true + # Ничего не публикуем наружу без явной метки traefik.enable=true + - --providers.docker.exposedbydefault=false + - --providers.docker.network=network + + # --- Точки входа --- + - --entrypoints.web.address=:80 + - --entrypoints.websecure.address=:443 + # Весь http автоматически переводим на https. Запросы ACME-проверки + # (/.well-known/acme-challenge/...) Traefik обслуживает до редиректа, + # поэтому выпуск сертификата не ломается. + - --entrypoints.web.http.redirections.entrypoint.to=websecure + - --entrypoints.web.http.redirections.entrypoint.scheme=https + + # --- Let's Encrypt (HTTP-01 challenge на 80 порту) --- + - --certificatesresolvers.le.acme.email=${ACME_EMAIL} + - --certificatesresolvers.le.acme.storage=/acme/acme.json + - --certificatesresolvers.le.acme.httpchallenge=true + - --certificatesresolvers.le.acme.httpchallenge.entrypoint=web + + # Заголовки, чьё имя является алиасом управляемого Traefik заголовка, + # отбрасываем — иначе клиент может подменить 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.json Traefik создаёт сам с правами 600 + - ./acme:/acme diff --git a/deploy/env.example b/deploy/env.example new file mode 100644 index 00000000..5ada5dd4 --- /dev/null +++ b/deploy/env.example @@ -0,0 +1,36 @@ +# Параметры раскатки и пограничного прокси. +# Скопируйте в deploy/.env и заполните. Файл .env в репозиторий не коммитится. +# +# cp deploy/env.example deploy/.env + +# Домен, на который отвечает сервис (для него Traefik выпустит сертификат). +# На этот домен должна быть A-запись, указывающая на IP сервера. +DOMAIN=example.com + +# Почта для регистрации в Let's Encrypt (уведомления об истечении и т.п.) +ACME_EMAIL=admin@example.com + +# --- Ниже можно не трогать: значения по умолчанию подходят для боевого сервера --- + +# Репозиторий образа в Docker Hub (тег версии добавляет rollout.sh) +# IMAGE_REPO=granstel/fillinthetextbot + +# Docker-сеть, общая для прокси, сервиса, redis и prometheus +# DOCKER_NETWORK=network + +# Стабильное сетевое имя экземпляра — по нему Prometheus скрейпит /metrics +# SERVICE_ALIAS=fitb + +# Каталоги на сервере, монтируемые в контейнер +# KEYS_DIR=/docker/keys # json-ключи сервис-аккаунтов Dialogflow +# LOGS_DIR=/docker/logs/fitb # логи приложения + +# Файл с секретами приложения, передаётся контейнеру как --env-file +# APP_ENV_FILE=./app.env + +# Сколько ждать готовности нового экземпляра, сек +# HEALTH_TIMEOUT=60 + +# Запас на слив трафика и завершение запросов при остановке старого, сек. +# Должен быть больше, чем DrainDelaySeconds + Shutdown.TimeoutSeconds сервиса. +# STOP_TIMEOUT=45 diff --git a/deploy/rollout.sh b/deploy/rollout.sh new file mode 100755 index 00000000..0f62784a --- /dev/null +++ b/deploy/rollout.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# +# Бесшовная раскатка новой версии сервиса. +# +# ./rollout.sh <версия> +# +# Идея: не гасить старый контейнер до того, как новый готов принимать трафик. +# 1. Тянем образ нужной версии. +# 2. Поднимаем НОВЫЙ контейнер рядом со старым, с теми же метками Traefik — +# Traefik добавляет его в пул балансировки как второй сервер. +# 3. Ждём, пока новый ответит здоровьем на /health. +# 4. Гасим старый: он по SIGTERM объявляет себя неготовым (health -> 503), +# Traefik уводит с него трафик, приложение доживает текущие запросы и +# разбирает очередь фоновых задач, и только потом выходит. +# +# В любой момент времени трафик обслуживает хотя бы один готовый экземпляр. + +set -euo pipefail + +VERSION="${1:?Использование: rollout.sh <версия>}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +# Параметры раскатки (пути, домен, имя образа) — из deploy/.env +if [ -f .env ]; then + set -a + # shellcheck disable=SC1091 + . ./.env + set +a +fi + +IMAGE_REPO="${IMAGE_REPO:-granstel/fillinthetextbot}" +IMAGE="${IMAGE_REPO}:${VERSION}" +NETWORK="${DOCKER_NETWORK:-network}" +ALIAS="${SERVICE_ALIAS:-fitb}" # стабильное сетевое имя для Prometheus +DOMAIN="${DOMAIN:?DOMAIN должен быть задан в deploy/.env}" +KEYS_DIR="${KEYS_DIR:-/docker/keys}" # ключи сервис-аккаунтов Dialogflow +LOGS_DIR="${LOGS_DIR:-/docker/logs/fitb}" +APP_ENV_FILE="${APP_ENV_FILE:-${SCRIPT_DIR}/app.env}" +HEALTH_TIMEOUT="${HEALTH_TIMEOUT:-60}" # сколько ждём готовности нового, сек +# Запас на слив трафика (drain) + завершение запросов и очереди фоновых задач. +# Должен быть больше, чем DrainDelaySeconds + Shutdown.TimeoutSeconds сервиса. +STOP_TIMEOUT="${STOP_TIMEOUT:-45}" + +if [ ! -f "$APP_ENV_FILE" ]; then + echo "!! Не найден файл окружения приложения: $APP_ENV_FILE" >&2 + echo " Скопируйте app.env.example в app.env и заполните секреты." >&2 + exit 1 +fi + +# Уникальное имя: версия + метка времени, чтобы можно было переката́ть ту же версию +SAFE_VERSION="${VERSION//[^A-Za-z0-9_.-]/_}" +NEW_NAME="fitb_${SAFE_VERSION}_$(date +%s)" + +echo "==> Тянем образ ${IMAGE}" +docker pull "$IMAGE" + +echo "==> Текущие контейнеры сервиса:" +mapfile -t OLD < <(docker ps --filter "label=app=fitb" --format '{{.Names}}') +if [ "${#OLD[@]}" -eq 0 ]; then + echo " (нет — первый запуск)" +else + printf ' %s\n' "${OLD[@]}" +fi + +echo "==> Поднимаем новый экземпляр ${NEW_NAME}" +docker run -d \ + --name "$NEW_NAME" \ + --restart unless-stopped \ + --network "$NETWORK" \ + --network-alias "$ALIAS" \ + --env-file "$APP_ENV_FILE" \ + -v "${KEYS_DIR}:/app/keys:ro" \ + -v "${LOGS_DIR}:/app/logs" \ + --label app=fitb \ + --label traefik.enable=true \ + --label "traefik.docker.network=${NETWORK}" \ + --label "traefik.http.routers.fitb.rule=Host(\`${DOMAIN}\`)" \ + --label traefik.http.routers.fitb.entrypoints=websecure \ + --label traefik.http.routers.fitb.tls=true \ + --label traefik.http.routers.fitb.tls.certresolver=le \ + --label traefik.http.services.fitb.loadbalancer.server.port=80 \ + --label traefik.http.services.fitb.loadbalancer.healthcheck.path=/health \ + --label traefik.http.services.fitb.loadbalancer.healthcheck.interval=3s \ + --label traefik.http.services.fitb.loadbalancer.healthcheck.timeout=2s \ + "$IMAGE" >/dev/null + +# Ждём готовности нового экземпляра, стучась прямо в его IP из сети docker. +# Хост дотягивается до контейнера напрямую, curl внутри образа не нужен. +NEW_IP="$(docker inspect -f "{{ (index .NetworkSettings.Networks \"${NETWORK}\").IPAddress }}" "$NEW_NAME")" +echo "==> Ждём готовности ${NEW_NAME} (${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 + docker logs --tail 50 "$NEW_NAME" || true + docker rm -f "$NEW_NAME" >/dev/null 2>&1 || true + exit 1 + fi + sleep 2 +done +echo " Новый экземпляр здоров." + +# Новый в пуле Traefik и принимает трафик — можно спокойно уводить старые. +for c in "${OLD[@]}"; do + [ -z "$c" ] && continue + echo "==> Сливаем и останавливаем старый ${c} (до ${STOP_TIMEOUT}s)" + docker stop -t "$STOP_TIMEOUT" "$c" >/dev/null || true + docker rm "$c" >/dev/null 2>&1 || true +done + +echo "==> Чистим повисшие образы" +docker image prune -f >/dev/null 2>&1 || true + +echo "==> Готово: активна версия ${VERSION} (${NEW_NAME})" From 287187dcc0234909998bdb7dc8d467e9e9a16b90 Mon Sep 17 00:00:00 2001 From: Stepan Grankin Date: Sun, 30 Aug 2026 14:04:44 +0300 Subject: [PATCH 05/26] =?UTF-8?q?ci:=20=D0=B2=D1=8B=D0=BF=D1=83=D1=81?= =?UTF-8?q?=D0=BA=20=D0=BE=D0=B1=D1=80=D0=B0=D0=B7=D0=B0=20=D0=BF=D0=BE=20?= =?UTF-8?q?=D1=80=D0=B5=D0=BB=D0=B8=D0=B7=D1=83,=20=D0=B0=20=D0=BD=D0=B5?= =?UTF-8?q?=20=D0=BF=D0=BE=20=D0=BF=D1=83=D1=88=D1=83=20=D0=B2=20master?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Версия и тег образа берутся из имени тега релиза — как было принято в проекте раньше. Пуш в master сборку больше не запускает. --- .github/workflows/docker-publish.yml | 13 +++++++++---- deploy/README.md | 7 ++++--- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 8c5b2150..0fc128be 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -1,9 +1,10 @@ name: Docker publish on: - push: - branches: [ master ] - workflow_dispatch: + # Выпуск версии — публикация релиза в GitHub. Имя тега релиза становится + # тегом образа и версией, которую раскатывает rollout.sh на сервере. + release: + types: [ published ] concurrency: # Раскатки не должны идти параллельно: две одновременные сломают порядок замены экземпляров @@ -24,7 +25,11 @@ jobs: - name: Read version id: version run: | - version=$(grep -oPm1 '(?<=)[^<]+' src/FillInTheTextBot.Api/FillInTheTextBot.Api.csproj) + version='${{ github.event.release.tag_name }}' + if [ -z "$version" ]; then + echo "У релиза пустой тег — нечего собирать" >&2 + exit 1 + fi echo "value=${version}" >> "$GITHUB_OUTPUT" echo "Version: ${version}" diff --git a/deploy/README.md b/deploy/README.md index 8cbdc18b..7b6d65be 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -50,9 +50,10 @@ ## Как выкатывается новая версия -Автоматически через GitHub Actions (`.github/workflows/docker-publish.yml`): на -push в `master` собирается и публикуется образ, затем по SSH на сервере -выполняется `rollout.sh <версия>`. +Автоматически через GitHub Actions (`.github/workflows/docker-publish.yml`): при +публикации релиза в GitHub собирается и публикуется образ, затем по SSH на сервере +выполняется `rollout.sh <версия>`. Версия — это имя тега релиза, им же тегируется +образ (дополнительно проставляются теги `` и `latest`). Чтобы шаг деплоя включился, задать в репозитории: From 6b9a4c4b8a5ebf52a41134e750a01db1361ab52a Mon Sep 17 00:00:00 2001 From: Stepan Grankin Date: Sun, 30 Aug 2026 14:06:36 +0300 Subject: [PATCH 06/26] =?UTF-8?q?ci(deps):=20=D0=BE=D0=B1=D0=BD=D0=BE?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B2=D0=B5=D1=80=D1=81?= =?UTF-8?q?=D0=B8=D0=B9=20GitHub=20Actions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit checkout v4 -> v7, setup-dotnet v4 -> v6, login-action v3 -> v4, build-push-action v6 -> v7. Мажорные версии переводят действия на рантайм Node 24; входные параметры, которыми мы пользуемся, не менялись. --- .github/workflows/build&test.yml | 4 ++-- .github/workflows/docker-publish.yml | 6 +++--- .github/workflows/loadtest.yml | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) 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 0fc128be..5617e375 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -20,7 +20,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Read version id: version @@ -34,13 +34,13 @@ jobs: 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 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: From 732811a6d51f35744783e9284ae803575119e4eb Mon Sep 17 00:00:00 2001 From: Stepan Grankin Date: Sun, 30 Aug 2026 14:43:40 +0300 Subject: [PATCH 07/26] =?UTF-8?q?chore(api):=20=D1=83=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=BD=D0=B8=20=D0=BB=D0=BE=D0=B3=D0=B8=D1=80=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20Information=20=D0=B2=D0=BC=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=BE=20Debug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Microsoft.* переведён на Warning: на Debug каждый HTTP-запрос давал около десятка строк внутренностей Kestrel и маршрутизации. На боевом сервере это вытесняло полезную историю из ограниченного по объёму лога. --- src/FillInTheTextBot.Api/appsettings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/FillInTheTextBot.Api/appsettings.json b/src/FillInTheTextBot.Api/appsettings.json index 11528f72..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": { From 6655183dd9aaea540d1668204a209dc499ca0e32 Mon Sep 17 00:00:00 2001 From: Stepan Grankin Date: Sun, 30 Aug 2026 14:43:40 +0300 Subject: [PATCH 08/26] =?UTF-8?q?chore(api):=20=D0=B2=D0=B5=D1=80=D1=81?= =?UTF-8?q?=D0=B8=D1=8F=20=D1=81=D0=B1=D0=BE=D1=80=D0=BA=D0=B8=201.25.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Тег образа задаётся релизом, версия в проекте влияет только на сборку — приводим её в соответствие с выпущенной версией. --- src/FillInTheTextBot.Api/FillInTheTextBot.Api.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/FillInTheTextBot.Api/FillInTheTextBot.Api.csproj b/src/FillInTheTextBot.Api/FillInTheTextBot.Api.csproj index 36d52f09..707ceb8e 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.25.0 Added new texts and computes From 2b5c929c35be6fe08b9cf26445bcb9bb616a6617 Mon Sep 17 00:00:00 2001 From: Stepan Grankin Date: Sun, 30 Aug 2026 15:41:38 +0300 Subject: [PATCH 09/26] =?UTF-8?q?fix(deploy):=20=D1=83=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BF=D1=80=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D0=BE=D1=8F=20=D0=BF=D1=80=D0=B8=20=D1=80=D0=B0=D1=81?= =?UTF-8?q?=D0=BA=D0=B0=D1=82=D0=BA=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Скрипт гасил старый экземпляр сразу после того, как новый отвечал на /health напрямую по IP. Но Traefik узнаёт о готовности только своей активной проверкой раз в 3 секунды, и в этот зазор в пуле не оставалось живых серверов: старый уже неготов, новый ещё не добавлен — клиент получал 503 от самого прокси. Теперь rollout.sh спрашивает у Traefik через его API, появился ли новый экземпляр в serverStatus со статусом UP, и только тогда уводит старый. API поднят на служебной точке входа :8080, наружу не публикуется. Замер во время раскатки: было 12 ошибок на 338 запросов, стало 0 из 911 и 0 из 1342 в двух прогонах. --- deploy/README.md | 39 +++++++++++++++++++++++++++++++-------- deploy/docker-compose.yml | 6 ++++++ deploy/rollout.sh | 37 +++++++++++++++++++++++++++++++++++-- 3 files changed, 72 insertions(+), 10 deletions(-) diff --git a/deploy/README.md b/deploy/README.md index 7b6d65be..62c6284a 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -78,14 +78,33 @@ cd алиасом `fitb` (по нему Prometheus скрейпит `/metrics`). 3. Скрипт ждёт, пока новый ответит `200` на `/health` (стучится прямо в его IP в docker-сети — curl внутри образа не нужен). -4. Traefik добавляет новый экземпляр в пул; старый получает `docker stop` → - приложение объявляет себя неготовым (`/health` → 503), Traefik уводит с него - трафик, сервис доживает текущие запросы и разбирает очередь фоновых задач, затем - выходит. Тайминги — в `Shutdown` (drain 10с + timeout 30с), запас на остановку — - `STOP_TIMEOUT` (45с). - -Если новый экземпляр не стал здоровым за `HEALTH_TIMEOUT`, он удаляется, старый -остаётся работать — раскат безопасно откатывается. +4. Скрипт спрашивает **у самого Traefik**, взял ли тот новый экземпляр в ротацию: + опрашивает `/api/http/services/fitb@docker`, пока в `serverStatus` не появится + `UP` для адреса нового контейнера (до `ROTATION_TIMEOUT`, 30с). +5. Старый получает `docker stop` → приложение объявляет себя неготовым + (`/health` → 503), Traefik уводит с него трафик, сервис доживает текущие запросы + и разбирает очередь фоновых задач, затем выходит. Тайминги — в `Shutdown` + (drain 10с + timeout 30с), запас на остановку — `STOP_TIMEOUT` (45с). + +Если новый экземпляр не стал здоровым за `HEALTH_TIMEOUT` или не попал в ротацию за +`ROTATION_TIMEOUT`, он удаляется, старый остаётся работать — раскат безопасно +откатывается. + +### Почему шаг 4 обязателен + +Готовность приложения и готовность прокси — разные события. Traefik узнаёт о новом +экземпляре только своей активной проверкой (`healthcheck.interval`, 3с). Если гасить +старый сразу после того, как новый ответил на `/health` напрямую, попадаешь в зазор: +старый уже объявил себя неготовым, новый ещё не в пуле, живых серверов нет — и Traefik +отвечает клиенту `503` от себя. + +Это не теория: до появления шага 4 замер во время раската давал 12 таких ответов на +338 запросов обычного трафика. После — 0 из 911 и 0 из 1342 в двух прогонах подряд. + +Отличать эти `503` просто: в access-логе Traefik у ответа от сливающегося экземпляра +указан бэкенд (`"http://172.18.0.4:80"`), а у ответа самого Traefik вместо бэкенда +стоит прочерк (`"-"`). Ответы `503` на `/health` от сливающегося экземпляра — штатные, +это и есть сигнал прокси уводить трафик. ## Заметки @@ -95,3 +114,7 @@ cd дней) и требует ACME-профиль `shortlived` — в этой конфигурации не заложено. - **Порты 25/465** на новой облачной платформе по умолчанию закрыты — если сервису нужна отправка почты, разблокировку запрашивают у поддержки. +- **API Traefik** включён на служебной точке входа `:8080` и наружу не публикуется — + её нет в `ports`, поэтому она доступна только изнутри docker-сети. Оттуда её и + спрашивает `rollout.sh`. Если понадобится открыть дашборд наружу — закрывать + аутентификацией, само по себе оно не защищено. diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index ed76c11c..03bdf525 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -36,6 +36,12 @@ services: # --- Точки входа --- - --entrypoints.web.address=:80 - --entrypoints.websecure.address=:443 + # Служебная точка входа с API. Наружу НЕ публикуется (её нет в ports), + # поэтому доступна только изнутри docker-сети — оттуда rollout.sh + # спрашивает, взял ли прокси новый экземпляр в ротацию. + - --entrypoints.traefik.address=:8080 + - --api=true + - --api.insecure=true # Весь http автоматически переводим на https. Запросы ACME-проверки # (/.well-known/acme-challenge/...) Traefik обслуживает до редиректа, # поэтому выпуск сертификата не ломается. diff --git a/deploy/rollout.sh b/deploy/rollout.sh index 0f62784a..095bba35 100755 --- a/deploy/rollout.sh +++ b/deploy/rollout.sh @@ -9,7 +9,10 @@ # 2. Поднимаем НОВЫЙ контейнер рядом со старым, с теми же метками Traefik — # Traefik добавляет его в пул балансировки как второй сервер. # 3. Ждём, пока новый ответит здоровьем на /health. -# 4. Гасим старый: он по SIGTERM объявляет себя неготовым (health -> 503), +# 4. Спрашиваем у Traefik, взял ли он новый экземпляр в ротацию. Готовность +# приложения и готовность прокси — разные события: прокси узнаёт о ней +# своей проверкой, и гасить старый до этого нельзя. +# 5. Гасим старый: он по SIGTERM объявляет себя неготовым (health -> 503), # Traefik уводит с него трафик, приложение доживает текущие запросы и # разбирает очередь фоновых задач, и только потом выходит. # @@ -42,6 +45,9 @@ HEALTH_TIMEOUT="${HEALTH_TIMEOUT:-60}" # сколько ждём готовн # Запас на слив трафика (drain) + завершение запросов и очереди фоновых задач. # Должен быть больше, чем DrainDelaySeconds + Shutdown.TimeoutSeconds сервиса. STOP_TIMEOUT="${STOP_TIMEOUT:-45}" +TRAEFIK_CONTAINER="${TRAEFIK_CONTAINER:-traefik}" # имя контейнера прокси +TRAEFIK_SERVICE="${TRAEFIK_SERVICE:-fitb}" # имя сервиса в метках ниже +ROTATION_TIMEOUT="${ROTATION_TIMEOUT:-30}" # сколько ждём попадания в пул, сек if [ ! -f "$APP_ENV_FILE" ]; then echo "!! Не найден файл окружения приложения: $APP_ENV_FILE" >&2 @@ -103,7 +109,34 @@ until curl -fsS -m 2 "http://${NEW_IP}/health" >/dev/null 2>&1; do done echo " Новый экземпляр здоров." -# Новый в пуле Traefik и принимает трафик — можно спокойно уводить старые. +# Готовность приложения — ещё не готовность к переключению: Traefik узнаёт о ней +# только своей проверкой (раз в healthcheck.interval). Если погасить старый раньше, +# чем прокси возьмёт новый в ротацию, в пуле не останется живых серверов и клиент +# получит 503. Поэтому спрашиваем у самого Traefik, а не полагаемся на тайминги. +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 " Не могу убедиться, что новый экземпляр в ротации — прерываюсь, старый остаётся работать." >&2 + docker rm -f "$NEW_NAME" >/dev/null 2>&1 || true + exit 1 +fi + +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 + docker rm -f "$NEW_NAME" >/dev/null 2>&1 || true + exit 1 + fi + sleep 1 +done +echo " Traefik балансирует на новый экземпляр." + +# Теперь в пуле гарантированно есть живой сервер — можно уводить старые. for c in "${OLD[@]}"; do [ -z "$c" ] && continue echo "==> Сливаем и останавливаем старый ${c} (до ${STOP_TIMEOUT}s)" From 8af5d9ab2a8414ed16c880e573bec1390fe14f6f Mon Sep 17 00:00:00 2001 From: Stepan Grankin Date: Sun, 30 Aug 2026 18:10:49 +0300 Subject: [PATCH 10/26] =?UTF-8?q?feat(deploy):=20=D1=81=D0=B8=D0=BD=D1=85?= =?UTF-8?q?=D1=80=D0=BE=D0=BD=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20deploy?= =?UTF-8?q?/=20=D1=81=20=D1=82=D0=B5=D0=B3=D0=BE=D0=BC=20=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D1=81=D0=B8=D0=B8=20=D0=BF=D1=80=D0=B8=20=D1=80=D0=B0?= =?UTF-8?q?=D1=81=D0=BA=D0=B0=D1=82=D0=BA=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Каталог deploy/ на сервере был копией, которую обновляли руками, — она молча расходилась с репозиторием. Исправление простоя, например, живёт в rollout.sh, и на неподновлённом сервере раскатка снова роняла бы запросы, ничем это не показывая. Обёртка fitb-deploy, на которую замкнут ключ раскатки, теперь перед запуском подтягивает deploy/ с тега выпускаемой версии: частичный клон (--filter=blob:none --sparse) разворачивает только этот каталог и весит около мегабайта. Скрипты раскатки всегда соответствуют версии. Локальная конфигурация с секретами и сама обёртка не синхронизируются: обёртка — граница доверия, иначе любой коммит получал бы на сервере права группы docker. --- deploy/README.md | 50 ++++++++++++++++++++++++++++-- deploy/fitb-deploy | 77 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 2 deletions(-) create mode 100755 deploy/fitb-deploy diff --git a/deploy/README.md b/deploy/README.md index 62c6284a..05003c72 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -61,8 +61,54 @@ `DEPLOY_SSH_KEY`, `DEPLOY_PATH`; - переменную `DEPLOY_ENABLED = true`. -`DEPLOY_PATH` на сервере — это каталог с содержимым `deploy/` (скопируйте его туда; -`rollout.sh` должен быть исполняемым). +`DEPLOY_PATH` на сервере — это каталог с содержимым `deploy/`. Вручную его копировать +не нужно: он синхронизируется сам, см. следующий раздел. + +## Ключ раскатки и синхронизация deploy/ + +`DEPLOY_SSH_KEY` — отдельный ключ, не тот, которым ходят руками. На сервере он записан +в `authorized_keys` с ограничениями: + +``` +restrict,command="/usr/local/sbin/fitb-deploy" ssh-ed25519 AAAA... fitb-ci-deploy +``` + +`restrict` снимает проброс портов, agent-forwarding, PTY и X11, а `command` заставляет +любое подключение этим ключом выполнять только обёртку `deploy/fitb-deploy`. Обёртка +достаёт из присланной команды номер версии по строгому шаблону и больше ничего не умеет. +Это важно, потому что пользователь раскатки состоит в группе `docker`, а она равносильна +root: без ограничения утёкший из CI ключ отдал бы сервер целиком. + +Обёртка перед раскаткой **подтягивает `deploy/` с тега выпускаемой версии** — частичным +клоном, в котором развёрнут только этот каталог: + +```bash +git clone --filter=blob:none --sparse --no-checkout git@github.com:granstel/FillInTheTextBot.git .repo +git -C .repo sparse-checkout set deploy +``` + +`--filter=blob:none` не качает содержимое файлов, пока оно не понадобится, `--sparse` + +`sparse-checkout` разворачивают в рабочем дереве один каталог. Клон занимает около 1 МБ. +Дальше на каждую раскатку делается `fetch` тега версии и `checkout`, а файлы из +`deploy/` копируются в рабочий каталог, если отличаются. + +Смысл в том, что скрипты раскатки всегда соответствуют выпускаемой версии, а не тому, +что кто-то когда-то скопировал руками. Обратная сторона: откат на старую версию вернёт +и старые скрипты раскатки — это осознанный размен на воспроизводимость. + +Не синхронизируются `.env`, `app.env`, `redis-compose.yml` и `acme/` — это локальная +конфигурация с секретами. + +Не синхронизируется и сама обёртка: она — граница доверия, и если бы CI мог её +переписать, любой коммит получал бы на сервере права группы `docker`. Ставится один раз +под root: + +```bash +install -m 755 -o root -g root deploy/fitb-deploy /usr/local/sbin/fitb-deploy +``` + +Изменение `docker-compose.yml` обёртка тоже копирует, но перезапуск Traefik не делает — +он рвёт установленные соединения. Вместо этого печатает предупреждение с командой. Вручную раскатать можно так же: diff --git a/deploy/fitb-deploy b/deploy/fitb-deploy new file mode 100755 index 00000000..08d2770e --- /dev/null +++ b/deploy/fitb-deploy @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# +# Форсированная команда для ключа раскатки из CI. +# +# Устанавливается на сервер ОДИН РАЗ вручную, под root: +# install -m 755 -o root -g root deploy/fitb-deploy /usr/local/sbin/fitb-deploy +# +# Сам себя этот файл НЕ обновляет — намеренно. Он и есть граница доверия: если бы +# CI мог его переписать, то любой коммит в репозиторий получал бы на сервере права +# группы docker, то есть фактически root. Меняется он редко, обновляется руками. +# +# Что делает: +# 1. Из присланной CI команды достаёт номер версии по строгому шаблону. +# Что бы ни прислали ещё — игнорируется. +# 2. Подтягивает каталог deploy/ РОВНО С ТЕГА этой версии (частичный клон, +# только нужный каталог) и обновляет им рабочую копию в /opt/fitb, чтобы +# скрипты раскатки всегда соответствовали выпущенной версии. +# 3. Запускает обновлённый rollout.sh. +set -euo pipefail + +WORK_DIR="${FITB_WORK_DIR:-/opt/fitb}" +REPO_DIR="${WORK_DIR}/.repo" + +# Синхронизируются только эти файлы. Всё остальное в /opt/fitb — локальная +# конфигурация с секретами (.env, app.env, redis-compose.yml, acme/), её не трогаем. +SYNCED=(rollout.sh docker-compose.yml README.md 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 "!! Ключу раскатки доступна только команда вида './rollout.sh <версия>'." >&2 + echo " В запросе не найден номер версии. Запрошено: ${requested:-<пусто>}" >&2 + exit 2 +fi + +echo "==> Раскатка версии ${version} (запрос от ключа CI)" + +# --- синхронизация deploy/ с тегом версии --- +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}" + [ "$f" = "docker-compose.yml" ] && compose_changed=1 + fi + done + chmod +x "${WORK_DIR}/rollout.sh" + + if [ "$compose_changed" = "1" ]; then + echo "!! docker-compose.yml изменился — конфигурация прокси на сервере устарела." >&2 + echo " Примените вручную (перезапуск Traefik рвёт соединения, поэтому не автоматом):" >&2 + echo " docker compose -f ${WORK_DIR}/docker-compose.yml up -d" >&2 + fi + else + echo "!! Тег ${version} в репозитории не найден — работаю с текущей копией deploy/." >&2 + fi +else + echo "!! ${REPO_DIR} не является клоном репозитория — синхронизация deploy/ пропущена." >&2 +fi + +cd "$WORK_DIR" +exec ./rollout.sh "$version" From 8e0043d363698e7e51a7e633ec14203a03ede660 Mon Sep 17 00:00:00 2001 From: Stepan Grankin Date: Sun, 30 Aug 2026 18:11:04 +0300 Subject: [PATCH 11/26] =?UTF-8?q?chore(api):=20=D0=B2=D0=B5=D1=80=D1=81?= =?UTF-8?q?=D0=B8=D1=8F=20=D1=81=D0=B1=D0=BE=D1=80=D0=BA=D0=B8=201.26.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/FillInTheTextBot.Api/FillInTheTextBot.Api.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/FillInTheTextBot.Api/FillInTheTextBot.Api.csproj b/src/FillInTheTextBot.Api/FillInTheTextBot.Api.csproj index 707ceb8e..04316b16 100644 --- a/src/FillInTheTextBot.Api/FillInTheTextBot.Api.csproj +++ b/src/FillInTheTextBot.Api/FillInTheTextBot.Api.csproj @@ -3,7 +3,7 @@ net10.0 Linux - 1.25.0 + 1.26.0 Added new texts and computes From 268c29d8a31e01670781421e91bd1dd4ccdabe79 Mon Sep 17 00:00:00 2001 From: Stepan Grankin Date: Sun, 30 Aug 2026 18:46:51 +0300 Subject: [PATCH 12/26] =?UTF-8?q?feat(deploy):=20docker-=D1=81=D0=B5=D1=82?= =?UTF-8?q?=D1=8C=20=D1=81=D0=BE=D0=B7=D0=B4=D0=B0=D1=91=D1=82=D1=81=D1=8F?= =?UTF-8?q?=20=D0=B0=D0=B2=D1=82=D0=BE=D0=BC=D0=B0=D1=82=D0=B8=D1=87=D0=B5?= =?UTF-8?q?=D1=81=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Общая сеть требовала ручного docker network create на новом сервере — лишний шаг, о котором легко забыть и который ничем себя не напоминает, кроме отказа при запуске. Теперь rollout.sh создаёт её, если её нет. В compose сеть остаётся external. Проверено, что compose умеет принимать уже существующую сеть и что при docker compose down с чужими контейнерами Docker удалить её не даёт. Но владение отдавать прокси всё равно не стоит: потребителей трое, и ни один из них не владелец. --- deploy/README.md | 10 ++++++++-- deploy/docker-compose.yml | 4 +++- deploy/rollout.sh | 9 +++++++++ 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/deploy/README.md b/deploy/README.md index 05003c72..0c4af073 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -30,10 +30,16 @@ 1. **DNS.** A-запись домена (`DOMAIN`) должна указывать на IP сервера — без этого Let's Encrypt не пройдёт HTTP-01 проверку. -2. **Docker-сеть.** Общая сеть для прокси, сервиса, redis и prometheus: +2. **Docker-сеть.** Общая сеть для прокси, сервиса, redis и prometheus создаётся + сама при первой раскатке — `rollout.sh` делает это, если её ещё нет. Руками + ничего не нужно. Если прокси поднимается на совсем чистом сервере раньше первой + раскатки, `docker compose` честно скажет, что external-сети нет; тогда достаточно + выполнить раскатку первой либо создать сеть однократно: ```bash - docker network create network # если ещё не создана + docker network create network ``` + В compose она помечена `external` намеренно: потребителей трое — прокси, redis и + контейнеры сервиса, — и ни один не должен считаться владельцем. 3. **Ключи Dialogflow** лежат в `KEYS_DIR` (по умолчанию `/docker/keys`), пути в `app.env` (`...JsonPath=keys/<файл>.json`) указывают на них. 4. **Конфигурация:** diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 03bdf525..a7abc9e4 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -12,7 +12,9 @@ name: fitb-edge networks: # Та же сеть, где живут redis/prometheus и контейнеры сервиса. - # Создаётся отдельно (см. README), поэтому external. + # Помечена external намеренно: потребителей у неё трое (прокси, redis, + # контейнеры раскатки) и ни один не должен считаться владельцем. Создаётся + # автоматически в rollout.sh, вручную делать ничего не нужно. network: external: true diff --git a/deploy/rollout.sh b/deploy/rollout.sh index 095bba35..3c4be038 100755 --- a/deploy/rollout.sh +++ b/deploy/rollout.sh @@ -59,6 +59,15 @@ fi SAFE_VERSION="${VERSION//[^A-Za-z0-9_.-]/_}" NEW_NAME="fitb_${SAFE_VERSION}_$(date +%s)" +# Общая сеть для прокси, сервиса и redis. Создаётся здесь, чтобы на новом сервере +# не было ручного шага: docker network create идемпотентен по смыслу — если сеть уже +# есть, ничего не делаем. Владельцем её никто не объявляет (в compose она external), +# потому что потребителей три и лишать двоих из них сети чужим `compose down` не надо. +if ! docker network inspect "$NETWORK" >/dev/null 2>&1; then + echo "==> Создаю docker-сеть ${NETWORK}" + docker network create "$NETWORK" >/dev/null +fi + echo "==> Тянем образ ${IMAGE}" docker pull "$IMAGE" From d47262eb78febcd5092fdf333aa32822373027e7 Mon Sep 17 00:00:00 2001 From: Stepan Grankin Date: Sun, 30 Aug 2026 18:54:49 +0300 Subject: [PATCH 13/26] =?UTF-8?q?chore(deploy):=20redis=20=D0=B2=20=D1=80?= =?UTF-8?q?=D0=B5=D0=BF=D0=BE=D0=B7=D0=B8=D1=82=D0=BE=D1=80=D0=B8=D0=B8,?= =?UTF-8?q?=20=D0=BA=D0=BE=D1=80=D0=BE=D1=82=D0=BA=D0=B8=D0=B5=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BC=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D1=80=D0=B8=D0=B8,?= =?UTF-8?q?=20=D0=B1=D0=B5=D0=B7=20=D0=BF=D1=83=D1=82=D0=B5=D0=B9=20=D1=81?= =?UTF-8?q?=D0=B5=D1=80=D0=B2=D0=B5=D1=80=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit redis-compose.yml существовал только на сервере: параметры кэша сессий нигде не были записаны и восстановить их после потери машины было бы неоткуда. Теперь он в deploy/ и синхронизируется по тегу, пароль берётся из .env, так что в git секрета нет. Комментарии сокращены до сути. Пути каталогов раскатки, ключей и логов из репозитория убраны: KEYS_DIR и LOGS_DIR теперь обязательны в .env без значений по умолчанию, каталог раскатки обёртка читает из /etc/default/fitb-deploy. Остались только пути внутри контейнеров и стандартные системные. --- deploy/README.md | 197 ++++++++++++++++---------------------- deploy/app-env.example | 39 +++----- deploy/docker-compose.yml | 38 +++----- deploy/env.example | 42 ++++---- deploy/fitb-deploy | 47 ++++----- deploy/redis-compose.yml | 35 +++++++ deploy/rollout.sh | 61 ++++-------- 7 files changed, 197 insertions(+), 262 deletions(-) create mode 100644 deploy/redis-compose.yml diff --git a/deploy/README.md b/deploy/README.md index 0c4af073..2ddfc819 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -1,7 +1,6 @@ # Раскатка сервиса -Схема деплоя: бесшовное обновление без простоя, TLS и сертификат — на стороне -пограничного прокси. +Обновление без простоя. TLS и сертификат — на стороне пограничного прокси. ``` Интернет (443/https) @@ -17,156 +16,120 @@ drain → выход principal трафик балансируется на здоровые ``` -- **Само приложение работает только по HTTP на порту 80** — сертификатов и pfx - внутри контейнера больше нет. TLS терминирует Traefik. -- **Traefik** выпускает сертификат Let's Encrypt при первом обращении к домену и - дальше продлевает его сам. Продление происходит внутри Traefik: сертификат - подменяется на лету, **контейнер сервиса не перезапускается, запросы не рвутся**. -- **`rollout.sh`** обновляет версию с перекрытием: поднимает новый экземпляр, ждёт - его готовности, и только потом гасит старый (тот сливает трафик и доживает - текущие запросы). В любой момент трафик обслуживает готовый экземпляр. +Приложение работает только по HTTP на порту 80 — сертификатов внутри контейнера нет. +Продление сертификата идёт внутри Traefik, контейнер сервиса при этом не перезапускается. -## Что требуется на сервере (однократно) +## Подготовка сервера (однократно) -1. **DNS.** A-запись домена (`DOMAIN`) должна указывать на IP сервера — без этого +1. **DNS.** A-запись домена (`DOMAIN`) должна указывать на IP сервера, иначе Let's Encrypt не пройдёт HTTP-01 проверку. -2. **Docker-сеть.** Общая сеть для прокси, сервиса, redis и prometheus создаётся - сама при первой раскатке — `rollout.sh` делает это, если её ещё нет. Руками - ничего не нужно. Если прокси поднимается на совсем чистом сервере раньше первой - раскатки, `docker compose` честно скажет, что external-сети нет; тогда достаточно - выполнить раскатку первой либо создать сеть однократно: + +2. **Конфигурация.** В каталоге раскатки: ```bash - docker network create network + cp env.example .env # домен, почта ACME, пути, пароль redis + cp app-env.example app.env # секреты приложения + mkdir -p acme ``` - В compose она помечена `external` намеренно: потребителей трое — прокси, redis и - контейнеры сервиса, — и ни один не должен считаться владельцем. -3. **Ключи Dialogflow** лежат в `KEYS_DIR` (по умолчанию `/docker/keys`), пути в - `app.env` (`...JsonPath=keys/<файл>.json`) указывают на них. -4. **Конфигурация:** + Каталоги `KEYS_DIR` и `LOGS_DIR` из `.env` должны существовать; в первый кладут + json-ключи сервис-аккаунтов Dialogflow. + +3. **Обёртка раскатки** — под root: ```bash - cp deploy/env.example deploy/.env # домен, почта ACME - cp deploy/app-env.example deploy/app.env # секреты приложения - mkdir -p deploy/acme # хранилище сертификатов + install -m 755 -o root -g root deploy/fitb-deploy /usr/local/sbin/fitb-deploy ``` - Заполнить `deploy/.env` и `deploy/app.env`. Оба файла в git не попадают. -5. **Поднять прокси** (работает постоянно, переживает перезагрузки): + и рядом `/etc/default/fitb-deploy` с одной строкой `FITB_WORK_DIR=<каталог раскатки>`. + +4. **Клон для синхронизации** `deploy/` — в подкаталоге `.repo` каталога раскатки: ```bash - docker compose -f deploy/docker-compose.yml up -d + git clone --filter=blob:none --sparse --no-checkout <репозиторий> .repo ``` + затем `git -C .repo sparse-checkout set deploy`. Нужен read-only deploy key + репозитория; приватную часть генерируют на самом сервере. -## Как выкатывается новая версия +5. **Прокси и redis:** + ```bash + docker compose -f docker-compose.yml up -d + docker compose -f redis-compose.yml up -d + ``` + Docker-сеть создаётся автоматически при первой раскатке. Если прокси поднимается + раньше неё, сеть нужно создать однократно: `docker network create network`. -Автоматически через GitHub Actions (`.github/workflows/docker-publish.yml`): при -публикации релиза в GitHub собирается и публикуется образ, затем по SSH на сервере -выполняется `rollout.sh <версия>`. Версия — это имя тега релиза, им же тегируется -образ (дополнительно проставляются теги `` и `latest`). +## Выпуск версии -Чтобы шаг деплоя включился, задать в репозитории: +Публикация релиза в GitHub запускает `.github/workflows/docker-publish.yml`: собирается +и публикуется образ, затем по SSH выполняется раскатка. Версия — имя тега релиза, им же +тегируется образ (плюс теги `` и `latest`). -- секреты: `DOCKERHUB_USERNAME`, `DOCKERHUB_TOKEN`, `DEPLOY_HOST`, `DEPLOY_USER`, - `DEPLOY_SSH_KEY`, `DEPLOY_PATH`; -- переменную `DEPLOY_ENABLED = true`. +Нужны секреты `DOCKERHUB_USERNAME`, `DOCKERHUB_TOKEN`, `DEPLOY_HOST`, `DEPLOY_USER`, +`DEPLOY_SSH_KEY`, `DEPLOY_PATH` и переменная `DEPLOY_ENABLED=true`. -`DEPLOY_PATH` на сервере — это каталог с содержимым `deploy/`. Вручную его копировать -не нужно: он синхронизируется сам, см. следующий раздел. +Вручную — из каталога раскатки: + +```bash +./rollout.sh 1.26.0 +``` ## Ключ раскатки и синхронизация deploy/ -`DEPLOY_SSH_KEY` — отдельный ключ, не тот, которым ходят руками. На сервере он записан -в `authorized_keys` с ограничениями: +`DEPLOY_SSH_KEY` — отдельный ключ, не тот, которым ходят руками. В `authorized_keys` +он ограничен: ``` restrict,command="/usr/local/sbin/fitb-deploy" ssh-ed25519 AAAA... fitb-ci-deploy ``` `restrict` снимает проброс портов, agent-forwarding, PTY и X11, а `command` заставляет -любое подключение этим ключом выполнять только обёртку `deploy/fitb-deploy`. Обёртка -достаёт из присланной команды номер версии по строгому шаблону и больше ничего не умеет. -Это важно, потому что пользователь раскатки состоит в группе `docker`, а она равносильна -root: без ограничения утёкший из CI ключ отдал бы сервер целиком. - -Обёртка перед раскаткой **подтягивает `deploy/` с тега выпускаемой версии** — частичным -клоном, в котором развёрнут только этот каталог: +любое подключение выполнять только обёртку. Она достаёт из запроса номер версии по +строгому шаблону и больше ничего не умеет. Это важно: пользователь раскатки состоит в +группе `docker`, а она равносильна root. -```bash -git clone --filter=blob:none --sparse --no-checkout git@github.com:granstel/FillInTheTextBot.git .repo -git -C .repo sparse-checkout set deploy -``` +Перед раскаткой обёртка подтягивает `deploy/` **с тега выпускаемой версии** частичным +клоном: `--filter=blob:none` не качает содержимое файлов до необходимости, а +`sparse-checkout` разворачивает один каталог — около мегабайта. Скрипты раскатки всегда +соответствуют выпускаемой версии. Обратная сторона: откат на старую версию вернёт и +старые скрипты — осознанный размен на воспроизводимость. -`--filter=blob:none` не качает содержимое файлов, пока оно не понадобится, `--sparse` + -`sparse-checkout` разворачивают в рабочем дереве один каталог. Клон занимает около 1 МБ. -Дальше на каждую раскатку делается `fetch` тега версии и `checkout`, а файлы из -`deploy/` копируются в рабочий каталог, если отличаются. +Не синхронизируются `.env`, `app.env` и `acme/`. Не синхронизируется и сама обёртка: +она граница доверия, иначе любой коммит получал бы на сервере права группы `docker`. -Смысл в том, что скрипты раскатки всегда соответствуют выпускаемой версии, а не тому, -что кто-то когда-то скопировал руками. Обратная сторона: откат на старую версию вернёт -и старые скрипты раскатки — это осознанный размен на воспроизводимость. - -Не синхронизируются `.env`, `app.env`, `redis-compose.yml` и `acme/` — это локальная -конфигурация с секретами. - -Не синхронизируется и сама обёртка: она — граница доверия, и если бы CI мог её -переписать, любой коммит получал бы на сервере права группы `docker`. Ставится один раз -под root: - -```bash -install -m 755 -o root -g root deploy/fitb-deploy /usr/local/sbin/fitb-deploy -``` - -Изменение `docker-compose.yml` обёртка тоже копирует, но перезапуск Traefik не делает — -он рвёт установленные соединения. Вместо этого печатает предупреждение с командой. - -Вручную раскатать можно так же: - -```bash -cd -./rollout.sh 1.24.0 -``` +Изменение compose-файла обёртка копирует, но перезапуск прокси не делает — он рвёт +установленные соединения. Вместо этого печатает предупреждение. -## Что происходит при раскате (по шагам) +## Что происходит при раскате 1. `docker pull` нужной версии. -2. Поднимается новый контейнер `fitb_<версия>_<время>` с метками Traefik и сетевым - алиасом `fitb` (по нему Prometheus скрейпит `/metrics`). -3. Скрипт ждёт, пока новый ответит `200` на `/health` (стучится прямо в его IP в - docker-сети — curl внутри образа не нужен). -4. Скрипт спрашивает **у самого Traefik**, взял ли тот новый экземпляр в ротацию: - опрашивает `/api/http/services/fitb@docker`, пока в `serverStatus` не появится - `UP` для адреса нового контейнера (до `ROTATION_TIMEOUT`, 30с). -5. Старый получает `docker stop` → приложение объявляет себя неготовым - (`/health` → 503), Traefik уводит с него трафик, сервис доживает текущие запросы - и разбирает очередь фоновых задач, затем выходит. Тайминги — в `Shutdown` - (drain 10с + timeout 30с), запас на остановку — `STOP_TIMEOUT` (45с). - -Если новый экземпляр не стал здоровым за `HEALTH_TIMEOUT` или не попал в ротацию за -`ROTATION_TIMEOUT`, он удаляется, старый остаётся работать — раскат безопасно -откатывается. +2. Поднимается новый контейнер с метками Traefik и сетевым алиасом `fitb` + (по нему Prometheus скрейпит `/metrics`). +3. Скрипт ждёт `200` на `/health`, стучась прямо в IP контейнера. +4. Скрипт спрашивает у Traefik через его API, появился ли новый экземпляр в + `serverStatus` со статусом `UP`. +5. Старый получает `docker stop` → объявляет себя неготовым (`/health` → 503), Traefik + уводит с него трафик, сервис доживает текущие запросы и разбирает очередь фоновых + задач, затем выходит. + +Не стал здоровым за `HEALTH_TIMEOUT` или не попал в ротацию за `ROTATION_TIMEOUT` — +новый удаляется, старый остаётся работать. ### Почему шаг 4 обязателен Готовность приложения и готовность прокси — разные события. Traefik узнаёт о новом -экземпляре только своей активной проверкой (`healthcheck.interval`, 3с). Если гасить -старый сразу после того, как новый ответил на `/health` напрямую, попадаешь в зазор: -старый уже объявил себя неготовым, новый ещё не в пуле, живых серверов нет — и Traefik -отвечает клиенту `503` от себя. +экземпляре только своей проверкой (`healthcheck.interval`, 3с). Гасить старый сразу +после прямого ответа на `/health` — значит попасть в зазор, где старый уже неготов, +новый ещё не в пуле, живых серверов нет и Traefik отвечает `503` от себя. -Это не теория: до появления шага 4 замер во время раската давал 12 таких ответов на -338 запросов обычного трафика. После — 0 из 911 и 0 из 1342 в двух прогонах подряд. +Замер во время раската: без шага 4 — 12 таких ответов на 338 запросов обычного трафика, +с ним — 0 из 911 и 0 из 1342 в двух прогонах. -Отличать эти `503` просто: в access-логе Traefik у ответа от сливающегося экземпляра -указан бэкенд (`"http://172.18.0.4:80"`), а у ответа самого Traefik вместо бэкенда -стоит прочерк (`"-"`). Ответы `503` на `/health` от сливающегося экземпляра — штатные, -это и есть сигнал прокси уводить трафик. +В access-логе они различимы: у ответа сливающегося экземпляра указан бэкенд, у ответа +самого Traefik вместо бэкенда прочерк. `503` на `/health` от сливающегося экземпляра — +штатные, это и есть сигнал уводить трафик. ## Заметки -- **Продление сертификата простоя не вызывает** — оно целиком внутри Traefik. -- **Домен, а не голый IP.** С доменом сертификат обычный (90 дней, автопродление). - На голый IP Let's Encrypt тоже умеет, но такой сертификат короткоживущий (~6 - дней) и требует ACME-профиль `shortlived` — в этой конфигурации не заложено. -- **Порты 25/465** на новой облачной платформе по умолчанию закрыты — если сервису - нужна отправка почты, разблокировку запрашивают у поддержки. -- **API Traefik** включён на служебной точке входа `:8080` и наружу не публикуется — - её нет в `ports`, поэтому она доступна только изнутри docker-сети. Оттуда её и - спрашивает `rollout.sh`. Если понадобится открыть дашборд наружу — закрывать - аутентификацией, само по себе оно не защищено. +- Продление сертификата простоя не вызывает — оно целиком внутри Traefik. +- Нужен домен, а не голый IP: на IP Let's Encrypt выдаёт короткоживущий сертификат + (~6 дней) и требует ACME-профиль `shortlived`, в этой конфигурации не заложено. +- Порты 25/465 на облачной платформе по умолчанию закрыты — разблокировку запрашивают + у поддержки. +- API Traefik включён на служебной точке входа `:8080` и наружу не публикуется. Если + понадобится открыть дашборд — закрывать аутентификацией, само по себе оно не защищено. diff --git a/deploy/app-env.example b/deploy/app-env.example index 32351dd9..4f86c198 100644 --- a/deploy/app-env.example +++ b/deploy/app-env.example @@ -1,18 +1,14 @@ -# Секреты и настройки приложения. Передаётся контейнеру как --env-file. -# Скопируйте в deploy/app.env и заполните реальными значениями: +# Секреты и настройки приложения, передаётся контейнеру как --env-file. # -# cp deploy/app-env.example deploy/app.env +# cp app-env.example app.env # -# В репозиторий app.env не коммитится. Реальные ключи/пароли — только на сервере. -# -# Иерархия конфигурации задаётся двойным подчёркиванием: __ превращается в ':' -# (AppConfiguration__Redis__ConnectionString == AppConfiguration:Redis:ConnectionString). +# В репозиторий app.env не коммитится. Двойное подчёркивание = ':' в конфигурации: +# AppConfiguration__Redis__ConnectionString == AppConfiguration:Redis:ConnectionString -# --------------------------------------------------------------------------- -# Dialogflow: агенты 0..N. JsonPath — путь к ключу внутри контейнера (/app/keys -# смонтирован из KEYS_DIR). ScopeId должен быть уникальным и непустым, иначе -# агент не участвует в выборе. -# --------------------------------------------------------------------------- +# 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 @@ -23,30 +19,21 @@ AppConfiguration__Dialogflow__1__ScopeId=scope-1 AppConfiguration__Dialogflow__1__JsonPath=keys/your-project-1.json AppConfiguration__Dialogflow__1__Region=europe-west1 -# ...добавьте остальные агенты по тому же образцу (2, 3, ...). +# ...остальные агенты по тому же образцу -# --------------------------------------------------------------------------- -# Redis (кэш сессий). Хост redis доступен по имени внутри docker-сети. -# --------------------------------------------------------------------------- +# Redis доступен по имени внутри docker-сети; пароль тот же, что REDIS_PASSWORD в .env AppConfiguration__Redis__ConnectionString=redis:6379,password=CHANGE-ME,abortConnect=false,syncTimeout=2000,asyncTimeout=2000 AppConfiguration__Redis__KeyPrefix= -# --------------------------------------------------------------------------- -# Трассировка (OTLP). Оставьте Host пустым, чтобы экспорт трейсов был выключен. -# --------------------------------------------------------------------------- +# Пустой Host выключает экспорт трейсов AppConfiguration__Tracing__Host= AppConfiguration__Tracing__Port=4317 -# --------------------------------------------------------------------------- -# Плавная остановка (значения по умолчанию подходят; менять не обязательно). -# --------------------------------------------------------------------------- +# Плавная остановка; значения по умолчанию подходят # AppConfiguration__Shutdown__DrainDelaySeconds=10 # AppConfiguration__Shutdown__TimeoutSeconds=30 -# --------------------------------------------------------------------------- -# Токены проверки входящих запросов от площадок. Подставляются в -# appsettings.<площадка>.json на место %ИМЯ-ПЕРЕМЕННОЙ%. -# --------------------------------------------------------------------------- +# Токены проверки входящих запросов от площадок 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 index a7abc9e4..aaf12dca 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -1,20 +1,15 @@ -# Пограничный прокси: терминирует TLS, сам выпускает и продлевает сертификат -# Let's Encrypt, и маршрутизирует трафик на контейнеры сервиса. +# Пограничный прокси: терминирует TLS, выпускает и продлевает сертификат +# Let's Encrypt, маршрутизирует трафик по docker-меткам контейнеров сервиса. # -# Сам сервис (fitb) этим compose НЕ поднимается — его раскатывает rollout.sh -# с перекрытием старого и нового экземпляра, чтобы обновление шло без простоя. -# Traefik обнаруживает контейнеры сервиса по docker-меткам на общей сети. +# Сам сервис этим compose не поднимается — его раскатывает rollout.sh. # -# Запускается один раз и работает постоянно: -# docker compose -f deploy/docker-compose.yml up -d +# docker compose -f docker-compose.yml up -d name: fitb-edge networks: - # Та же сеть, где живут redis/prometheus и контейнеры сервиса. - # Помечена external намеренно: потребителей у неё трое (прокси, redis, - # контейнеры раскатки) и ни один не должен считаться владельцем. Создаётся - # автоматически в rollout.sh, вручную делать ничего не нужно. + # External намеренно: потребителей трое (прокси, redis, контейнеры сервиса) + # и ни один не владелец. Создаётся автоматически в rollout.sh. network: external: true @@ -29,42 +24,35 @@ services: - "80:80" - "443:443" command: - # --- Провайдер: читаем конфигурацию из меток docker-контейнеров --- - --providers.docker=true - # Ничего не публикуем наружу без явной метки traefik.enable=true + # Без явной метки traefik.enable=true наружу ничего не выходит - --providers.docker.exposedbydefault=false - --providers.docker.network=network - # --- Точки входа --- - --entrypoints.web.address=:80 - --entrypoints.websecure.address=:443 - # Служебная точка входа с API. Наружу НЕ публикуется (её нет в ports), - # поэтому доступна только изнутри docker-сети — оттуда rollout.sh - # спрашивает, взял ли прокси новый экземпляр в ротацию. + # Служебная точка входа с API: наружу не публикуется (её нет в ports), + # доступна только изнутри docker-сети, откуда её опрашивает rollout.sh - --entrypoints.traefik.address=:8080 - --api=true - --api.insecure=true - # Весь http автоматически переводим на https. Запросы ACME-проверки - # (/.well-known/acme-challenge/...) Traefik обслуживает до редиректа, - # поэтому выпуск сертификата не ломается. + + # ACME-проверку Traefik обслуживает до редиректа, выпуск не ломается - --entrypoints.web.http.redirections.entrypoint.to=websecure - --entrypoints.web.http.redirections.entrypoint.scheme=https - # --- Let's Encrypt (HTTP-01 challenge на 80 порту) --- - --certificatesresolvers.le.acme.email=${ACME_EMAIL} - --certificatesresolvers.le.acme.storage=/acme/acme.json - --certificatesresolvers.le.acme.httpchallenge=true - --certificatesresolvers.le.acme.httpchallenge.entrypoint=web - # Заголовки, чьё имя является алиасом управляемого Traefik заголовка, - # отбрасываем — иначе клиент может подменить X-Forwarded-*. + # Иначе клиент может подменить X-Forwarded-* заголовком-алиасом - --entrypoints.web.http.aliasHeadersStrategy=delete - --entrypoints.websecure.http.aliasHeadersStrategy=delete - --log.level=INFO - --accesslog=true volumes: - # Только чтение сокета: Traefik лишь наблюдает за контейнерами + # Только чтение: Traefik лишь наблюдает за контейнерами - /var/run/docker.sock:/var/run/docker.sock:ro - # Хранилище сертификатов; acme.json Traefik создаёт сам с правами 600 - ./acme:/acme diff --git a/deploy/env.example b/deploy/env.example index 5ada5dd4..a001474f 100644 --- a/deploy/env.example +++ b/deploy/env.example @@ -1,36 +1,28 @@ # Параметры раскатки и пограничного прокси. -# Скопируйте в deploy/.env и заполните. Файл .env в репозиторий не коммитится. # -# cp deploy/env.example deploy/.env +# cp env.example .env +# +# В репозиторий .env не коммитится. -# Домен, на который отвечает сервис (для него Traefik выпустит сертификат). -# На этот домен должна быть A-запись, указывающая на IP сервера. +# Домен сервиса; на него должна быть A-запись на IP сервера DOMAIN=example.com -# Почта для регистрации в Let's Encrypt (уведомления об истечении и т.п.) +# Почта для регистрации в Let's Encrypt ACME_EMAIL=admin@example.com -# --- Ниже можно не трогать: значения по умолчанию подходят для боевого сервера --- - -# Репозиторий образа в Docker Hub (тег версии добавляет rollout.sh) -# IMAGE_REPO=granstel/fillinthetextbot - -# Docker-сеть, общая для прокси, сервиса, redis и prometheus -# DOCKER_NETWORK=network - -# Стабильное сетевое имя экземпляра — по нему Prometheus скрейпит /metrics -# SERVICE_ALIAS=fitb - # Каталоги на сервере, монтируемые в контейнер -# KEYS_DIR=/docker/keys # json-ключи сервис-аккаунтов Dialogflow -# LOGS_DIR=/docker/logs/fitb # логи приложения +KEYS_DIR=/путь/к/ключам/dialogflow +LOGS_DIR=/путь/к/логам/сервиса -# Файл с секретами приложения, передаётся контейнеру как --env-file -# APP_ENV_FILE=./app.env +# Пароль Redis; тот же попадает в строку подключения в app.env +REDIS_PASSWORD=CHANGE-ME -# Сколько ждать готовности нового экземпляра, сек -# HEALTH_TIMEOUT=60 +# --- ниже значения по умолчанию, менять не обязательно --- -# Запас на слив трафика и завершение запросов при остановке старого, сек. -# Должен быть больше, чем DrainDelaySeconds + Shutdown.TimeoutSeconds сервиса. -# STOP_TIMEOUT=45 +# IMAGE_REPO=granstel/fillinthetextbot +# DOCKER_NETWORK=network +# SERVICE_ALIAS=fitb # по этому имени Prometheus скрейпит /metrics +# 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 index 08d2770e..777ac724 100755 --- a/deploy/fitb-deploy +++ b/deploy/fitb-deploy @@ -1,29 +1,25 @@ #!/usr/bin/env bash # -# Форсированная команда для ключа раскатки из CI. +# Форсированная команда для ключа раскатки из CI: достаёт из запроса номер версии, +# подтягивает deploy/ с тега этой версии и запускает раскатку. Больше ничего не умеет. # -# Устанавливается на сервер ОДИН РАЗ вручную, под root: -# install -m 755 -o root -g root deploy/fitb-deploy /usr/local/sbin/fitb-deploy +# Ставится один раз под root в /usr/local/sbin, каталог раскатки задаётся +# в /etc/default/fitb-deploy — см. README. # -# Сам себя этот файл НЕ обновляет — намеренно. Он и есть граница доверия: если бы -# CI мог его переписать, то любой коммит в репозиторий получал бы на сервере права -# группы docker, то есть фактически root. Меняется он редко, обновляется руками. -# -# Что делает: -# 1. Из присланной CI команды достаёт номер версии по строгому шаблону. -# Что бы ни прислали ещё — игнорируется. -# 2. Подтягивает каталог deploy/ РОВНО С ТЕГА этой версии (частичный клон, -# только нужный каталог) и обновляет им рабочую копию в /opt/fitb, чтобы -# скрипты раскатки всегда соответствовали выпущенной версии. -# 3. Запускает обновлённый rollout.sh. +# Себя не обновляет намеренно: это граница доверия. Иначе любой коммит получал бы +# на сервере права группы docker, то есть фактически root. + set -euo pipefail -WORK_DIR="${FITB_WORK_DIR:-/opt/fitb}" +# 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" -# Синхронизируются только эти файлы. Всё остальное в /opt/fitb — локальная -# конфигурация с секретами (.env, app.env, redis-compose.yml, acme/), её не трогаем. -SYNCED=(rollout.sh docker-compose.yml README.md env.example app-env.example) +# Синхронизируется только это. Остальное в каталоге раскатки — локальная +# конфигурация с секретами (.env, app.env, acme/), её не трогаем. +SYNCED=(rollout.sh docker-compose.yml redis-compose.yml README.md env.example app-env.example) requested="${SSH_ORIGINAL_COMMAND:-}" @@ -33,14 +29,12 @@ version="$(printf '%s' "$requested" \ | head -1 || true)" if [ -z "$version" ]; then - echo "!! Ключу раскатки доступна только команда вида './rollout.sh <версия>'." >&2 - echo " В запросе не найден номер версии. Запрошено: ${requested:-<пусто>}" >&2 + echo "!! Этому ключу доступна только раскатка версии. Запрошено: ${requested:-<пусто>}" >&2 exit 2 fi echo "==> Раскатка версии ${version} (запрос от ключа CI)" -# --- синхронизация deploy/ с тегом версии --- 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 @@ -56,21 +50,20 @@ if [ -d "${REPO_DIR}/.git" ]; then if ! cmp -s "$src" "${WORK_DIR}/${f}"; then cp -f "$src" "${WORK_DIR}/${f}" echo " обновлён: ${f}" - [ "$f" = "docker-compose.yml" ] && compose_changed=1 + case "$f" in *compose.yml) compose_changed=1 ;; esac fi done chmod +x "${WORK_DIR}/rollout.sh" if [ "$compose_changed" = "1" ]; then - echo "!! docker-compose.yml изменился — конфигурация прокси на сервере устарела." >&2 - echo " Примените вручную (перезапуск Traefik рвёт соединения, поэтому не автоматом):" >&2 - echo " docker compose -f ${WORK_DIR}/docker-compose.yml up -d" >&2 + echo "!! compose-файл изменился — конфигурация на сервере устарела." >&2 + echo " Примените вручную: перезапуск прокси рвёт соединения, поэтому не автоматом." >&2 fi else - echo "!! Тег ${version} в репозитории не найден — работаю с текущей копией deploy/." >&2 + echo "!! Тег ${version} не найден — работаю с текущей копией deploy/." >&2 fi else - echo "!! ${REPO_DIR} не является клоном репозитория — синхронизация deploy/ пропущена." >&2 + echo "!! Клона репозитория нет — синхронизация deploy/ пропущена." >&2 fi cd "$WORK_DIR" diff --git a/deploy/redis-compose.yml b/deploy/redis-compose.yml new file mode 100644 index 00000000..b822a1dc --- /dev/null +++ b/deploy/redis-compose.yml @@ -0,0 +1,35 @@ +# Кэш сессий. Порты наружу не публикуются — redis доступен только внутри docker-сети. +# +# 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] + 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 index 3c4be038..8fbd98b8 100755 --- a/deploy/rollout.sh +++ b/deploy/rollout.sh @@ -1,22 +1,9 @@ #!/usr/bin/env bash # -# Бесшовная раскатка новой версии сервиса. +# Раскатка версии без простоя: ./rollout.sh <версия> # -# ./rollout.sh <версия> -# -# Идея: не гасить старый контейнер до того, как новый готов принимать трафик. -# 1. Тянем образ нужной версии. -# 2. Поднимаем НОВЫЙ контейнер рядом со старым, с теми же метками Traefik — -# Traefik добавляет его в пул балансировки как второй сервер. -# 3. Ждём, пока новый ответит здоровьем на /health. -# 4. Спрашиваем у Traefik, взял ли он новый экземпляр в ротацию. Готовность -# приложения и готовность прокси — разные события: прокси узнаёт о ней -# своей проверкой, и гасить старый до этого нельзя. -# 5. Гасим старый: он по SIGTERM объявляет себя неготовым (health -> 503), -# Traefik уводит с него трафик, приложение доживает текущие запросы и -# разбирает очередь фоновых задач, и только потом выходит. -# -# В любой момент времени трафик обслуживает хотя бы один готовый экземпляр. +# Новый контейнер поднимается рядом со старым и попадает в тот же пул Traefik. +# Старый гасится только после того, как прокси подтвердил, что балансирует на новый. set -euo pipefail @@ -25,7 +12,7 @@ VERSION="${1:?Использование: rollout.sh <версия>}" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$SCRIPT_DIR" -# Параметры раскатки (пути, домен, имя образа) — из deploy/.env +# Параметры — из .env рядом со скриптом if [ -f .env ]; then set -a # shellcheck disable=SC1091 @@ -36,33 +23,28 @@ fi IMAGE_REPO="${IMAGE_REPO:-granstel/fillinthetextbot}" IMAGE="${IMAGE_REPO}:${VERSION}" NETWORK="${DOCKER_NETWORK:-network}" -ALIAS="${SERVICE_ALIAS:-fitb}" # стабильное сетевое имя для Prometheus -DOMAIN="${DOMAIN:?DOMAIN должен быть задан в deploy/.env}" -KEYS_DIR="${KEYS_DIR:-/docker/keys}" # ключи сервис-аккаунтов Dialogflow -LOGS_DIR="${LOGS_DIR:-/docker/logs/fitb}" +ALIAS="${SERVICE_ALIAS:-fitb}" +DOMAIN="${DOMAIN:?задайте DOMAIN в .env}" +KEYS_DIR="${KEYS_DIR:?задайте KEYS_DIR в .env}" +LOGS_DIR="${LOGS_DIR:?задайте LOGS_DIR в .env}" APP_ENV_FILE="${APP_ENV_FILE:-${SCRIPT_DIR}/app.env}" -HEALTH_TIMEOUT="${HEALTH_TIMEOUT:-60}" # сколько ждём готовности нового, сек -# Запас на слив трафика (drain) + завершение запросов и очереди фоновых задач. -# Должен быть больше, чем DrainDelaySeconds + Shutdown.TimeoutSeconds сервиса. +HEALTH_TIMEOUT="${HEALTH_TIMEOUT:-60}" +# Должен быть больше, чем DrainDelaySeconds + Shutdown.TimeoutSeconds сервиса STOP_TIMEOUT="${STOP_TIMEOUT:-45}" -TRAEFIK_CONTAINER="${TRAEFIK_CONTAINER:-traefik}" # имя контейнера прокси -TRAEFIK_SERVICE="${TRAEFIK_SERVICE:-fitb}" # имя сервиса в метках ниже -ROTATION_TIMEOUT="${ROTATION_TIMEOUT:-30}" # сколько ждём попадания в пул, сек +TRAEFIK_CONTAINER="${TRAEFIK_CONTAINER:-traefik}" +TRAEFIK_SERVICE="${TRAEFIK_SERVICE:-fitb}" +ROTATION_TIMEOUT="${ROTATION_TIMEOUT:-30}" if [ ! -f "$APP_ENV_FILE" ]; then echo "!! Не найден файл окружения приложения: $APP_ENV_FILE" >&2 - echo " Скопируйте app.env.example в app.env и заполните секреты." >&2 + echo " Скопируйте app-env.example в app.env и заполните секреты." >&2 exit 1 fi -# Уникальное имя: версия + метка времени, чтобы можно было переката́ть ту же версию +# Версия + метка времени, чтобы можно было перекатать ту же версию SAFE_VERSION="${VERSION//[^A-Za-z0-9_.-]/_}" NEW_NAME="fitb_${SAFE_VERSION}_$(date +%s)" -# Общая сеть для прокси, сервиса и redis. Создаётся здесь, чтобы на новом сервере -# не было ручного шага: docker network create идемпотентен по смыслу — если сеть уже -# есть, ничего не делаем. Владельцем её никто не объявляет (в compose она external), -# потому что потребителей три и лишать двоих из них сети чужим `compose down` не надо. if ! docker network inspect "$NETWORK" >/dev/null 2>&1; then echo "==> Создаю docker-сеть ${NETWORK}" docker network create "$NETWORK" >/dev/null @@ -101,8 +83,7 @@ docker run -d \ --label traefik.http.services.fitb.loadbalancer.healthcheck.timeout=2s \ "$IMAGE" >/dev/null -# Ждём готовности нового экземпляра, стучась прямо в его IP из сети docker. -# Хост дотягивается до контейнера напрямую, curl внутри образа не нужен. +# Стучимся прямо в IP контейнера — curl внутри образа не нужен NEW_IP="$(docker inspect -f "{{ (index .NetworkSettings.Networks \"${NETWORK}\").IPAddress }}" "$NEW_NAME")" echo "==> Ждём готовности ${NEW_NAME} (${NEW_IP}) на /health, до ${HEALTH_TIMEOUT}s" @@ -118,15 +99,12 @@ until curl -fsS -m 2 "http://${NEW_IP}/health" >/dev/null 2>&1; do done echo " Новый экземпляр здоров." -# Готовность приложения — ещё не готовность к переключению: Traefik узнаёт о ней -# только своей проверкой (раз в healthcheck.interval). Если погасить старый раньше, -# чем прокси возьмёт новый в ротацию, в пуле не останется живых серверов и клиент -# получит 503. Поэтому спрашиваем у самого Traefik, а не полагаемся на тайминги. +# Готовность приложения ≠ готовность прокси: Traefik узнаёт о ней своей проверкой. +# Погасить старый раньше — значит оставить пул без живых серверов и отдать клиенту 503. 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 " Не могу убедиться, что новый экземпляр в ротации — прерываюсь, старый остаётся работать." >&2 + echo "!! Контейнер ${TRAEFIK_CONTAINER} не найден в сети ${NETWORK} — прерываюсь, старый работает." >&2 docker rm -f "$NEW_NAME" >/dev/null 2>&1 || true exit 1 fi @@ -145,7 +123,6 @@ do done echo " Traefik балансирует на новый экземпляр." -# Теперь в пуле гарантированно есть живой сервер — можно уводить старые. for c in "${OLD[@]}"; do [ -z "$c" ] && continue echo "==> Сливаем и останавливаем старый ${c} (до ${STOP_TIMEOUT}s)" From 8be0598800a2eca266e03f1b81379fe60286da7d Mon Sep 17 00:00:00 2001 From: Stepan Grankin Date: Sun, 30 Aug 2026 19:19:22 +0300 Subject: [PATCH 14/26] =?UTF-8?q?feat(deploy):=20=D0=B4=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D1=83=D0=BF=20=D0=BA=20redis=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7?= =?UTF-8?q?=20SSH-=D1=82=D1=83=D0=BD=D0=BD=D0=B5=D0=BB=D1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Порт публикуется только на loopback сервера. Адрес в публикации указан явно: без него docker открыл бы 6379 на все интерфейсы в обход ufw, и redis оказался бы доступен из интернета — пароля для этого мало. Подключение снаружи: ssh -L 6379:127.0.0.1:6379 <сервер> --- deploy/redis-compose.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/deploy/redis-compose.yml b/deploy/redis-compose.yml index b822a1dc..90246dc6 100644 --- a/deploy/redis-compose.yml +++ b/deploy/redis-compose.yml @@ -1,6 +1,11 @@ -# Кэш сессий. Порты наружу не публикуются — redis доступен только внутри docker-сети. +# Кэш сессий. # # docker compose -f redis-compose.yml up -d +# +# Сервису redis доступен по имени внутри docker-сети. Наружу порт не открыт: +# публикуется только на loopback сервера, подключаться через SSH-туннель +# +# ssh -L 6379:127.0.0.1:6379 <сервер> name: fitb-redis @@ -16,6 +21,10 @@ services: networks: network: aliases: [redis] + # Адрес обязателен: без него docker открыл бы порт на все интерфейсы + # в обход ufw, и redis оказался бы в интернете + ports: + - "127.0.0.1:6379:6379" command: > redis-server --requirepass ${REDIS_PASSWORD:?задайте REDIS_PASSWORD в .env} From 55e8c332d7d846054711603f4fd3ecca201726f1 Mon Sep 17 00:00:00 2001 From: Stepan Grankin Date: Sun, 30 Aug 2026 19:26:48 +0300 Subject: [PATCH 15/26] =?UTF-8?q?chore(deploy):=20=D1=83=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=D0=BE=20=D0=BE=D0=B1=D0=BE=D1=81=D0=BD=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=B8=D0=B7=20=D0=BA=D0=BE=D0=BC=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=D0=B0=D1=80=D0=B8=D1=8F=20=D0=BA=20=D0=BE?= =?UTF-8?q?=D0=B1=D1=91=D1=80=D1=82=D0=BA=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/README.md | 3 +-- deploy/fitb-deploy | 3 --- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/deploy/README.md b/deploy/README.md index 2ddfc819..b9de77ca 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -89,8 +89,7 @@ restrict,command="/usr/local/sbin/fitb-deploy" ssh-ed25519 AAAA... fitb-ci-deplo соответствуют выпускаемой версии. Обратная сторона: откат на старую версию вернёт и старые скрипты — осознанный размен на воспроизводимость. -Не синхронизируются `.env`, `app.env` и `acme/`. Не синхронизируется и сама обёртка: -она граница доверия, иначе любой коммит получал бы на сервере права группы `docker`. +Не синхронизируются `.env`, `app.env`, `acme/` и сама обёртка — её обновляют вручную. Изменение compose-файла обёртка копирует, но перезапуск прокси не делает — он рвёт установленные соединения. Вместо этого печатает предупреждение. diff --git a/deploy/fitb-deploy b/deploy/fitb-deploy index 777ac724..dd1cdcf9 100755 --- a/deploy/fitb-deploy +++ b/deploy/fitb-deploy @@ -5,9 +5,6 @@ # # Ставится один раз под root в /usr/local/sbin, каталог раскатки задаётся # в /etc/default/fitb-deploy — см. README. -# -# Себя не обновляет намеренно: это граница доверия. Иначе любой коммит получал бы -# на сервере права группы docker, то есть фактически root. set -euo pipefail From 73289d9ae4cc1d63a7e675a450ac10c6b49e7bb6 Mon Sep 17 00:00:00 2001 From: Stepan Grankin Date: Sun, 30 Aug 2026 19:30:22 +0300 Subject: [PATCH 16/26] =?UTF-8?q?docs(deploy):=20README=20=D1=82=D0=BE?= =?UTF-8?q?=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=BF=D1=80=D0=BE=20=D1=82=D0=BE,?= =?UTF-8?q?=20=D1=87=D0=B5=D0=B3=D0=BE=20=D0=BD=D0=B5=D1=82=20=D0=B2=20?= =?UTF-8?q?=D1=84=D0=B0=D0=B9=D0=BB=D0=B0=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Убраны шаги раската, команды запуска, копирование конфигов и список секретов — всё это читается из rollout.sh, шапок compose-файлов, env-примеров и workflow. Осталась подготовка сервера, которой в репозитории нет, и решения, которые из кода не выводятся. --- deploy/README.md | 134 +++++++++++++---------------------------------- 1 file changed, 36 insertions(+), 98 deletions(-) diff --git a/deploy/README.md b/deploy/README.md index b9de77ca..51a8f026 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -16,119 +16,57 @@ drain → выход principal трафик балансируется на здоровые ``` -Приложение работает только по HTTP на порту 80 — сертификатов внутри контейнера нет. -Продление сертификата идёт внутри Traefik, контейнер сервиса при этом не перезапускается. +Порядок действий при раскате — в `rollout.sh`, он проговаривает каждый шаг. +Конфигурация — в `env.example` и `app-env.example`, запуск прокси и redis — в шапках +соответствующих compose-файлов. Ниже только то, чего в файлах нет. ## Подготовка сервера (однократно) -1. **DNS.** A-запись домена (`DOMAIN`) должна указывать на IP сервера, иначе - Let's Encrypt не пройдёт HTTP-01 проверку. - -2. **Конфигурация.** В каталоге раскатки: - ```bash - cp env.example .env # домен, почта ACME, пути, пароль redis - cp app-env.example app.env # секреты приложения - mkdir -p acme - ``` - Каталоги `KEYS_DIR` и `LOGS_DIR` из `.env` должны существовать; в первый кладут - json-ключи сервис-аккаунтов Dialogflow. - -3. **Обёртка раскатки** — под root: - ```bash - install -m 755 -o root -g root deploy/fitb-deploy /usr/local/sbin/fitb-deploy - ``` - и рядом `/etc/default/fitb-deploy` с одной строкой `FITB_WORK_DIR=<каталог раскатки>`. - -4. **Клон для синхронизации** `deploy/` — в подкаталоге `.repo` каталога раскатки: - ```bash - git clone --filter=blob:none --sparse --no-checkout <репозиторий> .repo - ``` - затем `git -C .repo sparse-checkout set deploy`. Нужен read-only deploy key - репозитория; приватную часть генерируют на самом сервере. - -5. **Прокси и redis:** - ```bash - docker compose -f docker-compose.yml up -d - docker compose -f redis-compose.yml up -d - ``` - Docker-сеть создаётся автоматически при первой раскатке. Если прокси поднимается - раньше неё, сеть нужно создать однократно: `docker network create network`. - -## Выпуск версии - -Публикация релиза в GitHub запускает `.github/workflows/docker-publish.yml`: собирается -и публикуется образ, затем по SSH выполняется раскатка. Версия — имя тега релиза, им же -тегируется образ (плюс теги `` и `latest`). - -Нужны секреты `DOCKERHUB_USERNAME`, `DOCKERHUB_TOKEN`, `DEPLOY_HOST`, `DEPLOY_USER`, -`DEPLOY_SSH_KEY`, `DEPLOY_PATH` и переменная `DEPLOY_ENABLED=true`. - -Вручную — из каталога раскатки: +**DNS.** A-запись домена (`DOMAIN`) должна указывать на IP сервера, иначе Let's Encrypt +не пройдёт HTTP-01 проверку. + +**Обёртка раскатки** — под root, плюс `/etc/default/fitb-deploy` с одной строкой +`FITB_WORK_DIR=<каталог раскатки>`: ```bash -./rollout.sh 1.26.0 +install -m 755 -o root -g root deploy/fitb-deploy /usr/local/sbin/fitb-deploy ``` -## Ключ раскатки и синхронизация deploy/ - -`DEPLOY_SSH_KEY` — отдельный ключ, не тот, которым ходят руками. В `authorized_keys` -он ограничен: +**Клон для синхронизации** `deploy/` — в подкаталоге `.repo` каталога раскатки: +```bash +git clone --filter=blob:none --sparse --no-checkout <репозиторий> .repo +git -C .repo sparse-checkout set deploy ``` -restrict,command="/usr/local/sbin/fitb-deploy" ssh-ed25519 AAAA... fitb-ci-deploy -``` - -`restrict` снимает проброс портов, agent-forwarding, PTY и X11, а `command` заставляет -любое подключение выполнять только обёртку. Она достаёт из запроса номер версии по -строгому шаблону и больше ничего не умеет. Это важно: пользователь раскатки состоит в -группе `docker`, а она равносильна root. -Перед раскаткой обёртка подтягивает `deploy/` **с тега выпускаемой версии** частичным -клоном: `--filter=blob:none` не качает содержимое файлов до необходимости, а -`sparse-checkout` разворачивает один каталог — около мегабайта. Скрипты раскатки всегда -соответствуют выпускаемой версии. Обратная сторона: откат на старую версию вернёт и -старые скрипты — осознанный размен на воспроизводимость. +Нужен read-only deploy key репозитория; приватную часть генерируют на самом сервере. -Не синхронизируются `.env`, `app.env`, `acme/` и сама обёртка — её обновляют вручную. +**Ключ раскатки** — отдельный, не тот, которым ходят руками. В `authorized_keys` +ограничен форсированной командой: -Изменение compose-файла обёртка копирует, но перезапуск прокси не делает — он рвёт -установленные соединения. Вместо этого печатает предупреждение. - -## Что происходит при раскате - -1. `docker pull` нужной версии. -2. Поднимается новый контейнер с метками Traefik и сетевым алиасом `fitb` - (по нему Prometheus скрейпит `/metrics`). -3. Скрипт ждёт `200` на `/health`, стучась прямо в IP контейнера. -4. Скрипт спрашивает у Traefik через его API, появился ли новый экземпляр в - `serverStatus` со статусом `UP`. -5. Старый получает `docker stop` → объявляет себя неготовым (`/health` → 503), Traefik - уводит с него трафик, сервис доживает текущие запросы и разбирает очередь фоновых - задач, затем выходит. - -Не стал здоровым за `HEALTH_TIMEOUT` или не попал в ротацию за `ROTATION_TIMEOUT` — -новый удаляется, старый остаётся работать. +``` +restrict,command="/usr/local/sbin/fitb-deploy" ssh-ed25519 AAAA... fitb-ci-deploy +``` -### Почему шаг 4 обязателен +## Решения, о которых стоит знать -Готовность приложения и готовность прокси — разные события. Traefik узнаёт о новом -экземпляре только своей проверкой (`healthcheck.interval`, 3с). Гасить старый сразу -после прямого ответа на `/health` — значит попасть в зазор, где старый уже неготов, -новый ещё не в пуле, живых серверов нет и Traefik отвечает `503` от себя. +**Синхронизация `deploy/` идёт с тега выпускаемой версии**, а не с ветки: скрипты +раскатки всегда соответствуют выпускаемой версии. Обратная сторона — откат на старую +версию вернёт и старые скрипты. Осознанный размен на воспроизводимость. -Замер во время раската: без шага 4 — 12 таких ответов на 338 запросов обычного трафика, -с ним — 0 из 911 и 0 из 1342 в двух прогонах. +**Готовность приложения и готовность прокси — разные события.** Traefik узнаёт о новом +экземпляре только своей проверкой (`healthcheck.interval`, 3с). Если гасить старый сразу +после прямого ответа на `/health`, попадаешь в зазор: старый уже неготов, новый ещё не в +пуле, живых серверов нет и Traefik отвечает `503` от себя. Поэтому `rollout.sh` ждёт +подтверждения от самого прокси. Замер во время раската: без этого шага — 12 таких +ответов на 338 запросов обычного трафика, с ним — 0 из 911 и 0 из 1342 в двух прогонах. -В access-логе они различимы: у ответа сливающегося экземпляра указан бэкенд, у ответа -самого Traefik вместо бэкенда прочерк. `503` на `/health` от сливающегося экземпляра — -штатные, это и есть сигнал уводить трафик. +В access-логе эти `503` различимы: у ответа сливающегося экземпляра указан бэкенд, у +ответа самого Traefik вместо бэкенда прочерк. `503` на `/health` от сливающегося +экземпляра штатные — это и есть сигнал уводить трафик. -## Заметки +**Нужен домен, а не голый IP.** На IP Let's Encrypt выдаёт короткоживущий сертификат +(~6 дней) и требует ACME-профиль `shortlived` — в этой конфигурации не заложено. -- Продление сертификата простоя не вызывает — оно целиком внутри Traefik. -- Нужен домен, а не голый IP: на IP Let's Encrypt выдаёт короткоживущий сертификат - (~6 дней) и требует ACME-профиль `shortlived`, в этой конфигурации не заложено. -- Порты 25/465 на облачной платформе по умолчанию закрыты — разблокировку запрашивают - у поддержки. -- API Traefik включён на служебной точке входа `:8080` и наружу не публикуется. Если - понадобится открыть дашборд — закрывать аутентификацией, само по себе оно не защищено. +**Порты 25/465** на облачной платформе по умолчанию закрыты, разблокировку запрашивают +у поддержки. From 8e140d454a95be50ea84d44521ce8cba58dce72a Mon Sep 17 00:00:00 2001 From: Stepan Grankin Date: Sun, 30 Aug 2026 19:37:08 +0300 Subject: [PATCH 17/26] =?UTF-8?q?docs(deploy):=20=D0=BF=D0=BE=D0=B4=D0=B3?= =?UTF-8?q?=D0=BE=D1=82=D0=BE=D0=B2=D0=BA=D0=B0=20=D1=81=D0=B5=D1=80=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D0=B0=20=D0=B2=D1=8B=D0=BD=D0=B5=D1=81=D0=B5=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=B8=D0=B7=20=D1=80=D0=B5=D0=BF=D0=BE=D0=B7=D0=B8?= =?UTF-8?q?=D1=82=D0=BE=D1=80=D0=B8=D1=8F=20=D1=81=D0=B5=D1=80=D0=B2=D0=B8?= =?UTF-8?q?=D1=81=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit В README остались только схема и решения по раскатке. Описание установки обёртки, ограничений ключа CI и создания клона переехало в репозиторий инфраструктуры: это про конкретную машину, а не про сервис. --- deploy/README.md | 38 +++++++------------------------------- deploy/fitb-deploy | 3 +-- 2 files changed, 8 insertions(+), 33 deletions(-) diff --git a/deploy/README.md b/deploy/README.md index 51a8f026..a8a9ee97 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -18,38 +18,17 @@ Порядок действий при раскате — в `rollout.sh`, он проговаривает каждый шаг. Конфигурация — в `env.example` и `app-env.example`, запуск прокси и redis — в шапках -соответствующих compose-файлов. Ниже только то, чего в файлах нет. +соответствующих compose-файлов. -## Подготовка сервера (однократно) - -**DNS.** A-запись домена (`DOMAIN`) должна указывать на IP сервера, иначе Let's Encrypt -не пройдёт HTTP-01 проверку. - -**Обёртка раскатки** — под root, плюс `/etc/default/fitb-deploy` с одной строкой -`FITB_WORK_DIR=<каталог раскатки>`: - -```bash -install -m 755 -o root -g root deploy/fitb-deploy /usr/local/sbin/fitb-deploy -``` - -**Клон для синхронизации** `deploy/` — в подкаталоге `.repo` каталога раскатки: - -```bash -git clone --filter=blob:none --sparse --no-checkout <репозиторий> .repo -git -C .repo sparse-checkout set deploy -``` - -Нужен read-only deploy key репозитория; приватную часть генерируют на самом сервере. - -**Ключ раскатки** — отдельный, не тот, которым ходят руками. В `authorized_keys` -ограничен форсированной командой: - -``` -restrict,command="/usr/local/sbin/fitb-deploy" ssh-ed25519 AAAA... fitb-ci-deploy -``` +Однократная подготовка машины описана отдельно, вне этого репозитория: она про +пользователей, ключи и раскладку каталогов конкретного сервера, а не про сервис. ## Решения, о которых стоит знать +**Домен, а не голый IP.** На IP Let's Encrypt выдаёт короткоживущий сертификат (~6 дней) +и требует ACME-профиль `shortlived` — в этой конфигурации не заложено. A-запись должна +существовать до первого выпуска, иначе HTTP-01 проверка не пройдёт. + **Синхронизация `deploy/` идёт с тега выпускаемой версии**, а не с ветки: скрипты раскатки всегда соответствуют выпускаемой версии. Обратная сторона — откат на старую версию вернёт и старые скрипты. Осознанный размен на воспроизводимость. @@ -65,8 +44,5 @@ restrict,command="/usr/local/sbin/fitb-deploy" ssh-ed25519 AAAA... fitb-ci-deplo ответа самого Traefik вместо бэкенда прочерк. `503` на `/health` от сливающегося экземпляра штатные — это и есть сигнал уводить трафик. -**Нужен домен, а не голый IP.** На IP Let's Encrypt выдаёт короткоживущий сертификат -(~6 дней) и требует ACME-профиль `shortlived` — в этой конфигурации не заложено. - **Порты 25/465** на облачной платформе по умолчанию закрыты, разблокировку запрашивают у поддержки. diff --git a/deploy/fitb-deploy b/deploy/fitb-deploy index dd1cdcf9..75f0510a 100755 --- a/deploy/fitb-deploy +++ b/deploy/fitb-deploy @@ -3,8 +3,7 @@ # Форсированная команда для ключа раскатки из CI: достаёт из запроса номер версии, # подтягивает deploy/ с тега этой версии и запускает раскатку. Больше ничего не умеет. # -# Ставится один раз под root в /usr/local/sbin, каталог раскатки задаётся -# в /etc/default/fitb-deploy — см. README. +# Ставится на сервер один раз под root; себя не обновляет. set -euo pipefail From c1a97f498240dd0cc5f7ce488f31a596883edb40 Mon Sep 17 00:00:00 2001 From: Stepan Grankin Date: Sun, 30 Aug 2026 19:57:54 +0300 Subject: [PATCH 18/26] =?UTF-8?q?docs(deploy):=20=D1=83=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=D0=BE=20=D1=83=D0=BF=D0=BE=D0=BC=D0=B8=D0=BD=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=B2=D0=BD=D0=B5=D1=88=D0=BD=D0=B5=D0=B9=20?= =?UTF-8?q?=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/README.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/deploy/README.md b/deploy/README.md index a8a9ee97..110bb888 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -20,9 +20,6 @@ Конфигурация — в `env.example` и `app-env.example`, запуск прокси и redis — в шапках соответствующих compose-файлов. -Однократная подготовка машины описана отдельно, вне этого репозитория: она про -пользователей, ключи и раскладку каталогов конкретного сервера, а не про сервис. - ## Решения, о которых стоит знать **Домен, а не голый IP.** На IP Let's Encrypt выдаёт короткоживущий сертификат (~6 дней) From c13edc4b73d605a9e882713d1ad8fdf0bda70b3a Mon Sep 17 00:00:00 2001 From: Stepan Grankin Date: Sun, 30 Aug 2026 19:58:38 +0300 Subject: [PATCH 19/26] =?UTF-8?q?docs(deploy):=20=D0=B2=D0=BC=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=BE=20=D1=80=D0=B0=D1=81=D1=81=D1=83=D0=B6=D0=B4=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D0=BF=D1=80=D0=BE=20=D0=B3=D0=BE=D0=BB?= =?UTF-8?q?=D1=8B=D0=B9=20IP=20=E2=80=94=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA?= =?UTF-8?q?=D0=BE=20=D1=82=D1=80=D0=B5=D0=B1=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D0=BA=20A-=D0=B7=D0=B0=D0=BF=D0=B8=D1=81=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/deploy/README.md b/deploy/README.md index 110bb888..ed16e454 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -22,9 +22,9 @@ ## Решения, о которых стоит знать -**Домен, а не голый IP.** На IP Let's Encrypt выдаёт короткоживущий сертификат (~6 дней) -и требует ACME-профиль `shortlived` — в этой конфигурации не заложено. A-запись должна -существовать до первого выпуска, иначе HTTP-01 проверка не пройдёт. +**A-запись домена должна существовать до первого выпуска сертификата.** Проверка +владения идёт по HTTP-01: Let's Encrypt обращается к домену снаружи, и без записи ему +некуда стучаться. **Синхронизация `deploy/` идёт с тега выпускаемой версии**, а не с ветки: скрипты раскатки всегда соответствуют выпускаемой версии. Обратная сторона — откат на старую From 0d4bdb1c7c7509fd7b35bc011e17e338d06a8e45 Mon Sep 17 00:00:00 2001 From: Stepan Grankin Date: Sun, 30 Aug 2026 20:02:45 +0300 Subject: [PATCH 20/26] =?UTF-8?q?docs(deploy):=20=D1=83=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=20README,=20=D1=83=D0=BD=D0=B8=D0=BA=D0=B0=D0=BB=D1=8C?= =?UTF-8?q?=D0=BD=D0=BE=D0=B5=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BD=D0=B5=D1=81?= =?UTF-8?q?=D0=B5=D0=BD=D0=BE=20=D0=BA=20=D0=BA=D0=BE=D0=B4=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit После переноса подготовки сервера в README осталось четыре пункта, из которых два дублировались: требование к A-записи уже есть в env.example, а расхождение готовности приложения и прокси описано в комментарии rollout.sh и в документации GracefulShutdownService и ShutdownConfiguration. Заметка про порты 25/465 к сервису отношения не имеет. Уникальное переехало туда, где пригодится: замеры простоя и признак, по которому 503 прокси отличается от 503 экземпляра, — в комментарий rollout.sh рядом с ожиданием ротации, чтобы это ожидание не сократили как лишнее; размен синхронизации по тегу — в шапку fitb-deploy. Заодно убрано упоминание Prometheus в env.example: он не развёрнут. --- deploy/README.md | 45 --------------------------------------------- deploy/env.example | 2 +- deploy/fitb-deploy | 5 ++++- deploy/rollout.sh | 8 ++++++-- 4 files changed, 11 insertions(+), 49 deletions(-) delete mode 100644 deploy/README.md diff --git a/deploy/README.md b/deploy/README.md deleted file mode 100644 index ed16e454..00000000 --- a/deploy/README.md +++ /dev/null @@ -1,45 +0,0 @@ -# Раскатка сервиса - -Обновление без простоя. TLS и сертификат — на стороне пограничного прокси. - -``` - Интернет (443/https) - │ - ┌─────▼─────┐ выпускает и продлевает - │ Traefik │ сертификат Let's Encrypt, - │ (edge) │ терминирует TLS - └─────┬─────┘ - │ http:80 (по метке Host + healthcheck /health) - ┌───────┴────────┐ - ▼ ▼ - fitb (старый) fitb (новый) ← во время раската живут оба, - drain → выход principal трафик балансируется на здоровые -``` - -Порядок действий при раскате — в `rollout.sh`, он проговаривает каждый шаг. -Конфигурация — в `env.example` и `app-env.example`, запуск прокси и redis — в шапках -соответствующих compose-файлов. - -## Решения, о которых стоит знать - -**A-запись домена должна существовать до первого выпуска сертификата.** Проверка -владения идёт по HTTP-01: Let's Encrypt обращается к домену снаружи, и без записи ему -некуда стучаться. - -**Синхронизация `deploy/` идёт с тега выпускаемой версии**, а не с ветки: скрипты -раскатки всегда соответствуют выпускаемой версии. Обратная сторона — откат на старую -версию вернёт и старые скрипты. Осознанный размен на воспроизводимость. - -**Готовность приложения и готовность прокси — разные события.** Traefik узнаёт о новом -экземпляре только своей проверкой (`healthcheck.interval`, 3с). Если гасить старый сразу -после прямого ответа на `/health`, попадаешь в зазор: старый уже неготов, новый ещё не в -пуле, живых серверов нет и Traefik отвечает `503` от себя. Поэтому `rollout.sh` ждёт -подтверждения от самого прокси. Замер во время раската: без этого шага — 12 таких -ответов на 338 запросов обычного трафика, с ним — 0 из 911 и 0 из 1342 в двух прогонах. - -В access-логе эти `503` различимы: у ответа сливающегося экземпляра указан бэкенд, у -ответа самого Traefik вместо бэкенда прочерк. `503` на `/health` от сливающегося -экземпляра штатные — это и есть сигнал уводить трафик. - -**Порты 25/465** на облачной платформе по умолчанию закрыты, разблокировку запрашивают -у поддержки. diff --git a/deploy/env.example b/deploy/env.example index a001474f..ccf1efbe 100644 --- a/deploy/env.example +++ b/deploy/env.example @@ -21,7 +21,7 @@ REDIS_PASSWORD=CHANGE-ME # IMAGE_REPO=granstel/fillinthetextbot # DOCKER_NETWORK=network -# SERVICE_ALIAS=fitb # по этому имени Prometheus скрейпит /metrics +# SERVICE_ALIAS=fitb # APP_ENV_FILE=./app.env # HEALTH_TIMEOUT=60 # ждём готовности нового экземпляра, сек # ROTATION_TIMEOUT=30 # ждём, пока Traefik возьмёт его в ротацию, сек diff --git a/deploy/fitb-deploy b/deploy/fitb-deploy index 75f0510a..92b46341 100755 --- a/deploy/fitb-deploy +++ b/deploy/fitb-deploy @@ -3,6 +3,9 @@ # Форсированная команда для ключа раскатки из CI: достаёт из запроса номер версии, # подтягивает deploy/ с тега этой версии и запускает раскатку. Больше ничего не умеет. # +# Синхронизация идёт с тега, а не с ветки, чтобы скрипты раскатки соответствовали +# выпускаемой версии. Обратная сторона: откат вернёт и старые скрипты. +# # Ставится на сервер один раз под root; себя не обновляет. set -euo pipefail @@ -15,7 +18,7 @@ REPO_DIR="${WORK_DIR}/.repo" # Синхронизируется только это. Остальное в каталоге раскатки — локальная # конфигурация с секретами (.env, app.env, acme/), её не трогаем. -SYNCED=(rollout.sh docker-compose.yml redis-compose.yml README.md env.example app-env.example) +SYNCED=(rollout.sh docker-compose.yml redis-compose.yml env.example app-env.example) requested="${SSH_ORIGINAL_COMMAND:-}" diff --git a/deploy/rollout.sh b/deploy/rollout.sh index 8fbd98b8..6363229a 100755 --- a/deploy/rollout.sh +++ b/deploy/rollout.sh @@ -99,8 +99,12 @@ until curl -fsS -m 2 "http://${NEW_IP}/health" >/dev/null 2>&1; do done echo " Новый экземпляр здоров." -# Готовность приложения ≠ готовность прокси: Traefik узнаёт о ней своей проверкой. -# Погасить старый раньше — значит оставить пул без живых серверов и отдать клиенту 503. +# Готовность приложения ≠ готовность прокси: Traefik узнаёт о ней своей проверкой +# (healthcheck.interval, 3с). Погасить старый раньше — значит оставить пул без живых +# серверов, и клиент получит 503 от самого прокси. Замер во время раската: без этого +# ожидания 12 таких ответов на 338 запросов обычного трафика, с ним — 0 из 911 и 0 из +# 1342 в двух прогонах. В логе Traefik они отличимы: у ответа сливающегося экземпляра +# указан бэкенд, у ответа самого прокси вместо бэкенда прочерк. TRAEFIK_IP="$(docker inspect -f "{{ (index .NetworkSettings.Networks \"${NETWORK}\").IPAddress }}" "$TRAEFIK_CONTAINER" 2>/dev/null || true)" if [ -z "$TRAEFIK_IP" ]; then From 1e4ccd47d9bf889a2bf03254e7caa7c7ee002e54 Mon Sep 17 00:00:00 2001 From: Stepan Grankin Date: Sun, 30 Aug 2026 21:29:28 +0300 Subject: [PATCH 21/26] =?UTF-8?q?refactor(deploy):=20=D1=81=D0=B5=D1=82?= =?UTF-8?q?=D1=8C=20=D1=81=D0=BE=D0=B7=D0=B4=D0=B0=D1=91=D1=82=20=D1=81?= =?UTF-8?q?=D1=82=D0=B5=D0=BA=20=D0=BF=D1=80=D0=BE=D0=BA=D1=81=D0=B8,=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B0=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D0=BA=D1=81=D0=B8=20=E2=80=94=20=D0=B4=D0=BE=20=D1=80?= =?UTF-8?q?=D0=B0=D1=81=D0=BA=D0=B0=D1=82=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Сеть создавалась в rollout.sh, потому что изначально казалось опасным отдавать её compose: потребителей трое. Проверка показала, что опасности нет — Docker не даёт удалить сеть, пока в ней живут чужие контейнеры, чужой compose down её не тронет. Владельцем сделан стек прокси: он поднимается первым и работает постоянно. Это выпрямляет первое развёртывание — раньше первый запуск раскатки создавал сеть, тянул образ, поднимал контейнер, ждал его готовности и только потом обнаруживал, что прокси нет. Проверка прокси перенесена в начало rollout.sh: она же теперь косвенно проверяет и сеть, а отказ происходит до образа и контейнера. --- deploy/docker-compose.yml | 6 +++--- deploy/rollout.sh | 23 ++++++++++------------- 2 files changed, 13 insertions(+), 16 deletions(-) diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index aaf12dca..90d5df6c 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -8,10 +8,10 @@ name: fitb-edge networks: - # External намеренно: потребителей трое (прокси, redis, контейнеры сервиса) - # и ни один не владелец. Создаётся автоматически в rollout.sh. + # Сеть создаёт этот стек: прокси поднимается первым и работает постоянно. + # Redis и контейнеры сервиса подключаются к ней как к внешней. network: - external: true + name: network services: traefik: diff --git a/deploy/rollout.sh b/deploy/rollout.sh index 6363229a..b74163bc 100755 --- a/deploy/rollout.sh +++ b/deploy/rollout.sh @@ -41,15 +41,20 @@ if [ ! -f "$APP_ENV_FILE" ]; then 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 + # Версия + метка времени, чтобы можно было перекатать ту же версию SAFE_VERSION="${VERSION//[^A-Za-z0-9_.-]/_}" NEW_NAME="fitb_${SAFE_VERSION}_$(date +%s)" -if ! docker network inspect "$NETWORK" >/dev/null 2>&1; then - echo "==> Создаю docker-сеть ${NETWORK}" - docker network create "$NETWORK" >/dev/null -fi - echo "==> Тянем образ ${IMAGE}" docker pull "$IMAGE" @@ -105,14 +110,6 @@ echo " Новый экземпляр здоров." # ожидания 12 таких ответов на 338 запросов обычного трафика, с ним — 0 из 911 и 0 из # 1342 в двух прогонах. В логе Traefik они отличимы: у ответа сливающегося экземпляра # указан бэкенд, у ответа самого прокси вместо бэкенда прочерк. -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 - docker rm -f "$NEW_NAME" >/dev/null 2>&1 || true - exit 1 -fi - echo "==> Ждём, пока Traefik возьмёт ${NEW_IP} в ротацию, до ${ROTATION_TIMEOUT}s" deadline=$(( $(date +%s) + ROTATION_TIMEOUT )) API_URL="http://${TRAEFIK_IP}:8080/api/http/services/${TRAEFIK_SERVICE}@docker" From b273a9cf59e074cd8c7ec197420e21c3805646ab Mon Sep 17 00:00:00 2001 From: Stepan Grankin Date: Sun, 30 Aug 2026 22:48:29 +0300 Subject: [PATCH 22/26] =?UTF-8?q?refactor(deploy):=20=D1=81=D0=B5=D1=80?= =?UTF-8?q?=D0=B2=D0=B8=D1=81=20=D0=BF=D0=BE=D0=B4=D0=BD=D0=B8=D0=BC=D0=B0?= =?UTF-8?q?=D0=B5=D1=82=D1=81=D1=8F=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20com?= =?UTF-8?q?pose,=20blue/green?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docker run с двумя десятками флагов заменён на service-compose.yml. Метки Traefik, тома и переменные стали читаемой конфигурацией. Собственный механизм обновления compose не годится: он гасит старый контейнер и поднимает новый, а --scale не даёт держать две разные версии одного сервиса. Поэтому два проекта из одного файла, blue и green: новая версия поднимается в свободном цвете, прежний гасится после подтверждения ротации от прокси. Ручной цикл опроса /health убран — его заменяет compose up --wait, который ждёт HEALTHCHECK самого образа. До этого healthcheck образа не использовался ничем, кроме вывода docker ps. Слив трафика задаётся через stop_grace_period вместо docker stop -t. --- deploy/fitb-deploy | 2 +- deploy/rollout.sh | 103 ++++++++++++++----------------------- deploy/service-compose.yml | 38 ++++++++++++++ 3 files changed, 78 insertions(+), 65 deletions(-) create mode 100644 deploy/service-compose.yml diff --git a/deploy/fitb-deploy b/deploy/fitb-deploy index 92b46341..4e886fe3 100755 --- a/deploy/fitb-deploy +++ b/deploy/fitb-deploy @@ -18,7 +18,7 @@ REPO_DIR="${WORK_DIR}/.repo" # Синхронизируется только это. Остальное в каталоге раскатки — локальная # конфигурация с секретами (.env, app.env, acme/), её не трогаем. -SYNCED=(rollout.sh docker-compose.yml redis-compose.yml env.example app-env.example) +SYNCED=(rollout.sh docker-compose.yml redis-compose.yml service-compose.yml env.example app-env.example) requested="${SSH_ORIGINAL_COMMAND:-}" diff --git a/deploy/rollout.sh b/deploy/rollout.sh index b74163bc..7ef0c9e0 100755 --- a/deploy/rollout.sh +++ b/deploy/rollout.sh @@ -2,12 +2,14 @@ # # Раскатка версии без простоя: ./rollout.sh <версия> # -# Новый контейнер поднимается рядом со старым и попадает в тот же пул Traefik. -# Старый гасится только после того, как прокси подтвердил, что балансирует на новый. +# Два проекта compose из одного файла, blue и green. Новая версия поднимается в +# свободном цвете рядом с работающим, и только после подтверждения от прокси, +# что он балансирует на новый экземпляр, гасится прежний. set -euo pipefail VERSION="${1:?Использование: rollout.sh <версия>}" +export VERSION SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$SCRIPT_DIR" @@ -20,20 +22,15 @@ if [ -f .env ]; then set +a fi -IMAGE_REPO="${IMAGE_REPO:-granstel/fillinthetextbot}" -IMAGE="${IMAGE_REPO}:${VERSION}" +COMPOSE_FILE="${COMPOSE_FILE:-service-compose.yml}" NETWORK="${DOCKER_NETWORK:-network}" -ALIAS="${SERVICE_ALIAS:-fitb}" -DOMAIN="${DOMAIN:?задайте DOMAIN в .env}" -KEYS_DIR="${KEYS_DIR:?задайте KEYS_DIR в .env}" -LOGS_DIR="${LOGS_DIR:?задайте LOGS_DIR в .env}" APP_ENV_FILE="${APP_ENV_FILE:-${SCRIPT_DIR}/app.env}" HEALTH_TIMEOUT="${HEALTH_TIMEOUT:-60}" -# Должен быть больше, чем DrainDelaySeconds + Shutdown.TimeoutSeconds сервиса -STOP_TIMEOUT="${STOP_TIMEOUT:-45}" 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 @@ -51,59 +48,39 @@ if [ -z "$TRAEFIK_IP" ]; then exit 1 fi -# Версия + метка времени, чтобы можно было перекатать ту же версию -SAFE_VERSION="${VERSION//[^A-Za-z0-9_.-]/_}" -NEW_NAME="fitb_${SAFE_VERSION}_$(date +%s)" +compose() { docker compose -p "$1" -f "$COMPOSE_FILE" "${@:2}"; } -echo "==> Тянем образ ${IMAGE}" -docker pull "$IMAGE" +running() { + [ -n "$(docker ps -q --filter "label=com.docker.compose.project=$1" \ + --filter "label=com.docker.compose.service=fitb")" ] +} -echo "==> Текущие контейнеры сервиса:" -mapfile -t OLD < <(docker ps --filter "label=app=fitb" --format '{{.Names}}') -if [ "${#OLD[@]}" -eq 0 ]; then - echo " (нет — первый запуск)" +if running "$PROJECT_BLUE"; then + ACTIVE="$PROJECT_BLUE"; TARGET="$PROJECT_GREEN" +elif running "$PROJECT_GREEN"; then + ACTIVE="$PROJECT_GREEN"; TARGET="$PROJECT_BLUE" else - printf ' %s\n' "${OLD[@]}" + ACTIVE=""; TARGET="$PROJECT_BLUE" fi -echo "==> Поднимаем новый экземпляр ${NEW_NAME}" -docker run -d \ - --name "$NEW_NAME" \ - --restart unless-stopped \ - --network "$NETWORK" \ - --network-alias "$ALIAS" \ - --env-file "$APP_ENV_FILE" \ - -v "${KEYS_DIR}:/app/keys:ro" \ - -v "${LOGS_DIR}:/app/logs" \ - --label app=fitb \ - --label traefik.enable=true \ - --label "traefik.docker.network=${NETWORK}" \ - --label "traefik.http.routers.fitb.rule=Host(\`${DOMAIN}\`)" \ - --label traefik.http.routers.fitb.entrypoints=websecure \ - --label traefik.http.routers.fitb.tls=true \ - --label traefik.http.routers.fitb.tls.certresolver=le \ - --label traefik.http.services.fitb.loadbalancer.server.port=80 \ - --label traefik.http.services.fitb.loadbalancer.healthcheck.path=/health \ - --label traefik.http.services.fitb.loadbalancer.healthcheck.interval=3s \ - --label traefik.http.services.fitb.loadbalancer.healthcheck.timeout=2s \ - "$IMAGE" >/dev/null - -# Стучимся прямо в IP контейнера — curl внутри образа не нужен -NEW_IP="$(docker inspect -f "{{ (index .NetworkSettings.Networks \"${NETWORK}\").IPAddress }}" "$NEW_NAME")" -echo "==> Ждём готовности ${NEW_NAME} (${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 - docker logs --tail 50 "$NEW_NAME" || true - docker rm -f "$NEW_NAME" >/dev/null 2>&1 || true - exit 1 - fi - sleep 2 -done +echo "==> Активен: ${ACTIVE:-(нет — первый запуск)}; поднимаем в ${TARGET}" + +echo "==> Тянем образ версии ${VERSION}" +compose "$TARGET" pull + +# --wait ждёт HEALTHCHECK самого образа: он ходит в /health внутри контейнера +echo "==> Поднимаем ${TARGET} и ждём готовности, до ${HEALTH_TIMEOUT}s" +if ! compose "$TARGET" up -d --wait --wait-timeout "$HEALTH_TIMEOUT"; then + echo "!! Новый экземпляр не стал здоровым за ${HEALTH_TIMEOUT}s — откатываемся." >&2 + compose "$TARGET" logs --tail 50 || true + compose "$TARGET" down >/dev/null 2>&1 || true + exit 1 +fi echo " Новый экземпляр здоров." +NEW_ID="$(compose "$TARGET" ps -q fitb)" +NEW_IP="$(docker inspect -f "{{ (index .NetworkSettings.Networks \"${NETWORK}\").IPAddress }}" "$NEW_ID")" + # Готовность приложения ≠ готовность прокси: Traefik узнаёт о ней своей проверкой # (healthcheck.interval, 3с). Погасить старый раньше — значит оставить пул без живых # серверов, и клиент получит 503 от самого прокси. Замер во время раската: без этого @@ -117,21 +94,19 @@ until curl -fsS -m 2 "$API_URL" 2>/dev/null | grep -q "\"http://${NEW_IP}:80\":\ do if [ "$(date +%s)" -ge "$deadline" ]; then echo "!! Traefik не взял новый экземпляр в ротацию за ${ROTATION_TIMEOUT}s — откатываемся." >&2 - docker rm -f "$NEW_NAME" >/dev/null 2>&1 || true + compose "$TARGET" down >/dev/null 2>&1 || true exit 1 fi sleep 1 done echo " Traefik балансирует на новый экземпляр." -for c in "${OLD[@]}"; do - [ -z "$c" ] && continue - echo "==> Сливаем и останавливаем старый ${c} (до ${STOP_TIMEOUT}s)" - docker stop -t "$STOP_TIMEOUT" "$c" >/dev/null || true - docker rm "$c" >/dev/null 2>&1 || true -done +if [ -n "$ACTIVE" ]; then + echo "==> Сливаем и останавливаем ${ACTIVE}" + compose "$ACTIVE" down || true +fi echo "==> Чистим повисшие образы" docker image prune -f >/dev/null 2>&1 || true -echo "==> Готово: активна версия ${VERSION} (${NEW_NAME})" +echo "==> Готово: активна версия ${VERSION} в ${TARGET}" diff --git a/deploy/service-compose.yml b/deploy/service-compose.yml new file mode 100644 index 00000000..0ba2c226 --- /dev/null +++ b/deploy/service-compose.yml @@ -0,0 +1,38 @@ +# Контейнер сервиса. Поднимается rollout.sh под именем проекта fitb-blue или +# fitb-green: во время раската живут оба, поэтому имя проекта задаётся снаружи. +# +# 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 From 8daa72f7801d1d0b1864d45315f9aac8f2dc9cbe Mon Sep 17 00:00:00 2001 From: Stepan Grankin Date: Sun, 30 Aug 2026 23:00:34 +0300 Subject: [PATCH 23/26] =?UTF-8?q?chore(deploy):=20=D0=BA=D0=BE=D0=BC=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=D0=B0=D1=80=D0=B8=D0=B8=20=D1=81=D0=BE=D0=BA?= =?UTF-8?q?=D1=80=D0=B0=D1=89=D0=B5=D0=BD=D1=8B=20=D0=B4=D0=BE=20=D0=BE?= =?UTF-8?q?=D0=B4=D0=BD=D0=BE=D0=B9-=D0=B4=D0=B2=D1=83=D1=85=20=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=BE=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/docker-compose.yml | 12 ++++-------- deploy/fitb-deploy | 7 ++----- deploy/redis-compose.yml | 11 +++-------- deploy/rollout.sh | 15 ++++----------- deploy/service-compose.yml | 8 +++----- 5 files changed, 16 insertions(+), 37 deletions(-) diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 90d5df6c..36cc8de3 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -1,15 +1,12 @@ -# Пограничный прокси: терминирует TLS, выпускает и продлевает сертификат -# Let's Encrypt, маршрутизирует трафик по docker-меткам контейнеров сервиса. -# -# Сам сервис этим compose не поднимается — его раскатывает rollout.sh. +# Пограничный прокси: TLS, сертификат Let's Encrypt, маршрутизация по docker-меткам. +# Сервис поднимает rollout.sh. # # docker compose -f docker-compose.yml up -d name: fitb-edge networks: - # Сеть создаёт этот стек: прокси поднимается первым и работает постоянно. - # Redis и контейнеры сервиса подключаются к ней как к внешней. + # Создаётся этим стеком; redis и сервис подключаются к ней как к внешней network: name: network @@ -31,8 +28,7 @@ services: - --entrypoints.web.address=:80 - --entrypoints.websecure.address=:443 - # Служебная точка входа с API: наружу не публикуется (её нет в ports), - # доступна только изнутри docker-сети, откуда её опрашивает rollout.sh + # Наружу не публикуется; API опрашивает rollout.sh изнутри сети - --entrypoints.traefik.address=:8080 - --api=true - --api.insecure=true diff --git a/deploy/fitb-deploy b/deploy/fitb-deploy index 4e886fe3..a240a9ca 100755 --- a/deploy/fitb-deploy +++ b/deploy/fitb-deploy @@ -3,9 +3,7 @@ # Форсированная команда для ключа раскатки из CI: достаёт из запроса номер версии, # подтягивает deploy/ с тега этой версии и запускает раскатку. Больше ничего не умеет. # -# Синхронизация идёт с тега, а не с ветки, чтобы скрипты раскатки соответствовали -# выпускаемой версии. Обратная сторона: откат вернёт и старые скрипты. -# +# Тег, а не ветка: откат вернёт и старые скрипты раскатки. # Ставится на сервер один раз под root; себя не обновляет. set -euo pipefail @@ -16,8 +14,7 @@ set -euo pipefail WORK_DIR="${FITB_WORK_DIR:?задайте FITB_WORK_DIR в /etc/default/fitb-deploy}" REPO_DIR="${WORK_DIR}/.repo" -# Синхронизируется только это. Остальное в каталоге раскатки — локальная -# конфигурация с секретами (.env, app.env, acme/), её не трогаем. +# Остальное в каталоге раскатки — локальная конфигурация с секретами. SYNCED=(rollout.sh docker-compose.yml redis-compose.yml service-compose.yml env.example app-env.example) requested="${SSH_ORIGINAL_COMMAND:-}" diff --git a/deploy/redis-compose.yml b/deploy/redis-compose.yml index 90246dc6..1617215a 100644 --- a/deploy/redis-compose.yml +++ b/deploy/redis-compose.yml @@ -1,11 +1,7 @@ -# Кэш сессий. +# Кэш сессий. Наружу порт не открыт, снаружи — через туннель: +# ssh -L 6379:127.0.0.1:6379 <сервер> # # docker compose -f redis-compose.yml up -d -# -# Сервису redis доступен по имени внутри docker-сети. Наружу порт не открыт: -# публикуется только на loopback сервера, подключаться через SSH-туннель -# -# ssh -L 6379:127.0.0.1:6379 <сервер> name: fitb-redis @@ -21,8 +17,7 @@ services: networks: network: aliases: [redis] - # Адрес обязателен: без него docker открыл бы порт на все интерфейсы - # в обход ufw, и redis оказался бы в интернете + # Без явного адреса docker открыл бы порт наружу в обход ufw ports: - "127.0.0.1:6379:6379" command: > diff --git a/deploy/rollout.sh b/deploy/rollout.sh index 7ef0c9e0..b28cd49c 100755 --- a/deploy/rollout.sh +++ b/deploy/rollout.sh @@ -2,9 +2,8 @@ # # Раскатка версии без простоя: ./rollout.sh <версия> # -# Два проекта compose из одного файла, blue и green. Новая версия поднимается в -# свободном цвете рядом с работающим, и только после подтверждения от прокси, -# что он балансирует на новый экземпляр, гасится прежний. +# Два проекта compose из одного файла: новая версия поднимается в свободном цвете, +# прежний гасится после подтверждения ротации от прокси. set -euo pipefail @@ -38,7 +37,6 @@ if [ ! -f "$APP_ENV_FILE" ]; then exit 1 fi -# Прокси нужен раскатке дважды: он держит общую сеть и подтверждает ротацию. # Проверяем до образа и контейнера, чтобы не тратить цикл ради отказа в конце. TRAEFIK_IP="$(docker inspect -f "{{ (index .NetworkSettings.Networks \"${NETWORK}\").IPAddress }}" "$TRAEFIK_CONTAINER" 2>/dev/null || true)" @@ -68,7 +66,7 @@ echo "==> Активен: ${ACTIVE:-(нет — первый запуск)}; п echo "==> Тянем образ версии ${VERSION}" compose "$TARGET" pull -# --wait ждёт HEALTHCHECK самого образа: он ходит в /health внутри контейнера +# --wait ждёт HEALTHCHECK образа echo "==> Поднимаем ${TARGET} и ждём готовности, до ${HEALTH_TIMEOUT}s" if ! compose "$TARGET" up -d --wait --wait-timeout "$HEALTH_TIMEOUT"; then echo "!! Новый экземпляр не стал здоровым за ${HEALTH_TIMEOUT}s — откатываемся." >&2 @@ -81,12 +79,7 @@ echo " Новый экземпляр здоров." NEW_ID="$(compose "$TARGET" ps -q fitb)" NEW_IP="$(docker inspect -f "{{ (index .NetworkSettings.Networks \"${NETWORK}\").IPAddress }}" "$NEW_ID")" -# Готовность приложения ≠ готовность прокси: Traefik узнаёт о ней своей проверкой -# (healthcheck.interval, 3с). Погасить старый раньше — значит оставить пул без живых -# серверов, и клиент получит 503 от самого прокси. Замер во время раската: без этого -# ожидания 12 таких ответов на 338 запросов обычного трафика, с ним — 0 из 911 и 0 из -# 1342 в двух прогонах. В логе Traefik они отличимы: у ответа сливающегося экземпляра -# указан бэкенд, у ответа самого прокси вместо бэкенда прочерк. +# Гасить старый до подтверждения ротации — оставить пул без живых серверов. echo "==> Ждём, пока Traefik возьмёт ${NEW_IP} в ротацию, до ${ROTATION_TIMEOUT}s" deadline=$(( $(date +%s) + ROTATION_TIMEOUT )) API_URL="http://${TRAEFIK_IP}:8080/api/http/services/${TRAEFIK_SERVICE}@docker" diff --git a/deploy/service-compose.yml b/deploy/service-compose.yml index 0ba2c226..ffd682fd 100644 --- a/deploy/service-compose.yml +++ b/deploy/service-compose.yml @@ -1,5 +1,4 @@ -# Контейнер сервиса. Поднимается rollout.sh под именем проекта fitb-blue или -# fitb-green: во время раската живут оба, поэтому имя проекта задаётся снаружи. +# Контейнер сервиса. Имя проекта задаёт rollout.sh — во время раската живут оба цвета. # # VERSION=1.27.0 docker compose -p fitb-green -f service-compose.yml up -d --wait @@ -13,7 +12,7 @@ services: restart: unless-stopped networks: network: - # Стабильное имя экземпляра, одинаковое у обоих цветов + # Одинаковый у обоих цветов aliases: - ${SERVICE_ALIAS:-fitb} env_file: @@ -21,8 +20,7 @@ services: volumes: - ${KEYS_DIR:?задайте KEYS_DIR в .env}:/app/keys:ro - ${LOGS_DIR:?задайте LOGS_DIR в .env}:/app/logs - # Запас на слив трафика и завершение запросов при остановке. - # Должен быть больше, чем DrainDelaySeconds + Shutdown.TimeoutSeconds сервиса. + # Больше, чем DrainDelaySeconds + Shutdown.TimeoutSeconds сервиса stop_grace_period: ${STOP_TIMEOUT:-45}s labels: app: fitb From c454985dddcd6880396b2849057059856b4e5e7e Mon Sep 17 00:00:00 2001 From: Stepan Grankin Date: Sun, 30 Aug 2026 23:06:42 +0300 Subject: [PATCH 24/26] =?UTF-8?q?chore(docker):=20=D0=BA=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D1=82=D0=BA=D0=B8=D0=B9=20=D0=BA=D0=BE=D0=BC=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D1=82=D0=B0=D1=80=D0=B8=D0=B9=20=D0=BF=D1=80=D0=BE=20curl?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/FillInTheTextBot.Api/Dockerfile | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/FillInTheTextBot.Api/Dockerfile b/src/FillInTheTextBot.Api/Dockerfile index a2c6027c..ecc017a3 100644 --- a/src/FillInTheTextBot.Api/Dockerfile +++ b/src/FillInTheTextBot.Api/Dockerfile @@ -1,8 +1,7 @@ #See https://aka.ms/containerfastmode to understand how Visual Studio uses this Dockerfile to build your images for faster debugging. FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base -# curl нужен только для HEALTHCHECK: по его состоянию скрипт раскатки понимает, -# что новый экземпляр готов принимать трафик +# curl нужен для HEALTHCHECK ниже; в базовом образе его нет RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* WORKDIR /app # Начиная с .NET 8 образы по умолчанию слушают 8080. Порт зафиксирован явно, From d163237b3908173ce0736abf7a96b726f0f5fffd Mon Sep 17 00:00:00 2001 From: Stepan Grankin Date: Sun, 30 Aug 2026 23:17:38 +0300 Subject: [PATCH 25/26] =?UTF-8?q?refactor:=20HEALTHCHECK=20=D0=B8=20curl?= =?UTF-8?q?=20=D1=83=D0=B1=D1=80=D0=B0=D0=BD=D1=8B=20=D0=B8=D0=B7=20=D0=BE?= =?UTF-8?q?=D0=B1=D1=80=D0=B0=D0=B7=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Готовность нового экземпляра rollout.sh снова проверяет сам, стучась с хоста в IP контейнера. Compose --wait без HEALTHCHECK ждал только состояния running и возвращался за секунду, то есть шаг готовности вместе с выводом логов при отказе пропал бы. Логи теперь печатаются и при таймауте ротации: причина отказа там может быть той же, а сообщение уводило разбираться в прокси. --- deploy/rollout.sh | 26 +++++++++++++++++--------- src/FillInTheTextBot.Api/Dockerfile | 3 --- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/deploy/rollout.sh b/deploy/rollout.sh index b28cd49c..68fe62df 100755 --- a/deploy/rollout.sh +++ b/deploy/rollout.sh @@ -66,19 +66,26 @@ echo "==> Активен: ${ACTIVE:-(нет — первый запуск)}; п echo "==> Тянем образ версии ${VERSION}" compose "$TARGET" pull -# --wait ждёт HEALTHCHECK образа -echo "==> Поднимаем ${TARGET} и ждём готовности, до ${HEALTH_TIMEOUT}s" -if ! compose "$TARGET" up -d --wait --wait-timeout "$HEALTH_TIMEOUT"; then - echo "!! Новый экземпляр не стал здоровым за ${HEALTH_TIMEOUT}s — откатываемся." >&2 - compose "$TARGET" logs --tail 50 || true - compose "$TARGET" down >/dev/null 2>&1 || true - exit 1 -fi -echo " Новый экземпляр здоров." +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 )) @@ -87,6 +94,7 @@ until curl -fsS -m 2 "$API_URL" 2>/dev/null | grep -q "\"http://${NEW_IP}:80\":\ 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 diff --git a/src/FillInTheTextBot.Api/Dockerfile b/src/FillInTheTextBot.Api/Dockerfile index ecc017a3..1161fea2 100644 --- a/src/FillInTheTextBot.Api/Dockerfile +++ b/src/FillInTheTextBot.Api/Dockerfile @@ -1,8 +1,6 @@ #See https://aka.ms/containerfastmode to understand how Visual Studio uses this Dockerfile to build your images for faster debugging. FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base -# curl нужен для HEALTHCHECK ниже; в базовом образе его нет -RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* WORKDIR /app # Начиная с .NET 8 образы по умолчанию слушают 8080. Порт зафиксирован явно, # чтобы контракт контейнера не поменялся вместе с версией рантайма. @@ -31,5 +29,4 @@ RUN dotnet publish "FillInTheTextBot.Api.csproj" -c Release -o /app/publish FROM base AS final WORKDIR /app COPY --from=publish /app/publish . -HEALTHCHECK --interval=5s --timeout=3s --start-period=20s --retries=3 CMD curl -fsS http://localhost:80/health || exit 1 ENTRYPOINT ["dotnet", "FillInTheTextBot.Api.dll"] From 1d1837d33cab20d702e0347e2606c50e795a2d0b Mon Sep 17 00:00:00 2001 From: Stepan Grankin Date: Wed, 9 Sep 2026 10:02:18 +0300 Subject: [PATCH 26/26] =?UTF-8?q?=D0=BF=D0=BE=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitattributes | 2 -- .gitignore | 1 - 2 files changed, 3 deletions(-) diff --git a/.gitattributes b/.gitattributes index 9383aa80..d2cda07c 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,4 +1,2 @@ -# Скрипты и конфиги раскатки исполняются на Linux-сервере — только LF, -# иначе ломается shebang и парсинг (репозиторий разрабатывается на Windows). *.sh text eol=lf /deploy/** text eol=lf diff --git a/.gitignore b/.gitignore index 50637a70..c4c79921 100644 --- a/.gitignore +++ b/.gitignore @@ -9,7 +9,6 @@ **/.hg* **/.hg **/Keys/ -# deploy/: конфиги раскатки трекаются, секреты и сертификаты — нет /deploy/.env /deploy/app.env /deploy/acme/