DEV Community

Cover image for ASP.NET Web API ile Kubernetes Üzerinde Kesintisiz Deployment Kurgusu
Tugay Ersoy
Tugay Ersoy

Posted on • Edited on

ASP.NET Web API ile Kubernetes Üzerinde Kesintisiz Deployment Kurgusu

Günümüz dünyasında ihtiyaçlara binaen gün içerisinde servislere çok sık deploy çıkılabilmekte ve service özelinde yeni geliştirmeler devreye alınabilmektedir. Kimi zaman bu servisler anlık kesintinin problem olmayacağı kimi zaman da tek bir request özelinde bile kesinti yaşandığında müşteri tarafının olumsuz bir şekilde etkilenebileceği servisler olabilmektedir. Bu kesinti durumunu ortadan kaldırmak için gerek Kubernetes özelinde gerek de ASP.NET ile geliştirilen bir servis özelinde neler yapabileceğimize yazı kapsamında değiniyor olacağım.

İçerik

  • Kubernetes tarafında pod'un sonlandırılması, oluşturulması ve update stratejileri
  • .Net tarafında Host yapısının incelenmesi
  • IHostedLifecycleService, IHostedService ve IHostApplicationLifetime interface'lerinin incelenmesi
  • Host'un shutdown sürecinin incelenmesi
  • Kind ile Kubernetes Cluster oluşturulması
  • Örnek .Net projesi, Dockerfile ve Deployment manifest'in oluşturulması
  • Servisin Kubernetes Cluster'a deploy edilmesi ve testin gerçekleştirilmesi
  • Kubernetes Ingress ve Kubernetes Control Plane arasındaki gecikme

Kubernetes Üzerinde Update Stratejisi

Kubernetes tarafında, deployment objesinin manifestinde .spec.strategy.type altında update stratejisi belirtilmektedir. Bu strateji Recreate ya da verilmediği taktirde default davranış olan RollingUpdate'dir.

Bu makele kapsamında uygulamanın bir Deployment objesi olarak yayınlandığı baz alınmıştır. StatefulSets ve DaemonSets objelerinde .spec.updateStrategy.type spec'di altında bu stratejiler belirlenmektedir. Bu stratejiler ise OnDelete ve RollingUpdate'dir.

Recreate

spec:
  replicas: 10
  strategy:
    type: Recreate
Enter fullscreen mode Exit fullscreen mode

Recreate update stratejisinde; öncesinde bütün pod'lar terminate edilir ve ardından yeni versiyona ait pod'lar ayağa kaldırılır. Yukarıda verilen manifest'e göre öncesinde Kubernetes bütün 10 pod'u öldürecek ve ardından yeni pod'ları ayağa kaldıracaktır. Bu stratejide muhtemel down-time'a (kesinti) neden olacaktır. Bunun sebebi ise Kubernetes'in öncesinde bütün pod'ları; yeni pod'lar oluşup yerlerine geçmeden terminate etmesidir.

RollingUpdate

spec:
  replicas: 4
  strategy:
    type: RollingUpdate
    rollingUpdate:
        maxUnavailable: %25
        maxSurge: %25
Enter fullscreen mode Exit fullscreen mode

RollingUpdate update stratejisinde ise kademeli olarak pod'lar yeni versiyonlu image ile replace edilir. Öncelikle pod yeni image numarası ile oluşturulur ve ardından eski image numaralı pod öldürülür. Bu operasyon bütün pod'lar için gerçekleşene kadar devam eder.

maxSurge ve maxUnavailable parametreleri RollingUpdate stratejisinde belirtilmektedir.

  • maxUnavailable: Update aşamasında unavailable olabilecek pod sayısını belirtmektedir. Değer olarak yüzdelik ya da doğrudan pod sayısı verilebilir. Optional bir field'dır ve default değeri %25'dir.
  • maxSurge: Deployment manifest içerisinde belirtilen replica sayısının üstünde olabilecek pod sayısını ifade etmektedir. maxUnavailable'da olduğu gibi yüzdelik ya da doğrudan pod sayısı değeri verilebilir. Optional bir field'dır ve default değeri %25'dir.

Yukarıda belirtilen field'lar için yüzdelik değer verildiği durumlarda; pod sayısı tam sayı gelmez ise maxUnavailable için bu değer aşağıya maxSurge için ise bu değer yukarıya yuvarlanır. Örneğin 1 replica belirtildiği durumda maxSurge değeri yukarı yuvarlanarak 1'e maxUnavailable değeri ise aşağıya yuvarlanarak 0'a karşılık gelmektedir. Bu durumda önce bir pod oluşturulur ve running duruma geçtikten sonra hali hazırdaki pod terminate edilir.

Rolling Update stratejisi ile beraber Kubernetes uygulamanın minimum kesintiye uğrayarak deploy edilmesini sağlamaktadır. Aşağıda Rolling Update operasyonu sırasında tek replica'ya sahip bir deployment için oluşan aşamalar ve örnek deployment manifest aktarılmıştır;

apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-deployment
  labels:
    app: nginx
spec:
  replicas: 3
  selector:
    matchLabels:
      app: nginx
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
        - name: nginx
          image: nginx:1.14.2
          ports:
            - containerPort: 80
          resources:
            limits:
              cpu: "0.3"
              memory: "75Mi"
            requests:
              cpu: "0.1"
              memory: "50Mi"
  strategy:
    type: RollingUpdate
Enter fullscreen mode Exit fullscreen mode

kubectl set image deployment/nginx-deployment nginx=nginx:1.14.1 komutu çalıştırılarak image versiyonu update edildiğinde aşağıdaki aşamalar gerçekleştirilmektedir;

  1. maxSurge değeri 1 olduğundan öncelikle yeni image numarası ile bir pod ayağa kaldırılmaya başlanır.
  2. Pod status'ü Running'e geçtikten sonra hali hazırda olan Pod'a Kubernetes tarafından SIGTERM sinyali gönderilir. Pod'a yeni gelen isteklerin yönlendirilmesi kesilir ve hali hazırda olan isteklerin tamamlanması için açık connection'lar spec.terminationGracePeriodSeconds süresi zarfınca beklenir.
  3. Pod'un terminate olması spec.terminationGracePeriodSeconds süresinde fazla ise Kubernetes tarafından SIGKILL sinyali gönderilir ve pod kill edilir.

Kubernetes tarafında bu süre beklenir fakat uygulama tarafında da belirtilen POSIX sinyali neticesinde graceful shutdown için işlemin yapılması gerekmektedir.

Yukarıdaki deployment için aşağıda image versiyonu update edildiğinde pod status'leri sırası ile verilmiştir;

kubectl get pods -n default -w
NAME                                READY   STATUS    RESTARTS   AGE
nginx-deployment-59994fb97c-5j4fv   1/1     Running   0          8m8s
nginx-deployment-59994fb97c-g789c   1/1     Running   0          8m9s
nginx-deployment-59994fb97c-nddlf   1/1     Running   0          8m9s
nginx-deployment-5fffc966ff-8crmb   0/1     Pending   0          1s
nginx-deployment-5fffc966ff-8crmb   0/1     Pending   0          1s
nginx-deployment-5fffc966ff-8crmb   0/1     ContainerCreating   0          1s
nginx-deployment-5fffc966ff-8crmb   1/1     Running             0          1s
nginx-deployment-59994fb97c-5j4fv   1/1     Terminating         0          8m16s
nginx-deployment-5fffc966ff-52knq   0/1     Pending             0          0s
nginx-deployment-5fffc966ff-52knq   0/1     Pending             0          0s
nginx-deployment-5fffc966ff-52knq   0/1     ContainerCreating   0          0s
nginx-deployment-59994fb97c-5j4fv   0/1     Terminating         0          8m16s
nginx-deployment-5fffc966ff-52knq   1/1     Running             0          1s
nginx-deployment-59994fb97c-g789c   1/1     Terminating         0          8m18s
nginx-deployment-5fffc966ff-jwmtt   0/1     Pending             0          0s
nginx-deployment-5fffc966ff-jwmtt   0/1     Pending             0          0s
nginx-deployment-5fffc966ff-jwmtt   0/1     ContainerCreating   0          0s
nginx-deployment-59994fb97c-5j4fv   0/1     Terminating         0          8m17s
nginx-deployment-59994fb97c-5j4fv   0/1     Terminating         0          8m17s
nginx-deployment-59994fb97c-5j4fv   0/1     Terminating         0          8m17s
nginx-deployment-59994fb97c-g789c   0/1     Terminating         0          8m18s
nginx-deployment-5fffc966ff-jwmtt   1/1     Running             0          1s
nginx-deployment-59994fb97c-g789c   0/1     Terminating         0          8m19s
nginx-deployment-59994fb97c-g789c   0/1     Terminating         0          8m19s
nginx-deployment-59994fb97c-g789c   0/1     Terminating         0          8m19s
nginx-deployment-59994fb97c-nddlf   1/1     Terminating         0          8m19s
nginx-deployment-59994fb97c-nddlf   0/1     Terminating         0          8m19s
nginx-deployment-59994fb97c-nddlf   0/1     Terminating         0          8m20s
nginx-deployment-59994fb97c-nddlf   0/1     Terminating         0          8m20s
nginx-deployment-59994fb97c-nddlf   0/1     Terminating         0          8m20s
Enter fullscreen mode Exit fullscreen mode

Belirtilen spec.terminationGracePeriodSeconds değeri pod özelinde tanımlanmaktadır. Default değeri 30s'dir.

Sidecar container'lara SIGTERM sinyali gönderilmeden önce aynı pod içerisinde bulunan main container terminate edilip sonrasında sidecar container'lara tanımlandıkları sıranın tersi yönde SIGTERM sinyali gönderilmektedir. Bu şekilde pod içerisinde sidecar container'a ihtiyaç kalmadığı durumda sonlandırılması sağlanmış olur

Deployment içerisinde yer alan nginx uygulaması SIGTERM sinyali aldığında açık connection'lar da dahil olmak üzere hızlıca process'i sonlandırır. Bu sebeple graceful shutdown edilebilmesi için SIGTERM sinyali geldiğinde track edilmeli ve SIGQUIT sinyali verilmelidir. Nginx tarafında graceful shutdown yapılabilmesi için SIGQUIT sinyali beklemektedir. Bunun için bash ile operasyon gerçekleştirilebilir.

.Net Host Model

.Net Core framework ile dünyamıza gelen yeni Host model yaklaşımı ile birlikte Host objesi içerisinde uygulamanın ihtiyaç duyacağı kaynaklarının ve hayat döngüsünde olan fonksiyonlarının sarmalanması amaçlanmıştır. Belirtilen yapı ile de aynı zamanda default template'ler içerisinde yer alan bir çok boilerplate kodun da çıkarılması amaçlanmıştır. Bu obje üzerinde belli düzenlemeler yapılarak uygulama türüne göre aşağıda belirtilen fonksiyonaliteler default'da hazır gelmekte ve ihtiyaca binaen düzenlenebilmektedir;

  • Dependency Injection (DI) yapısı
  • Logging
  • Configuration
  • App Shutdown süreci
  • IHostedService implementasyonu

Belirtilen Host model yaklaşımı .Net framework versiyonları değiştikçe uygulama templatelerinde de farklılık göstermiştir. Aşağıda 3 farklı yaklaşım .Net versiyonları ile belirtilmiştir;

.NET Core 2.x sürümü ile birlikte WebHost sınıfı altında yer alan CreateDefaultBuilder method'u ile Host model oluşturulmakta ve configüre edilmektedir.

public class Program
{
    public static void Main(string[] args)
    {
        CreateWebHostBuilder(args).Build().Run();
    }

    public static IWebHostBuilder CreateWebHostBuilder(string[] args) =>
        WebHost.CreateDefaultBuilder(args)
            .UseStartup<Startup>();
}
Enter fullscreen mode Exit fullscreen mode

Yukarıda dotnet cli'ı ile belirtilen sürümde bir ASP.Net Core projesi sıfırdan oluşturulduğunda Program.cs dosyasında yer alan kod bloğu aktarılmıştır.

.NET Core 3.x ile .NET 5 versiyonları arasında Host model yaklaşımında büyük değişiklik sağlanarak web projelerinin de generic Host üzerinden oluşturulması için yaklaşım değiştirilmiştir. Bu değişim ile beraber aynı base kod kullanılarak Host model üzerinden worker servisler, gRPC servisler, Windows servisler geliştirilebilir hale gelmiştir. Bu yöntem ile IWebHostBuilder interface'i yerine IHostBuilder interface'i üzerinde Host model kurgulanmıştır.

public class Program
{
    public static void Main(string[] args)
    {
        CreateHostBuilder(args).Build().Run();
    }
    public static IHostBuilder CreateHostBuilder(string[] args) =>
        Host.CreateDefaultBuilder(args)
            .ConfigureWebHostDefaults(webBuilder =>
            {
                webBuilder.UseStartup<Startup>();
            }); 
    }
}
Enter fullscreen mode Exit fullscreen mode

Yukarıda .NET Core 3.x ile .NET 5 versiyonları arasında dotnet cli ile sıfırdan oluşturulan ASP.NET projesinin Program.cs dosyasında yer alan kod bloğu paylaşılmıştır.

Yukarıda paylaşılan iki yaklaşımda da Startup sınıfının ayrılmaz bir şekilde web uygulamasına bağımlı olduğu gözden kaçırılmamalıdır fakat IHostBuilder interface'i üzerinde konumlanan Host model yaklaşımında web uygulamaları dışında farklı uygulamalarında geliştirilebileceğini aktarmıştık. Bu uygulamalarda Startup sınıfına ihtiyaç bulunmayabilmektedir. (Örneğin Configure adlı method uygulama içerisinde yer alan middleware'leri ayarlamak için kullanılmaktadır fakat worker service'ler için böyle bir configürasyona ihtiyaç bulunmamaktadır.) Bu sebeple framework'ü geliştirenler ConfigureWebHostDefaults extension method'u ile bu durumu aşmışlardır.

.NET 6 ile birlikte iki farklı dosya üzerinde yapılan configürasyonlar (Startup.cs ve Program.cs) tek dosya üzerinde birleştirilmiş ve Host model IHostApplicationBuilder sınıfı üzerine konumlandırılmıştır. dotnet ekibi Migration notlarında bu yaklaşımı Minimal Hosting olarak ifade etmişler ve Minimal API'ı default web template olarak konumlandırmışlardır. ref

namespace Example.Processor.Api;

public class Program
{
    public static void Main(string[] args)
    {
        var builder = WebApplication.CreateBuilder(args);
        // Add services to the container.
        builder.Services.AddControllers();
        // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle

        builder.Services.AddEndpointsApiExplorer();
        builder.Services.AddSwaggerGen();

        var app = builder.Build();
        // Configure the HTTP request pipeline.
        if (app.Environment.IsDevelopment())

        {
            app.UseSwagger();
            app.UseSwaggerUI();
        }
        app.UseHttpsRedirection();
        app.UseAuthorization();
        
        app.MapControllers();
        app.Run();
    }
}
Enter fullscreen mode Exit fullscreen mode

Yukarıda .NET 6 versiyonu ile dotnet cli'da sıfırdan oluşturulan ASP.NET projesinin Program.cs dosyasında yer alan kod bloğu paylaşılmıştır. Görüldüğü gibi bütün configürasyonlar tek bir dosya içerisinde konumlandırılıyor ve Startup sınıfı bulunmuyor. Ek olarak bu yaklaşım ile beraber Host.CreateDefaultBuilder method'u yerine WebApplication.CreateBuilder method'u kullanmakta ve IHostBuilder yerine IHostApplicationBuilder dönülmektedir. .NET geliştiricileri .NET 7 ile birlikte de Host.CreateApplicationBuilder method'u tanıtmış ve yaklaşım olarak Web ve Non-Web uygulamalar için Host model yaklaşımını aşağıda belirtildiği gibi sürdürülmesini tavsiye etmiştir. Bu konuda David Fowler'n yorumuna ulaşabilirsiniz.

  • Web Application'lar için örnek yaklaşım
var builder = WebApplication.CreateBuilder();

builder.Logging.AddConsole();

builder.Services.AddOptions<MyOptions>().BindConfiguration("MyConfig");

builder.Services.AddHostedService<MyWorker>();

var app = builder.Build();

app.MapGet("/", () => "Hello World");

app.Run();
Enter fullscreen mode Exit fullscreen mode
  • Non-Web Application'lar için örnek yaklaşım
var builder = Host.CreateApplicationBuilder();

builder.Logging.AddConsole();

builder.Services.AddOptions<MyOptions>().BindConfiguration("MyConfig");

builder.Services.AddHostedService<MyWorker>();

var host = builder.Build();

host.Run();
Enter fullscreen mode Exit fullscreen mode

WebApplication sınıfı web uygulaması için gerekli olan 3 interface'i implemente etmiştir;

  • IHost - Host'un başlatılması ve sonlandırılmasından sorumludur.
  • IApplicationBuilder - Middleware pipeline'ları oluşturmak için kullanılır
  • IEndpointRouteBuilder - Endpoint'ler için kullanılır

Aynı zamanda aşağıda belirtilen 3 service'i de HostApplicationBuilder.Build() methodu çağrıldığı an DI container'a otomatik olarak register edilmektedir;

  • IHostApplicationLifetime - Herhangi bir sınıfa inject edilerek graceful shutdown ve başlatma sonrası operasyonları handle etmek için kullanılır
  • IHostLifetime - Uygulamanın ne zaman başlayacağını ya da sonlanacağını kontrol eder.
  • IHostEnvironment - Uygulama ismi, Root Path, Environment Name vb. bilgiler almak için kullanılır.

IHostApplicationLifetime, IHostLifecycleService, IHostedService Interface'lerinin İncelenmesi

Belirtilen interface'leri ve bu interface'ler ile implemente edilen Lifetime event'lerini kavrayabilmek için aşağıda örnek bir IHostedService implementasyonu aktarılmıştır;

BackgroundService bir abstract sınıftır ve background service'ler oluşturmak için kullanılır. IHostedService ise BackgroundService tarafından implemente edilen bir interface'dir. İçerisinde Host'u yönetmek için method'lar barındırır. Worker Service ise background service oluşturmak için bir template'dir. dotnet new worker command'ı ile oluşturulur.

Sadece IHostedService implemente edilerek de background processing gerçekleştirilebilecek service'ler oluşturmak mümkündür fakat burada uygulamanın lifetime event'leri dinlenerek service'de graceful shutdown operasyonunun manuel implemente edilmesi gerekmektedir. BackgroundService'de ise override edilen ExecuteAsync method'na parametre olarak geçilen CancellationToken yapılan operasyonlarda kontrol edilerek graceful shutdown operasyonu daha rahat bir şekilde gerçekleştirilebilir.

public class Worker : IHostedLifecycleService
{
    private readonly ILogger<Worker> _logger;

    public Worker(ILogger<Worker> logger, IHostApplicationLifetime applicationLifetime)
    {
        applicationLifetime.ApplicationStarted.Register(OnStarted);
        applicationLifetime.ApplicationStopping.Register(OnStopping);
        applicationLifetime.ApplicationStopped.Register(OnStopped);
        _logger = logger;
    }

    public Task StartAsync(CancellationToken cancellationToken)
    {
        _logger.LogInformation("IHostedService StartAsync has been called");
        return Task.CompletedTask;
    }

    public Task StartedAsync(CancellationToken cancellationToken)
    {
        _logger.LogInformation("IHostedLifecycleService StartedAsync has been called");
        return Task.CompletedTask;
    }

    public Task StartingAsync(CancellationToken cancellationToken)
    {
        _logger.LogInformation("IHostedLifecycleService StartingAsync has been called");
        return Task.CompletedTask;
    }

    public Task StopAsync(CancellationToken cancellationToken)
    {
        _logger.LogInformation("IHostedService StopAsync has been called");
        return Task.CompletedTask;
    }

    public Task StoppedAsync(CancellationToken cancellationToken)
    {
        _logger.LogInformation("IHostedLifecycleService StoppedAsync has been called");
        return Task.CompletedTask;
    }

    public Task StoppingAsync(CancellationToken cancellationToken)
    {
        _logger.LogInformation("IHostedLifecycleService StoppingAsync has been called");
        return Task.CompletedTask;
    }

    private void OnStarted()
    {
        _logger.LogInformation("IHostApplicationLifetime OnStarted has been called");
    }

    private void OnStopped()
    {
        _logger.LogInformation("IHostApplicationLifetime OnStopped has been called");
    }

    private void OnStopping()
    {
        _logger.LogInformation("IHostApplicationLifetime OnStopping has been called");
    }
}

Enter fullscreen mode Exit fullscreen mode

Bir background processing yapmak istediğim için IHostedService'i implemente etmek gerektiğini iletmiştim. Burada IHostedLifecycleService kullanmamın sebebi ise zaten bu interface'in IHostedService'den kalıtım alıyor olmasıdır. Bu interface .Net 8 ile beraber getirildi. Bu sayede uygulamanın lifecycle döngüsüne daha rahat bir şekilde müdehale edebiliyor ve operasyon gerçekleştirebiliyoruz. İçerisinde 4 yeni method barındırıyor. Bunlar StartingAsync, StartedAsync, StoppingAsync ve StoppedAsync 'dir. Ek olarak IHostApplicationLifetime'ın host build edilirken otomatik olarak register edildiğini iletmiştim. Bu sınıf altında bulunan 3 property aslında birer CancellationToken ve Host'un lifetime'na göre tetikleniyorlar. Bu token'lara yukarıda ilettiğim şekilde register oluyorum.

Yukarıda oluşturduğum Hosted Service'i DI container'a aşağıda ilettiğim gibi register ediyorum.

using Example.Worker.Service;

var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddHostedService<Worker>();


var host = builder.Build();
host.Run();

Enter fullscreen mode Exit fullscreen mode

Uygulamayı çalıştırıp ve ardından da CTRL+C yaparak sonlandırdığımda aşağıdaki console çıktısı karşıma çıkıyor.

info: Example.Worker.Service.Worker[0]
      IHostedLifecycleService StartingAsync has been called
info: Example.Worker.Service.Worker[0]
      IHostedService StartAsync has been called
info: Example.Worker.Service.Worker[0]
      IHostedLifecycleService StartedAsync has been called
info: Example.Worker.Service.Worker[0]
      IHostApplicationLifetime OnStarted has been called
info: Microsoft.Hosting.Lifetime[0]
      Application started. Press Ctrl+C to shut down.
info: Microsoft.Hosting.Lifetime[0]
      Hosting environment: Development
info: Microsoft.Hosting.Lifetime[0]
      Content root path: C:\Codes\Example.Worker.Service
info: Example.Worker.Service.Worker[0]
      IHostApplicationLifetime OnStopping has been called
info: Microsoft.Hosting.Lifetime[0]
      Application is shutting down...
info: Example.Worker.Service.Worker[0]
      IHostedLifecycleService StoppingAsync has been called
info: Example.Worker.Service.Worker[0]
      IHostedService StopAsync has been called
info: Example.Worker.Service.Worker[0]
      IHostedLifecycleService StoppedAsync has been called
info: Example.Worker.Service.Worker[0]
      IHostApplicationLifetime OnStopped has been called

C:\Codes\Example.Worker.Service\bin\Debug\net8.0\Example.Worker.Service.exe (process 35456) exited with code 0.
Enter fullscreen mode Exit fullscreen mode

Çıktıyı kontrol ettiğimizde sıranın aşağıdaki gibi olduğu görünmektedir;

  1. IHostedLifecycleService.StartingAsync çağrıldığı görülmüştür. Uygulama başlamadan önce çağrılan method'dur.
  2. IHostedService.StartAsync çağrılmıştır. Host'un service'i başlatmaya hazır olduğunda çağrılan method'dur.
  3. IHostedLifecycleService.StartedAsync çağrılmıştır. IHostedService.StartAsync hemen sonra yani başlatma işlemi bittikten sonra çağrılır.
  4. IHostApplicationLifetime.ApplicationStarted Host'un tamamen başladığını ifade eder.

Uygulama stop edildikten sonra aşağıdaki sıralamanın gerçekleştiği gözlemlenmiştir;

  1. IHostApplicationLifetime.ApplicationStopping uygulama graceful shutdown operasyonu gerçekleştirmeye başladığında tetiklenir.
  2. IHostedLifecycleService.StoppingAsync uygulama sonlandırılmaya başlanırken öncesinde çağrılır. IHostedService.StopAsync operasyonunun hemen öncesinde yer alır.
  3. IHostedService.StopAsync uygulama graceful shutdown operasyonu gerçekleştirir.
  4. IHostedLifecycleService.StoppedAsync uygulamanın graceful shutdown operasyonunu tamamlandığında çağrılır.
  5. IHostApplicationLifetime.ApplicationStopped uygulamanın graceful shutdown operasyonunu tamamlandığını ifade eder.

Burada yer alan diğer önemli bir nokta ise uygulamanın CTRL+C kombinasyonu ile sonlandırılıyor olmasıdır. Host default'da otomatik olarak IHostLifetime interface'ni ConsoleLifetime olarak register etmiştir. Web ve Background Service için de bu geçerlidir. IHostLifetime interface'de uygulamanın ne zaman başlayacağını ve sonlanacağını kontrol ettiğini ifade etmiştim. ConsoleLifetime içerisinde de yukarıda ilettiğim kombinasyon ile tuşlara basılarak uygulamaya SIGINT sinyali vermiş oluyoruz. Kubernetes'de ilk bölümde anlattığım nedenlerden dolayı pod'un sonlandırılması için container'a SIGTERM sinyali göndermektedir. Bu belirtilen sinyaller neticesinde graceful shutdown operasyonu gerçekleştirilir.

.Net 6 öncesinde posix signal'ler desteklenip signal tipine göre handle edilmiyordu. .Net 6 sonrası ConsoleLifetime, SIGINT, SIGQUIT, SIGTERM sinyallerini dinleyerek graceful shutdown operasyonunu gerçekleştirebilir hale geldi.

Host'un Shutdown Süreci

Default'da .Net 6 ve sonrasında generic host'un IHostLifetime interface'ni ConsoleLifetime olarak implemente edildiğini aktarmıştım. Host'un da gracefully olarak sonlandırılması için ConsoleLifetime'a aşağıdaki sinyaller gönderilerek bu operasyon gerçekleştirilebilir;

  • SIGINT ya da CTRL+C
  • SIGQUIT ya da CTRL+BREAK (Windows)
  • SIGTERM (Kubernetes tarafında pod'un sonlandırılması için container'a gönderilen sinyal docker stop)

.Net 6 öncesinde SIGTERM sinyali geldiğinde gracefully olarak shut down operasyonu gerçekleştirilemiyordu. Bu durum ile alakalı work around olarak ConsoleLifetime tarafında, System.AppDomain.ProcessExit event'ni dinlenerek ProcessExit thread'i stop edilir ve host'un durması beklenirdi.

Host shut down süreci

Yukarıda gracefully shut down operasyonu sırasında gerçekleşen süreç aktarılmıştır. Sırasıyla;

  1. Kubernetes'den ya da kullanıcıdan SIGTERM sinyali geliyor. Bu sinyal neticesinde de IHostApplicationLifetime altında bulunan StopApplication() method'u tetikleniyor ve ApplicationStopping event'i fırlatılıyor. Öncesinde IHost.WaitForShutdownAsync methodu bu belirtilen event'i dinliyor ve event tetiklendiğinden dolayı Main işletimi ublock'luyor.
  2. IHost.StopAsync() method'u tetikleniyor ve bu method içerisinden de IHostedService.StopAsync() tetiklenerek her bir hosted service'in stop edilmesini sağlayıp ardından da stop edildiğine dair event'leri fırlatıyor.
  3. Son olarak IHost.WaitForShutdownAsync tamamlanıyor ve uygulamanın işletmesi gereken code block'ları yürütülüp gracefully shut-down operasyonu gerçekleştiriliyor.

Host tarafında configürasyon gerçekleştirilerek ShutdownTimeout ayarlanması mümkündür. Bu değer IHost.StopAsync() method'u için belirtilen timeout süresidir ve default değeri 30 saniyedir.

Kind İle Kubernetes Cluster'ın Ayağa Kaldırılması

OpenSource olarak geliştirilen Kind ürünü ile local'de hızlı bir şekilde Kubernetes cluster'ları ayağa kaldırmak mümkündür. Bu makale için de geliştirilen uygulamanın yayınlanacağı Kubernetes cluster'ı Kind ürünü ile oluşturulmuştur.

Öncelikle Kind cli'ı local'e aşağıda belirtilen komut ile yüklüyoruz;

curl.exe -Lo kind-windows-amd64.exe https://kind.sigs.k8s.io/dl/v0.23.0/kind-windows-amd64
Move-Item .\kind-windows-amd64.exe c:\some-dir-in-your-PATH\kind.exe
Enter fullscreen mode Exit fullscreen mode

Her seferinde cli'ın olduğu dizine gidip işlem yapmamak için belirttiğiniz dizini Path Environment Variable'ı içerisine eklemeyi unutmayınız.

Ardından 3 worker node içeren bir cluster ayağa kaldırmak için aşağıda belirtilen yaml dosyası oluşturulur.

kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
- role: control-plane
  kubeadmConfigPatches:
  -
    kind: InitConfiguration
    nodeRegistration:
      kubeletExtraArgs:
        node-labels: "ingress-ready=true"    
  extraPortMappings:
  - containerPort: 80
    hostPort: 8081
    protocol: TCP
  - containerPort: 443
    hostPort: 8443
    protocol: TCP
- role: worker
- role: worker
- role: worker
Enter fullscreen mode Exit fullscreen mode

Yukarıda ilettiğim yaml dosyasını incelediğimizde; her bir node rolü için tanım olduğu ve local'de 8081 ve 8443 port'larına gönderilen isteklerin cluster üzerinde kuracağımız ingress controller'a forward etmek için tanımlandığı gözlenmektedir.

kind create cluster --config .\kind-cluster.yaml
Enter fullscreen mode Exit fullscreen mode

Yukarıda ilettiğim komut ile cluster'ı ayağa kaldırıyorum

kind create cluster --config .\kind-config\kind-cluster.yaml
Creating cluster "kind" ...
 ✓ Ensuring node image (kindest/node:v1.30.0) 🖼
 ✓ Preparing nodes 📦 📦 📦 📦
 ✓ Writing configuration 📜
 ✓ Starting control-plane 🕹️
 ✓ Installing CNI 🔌
 ✓ Installing StorageClass 💾
 ✓ Joining worker nodes 🚜
Set kubectl context to "kind-kind"
You can now use your cluster with:

kubectl cluster-info --context kind-kind

Have a question, bug, or feature request? Let us know! https://kind.sigs.k8s.io/#community 🙂
Enter fullscreen mode Exit fullscreen mode

Ardından aşağıda ilettiğim komut ile ingress controller'ı kuruyorum;

kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/kind/deploy.yaml
Enter fullscreen mode Exit fullscreen mode

Kind'a özel hazırlanan bu manifest dosyası içerisinde bazı patch'ler ve forward operasyonu için ayarlamar mevcuttur.

namespace/ingress-nginx created
serviceaccount/ingress-nginx created
serviceaccount/ingress-nginx-admission created
role.rbac.authorization.k8s.io/ingress-nginx created
role.rbac.authorization.k8s.io/ingress-nginx-admission created
clusterrole.rbac.authorization.k8s.io/ingress-nginx created
clusterrole.rbac.authorization.k8s.io/ingress-nginx-admission created
rolebinding.rbac.authorization.k8s.io/ingress-nginx created
rolebinding.rbac.authorization.k8s.io/ingress-nginx-admission created
clusterrolebinding.rbac.authorization.k8s.io/ingress-nginx created
clusterrolebinding.rbac.authorization.k8s.io/ingress-nginx-admission created
configmap/ingress-nginx-controller created
service/ingress-nginx-controller created
service/ingress-nginx-controller-admission created
deployment.apps/ingress-nginx-controller created
job.batch/ingress-nginx-admission-create created
job.batch/ingress-nginx-admission-patch created
ingressclass.networking.k8s.io/nginx created
validatingwebhookconfiguration.admissionregistration.k8s.io/ingress-nginx-admission created
Enter fullscreen mode Exit fullscreen mode

Yukarıdaki işlemler tamamlandıktan sonra cluster'ın kullanıma hazır hale gelmiş bulunuyor.

Web API'ın Oluşturulması

.Net 8 ile bir web api projesi oluşturduğumuzda karşıma aşağıda belirtildiği gibi bir Program.cs dosyası çıkıyor.

public class Program
{
    public static void Main(string[] args)
    {
        var builder = WebApplication.CreateBuilder(args);

        // Add services to the container.
        builder.Services.AddControllers();

        var app = builder.Build();

        // Configure the HTTP request pipeline.
        if (app.Environment.IsDevelopment())
        {

        }

        app.UseAuthorization();

        app.MapControllers();

        app.Run();
    }
}
Enter fullscreen mode Exit fullscreen mode

İhtiyaç olmayan service'ler kaldırılmıştır

Controller ve Action isimlerini aşağıda belirtildiği gibi düzenliyorum. Bir önceki bölümde ShutdownTimeout'un default değerinin 30 saniye olduğu belirtmiştim. Operasyon hata alması için action içerisinde 35 saniye bir bekleme gerçekleştirip response dönecek şekilde düzenleme yapıyorum.

[ApiController]
[Route("[controller]")]
public class PingPongController : ControllerBase
{
    private readonly ILogger<PingPongController> _logger;

    public PingPongController(ILogger<PingPongController> logger)
    {
        _logger = logger;
    }

    [HttpGet(Name = "Ping")]
    public async Task<IActionResult> Ping()
    {
        await Task.Delay(TimeSpan.FromSeconds(35));
        return Ok("Pong");
    }
}
Enter fullscreen mode Exit fullscreen mode

Kind ile oluşturduğumuz Kubernetes cluster'a deployment'ın gerçekleştirilebilmesi için aşağıda ilettiğim deployment manifest'i oluşturuyorum.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: pingapi-deployment
  labels:
    app: pingapi
spec:
  replicas: 1
  selector:
    matchLabels:
      app: pingapi
  template:
    metadata:
      labels:
        app: pingapi
    spec:
      containers:
        - name: pingapi
          image: pingapi:0.1
          ports:
            - containerPort: 8080
          resources:
            limits:
              cpu: "0.5"
              memory: "150Mi"
            requests:
              cpu: "0.1"
              memory: "150Mi"

Enter fullscreen mode Exit fullscreen mode

.Net 8 ile birlikte yayınlanan image'larda uygulamanın çalıştığı default port 80'den 8080'e değiştirilmiştir. Bu sebeple manifest'de port bilgisi 8080 olarak ayarlanmıştır. İlgili doc

Dockerfile'ı aşağıda belirttiğim gibi basitçe oluşturuyorum;

FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
WORKDIR /app
COPY ./Publish .
ENTRYPOINT ["dotnet", "Example.Ping.Api.dll"]
Enter fullscreen mode Exit fullscreen mode

Service'in Deploy Edilmesi ve Testin Gerçekleştirilmesi

Öncelikle uygulamamı Publish dizini altında publish ediyorum

dotnet publish -o ./Publish
Enter fullscreen mode Exit fullscreen mode

Ardından image'ı build ediyorum;

docker build -t pingapi:0.1 .
Enter fullscreen mode Exit fullscreen mode

Cluster üzerinde deployment manifest'i apply etmeden önce etiketlediğim image'ı aşağıda belirtilen komut ile her bir node üzerine aktarıyorum, diğer türlü bir image repository'e bu image'ı atmam ve cluster tarafından pull etmem gibi bir ihtiyaç oluşacaktı. Kind burda bize kolaylık sağlıyor.

kind load docker-image pingapi:0.1
Enter fullscreen mode Exit fullscreen mode
kind load docker-image pingapi:0.1
Image: "pingapi:0.1" with ID "sha256:2e5cfec8e475ed2d2ccfd0ae9753a7f5feda0e01de0081718ab678203d25edcf" not yet present on node "kind-worker3", loading...
Image: "pingapi:0.1" with ID "sha256:2e5cfec8e475ed2d2ccfd0ae9753a7f5feda0e01de0081718ab678203d25edcf" not yet present on node "kind-worker", loading...
Image: "pingapi:0.1" with ID "sha256:2e5cfec8e475ed2d2ccfd0ae9753a7f5feda0e01de0081718ab678203d25edcf" not yet present on node "kind-control-plane", loading...
Image: "pingapi:0.1" with ID "sha256:2e5cfec8e475ed2d2ccfd0ae9753a7f5feda0e01de0081718ab678203d25edcf" not yet present on node "kind-worker2", loading...
Enter fullscreen mode Exit fullscreen mode

Deployment'ı aşağıda belirttiğim komutu ile apply ediyorum;

kubectl apply -f .\Kubernetes\deployment.yaml
Enter fullscreen mode Exit fullscreen mode

Container'ın 8080 portunu aşağıda belirttiğim komut ile forward ediyorum;

kubectl port-forward deployment/pingapi-deployment -n default 8080:8080
Forwarding from 127.0.0.1:8080 -> 8080
Forwarding from [::1]:8080 -> 8080
Enter fullscreen mode Exit fullscreen mode

Artık teste başlayabiliriz, öncelikle httpstat http://localhost:8080/pingpong/ping komutu ile istek atıyorum ardından da pod'u delete etmek için kubectl delete pod/pingapi-deployment-5c78cbdfc-bfd9b komutunu çalıştırıyorum. Beklediğimiz üzere hem Kubernetes tarafında terminationGracePeriodSeconds süresinin deployment manifest'de belirtmediğimiz için default değer olan 30 saniye olmasından hem de .Net tarafında Host objesinin ShutdownTimeout belirtmediğimiz için default değer olan 30 saniye olmasından dolayı attığımız istek için hatada da göründüğü üzere bağlantının sonlandırıldığı görünmektedir.

Yeni bir image versiyonu ile deploy çıkılarak da benzer senaryo test edilebilir buradaki amaç pod'a SIGTERM sinyali göndermek olduğu için pod silme operasyonu ile test işlemi gerçekleştirilmiştir.

Terminal görünümü

Bu durumu aşmak için öncelikle uygulama tarafında Host'u configüre ediyorum ardından da deployment manifest altında spec.terminationGracePeriodSeconds adlı değeri güncelliyorum.

Program.cs dosyasının yeni hali aşağıda belirtildiği gibi oluşturulmuştur;


namespace Example.Ping.Api;

public class Program
{
    public static void Main(string[] args)
    {
        var builder = WebApplication.CreateBuilder(args);
        builder.Host.ConfigureHostOptions(opts => opts.ShutdownTimeout = TimeSpan.FromSeconds(45));

        // Add services to the container.

        builder.Services.AddControllers();
        // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
        builder.Services.AddEndpointsApiExplorer();
        var app = builder.Build();


        // Configure the HTTP request pipeline.
        if (app.Environment.IsDevelopment())
        {

        }

        app.UseAuthorization();

        app.MapControllers();

        app.Run();
    }
}

Enter fullscreen mode Exit fullscreen mode

deployment.yaml dosyasının da yeni hali aşağıdaki gibidir;

apiVersion: apps/v1
kind: Deployment
metadata:
  name: pingapi-deployment
  labels:
    app: pingapi
spec:
  replicas: 1
  selector:
    matchLabels:
      app: pingapi
  template:
    metadata:
      labels:
        app: pingapi
    spec:
      containers:
        - name: pingapi
          image: pingapi:0.2
          ports:
            - containerPort: 8080
          resources:
            limits:
              cpu: "0.5"
              memory: "150Mi"
            requests:
              cpu: "0.1"
              memory: "150Mi"
      terminationGracePeriodSeconds: 50
Enter fullscreen mode Exit fullscreen mode

deployment manifest içerisinde yeni build ettiğimiz image'ı pingapi:0.2 olarak update ediyoruz.

Belirtilen günceleme yapıldıktan sonra testi tekrar uyguladığımızda bir problem ile karşılaşılmadığı gözlemlenmektedir;

Terminal logları

Kubernetes Ingress ile Control Plane Arasındaki Gecikme

Yukarıda yaptığımız ayarlamalara rağmen yoğun kullanılan service'ler özelinde RollingUpdate aşamasında birkaç istek özelinde de olsa hata alma ihtimalleri bulunmaktadır. Bunun sebebi ingress ve control plane arasındaki gecikmeden kaynaklanmaktadır.

Bunun sebebi Kubernetes içerisinde ingress ve Kubernetes control plane farklı entity'lerdir ve bağımsız olarak operasyonlarını gerçekleştirmektedir. Kubernetes bir pod'un sonlandırmak istediğinde control plane sonlandırılacak pod'ları Service'den kaldırır ve ingress'de bundan haberdar olur ve sonlandırlacak pod'lara request'leri yönlendirmeyi sonlandırır. Bu iki operasyon arasında bir gecikme mevcuttur çünkü ingress belli aralıklarla Service'lerde yapılan bu değişiklikleri kendi tarafında güncellemektedir. Bu durum da terminating status içerisinde bulunan pod'lara az bir request özelinde isteklerin iletilebilmesine sebep olmaktadır.

..Net tarafında uygulama IHost.StopAsync() çağrıldıktan sonra hali hazırda açık olan connection'lar üzerinden yeni requestlerin gelmesi ve aynı zamanda da yeni bir connection açılmasına izin vermez. Bu sebeple de bu aradaki gecikme IHost.StopAsync() işlemi başladığında yeni isteklerin gelebileceği anlamına gelmektedir. Bu durum istek atan taraf için hata almasına neden olacaktır.

Bu duruma bir çözüm olarak aşağıda ileteceğim yöntem dotnet ekibi tarafından tavsiye edilmiştir. ref

Önceki bölümlerde IHostLifetime'ın uygulamanın ne zaman başlayacağını ya da durdurulacağını kontrol ettiği aktarılmıştı. Bu sebeple öncelikle yeni bir IHostLifetime implementasyonu gerçekleştiriyoruz.

using System.Runtime.InteropServices;

public class DelayedShutdownHostLifetime : IHostLifetime, IDisposable
{
    private IHostApplicationLifetime _applicationLifetime;
    private TimeSpan _delay;
    private IEnumerable<IDisposable>? _disposables;

    public DelayedShutdownHostLifetime(IHostApplicationLifetime applicationLifetime, TimeSpan delay) { 
        _applicationLifetime = applicationLifetime;
        _delay = delay;
    }

    public Task StopAsync(CancellationToken cancellationToken)
    {
        return Task.CompletedTask;
    }

    public Task WaitForStartAsync(CancellationToken cancellationToken)
    {
        _disposables = new IDisposable[]
        {
            PosixSignalRegistration.Create(PosixSignal.SIGINT, HandleSignal),
            PosixSignalRegistration.Create(PosixSignal.SIGQUIT, HandleSignal),
            PosixSignalRegistration.Create(PosixSignal.SIGTERM, HandleSignal)
        };
        return Task.CompletedTask;
    }

    protected void HandleSignal(PosixSignalContext ctx)
    {
        ctx.Cancel = true;
        Task.Delay(_delay).ContinueWith(t => _applicationLifetime.StopApplication());
    }

    public void Dispose()
    {
        foreach (var disposable in _disposables ?? Enumerable.Empty<IDisposable>()) 
        {
            disposable.Dispose(); 
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Yapılan işlem incelendiğinde uygulama en başta ayağa kalkarken belli POSIX sinyalleri için registration operasyonu gerçekleştirilmiştir. Bu sayede uygulama tarafına bu sinyallerden biri geldiğinde IHostApplicationLifetime.StopApplication() operasyonunu başlatmadan önce Task.Delay ile bir gecikme tanımlanmıştır. Bu gecikme sayesinde gracefully shut-down işlemi belirtilen sinyaller geldiği an başlamayacak ve belirtilen gecikme zarfında da pod'a gelen yeni istekler karşılanabilecektir.

Son aşama olarak Program.cs altında yeni oluşturduğum IHostLifetime implementasyonunu register ediyorum.

public class Program
{
    public static void Main(string[] args)
    {
        var builder = WebApplication.CreateBuilder(args);
        builder.Host.ConfigureHostOptions(opts => opts.ShutdownTimeout = TimeSpan.FromSeconds(45));

        // Add services to the container.
        builder.Services.AddControllers();
        // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
        builder.Services.AddEndpointsApiExplorer();
        builder.Services.AddSingleton<IHostLifetime>(sp =>
            new DelayedShutdownHostLifetime(sp.GetRequiredService<IHostApplicationLifetime>(), TimeSpan.FromSeconds(5)));

        var app = builder.Build();

        // Configure the HTTP request pipeline.
        if (app.Environment.IsDevelopment())
        {

        }

        app.UseAuthorization();

        app.MapControllers();

        app.Run();
    }
}
Enter fullscreen mode Exit fullscreen mode

HostApplicationBuilder.Build() method'u içerisinde IHostApplicationLifetime ve IHostLifetime 'ın service olarak register edildiği aktarılmıştı. Burada IHostLifetime tekrar register edilerek belirtilen DelayedShutdownHostLifetime implementasyonu ile kullanılması sağlanmıştır.

Son yapılan işlemle beraber Kubernetes tarafında kesintisiz bir deployment işlemi sağlanmış bulunmaktadır. Okuduğunuz için teşekkürler :)

Referanslar

Top comments (0)