Levelwise
فارسی
زیرساخت و DevOps

Kubernetes برای برنامه‌نویس .NET

در Kubernetes می‌گویی «سه نسخه از سرویس سفارش می‌خواهم» و خودش آن‌ها را اجرا و سالم نگه می‌دارد. برای اینکه این کار درست انجام شود، برنامه باید منابع درست بخواهد، سلامتش را گزارش کند و امن خاموش شود.

بازبینی نشدهبا کمک AI نوشته شدهزمان خواندن: ۱۷ دقیقهمثال سرویس سفارش فروشگاه اینترنتیکد YAML و C# با .NET 10

نویسنده: bezzad

مشکل: کانتینر داریم، ولی چه کسی مراقبش است؟

سرویس سفارش فروشگاه ما حالا یک Image دارد. در روزهای شلوغ باید سه نسخه از آن روی چند سرور اجرا شود. سؤال‌ها زیاد است:

  1. اگر یک نسخه crash کرد، چه کسی دوباره روشنش می‌کند؟
  2. اگر یک سرور خاموش شد، نسخه‌های روی آن کجا بروند؟
  3. نسخه جدید را چطور deploy کنیم که کاربر خطا نبیند؟
  4. در ساعت شلوغی، چه کسی نسخه بیشتر می‌سازد؟

انجام این کارها با دست ممکن نیست. ابزار Kubernetes برای همین است.

ایده: وضعیت دلخواه را بنویس

در Kubernetes دستور «این کار را بکن» نمی‌دهیم. فقط وضعیت دلخواه را در یک فایل YAML می‌نویسیم. مثلاً «سه نسخه از Image سفارش، نسخه 1.4». بعد Kubernetes دائم وضعیت واقعی را با وضعیت دلخواه مقایسه می‌کند و فرق را درست می‌کند.

چند مفهوم اصلی:

  • واحد Pod. کوچک‌ترین واحد اجرا است. معمولاً یک کانتینر برنامه دارد. هر Pod موقت است. ممکن است هر لحظه از بین برود و یک Pod جدید با آدرس جدید جایش بیاید.
  • شیء Deployment. می‌گوید از یک Pod چند نسخه می‌خواهیم و نسخه جدید را چطور جایگزین کنیم.
  • شیء Service. یک آدرس و اسم ثابت به ما می‌دهد. ترافیک را فقط بین Pod های آماده پخش می‌کند.
کاربران فروشگاهService: orders-apiیک آدرس ثابت برای همه نسخه‌هاPod 1آمادهPod 2آمادهPod 3هنوز آماده نیستDeployment: replicas = 3اگر یک نسخه بمیرد، یکی جدید می‌سازد
سرویس فقط به Pod هایی ترافیک می‌دهد که آماده‌اند. Deployment تعداد Pod ها را ثابت نگه می‌دارد.
نتیجه برای کد: چون Pod موقت است، برنامه باید stateless باشد. سبد خرید مشتری را در حافظه Pod نگه ندار. آن را در Redis یا دیتابیس بگذار. وگرنه با هر deploy یا scale، سبد مشتری گم می‌شود.

منابع: request و limit

برای هر کانتینر دو عدد می‌نویسیم:

  1. مقدار request یعنی رزرو. زمان‌بند (scheduler) فقط Pod را روی سروری می‌گذارد که این مقدار را آزاد داشته باشد.
  2. مقدار limit یعنی سقف. کانتینر نمی‌تواند بیشتر از این مصرف کند.

ولی رفتار CPU و حافظه در سقف کاملاً فرق دارد:

CPUحافظهرزرو شدهrequestlimitمصرف بیشتر تا سقف مجاز استبه سقف رسید: کند می‌شودرزرو شده و سقف برابرlimitrequestاز سقف گذشت: کشته می‌شود
رسیدن به سقف CPU برنامه را کند می‌کند. گذشتن از سقف حافظه Pod را می‌کشد.
  • وقتی CPU به سقف می‌رسد. کانتینر کشته نمی‌شود. فقط کند می‌شود. به این throttling می‌گویند. زمان پاسخ بالا می‌رود، ولی خطایی در لاگ نمی‌بینی.
  • وقتی حافظه از سقف می‌گذرد. کانتینر فوراً کشته می‌شود و وضعیتش OOMKilled است. بعد از نو شروع می‌شود.

دو نکته مخصوص .NET:

  1. تعداد هسته‌ها از سقف CPU می‌آید. اگر سقف CPU یک هسته باشد، برنامه فکر می‌کند فقط یک پردازنده دارد. thread pool و GC هم بر همین اساس تنظیم می‌شوند.
  2. سیستم GC سقف حافظه را می‌بیند. به طور پیش‌فرض حدود ۷۵٪ سقف حافظه کانتینر را برای heap استفاده می‌کند. بقیه برای چیزهای دیگر مثل stack ها و حافظه native می‌ماند.
اگر request نگذاری: زمان‌بند نمی‌داند Pod چقدر منابع لازم دارد. ممکن است Pod های زیادی روی یک سرور جمع شوند و در زمان شلوغی همه با هم کند شوند یا بیرون انداخته شوند.

سلامت: سه نوع Probe

ابزار Kubernetes از کجا بفهمد Pod سالم است؟ از سه نوع پرسش (Probe):

  1. پرسش readiness. «آیا الان می‌توانی ترافیک بگیری؟» اگر جواب منفی باشد، Service به این Pod ترافیک نمی‌دهد. Pod کشته نمی‌شود.
  2. پرسش liveness. «آیا زنده‌ای یا گیر کرده‌ای؟» اگر جواب منفی باشد، Kubernetes کانتینر را ری‌استارت می‌کند.
  3. پرسش startup. «آیا بالا آمدنت تمام شد؟» تا وقتی جواب مثبت نشده، دو پرسش دیگر شروع نمی‌شوند. برای برنامه‌ای که دیر بالا می‌آید مفید است.
اشتباه خطرناک: پرسش liveness نباید دیتابیس را چک کند. فرض کن دیتابیس چند ثانیه کند شود. همه Pod ها با هم liveness را رد می‌کنند و همه با هم ری‌استارت می‌شوند. یک مشکل کوچک دیتابیس به خاموشی کامل سرویس تبدیل می‌شود. وابستگی‌ها را فقط در readiness چک کن، آن هم با احتیاط.

مقیاس خودکار با HPA

ابزار HPA (Horizontal Pod Autoscaler) تعداد Pod ها را بر اساس یک معیار بالا و پایین می‌برد. مثلاً «اگر میانگین CPU از ۷۰٪ بیشتر شد، Pod اضافه کن».

  1. درصد CPU نسبت به request حساب می‌شود. نه نسبت به limit. اگر request نیم هسته باشد، ۷۰٪ یعنی حدود ۰.۳۵ هسته.
  2. معیار باید گلوگاه واقعی را نشان دهد. اگر سرویس منتظر دیتابیس است، CPU پایین می‌ماند و HPA هیچ کاری نمی‌کند. Pod بیشتر هم فقط بار دیتابیس را بیشتر می‌کند.
  3. مصرف‌کننده صف معیار دیگری دارد. برای سرویسی که از Kafka می‌خواند، تعداد پیام‌های خوانده‌نشده (lag) معیار بهتری است. ابزار KEDA این کار را انجام می‌دهد.
  4. حداکثر Pod مفید برای مصرف‌کننده Kafka محدود است. در یک consumer group هر partition فقط به یک مصرف‌کننده داده می‌شود. پس اگر topic شش partition دارد، Pod هفتم بیکار می‌ماند.
  5. مقیاس خودکار دیر واکنش نشان می‌دهد. ساختن Pod و گرم شدن برنامه زمان می‌برد. برای شلوغی قابل پیش‌بینی، حداقل Pod ها را از قبل بالا ببر.

خاموش شدن امن

هر deploy یعنی Pod های قدیمی خاموش می‌شوند. اگر این کار درست نباشد، کاربر خطای 502 می‌بیند. بیا قدم به قدم ببینیم Kubernetes یک Pod را چطور خاموش می‌کند:

  1. دو کار همزمان شروع می‌شود. حذف Pod از لیست مقصدهای Service، و فرستادن سیگنال خاموشی به برنامه.
  2. حذف از لیست چند ثانیه طول می‌کشد. این تغییر باید به همه سرورها و Load Balancer ها برسد.
  3. سیگنال SIGTERM فوراً می‌رسد. برنامه ASP.NET Core با این سیگنال دیگر درخواست جدید قبول نمی‌کند.
  4. پس یک فاصله خطرناک داریم. برنامه بسته شده، ولی Load Balancer هنوز چند ثانیه به آن درخواست می‌فرستد.
  5. در آخر SIGKILL می‌آید. اگر بعد از مهلت (پیش‌فرض ۳۰ ثانیه) برنامه هنوز باز باشد، فوراً کشته می‌شود، حتی وسط کار.

راه حل این است که قبل از SIGTERM چند ثانیه صبر کنیم (با preStop). این‌طور اول ترافیک قطع می‌شود و بعد برنامه بسته می‌شود.

زمان از لحظه دستور خاموش شدن0s5s45spreStop sleepفقط صبرحذف از لیست مقصدهاچند ثانیه طول می‌کشدSIGTERMتمام کردن درخواست‌ها و پیام‌های جاریSIGKILLterminationGracePeriodSeconds = 45کل مهلت از همان لحظه اول حساب می‌شودمدت صبر به‌علاوه زمان تمام کردن کارها باید کمتر از کل مهلت باشد
کار preStop فقط صبر می‌کند تا حذف از Service کامل شود. بعد SIGTERM می‌رسد.

مثال زنده

یک حالت را انتخاب کن و ببین چه درخواست‌هایی خطا می‌گیرند:

خاموش شدن یک Pod هنگام deploy
در لیست Service: بله
برنامه: درخواست می‌گیرد
ثانیه ۰
یک حالت را انتخاب کن. هر مربع یک درخواست کاربر به این Pod است.

    کد

    فایل Deployment

    apiVersion: apps/v1
    kind: Deployment
    metadata:
      name: orders-api
    spec:
      replicas: 3
      selector:
        matchLabels: { app: orders-api }
      strategy:
        rollingUpdate:
          maxUnavailable: 0   # never go below 3 ready pods
          maxSurge: 1         # add one new pod at a time
      template:
        metadata:
          labels: { app: orders-api }
        spec:
          terminationGracePeriodSeconds: 45
          containers:
            - name: api
              image: registry.example.com/shop/orders-api:1.4.0
              ports: [{ containerPort: 8080 }]
              resources:
                requests: { cpu: 500m, memory: 512Mi }
                limits: { memory: 512Mi }
              readinessProbe:
                httpGet: { path: /health/ready, port: 8080 }
                periodSeconds: 5
              livenessProbe:
                httpGet: { path: /health/live, port: 8080 }
                periodSeconds: 10
              lifecycle:
                preStop:
                  sleep: { seconds: 5 }

    حساب زمان‌ها در این فایل ساده است: پنج ثانیه preStop به‌علاوه سی ثانیه برای تمام کردن کارها، کمتر از ۴۵ ثانیه مهلت کل است.

    درباره preStop: عمل sleep داخل خود Kubernetes در نسخه‌های جدید آن وجود دارد. در نسخه‌های قدیمی‌تر باید با یک دستور exec برنامه sleep را اجرا کرد. ولی Image های chiseled اصلاً shell و برنامه sleep ندارند. نسخه Kubernetes خودت را چک کن.

    فایل HPA

    apiVersion: autoscaling/v2
    kind: HorizontalPodAutoscaler
    metadata:
      name: orders-api
    spec:
      scaleTargetRef:
        apiVersion: apps/v1
        kind: Deployment
        name: orders-api
      minReplicas: 3
      maxReplicas: 10
      metrics:
        - type: Resource
          resource:
            name: cpu
            target:
              type: Utilization
              averageUtilization: 70   # percent of the CPU request

    سمت برنامه .NET

    using Microsoft.AspNetCore.Diagnostics.HealthChecks;
    using Microsoft.Extensions.Diagnostics.HealthChecks;
    
    var builder = WebApplication.CreateBuilder(args);
    
    // How long to wait for running requests after SIGTERM.
    builder.Services.Configure<HostOptions>(o => o.ShutdownTimeout = TimeSpan.FromSeconds(30));
    
    builder.Services.AddHealthChecks()
        // Liveness: only "is the process alive?"
        .AddCheck("self", () => HealthCheckResult.Healthy(), tags: ["live"])
        // Readiness: can we serve traffic? (needs the EF Core health checks package)
        .AddDbContextCheck<OrdersDbContext>(tags: ["ready"]);
    
    var app = builder.Build();
    
    app.MapHealthChecks("/health/live", new HealthCheckOptions
    {
        Predicate = check => check.Tags.Contains("live")
    });
    app.MapHealthChecks("/health/ready", new HealthCheckOptions
    {
        Predicate = check => check.Tags.Contains("ready")
    });
    
    app.Run();

    در کارهای پس‌زمینه (BackgroundService) هم توکن توقف را به همه متدها بده. این‌طور بعد از SIGTERM کار جدیدی شروع نمی‌شود. مصرف‌کننده Kafka هم باید اول پیام جاری را تمام کند، offset را ثبت کند و بعد بسته شود.

    قانون‌های مهم

    1. برنامه stateless باشد. هر Pod ممکن است هر لحظه از بین برود.
    2. برای هر کانتینر request بنویس. بر اساس مصرف واقعی، نه حدس.
    3. مقدار request حافظه را برابر limit آن بگذار. رفتار قابل پیش‌بینی‌تر است. درباره سقف CPU تیم‌ها نظر یکسانی ندارند، چون سقف CPU باعث throttling می‌شود.
    4. پرسش liveness فقط خود برنامه را چک کند. وابستگی‌ها در readiness.
    5. خاموش شدن را طراحی کن. کار preStop، مهلت ShutdownTimeout در برنامه، و مهلت کل Pod باید با هم جور باشند.
    6. پردازش پیام idempotent باشد. حتی با همه این‌ها، Pod گاهی ناگهانی می‌میرد. مثلاً سرور خراب می‌شود. پس یک پیام ممکن است دو بار پردازش شود.

    اشتباه‌های رایج

    اشتباه نتیجه راه درست
    نگه داشتن session یا سبد خرید در حافظه Pod با هر deploy یا scale، داده کاربر گم می‌شود. ذخیره در Redis یا دیتابیس.
    چک کردن دیتابیس در liveness کند شدن دیتابیس همه Pod ها را با هم ری‌استارت می‌کند. پرسش liveness فقط برای خود برنامه.
    بدون preStop در هر deploy چند درخواست خطای 502 می‌گیرند. چند ثانیه صبر با preStop.
    مهلت کل کمتر از زمان لازم پیام‌ها و درخواست‌ها وسط کار با SIGKILL قطع می‌شوند. جمع preStop و ShutdownTimeout کمتر از مهلت کل.
    مقیاس با CPU برای مصرف‌کننده Kafka پیام‌ها عقب می‌افتند، ولی HPA چیزی نمی‌بیند. مقیاس بر اساس lag، مثلاً با KEDA.
    حداکثر Pod بیشتر از تعداد partition Pod های اضافه بیکارند و فقط هزینه دارند. سقف Pod برابر تعداد partition.

    چه وقت Kubernetes؟

    مناسب

    • چند سرویس با چند نسخه که باید خودکار سالم بمانند و scale شوند.
    • تیم یا سازمانی که کسی را برای نگهداری cluster دارد، یا از یک سرویس مدیریت‌شده ابری استفاده می‌کند.
    • چند deploy در روز بدون قطعی.

    نامناسب

    • یک برنامه کوچک با یک نسخه. یک سرور یا یک سرویس ساده ابری کافی است.
    • تیم کوچکی که هیچ تجربه‌ای در Kubernetes ندارد و وقتی هم برای یاد گرفتنش ندارد.

    خلاصه در هفت خط

    1. در Kubernetes وضعیت دلخواه را می‌نویسی و خودش وضعیت واقعی را درست نگه می‌دارد.
    2. هر Pod موقت است، پس برنامه باید stateless باشد.
    3. مقدار request رزرو است و limit سقف. سقف CPU کند می‌کند، سقف حافظه می‌کشد.
    4. پرسش readiness ترافیک را قطع می‌کند، پرسش liveness ری‌استارت می‌کند. دیتابیس را در liveness چک نکن.
    5. درصد HPA نسبت به request است و معیارش باید گلوگاه واقعی باشد.
    6. برای خاموش شدن امن: preStop، بعد SIGTERM و تمام کردن کارها، همه داخل مهلت کل.
    7. با این حال پردازش پیام را idempotent بساز، چون Pod گاهی ناگهانی می‌میرد.