Microservices su .NET 8 ir Docker

Mikroservisų architektūra jau seniai nėra tik madinga tema konferencijose – tai realybė, su kuria susiduria vis daugiau kūrėjų. Kai projektai auga, monolitinės aplikacijos tampa sunkiai valdomos, o skalabilumas virsta tikra problema. Čia ir ateina į pagalbą mikroservisai, ypač kai juos derinate su .NET 8 ir Docker ekosistema.

Šiame straipsnyje pasidalinsiu praktine patirtimi, kaip sukurti mikroservisų architektūrą naudojant naujausią .NET 8 versiją ir Docker konteinerius. Nebus teorinių pamokslų – tik realūs patarimai, su kuriais susidursiu kasdien.

Kodėl mikroservisai su .NET 8 yra gera idėja

.NET 8 atsirado su nemažai patobulinimų, kurie tiesiogiai palengvina mikroservisų kūrimą. Pirmiausia, performance – tai ne tik marketingas. Nauja versija tikrai greitesnė, o tai svarbu, kai turite dešimtis ar net šimtus servisų, kurie nuolat komunikuoja tarpusavyje.

Vienas didžiausių privalumų yra native AOT (Ahead-of-Time) kompiliacija. Tai leidžia sukurti mažesnius Docker image’us ir greitesnį paleidimo laiką. Kai turite mikroservisų architektūrą, kur servisai dažnai skalūojami horizontaliai, kiekviena sutaupyta sekundė paleidžiant naują instanciją tampa svarbi.

Minimal APIs, kurios buvo pristatytos .NET 6, dabar yra dar brandesnės. Galite sukurti paprastą API endpoint’ą vos keliomis eilutėmis kodo. Štai pavyzdys:

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapGet("/health", () => Results.Ok(new { status = "healthy" }));
app.MapGet("/api/products/{id}", async (int id, IProductService service) => 
{
    var product = await service.GetProductAsync(id);
    return product is not null ? Results.Ok(product) : Results.NotFound();
});

app.Run();

Tokia sintaksė puikiai tinka mikroservisams, kur dažnai turite nedidelius, specifinės paskirties servisus. Nereikia kurti pilnų controller’ių su visais ceremonijomis – tiesiog aprašote endpoint’us ir einate toliau.

Docker konteinerių pagrindai mikroservisams

Docker tapo de facto standartu konteinerizacijai, ir tai ne be priežasties. Kai kalbame apie mikroservisus, Docker sprendžia vieną iš didžiausių skausmų – „works on my machine” problemą. Kiekvienas servisas gyvena savo konteineryje su visomis reikalingomis priklausomybėmis.

Dockerfile .NET 8 aplikacijai turėtų būti daugiapakopis (multi-stage). Tai leidžia turėti mažesnį galutinį image’ą, nes build dependencies nepateks į production versiją:

FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY ["ProductService/ProductService.csproj", "ProductService/"]
RUN dotnet restore "ProductService/ProductService.csproj"
COPY . .
WORKDIR "/src/ProductService"
RUN dotnet build "ProductService.csproj" -c Release -o /app/build

FROM build AS publish
RUN dotnet publish "ProductService.csproj" -c Release -o /app/publish /p:UseAppHost=false

FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final
WORKDIR /app
EXPOSE 80
EXPOSE 443
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "ProductService.dll"]

Pastebėkite, kad naudojame tris stage’us: build, publish ir final. Galutiniame konteineryje yra tik runtime ir sukompiliuota aplikacija – jokių SDK ar tarpinių failų.

Servisų komunikacija ir API Gateway

Vienas iš didžiausių iššūkių mikroservisų architektūroje – kaip servisai bendrauja tarpusavyje. Turite kelis pagrindinius pasirinkimus: sinchroninė komunikacija per HTTP/gRPC arba asinchroninė per message broker’ius kaip RabbitMQ ar Kafka.

Praktikoje dažniausiai naudoju hibridinį požiūrį. Kritiniams, real-time užklausoms – HTTP arba gRPC. Ilgai trunkančioms operacijoms ar event’ams, kurie nereikalauja akimirksnio atsakymo – message queue.

API Gateway yra būtinas komponentas. Jis veikia kaip vienas įėjimo taškas į visą jūsų mikroservisų ekosistemą. Galite naudoti Ocelot, YARP (Yet Another Reverse Proxy) arba net Nginx. YARP yra Microsoft produktas ir puikiai integruojasi su .NET:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddReverseProxy()
    .LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"));

var app = builder.Build();
app.MapReverseProxy();
app.Run();

Konfigūracijoje appsettings.json galite apibrėžti route’us:

{
  "ReverseProxy": {
    "Routes": {
      "product-route": {
        "ClusterId": "product-cluster",
        "Match": {
          "Path": "/api/products/{**catch-all}"
        }
      },
      "order-route": {
        "ClusterId": "order-cluster",
        "Match": {
          "Path": "/api/orders/{**catch-all}"
        }
      }
    },
    "Clusters": {
      "product-cluster": {
        "Destinations": {
          "destination1": {
            "Address": "http://product-service:80"
          }
        }
      },
      "order-cluster": {
        "Destinations": {
          "destination1": {
            "Address": "http://order-service:80"
          }
        }
      }
    }
  }
}

Docker Compose ir lokalus development

Kai turite daugiau nei vieną servisą, Docker Compose tampa gyvybiškai svarbus įrankis lokaliam developmentui. Galite paleisti visą infrastruktūrą viena komanda – duombazę, cache, message broker ir visus mikroservisus.

Štai pavyzdinis docker-compose.yml failas:

version: '3.8'

services:
  api-gateway:
    build:
      context: .
      dockerfile: ApiGateway/Dockerfile
    ports:
      - "5000:80"
    depends_on:
      - product-service
      - order-service
    networks:
      - microservices-network

  product-service:
    build:
      context: .
      dockerfile: ProductService/Dockerfile
    environment:
      - ASPNETCORE_ENVIRONMENT=Development
      - ConnectionStrings__DefaultConnection=Server=sqlserver;Database=ProductDb;User=sa;Password=YourStrong@Passw0rd
    depends_on:
      - sqlserver
      - rabbitmq
    networks:
      - microservices-network

  order-service:
    build:
      context: .
      dockerfile: OrderService/Dockerfile
    environment:
      - ASPNETCORE_ENVIRONMENT=Development
      - ConnectionStrings__DefaultConnection=Server=sqlserver;Database=OrderDb;User=sa;Password=YourStrong@Passw0rd
    depends_on:
      - sqlserver
      - rabbitmq
    networks:
      - microservices-network

  sqlserver:
    image: mcr.microsoft.com/mssql/server:2022-latest
    environment:
      - ACCEPT_EULA=Y
      - SA_PASSWORD=YourStrong@Passw0rd
    ports:
      - "1433:1433"
    volumes:
      - sqlserver-data:/var/opt/mssql
    networks:
      - microservices-network

  rabbitmq:
    image: rabbitmq:3-management
    ports:
      - "5672:5672"
      - "15672:15672"
    networks:
      - microservices-network

  redis:
    image: redis:alpine
    ports:
      - "6379:6379"
    networks:
      - microservices-network

volumes:
  sqlserver-data:

networks:
  microservices-network:
    driver: bridge

Su šia konfigūracija galite paleisti visą sistemą komanda docker-compose up. Visi servisai matys vienas kitą per bendrą network’ą, o duomenų bazė išliks tarp paleidimų dėl volume.

Observability ir monitoring

Kai turite paskirstytą sistemą, debugging tampa sudėtingesnis. Negalite tiesiog prijungti debugger’io ir žingsnis po žingsnio sekti kodo. Čia reikalingas tinkamas observability stack.

.NET 8 turi puikią integraciją su OpenTelemetry. Tai standartas, kuris leidžia rinkti metrics, traces ir logs vienodai, nepriklausomai nuo vendor’iaus. Štai kaip pridėti OpenTelemetry prie savo serviso:

builder.Services.AddOpenTelemetry()
    .WithTracing(tracerProviderBuilder =>
    {
        tracerProviderBuilder
            .AddAspNetCoreInstrumentation()
            .AddHttpClientInstrumentation()
            .AddSqlClientInstrumentation()
            .AddOtlpExporter(options =>
            {
                options.Endpoint = new Uri("http://jaeger:4317");
            });
    })
    .WithMetrics(metricsProviderBuilder =>
    {
        metricsProviderBuilder
            .AddAspNetCoreInstrumentation()
            .AddHttpClientInstrumentation()
            .AddPrometheusExporter();
    });

Distributed tracing yra absoliučiai būtinas. Kai užklausa keliauja per kelis servisus, turite matyti visą kelią. Jaeger arba Zipkin puikiai tinka šiam tikslui. Galite pridėti juos prie docker-compose ir iškart matyti, kur užklausa užstringa.

Health checks taip pat neturėtų būti ignoruojami. .NET turi įtaisytą health check sistemą:

builder.Services.AddHealthChecks()
    .AddSqlServer(connectionString)
    .AddRabbitMQ(rabbitConnectionString)
    .AddRedis(redisConnectionString);

app.MapHealthChecks("/health");
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
    Predicate = check => check.Tags.Contains("ready")
});

Kubernetes arba Docker Swarm gali naudoti šiuos endpoint’us, kad žinotų, ar konteineris yra sveikas ir paruoštas priimti traffic’ą.

Duomenų bazių strategija mikroservisuose

Vienas iš fundamentalių mikroservisų principų – kiekvienas servisas turėtų turėti savo duomenų bazę. Tai vadinama „database per service” pattern. Skamba paprasta, bet praktikoje kelia nemažai klausimų.

Pirmiausia, ar tai reiškia, kad reikia atskirų SQL Server instancijų? Ne būtinai. Galite turėti vieną serverį, bet atskiras duomenų bazes. Svarbiausia – joks servisas neturėtų tiesiogiai kreiptis į kito serviso duomenų bazę.

Entity Framework Core puikiai tinka šiam scenarijui. Kiekvienas servisas turi savo DbContext su tik jam reikalingomis lentelėmis:

public class ProductDbContext : DbContext
{
    public ProductDbContext(DbContextOptions options) 
        : base(options)
    {
    }

    public DbSet Products { get; set; }
    public DbSet Categories { get; set; }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.ApplyConfigurationsFromAssembly(typeof(ProductDbContext).Assembly);
    }
}

Migracijos taip pat turėtų būti atskirtos. Kiekvienas servisas valdo savo schemos evoliuciją. Galite automatizuoti migracijų paleidimą konteinerio startup metu, bet būkite atsargūs production aplinkoje – geriau valdyti tai per deployment pipeline.

Kai reikia duomenų iš kito serviso, naudokite API calls arba event-driven replikaciją. Pavyzdžiui, jei Order Service reikia produkto informacijos, jis gali arba iškviesti Product Service API, arba klausytis „ProductUpdated” event’ų ir laikyti lokalią kopiją reikalingų duomenų.

Security aspektai ir autentifikacija

Mikroservisų saugumas – tai ne vienas sprendimas, o kelių sluoksnių sistema. Pirmiausia, tarpservisinis komunikavimas turėtų būti saugus. Jei naudojate HTTPS, tai gerai, bet mikroservisų aplinkoje dažnai naudojamas mutual TLS (mTLS), kur abu komunikuojantys servisai autentifikuoja vienas kitą.

Autentifikacijai ir autorizacijai rekomenduoju naudoti JWT tokens su centralizuotu Identity Provider. Tai gali būti IdentityServer, Keycloak arba Azure AD. Pagrindinis principas – autentifikacija vyksta vieną kartą API Gateway lygyje, o toliau token’as perduodamas kitiems servisams.

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.Authority = "https://identity-server:5001";
        options.Audience = "product-api";
        options.RequireHttpsMetadata = false; // tik development'e!
        
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateAudience = true,
            ValidateIssuer = true,
            ValidateLifetime = true
        };
    });

builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("ProductRead", policy => 
        policy.RequireClaim("scope", "product.read"));
    options.AddPolicy("ProductWrite", policy => 
        policy.RequireClaim("scope", "product.write"));
});

Rate limiting taip pat svarbus aspektas, ypač API Gateway lygyje. .NET 8 turi įtaisytą rate limiting middleware, kurį lengva sukonfigūruoti:

builder.Services.AddRateLimiter(options =>
{
    options.AddFixedWindowLimiter("fixed", opt =>
    {
        opt.Window = TimeSpan.FromSeconds(10);
        opt.PermitLimit = 100;
        opt.QueueLimit = 0;
    });
});

app.UseRateLimiter();

Kai viskas susideda į vieną paveikslą

Mikroservisų architektūra su .NET 8 ir Docker nėra silver bullet, bet tai tikrai galingas įrankių derinys modernių aplikacijų kūrimui. Svarbiausia – nepersistengti. Jei jūsų aplikacija maža, galbūt mikroservisai dar nereikalingi. Bet kai projektas auga, komanda didėja, o skirtingos dalys turi skirtingus skalabilumo poreikius – mikroservisai tampa logiška evoliucija.

Pradėkite paprastai. Nebandykite iš karto sukurti 20 servisų su pilnu observability stack. Pirmiausia išskirkite vieną ar du servisus iš monolito, išmokite juos deployt’inti, monitor’inti, debug’inti. Tik tada plėskite toliau.

Docker Compose puikiai tinka lokaliam developmentui, bet production’e žiūrėkite į orchestration sprendimus – Kubernetes tapo industrijos standartu, nors ir turi mokymosi kreivę. Jei jūsų infrastruktūra cloud’e, AWS ECS, Azure Container Apps ar Google Cloud Run gali būti paprastesni alternatyvos.

Ir nepamirškite – mikroservisai sprendžia techninius iššūkius, bet sukuria organizacinius. Jums reikės DevOps praktikų, CI/CD pipeline’ų, monitoring’o, incident management procesų. Bet jei visa tai yra vietoje, mikroservisų architektūra su .NET 8 ir Docker leidžia kurti sistemas, kurios tikrai skalūojasi ir vystosi kartu su jūsų verslu.

Daugiau

Hono.js: Krašto skaičiavimo sistema