Kubernetes برای برنامهنویس .NET
در Kubernetes میگویی «سه نسخه از سرویس سفارش میخواهم» و خودش آنها را اجرا و سالم نگه میدارد. برای اینکه این کار درست انجام شود، برنامه باید منابع درست بخواهد، سلامتش را گزارش کند و امن خاموش شود.
نویسنده: bezzad
مشکل: کانتینر داریم، ولی چه کسی مراقبش است؟
سرویس سفارش فروشگاه ما حالا یک Image دارد. در روزهای شلوغ باید سه نسخه از آن روی چند سرور اجرا شود. سؤالها زیاد است:
- اگر یک نسخه crash کرد، چه کسی دوباره روشنش میکند؟
- اگر یک سرور خاموش شد، نسخههای روی آن کجا بروند؟
- نسخه جدید را چطور deploy کنیم که کاربر خطا نبیند؟
- در ساعت شلوغی، چه کسی نسخه بیشتر میسازد؟
انجام این کارها با دست ممکن نیست. ابزار Kubernetes برای همین است.
ایده: وضعیت دلخواه را بنویس
در Kubernetes دستور «این کار را بکن» نمیدهیم. فقط وضعیت دلخواه را در یک فایل YAML مینویسیم. مثلاً «سه نسخه از Image سفارش، نسخه 1.4». بعد Kubernetes دائم وضعیت واقعی را با وضعیت دلخواه مقایسه میکند و فرق را درست میکند.
چند مفهوم اصلی:
- واحد Pod. کوچکترین واحد اجرا است. معمولاً یک کانتینر برنامه دارد. هر Pod موقت است. ممکن است هر لحظه از بین برود و یک Pod جدید با آدرس جدید جایش بیاید.
- شیء Deployment. میگوید از یک Pod چند نسخه میخواهیم و نسخه جدید را چطور جایگزین کنیم.
- شیء Service. یک آدرس و اسم ثابت به ما میدهد. ترافیک را فقط بین Pod های آماده پخش میکند.
منابع: request و limit
برای هر کانتینر دو عدد مینویسیم:
- مقدار request یعنی رزرو. زمانبند (scheduler) فقط Pod را روی سروری میگذارد که این مقدار را آزاد داشته باشد.
- مقدار limit یعنی سقف. کانتینر نمیتواند بیشتر از این مصرف کند.
ولی رفتار CPU و حافظه در سقف کاملاً فرق دارد:
- وقتی CPU به سقف میرسد. کانتینر کشته نمیشود. فقط کند میشود. به این throttling میگویند. زمان پاسخ بالا میرود، ولی خطایی در لاگ نمیبینی.
- وقتی حافظه از سقف میگذرد. کانتینر فوراً کشته میشود و وضعیتش OOMKilled است. بعد از نو شروع میشود.
دو نکته مخصوص .NET:
- تعداد هستهها از سقف CPU میآید. اگر سقف CPU یک هسته باشد، برنامه فکر میکند فقط یک پردازنده دارد. thread pool و GC هم بر همین اساس تنظیم میشوند.
- سیستم GC سقف حافظه را میبیند. به طور پیشفرض حدود ۷۵٪ سقف حافظه کانتینر را برای heap استفاده میکند. بقیه برای چیزهای دیگر مثل stack ها و حافظه native میماند.
سلامت: سه نوع Probe
ابزار Kubernetes از کجا بفهمد Pod سالم است؟ از سه نوع پرسش (Probe):
- پرسش readiness. «آیا الان میتوانی ترافیک بگیری؟» اگر جواب منفی باشد، Service به این Pod ترافیک نمیدهد. Pod کشته نمیشود.
- پرسش liveness. «آیا زندهای یا گیر کردهای؟» اگر جواب منفی باشد، Kubernetes کانتینر را ریاستارت میکند.
- پرسش startup. «آیا بالا آمدنت تمام شد؟» تا وقتی جواب مثبت نشده، دو پرسش دیگر شروع نمیشوند. برای برنامهای که دیر بالا میآید مفید است.
مقیاس خودکار با HPA
ابزار HPA (Horizontal Pod Autoscaler) تعداد Pod ها را بر اساس یک معیار بالا و پایین میبرد. مثلاً «اگر میانگین CPU از ۷۰٪ بیشتر شد، Pod اضافه کن».
- درصد CPU نسبت به request حساب میشود. نه نسبت به limit. اگر request نیم هسته باشد، ۷۰٪ یعنی حدود ۰.۳۵ هسته.
- معیار باید گلوگاه واقعی را نشان دهد. اگر سرویس منتظر دیتابیس است، CPU پایین میماند و HPA هیچ کاری نمیکند. Pod بیشتر هم فقط بار دیتابیس را بیشتر میکند.
- مصرفکننده صف معیار دیگری دارد. برای سرویسی که از Kafka میخواند، تعداد پیامهای خواندهنشده (lag) معیار بهتری است. ابزار KEDA این کار را انجام میدهد.
- حداکثر Pod مفید برای مصرفکننده Kafka محدود است. در یک consumer group هر partition فقط به یک مصرفکننده داده میشود. پس اگر topic شش partition دارد، Pod هفتم بیکار میماند.
- مقیاس خودکار دیر واکنش نشان میدهد. ساختن Pod و گرم شدن برنامه زمان میبرد. برای شلوغی قابل پیشبینی، حداقل Pod ها را از قبل بالا ببر.
خاموش شدن امن
هر deploy یعنی Pod های قدیمی خاموش میشوند. اگر این کار درست نباشد، کاربر خطای 502 میبیند. بیا قدم به قدم ببینیم Kubernetes یک Pod را چطور خاموش میکند:
- دو کار همزمان شروع میشود. حذف Pod از لیست مقصدهای Service، و فرستادن سیگنال خاموشی به برنامه.
- حذف از لیست چند ثانیه طول میکشد. این تغییر باید به همه سرورها و Load Balancer ها برسد.
- سیگنال SIGTERM فوراً میرسد. برنامه ASP.NET Core با این سیگنال دیگر درخواست جدید قبول نمیکند.
- پس یک فاصله خطرناک داریم. برنامه بسته شده، ولی Load Balancer هنوز چند ثانیه به آن درخواست میفرستد.
- در آخر SIGKILL میآید. اگر بعد از مهلت (پیشفرض ۳۰ ثانیه) برنامه هنوز باز باشد، فوراً کشته میشود، حتی وسط کار.
راه حل این است که قبل از SIGTERM چند ثانیه صبر کنیم (با preStop). اینطور اول ترافیک قطع میشود و بعد برنامه بسته میشود.
مثال زنده
یک حالت را انتخاب کن و ببین چه درخواستهایی خطا میگیرند:
کد
فایل 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 بهعلاوه سی ثانیه برای تمام کردن کارها، کمتر از ۴۵ ثانیه مهلت کل است.
فایل 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 را ثبت کند و بعد بسته شود.
قانونهای مهم
- برنامه stateless باشد. هر Pod ممکن است هر لحظه از بین برود.
- برای هر کانتینر request بنویس. بر اساس مصرف واقعی، نه حدس.
- مقدار request حافظه را برابر limit آن بگذار. رفتار قابل پیشبینیتر است. درباره سقف CPU تیمها نظر یکسانی ندارند، چون سقف CPU باعث throttling میشود.
- پرسش liveness فقط خود برنامه را چک کند. وابستگیها در readiness.
- خاموش شدن را طراحی کن. کار preStop، مهلت ShutdownTimeout در برنامه، و مهلت کل Pod باید با هم جور باشند.
- پردازش پیام 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 ندارد و وقتی هم برای یاد گرفتنش ندارد.
خلاصه در هفت خط
- در Kubernetes وضعیت دلخواه را مینویسی و خودش وضعیت واقعی را درست نگه میدارد.
- هر Pod موقت است، پس برنامه باید stateless باشد.
- مقدار request رزرو است و limit سقف. سقف CPU کند میکند، سقف حافظه میکشد.
- پرسش readiness ترافیک را قطع میکند، پرسش liveness ریاستارت میکند. دیتابیس را در liveness چک نکن.
- درصد HPA نسبت به request است و معیارش باید گلوگاه واقعی باشد.
- برای خاموش شدن امن: preStop، بعد SIGTERM و تمام کردن کارها، همه داخل مهلت کل.
- با این حال پردازش پیام را idempotent بساز، چون Pod گاهی ناگهانی میمیرد.