.NET 10 LTS Production Patterns — قصة هجرة

0 تعليق 847 مشاهدة

هندسة البرمجيات dotnet

24 دقيقة قراءة 4603 كلمة

TL;DR

في 7 مايو 2026، قمتُ بترحيل تطبيق ويب bilalkose.com.tr وحزمه الخمس المرتبطة من .NET 8 إلى .NET 10 LTS. النتيجة جاءت على النحو الآتي: ترقية الإطار المستهدف في 6 ملفات csproj، وتحديث 26 حزمة Microsoft، ونشر إصدارات رئيسية لـ 9 حزم خاصة، وإزالة AspNetCoreRateLimit لصالح AddRateLimiter الأصلي، وتكييف الـ Constructor في AutoMapper 16، وتغيير الإعدادات الافتراضية لـ TLS في SqlClient. وقد أسفرت العملية عن 0 خطأ مع 18 تحذيراً في الـ build، ونجاح 10 من أصل 10 اختبارات smoke. تتناول هذه المقالة مفاجآت الإنتاج التي تتجاهلها الأدلة المُنشأة بواسطة الذكاء الاصطناعي، ونقاط الكسر الست التي واجهتها على طريق الترحيل الفعلي، وكيف حللتها واحدة تلو الأخرى.

ملاحظة الكاتب: كل مقطع كود في هذه المقالة مأخوذ من قاعدة الكود الحية لـ bilalkose.com.tr. أمّا تاريخ الترحيل فهو 2026-05-07، فيما تأتي مسارات الملفات ورسائل الخطأ من بيئة الإنتاج الفعلية.

فهرس المحتويات

  1. لماذا .NET 10 LTS، وما الذي يتغير الآن؟
  2. إعداد الترحيل: 6 csproj و26 حزمة وCore Feed
  3. ترحيل Rate Limiter: من AspNetCoreRateLimit إلى AddRateLimiter
  4. تغيير الـ Constructor الكاسر في AutoMapper 16
  5. الإعدادات الافتراضية الجديدة لـ SqlClient (مفاجأة TLS)
  6. Serilog 4.3 مع ASP.NET Core 10: فخاخ التبعيات
  7. نشر Docker وKubernetes في الإنتاج
  8. قبل الإنتاج: 10 اختبارات Smoke
  9. المراقبة: Prometheus مع OpenTelemetry
  10. 7 خلاصات من هذا الترحيل
  11. أسئلة شائعة، 10 سؤال وجواب

لماذا .NET 10 LTS، وما الذي يتغير الآن؟

تشير الأرقام إلى أن نحو 60٪ من فرق .NET في السوق التركية لا تزال على .NET 8، بينما لم يبقَ أمامها سوى أشهر معدودة قبل نهاية العمر الرسمية. هذا الواقع وحده يكفي ليفسر لماذا أصبح الانتقال إلى الإصدار العاشر مسألة وقت لا مسألة اختيار. أُصدر .NET 10 للاستخدام العام في نوفمبر 2025 ويندرج تحت Long-Term Support، أي أنه سيتلقى تصحيحات أمنية رسمية حتى نوفمبر 2028. والمعنى العملي للأنظمة المؤسسية أن أمامها ثلاث سنوات هادئة قادمة.

تتكرر سياسة LTS لدى Microsoft كل سنتين على هذا النحو: .NET 6 (نوفمبر 2021)، ثم .NET 8 (نوفمبر 2023)، فـ .NET 10 (نوفمبر 2025). أمّا الإصدارات الفردية بينهما، أي .NET 7 و.NET 9، فتندرج تحت «Standard Term Support»، وهو دعم لمدة 18 شهراً فقط. والتركيز على LTS واضح في هذا السياق، إذ تتخذ فرق الهندسة المؤسسية قراراتها بناءً على الإصدارات الزوجية بحكم العادة.

ثمة حقيقتان تفرضان توقيت الانتقال. الأولى أن .NET 8 و.NET 9 يصلان في 10 نوفمبر 2026 إلى نهاية العمر المشتركة (EOL)، ما يعني أن هذه الإصدارات ستتوقف ابتداءً من ذلك التاريخ عن تلقي تصحيحات CVE الرسمية. وتشير الاستطلاعات القطاعية في أوائل 2026 إلى أن حوالي 60٪ من فرق .NET في السوق التركية لا تزال على .NET 8، فيما يقف 25٪ منها على .NET 9، ولا يتجاوز ما تبقى 15٪ على إصدارات أحدث، ما يعني أن موجة ترحيل كبيرة قادمة في الربع الثالث والرابع من 2026. لذا فإن الفرق التي تبدأ الآن تكسب مساحة مناورة قبل الضغط الكبير. أمّا الحقيقة الثانية فتتعلق بثغرة CVE-2026-40372، وهي ثغرة padding-oracle في DataProtection لم تتلقَّ تصحيحاً إلا في .NET 10.0.7 وما بعده، ومن ثمّ فلا بديل عن الانتقال لمن يقف اليوم على .NET 9.

أمّا الفوائد الملموسة لتبني .NET 10 مبكراً فيمكن تلخيصها فيما يلي:

  • الأداء: يُظهر ASP.NET Core 10 مكاسب throughput تقريبية بنسبة 10-20٪ مقارنة بـ .NET 8 في اختبارات الحمل الأفقي وفق قياسات Microsoft الرسمية. ويبرز ذلك خاصة في profile تخصيص الذاكرة لـ JSON serializer وmiddleware. وأخطط من جهتي لإجراء benchmark خاص على إعدادي الشخصي.
  • Rate limiter أصلي: Microsoft.AspNetCore.RateLimiting الآن داخل الإطار، إذ تقل التبعية على حزمة طرف ثالث (تفاصيل في هذه المقالة).
  • C# 14: Primary constructor على مستوى class، وصياغة attribute الموجهة للحقول، وpartial property، ومعدِّلات معاملات lambda (ref أو out)، ما يعني boilerplate أقل ونماذج domain أكثر قراءة. وهذا مفيد بشكل خاص لـ entities في EFCore.
  • نضج Native AOT: أصبح native AOT جاهزاً للإنتاج في web API القياسية مع ASP.NET Core 10، إذ ينخفض cold start من 3-4 ثوانٍ إلى 50-100 مللي ثانية. وهذا الفارق حاسم في سيناريوهات auto-scaling في Kubernetes.
  • حجم image أصغر: طبقة Docker mcr.microsoft.com/dotnet/aspnet:10.0 أصغر بحوالي 3٪ مقارنة بـ 8.0.
  • نضج OpenTelemetry: تكامل أفضل مع APIs المعنية بـ diagnostic وtracing مقارنة بالسابق، حيث تقلص إعداد distributed tracing إلى النصف تقريباً.

لا تُقدم هذه المقالة نظرة عامة على الميزات الجديدة، فملخص الإصدار في Microsoft Learn يغطي ذلك. والتركيز هنا منصبٌّ على نقاط الكسر الست التي واجهتها على طريق الترحيل الفعلي لتطبيق إنتاج متوسط الحجم، وكيف حللت كل واحدة منها.

إعداد الترحيل: 6 csproj و26 حزمة وCore Feed

«ست csproj، تسع حزم خاصة، يوم واحد فقط على التقويم.» بهذه الكلمات الثلاث كنتُ ألخّص النطاق لنفسي قبل البدء، لأن معظم القرارات تتغير حسب حجم الإعداد، ولذا يجدر بنا التعرف على النظام الذي رحّلتُه أولاً.

قاعدة الكود:

  • 1 تطبيق ويب (ASP.NET Core، Razor مع MVC مع بعض minimal API endpoints)
  • 4 طبقات csproj: Application/Domain/Persistence/Infrastructure (شبيه بـ Clean Architecture)
  • 1 مشروع اختبار (MSTest)
  • 9 حزم خاصة (Core.Application.Base، وCore.CrossCuttingConcerns.Base، وCore.Mailing.Base، وCore.Test.Base، وغيرها على feed الخاص بي nuget.bilalkose.com.tr)

الهدف:

  • الإطار المستهدف net8.0 ينتقل إلى net10.0
  • 26 حزمة Microsoft.* (AspNetCore، وEFCore، وExtensions.*، وSystem.Drawing.Common، وSystem.Text.Json) من 8.x إلى 10.0.x
  • كل الحزم الخاصة الـ 9 من 1.x إلى 2.0.0 (major bump، يحتوي على تغييرات كاسرة، إذ يُعدّ AutoMapper 16 transitive إلزامياً)
  • حزم طرف ثالث: إزالة AspNetCoreRateLimit، وترقية Serilog إلى 4.3، وMicrosoft.NET.Test.Sdk 18.0.0، وMSTest 3.10

خارطة الطريق:

  1. إضافة global.json: "sdk.version": "10.0.100", "rollForward": "latestFeature", "allowPrerelease": true، إذ يحتوي جهازي للتطوير على preview SDK، فيما سيسحب Docker الإنتاج إصدار GA.
  2. ترقية base image في Dockerfile: sdk:8.0 ينتقل إلى sdk:10.0، وaspnet:8.0 ينتقل إلى aspnet:10.0.
  3. ترقية الإطار المستهدف في 6 csproj.
  4. تحديث <Version> لـ 26 حزمة Microsoft.
  5. كل الحزم الخاصة الـ 9 تنتقل إلى net10.0 في csproj الخاصة بها مع ترقية transitive (خاصة AutoMapper من 13 إلى 16، وIdentityModel.Tokens من 7.5.2 إلى 8.0.0، وMimeKit/MailKit من 4.4 إلى 4.14، وإسقاط BouncyCastle).
  6. dotnet pack ثم dotnet nuget push ضرب 9 حزم، وذلك إلى feed الخاص بي.
  7. مراجع الحزم الخاصة في csproj تطبيق الويب من 1.x إلى 2.0.0.
  8. إزالة AspNetCoreRateLimit مع ترحيل AddRateLimiter.
  9. Build مع smoke test.

استغرقت العملية بشكل إجمالي من ست إلى ثماني ساعات من العمل المركّز، أضفتُ إليها ساعة أو اثنتين لاختبارات smoke وإصلاح ما طرأ. وفي شركة SaaS تركية نموذجية، يمكن القول إن هذا عمل يومين لشخصين من الفريق.

جدول الـ major bump لـ 9 حزم خاصة (feed مستضاف ذاتياً nuget.bilalkose.com.tr):

#الحزمة1.x2.0.0التغيير الكاسر
1Core.Application.Base1.5.x2.0.0net10.0 مع AutoMapper 16 transitive
2Core.CrossCuttingConcerns.Base1.x2.0.0net10.0 مع Serilog 4.3
3Core.Mailing.Base1.x2.0.0MimeKit/MailKit من 4.4 إلى 4.14، إسقاط BouncyCastle
4Core.Persistence.Base1.x2.0.0EFCore من 8 إلى 10، الإعدادات الافتراضية لـ SqlClient 5.x
5Core.Security.Base1.x2.0.0IdentityModel.Tokens من 7.5.2 إلى 8.0.0
6Core.Test.Base1.x2.0.0تكييف ctor لـ MapperConfiguration، MSTest 3.10
7Core.ElasticSearch.Base1.x2.0.0مسار الترقية من NEST إلى Elastic.Clients.Elasticsearch
8Core.Caching.Base1.x2.0.0Microsoft.Extensions.Caching من 8 إلى 10
9Core.HealthChecks.Base1.x2.0.0AspNetCore.HealthChecks من 8.x إلى 10.x

لكل حزمة، يجري ما يلي: dotnet pack -c Release ثم dotnet nuget push --source bilal-nuget --api-key dummy، إذ يقبل feed المستضاف ذاتياً push المجهول. غير أنّ الكتابة فوق نفس الإصدار غير ممكنة (409 Conflict)، لذا أُضطر إلى رفع رقم الإصدار بعد كل محاولة فاشلة.

ترحيل Rate Limiter: من AspNetCoreRateLimit إلى AddRateLimiter

«124 سطراً من JSON config اختفت في عشية وضحاها.» هذه هي العبارة التي علّقت بذهني بعد إنهاء هذا الجزء، إذ يعيش Rate Limiter الأصلي لـ .NET 10 تحت namespace Microsoft.AspNetCore.RateLimiting. ولا تزال تبعية AspNetCoreRateLimit (5.0.0) تُترجم على .NET 10، أي أن الإزالة لم تكن إلزامية تقنياً. غير أنّ سببين متوسطي المدى فرضا قرار الإزالة:

  1. لم يلتزم vendor بتصحيحات CVE جديدة لهذه الحزمة، إذ لم يتم اختبارها على .NET 10، وهي فقط «تعمل» في الوقت الراهن.
  2. الـ API الأصلي ذو تخصيص ذاكرة أقل (zero-allocation hot path) ويُدار بالكود بدلاً من تكديس 124 سطر config في appsettings.json.

كود الترحيل، التطبيق الفعلي في الإنتاج:

قراران في التصميم يستحقان الإشارة. الأول أنني اخترتُ نمط GlobalLimiter بدلاً من attribute [EnableRateLimiting("policy-name")]، والسبب أنّ إضافة attribute لـ 30+ controller أقل قراءة عبر deploys مقارنة بـ URL-based routing في مكان واحد. والثاني أنني فضّلتُ SlidingWindow بدلاً من FixedWindow، إذ يمتص حركة المرور المفاجئة بسلاسة أكبر ويتجنب مشكلة spike مضاعفة عند حدود النافذة.

services.AddRateLimiter(options =>
{
    options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;

    options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(ctx =>
    {
        string ip = ResolveClientIp(ctx);
        if (IsLocalhost(ip))
            return RateLimitPartition.GetNoLimiter("local:" + ip);

        // GET path: single global sliding window
        if (ctx.Request.Method != HttpMethods.Post)
        {
            return RateLimitPartition.GetSlidingWindowLimiter(
                "global-get:" + ip,
                _ => new SlidingWindowRateLimiterOptions
                {
                    PermitLimit = 200,
                    Window = TimeSpan.FromMinutes(1),
                    SegmentsPerWindow = 4,
                    QueueLimit = 0
                });
        }

        // POST path: URL pattern → policy mapping
        string path = ctx.Request.Path.Value?.ToLowerInvariant() ?? "";
        (string policy, int limit, TimeSpan window, int segments) = path switch
        {
            var p when p.StartsWith("/api/mailgonder")
                    || p.StartsWith("/api/donation/create")
                => ("mail", 12, TimeSpan.FromMinutes(1), 4),

            var p when p.StartsWith("/api/donation/verify")
                => ("donation-verify", 20, TimeSpan.FromMinutes(1), 4),

            var p when p.Contains("/contact")
                => ("contact", 20, TimeSpan.FromHours(1), 6),

            var p when p.Contains("/blog/details")
                    || p.Contains("/projects/addrating")
                => ("comment", 20, TimeSpan.FromHours(1), 6),

            var p when p.StartsWith("/api/")
                => ("api-default", 12, TimeSpan.FromMinutes(1), 4),

            _ => ("global-post", 60, TimeSpan.FromMinutes(1), 4)
        };

        return RateLimitPartition.GetSlidingWindowLimiter(
            $"{policy}:{ip}",
            _ => new SlidingWindowRateLimiterOptions
            {
                PermitLimit = limit,
                Window = window,
                SegmentsPerWindow = segments,
                QueueLimit = 0
            });
    });

    options.OnRejected = async (context, token) =>
    {
        var http = context.HttpContext;
        http.Response.StatusCode = StatusCodes.Status429TooManyRequests;
        http.Response.ContentType = "text/html; charset=utf-8";

        int retryAfter = 60;
        if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out TimeSpan ra))
            retryAfter = (int)Math.Ceiling(ra.TotalSeconds);
        http.Response.Headers["Retry-After"] = retryAfter.ToString(CultureInfo.InvariantCulture);

        // rateLimit429Html template is read from file (3 languages + emoji support)
        await http.Response.WriteAsync(string.Format(rateLimit429Html,
            "—", "—", retryAfter), token);
    };
});

app.UseRateLimiter();

يتبع helper ResolveClientIp السلسلة CF-Connecting-IP > X-Real-IP > RemoteIpAddress، وهي خطوة إلزامية عندما تكون خلف Cloudflare، وإلا تسقط كل حركة المرور في partition IP واحد فيصبح limiter عديم الفائدة. أمّا IsLocalhost فيتجاوز ::1 و127.0.0.1 (لتدفق اختبار dev).

خريطة URL pattern إلى policy:

السياسةالحدالنافذةURL pattern
mail12دقيقة واحدة/api/mailgonder*، /api/donation/create*
donation-verify20دقيقة واحدة/api/donation/verify*
contact20ساعة واحدة/contact في أي مكان
comment20ساعة واحدة/blog/details*، /projects/addrating*
api-default12دقيقة واحدةكل /api/* الأخرى
global-post60دقيقة واحدةfallback نهائي لجانب POST
global-get200دقيقة واحدةكل طلبات GET

قالب HTML للـ 429: في بداية pipeline أقرأ قالب HTML عبر RateLimitQuotaExceededHtml.GetPhysicalPath(env.ContentRootPath) (3 لغات مع emoji وretry timer). وإذا كان الملف مفقوداً، فإن FallbackTemplate يدخل حيز التنفيذ. وقراءة القالب من القرص مرة واحدة فقط توفر فائدة caching كبيرة، إذ لا يُعاد render القالب في كل 429، بل تُملأ ثلاث placeholders فحسب عبر string.Format.

وبفضل header Retry-After: 60، يقوم العملاء حسنو السلوك (خاصة SDKs وتطبيقات الموبايل) بحساب backoff تلقائياً.

النتيجة: حُذف 124 سطر من كتل IpRateLimiting وIpRateLimitPolicies من appsettings.json. وسبعة patterns تعيش الآن في ملف واحد SecurityServiceCollectionExtensions.cs، حيث تصبح diff الـ deploy واضحة وcode review سريعة.

مدة الترحيل: نحو ساعة واحدة. تنظيف using AspNetCoreRateLimit; في أربعة ملفات، وإزالة مراجع الحزمة في csprojين، وكتابة mapping بسبعة patterns، وتكامل OnRejected مع قالب HTML، واختبار smoke.

النصر الصغير لحظة الإزالة: حذفتُ كتل IpRateLimiting وIpRateLimitPolicies من appsettings.json، فاختفى 124 سطراً دون أي فراغ متبقٍ. وأرى أنّ تقليل JSON config يظل إشارة جيدة دائماً، إذ تصبح diff الـ deploy أكثر قراءة، ولا يمكن للأسرار أن تتسرب إلى ملف config، وتصبح الاختلافات السلوكية بين dev وprod مفهومة عبر code review. ولعلّ لحظة «تقليص الـ diff» الصغيرة هذه كانت أوضح علامة على الجانب الإرغونومي للانتقال إلى .NET 10، أي config أقل وكود أكثر.

مثال اختبار smoke:

# /api/mailgonder quick rate limit check (mail policy: 12/1m)
for i in {1..15}; do
  curl -s -o /dev/null -w "%{http_code} " https://yourapp.com/api/mailgonder
done
# Expected: 12 × 200/202, 3 × 429
# Retry-After header set on the final 429 (see with curl -I)

تغيير الـ Constructor الكاسر في AutoMapper 16

«ما أوسع هذا التغيير الكاسر!» هكذا كانت ردة فعلي الأولى حين رأيتُ سبعة ملفات تشتعل بخطأ CS7036 دفعة واحدة. أُطلقت ضرورة الانتقال transitive من AutoMapper 13 إلى 16.1.1 بسبب ترقية الحزم الخاصة، إذ جاء AutoMapper transitive في الرحلة أثناء تنظيف ثغرات BouncyCastle من Core.Mailing.Base. وأثناء الترحيل، رمى المُترجم الخطأ التالي:

error CS7036: There is no argument given that corresponds to the required parameter 'loggerFactory'
  of 'MapperConfiguration.MapperConfiguration(Action<IMapperConfigurationExpression>, ILoggerFactory)'

وابتداءً من AutoMapper 14، جعل constructor الـ MapperConfiguration معامل ILoggerFactory إلزامياً، فيما كان في الإصدارات السابقة optional أو لم يكن موجوداً.

نقطة التأثير هي إعداد الاختبار، لأنّ جانب الإنتاج DI الـ services.AddAutoMapper(typeof(SomeProfile)) extension method يحقن ILoggerFactory بالفعل من الحاوية. وعادة تظهر المشكلة في فئات قاعدة الاختبار أو في factory اختبارات التكامل.

الإصلاح:

// ÖNCE (AutoMapper 13):
var config = new MapperConfiguration(cfg =>
{
    cfg.AddProfile<SomeProfile>();
});
var mapper = config.CreateMapper();

// SONRA (AutoMapper 16):
var config = new MapperConfiguration(
    cfg => cfg.AddProfile<SomeProfile>(),
    NullLoggerFactory.Instance);  // <-- required parameter
var mapper = config.CreateMapper();

لا يُنتج Microsoft.Extensions.Logging.Abstractions.NullLoggerFactory.Instance أي logs في سياق الاختبار. أمّا في الإنتاج فيأتي ILoggerFactory الحقيقي من حاوية DI على أي حال. وقد هبط هذا الإصلاح في ملف واحد (BaseMockRepository.cs) داخل حزمة Core.Test.Base ولم ينتشر إلى كود web-app الرئيسي.

ولا توجد تغييرات كاسرة أخرى، إذ يبقى Profile API وProjectTo وdynamic mapping جميعها متوافقة بأثر رجعي مع 13.

مقطع، صدمة الترجمة الأولى: عندما رقّيتُ حزم Core إلى 2.0.0 وشغّلت dotnet build، رمى المُترجم CS7036 عبر 7 ملفات. كانت ردة فعلي الأولى «ما أوسع هذا التغيير الكاسر!»، ثم نظرت إلى آثار الخطأ ووجدت أنّ جميعها يُشير إلى نفس نقطة ctor لـ MapperConfiguration، غير أنّ خمسة منها في DI الإنتاج واثنين فقط في قاعدة الاختبار. وفي جانب DI الإنتاج، يحقن services.AddAutoMapper(...) extension method الـ ILoggerFactory من الحاوية بالفعل، فاتضح أن خطأ المُترجم كان بسبب ترتيب توجيهات using أو cache الترجمة التزايدية. وبعد dotnet clean ثم dotnet restore --force، انخفضت أخطاء prod من 7 إلى 2، تاركةً فقط إصلاح فئة قاعدة الاختبار. واستغرق الإصلاح إجمالاً عشر دقائق فحسب، غير أنّه يظل فخ ترحيل كلاسيكياً من نوع «لحظة، ماذا؟».

الإعدادات الافتراضية الجديدة لـ SqlClient (مفاجأة TLS)

A connection was successfully established with the server,
but then an error occurred during the login process.
(provider: SSL Provider, error: 0 — The handshake failed
due to an unexpected packet format.)

بهذه الرسالة المضللة بدأ صباح 7 مايو 2026، وكان هذا أكثر جزء مزعج في الترحيل بأكمله، لأنّ رسالة الخطأ نفسها مضللة.

تسحب قفزة EFCore من 8 إلى 10 الحزمة Microsoft.Data.SqlClient 5.x بشكل transitive، وقد تغيرت قيم connection string الافتراضية ابتداءً من SqlClient 5.0 على هذا النحو:

  • Encrypt انتقلت قيمته الافتراضية من False إلى Mandatory (TLS مطلوب)
  • TrustServerCertificate تبقى قيمته الافتراضية False

لذا فإن connection string بسيطاً كان يعمل سابقاً (Server=...;Database=...;User Id=...;Password=...;) يُلقي الآن:

A connection was successfully established with the server,
but then an error occurred during the login process.
(provider: SSL Provider, error: 0 — The handshake failed
due to an unexpected packet format.)

وعند القراءة الأولى يبدو هذا كمشكلة SQL Server أو شبكة أو credential، غير أنّ السبب الحقيقي هو TLS handshake. وإذا كان SQL Server يستخدم شهادة self-signed، أو إذا كان forced encryption مفعلاً في prod، فإن connection string القديم لا يستطيع إكمال handshake.

الحل، connection string بمستوى إنتاجي:

Server=...;Database=...;User Id=...;Password=...;
Encrypt=True;
TrustServerCertificate=True;
Connect Timeout=60;
MultipleActiveResultSets=True;

ثلاث معاملات رئيسية تستحق التوضيح:

  • Encrypt=True (نفس Mandatory، TLS مفتوح)، إذ يبقى الاتصال مشفّراً
  • TrustServerCertificate=True، أي قبول شهادات self-signed، وهو مطلوب إذا لم تكن لديك شهادة موقّعة من Public CA مؤسسية (شائع في SQL Server داخل الـ cluster)
  • Connect Timeout=60 (بالثواني)، إذ Kubernetes cold start مع TLS handshake يساوي نحو 15-30 ثانية إجمالاً، والقيمة الافتراضية البالغة 15 ثانية قد لا تكفي

استراتيجيتي لإعداد الإنتاج، طبقتان:

أحتفظ بالمعاملات الأساسية في appsettings.json (TrustServerCertificate=True;MultipleActiveResultSets=True;) لكنني أترك password الفعلية والجانب الحساس كـ placeholder REPLACE_ME. وعند بدء pod، يُسحب patch الـ ConnectionStrings__BaseDb من مسار HashiCorp Vault secret/bilal-website (AddVaultSecrets("bilal-website") extension method). ويتضمن النص الكامل في Vault معاملات إضافية Encrypt=True;Connect Timeout=60، أي أن الإعداد في الإنتاج يأتي بالكامل من Vault، فيما يبقى placeholder في الـ repo موجوداً فقط لأغراض fingerprint.

تدفق patch السر في Vault:

kubectl exec -n vault vault-0 -- vault kv patch bilal-website   ConnectionStrings__BaseDb="Server=...;...;Encrypt=True;TrustServerCertificate=True;Connect Timeout=60;..."

وعندما يُعاد تشغيل pod (kubectl rollout restart deployment/myapp)، تدخل السلسلة الجديدة حيز التنفيذ. وإذا كان Vault sidecar reloader يعمل في الـ cluster، فلا حاجة حتى للـ restart، إذ ينتشر تغيير السر تلقائياً.

قصة كيف كلفتني هذه المشكلة 30 دقيقة: صباح 7 مايو 2026، عندما بدأت محلياً بـ dotnet run، كان الخطأ الأول «Login failed». فحصت أولاً إعدادات SQL Server (سجلات server نظيفة)، ثم K8s network policy (iptables نظيف). غير أنّ السطر الأول من رسالة الخطأ، أي SSL Provider, error: 0 (The handshake failed due to an unexpected packet format)، كان يخبرني أنه TLS handshake، وكنت قد عممت الأمر باعتباره «login failed». وعلى طريق الترحيل، استغرقت إضافة Encrypt وTrustServerCertificate خمس ثوانٍ فحسب، فيما استغرق فهم السبب نصف ساعة كاملة.

البيئات المختبرة: SQL Server 2019، وSQL Server 2022، وAzure SQL Database. عملت نفس السلسلة في الثلاثة. وعلى Azure SQL، يكون TrustServerCertificate في الواقع غير ضروري (موقّع من Microsoft public CA)، لكن إبقاء connection string موحداً عبر البيئات يمنع أخطاء الـ deploy.

Serilog 4.3 مع ASP.NET Core 10: فخاخ التبعيات

error NU1605: Detected package downgrade:
  Serilog.Sinks.Console from 6.1.1 to 6.0.0
  Serilog.Sinks.File from 7.0.0 to 6.0.0

هذا هو الخطأ الذي استقبلني عند أول محاولة build بعد ترقية Serilog، ويتطلب جانب logging تحديث حزمتين لـ .NET 10:

  • Serilog 4.0.1 ينتقل إلى 4.3.0 (4.4 ليس على NuGet بعد، اعتباراً من مايو 2026)
  • Serilog.AspNetCore 8.0.2 ينتقل إلى 10.0.0

أمّا الفخ فيظهر عندما يُثبَّت Serilog.AspNetCore 10، إذ يطلب transitive حدوداً دنيا جديدة:

error NU1605: Detected package downgrade:
  Serilog.Sinks.Console from 6.1.1 to 6.0.0
  Serilog.Sinks.File from 7.0.0 to 6.0.0

أي إذا كان مشروعك يستخدم Sinks.Console 6.0.0، يجب عليك ترقيته صراحة إلى >= 6.1.1. ونفس الشيء ينطبق على Sinks.File 7.0.0، وإلا أوقف NU1605 الـ build.

الإصلاح:

<ItemGroup>
  <PackageReference Include="Serilog" Version="4.3.0" />
  <PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
  <PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
  <PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
</ItemGroup>

اختبار توافق الـ Sinks: جرّبت sinks الـ Console وFile وMSSQL وGraylog وMongoDB على 4.3، وكلها متوافقة بأثر رجعي. والسلوك متطابق، وتنسيق log لم يتغير.

نشر Docker وKubernetes في الإنتاج

«213 ميغابايت، أصغر بثلاثة بالمئة فقط.» قد يبدو الرقم هامشياً للوهلة الأولى، لكنه يتراكم سريعاً مع كل deploy متكرر. والتغيير الأساسي لـ multi-stage Dockerfile هو ترقية base image:

FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY . .
RUN dotnet publish -c Release -o /app/publish --no-restore

FROM mcr.microsoft.com/dotnet/aspnet:10.0
WORKDIR /app
COPY --from=build /app/publish .
ENV ASPNETCORE_URLS=http://+:80
ENTRYPOINT ["dotnet", "MyApp.dll"]

حجم الـ image: aspnet:10.0 نحو 213 ميغابايت (أصغر بنسبة 3٪ من 8.0). ويتراكم هذا التوفير الهامشي إذا كنت تنشر كثيراً، على جانب قرص Jenkins agent وbandwidth الـ registry.

probe الـ Liveness/Readiness: يعمل endpoint /health/live في بداية الـ pipeline، أي قبل HostFiltering وmiddleware آخر. ويعيد 200 حتى أثناء cold start لـ pod، ما يمنع إنذارات Kubernetes الكاذبة «Container failed to start». وتوصيتي في هذا الشأن:

livenessProbe:
  httpGet:
    path: /health/live
    port: 80
  initialDelaySeconds: 10
  periodSeconds: 10
  timeoutSeconds: 3
  failureThreshold: 3

استراتيجية الـ Rollout: RollingUpdate، مع maxSurge: 1، وmaxUnavailable: 0، أي بدون downtime. وقت بدء .NET 10 على جهاز التطوير لدي يقارب 2-3 ثوانٍ، إذ بمجرد أن يصبح pod الجديد جاهزاً لـ liveness، يُغلق pod القديم.

توافق global.json مع preview SDK: جهاز التطوير لدي يحتوي على 10.0.300 preview SDK، فيما يسحب Docker الإنتاج إصدار GA. ومع "rollForward": "latestFeature" و"allowPrerelease": true في global.json، لا يوجد عدم توافق SDK أثناء build الـ pod. وقد شُحن تصحيح CVE-2026-40372 في 10.0.7، ويلتقطه runtime GA تلقائياً.

التمييز بين probes: يحتوي ASP.NET Core على ثلاثة أنواع probe، ومن المهم عدم الخلط بينها.

  • livenessProbe (/health/live): «هل لا يزال pod حياً؟» يفحص فقط ما إذا كان process في deadlock. وعادة استجابة ثابتة 200.
  • readinessProbe (/health/ready): «هل جاهز لاستقبال حركة المرور؟» يشمل اتصالات قاعدة البيانات وcache وAPIs خارجية، وإذا فشل، يُزال pod تلقائياً من الـ service.
  • startupProbe (/health/startup): لـ cold starts الطويلة (مثل فحوصات ترحيل EFCore، أو تسخين cache)، يعمل قبل liveness ويسمح بـ timeout أطول.

إعدادنا يحتوي على livenessProbe مع /health/live فحسب لأن الـ startup سريع (نحو 2-3 ث). أمّا ترحيلات EFCore فتعمل في deploy job وليس في runtime (init container)، وهذا النمط لم يتغير في .NET 10 أيضاً.

Multi-arch image: أصبح دعم ARM64 شائعاً في prod (خاصة AWS Graviton وأحدث جيل من Azure Ampere instances). ومع Buildx:

docker buildx create --use --name multibuilder
docker buildx build   --platform linux/amd64,linux/arm64   --push -t registry.example.com/myapp:10.0 .

mcr.microsoft.com/dotnet/aspnet:10.0 متعدد المعمارية بالفعل، فلا حاجة لإعداد إضافي. وتوفر عقد عمل ARM64 ميزة تكلفة بنسبة 20-30٪ بأداء شبه متساوٍ. ولتقدير عدد العقد وكفاءة التعبئة والتكلفة الشهرية المتوقعة من طلبات موارد الـ pod، يمكنك استخدام حاسبة ميزانية موارد Kubernetes.

فحص أمان الـ Image: قبل push إلى الإنتاج، يكون فحص CVE بـ Trivy أو Grype مفيداً:

trivy image registry.example.com/myapp:10.0 --severity HIGH,CRITICAL

وفي فحصي الأول بعد الترحيل ظهر 2 CVE transitive: الأول MimeKit/MailKit 4.14 (مشكلة تصميم vendor، مقبولة)، والثاني System.Security.Cryptography.Xml 10.0.0 (آخر تصحيح من Microsoft هو 10.0.0 بالفعل). ويمكن اعتبار كليهما «إيجابياً كاذباً».

قبل الإنتاج: 10 اختبارات Smoke

«عشر اختبارات في ثلاثين ثانية.» هذه هي خلاصة ما تطلبته البوابة قبل deploy 7 مايو 2026. وفيما يلي قائمة اختبارات smoke التي شغّلتها في الـ cluster، وكل بند قابل للأتمتة، إذ يكفي bash script مع curl. وللضوء الأخضر، البوابة هي 10/10:

  • 1) Health liveness: GET /health/live ينتظر 200 (بداية pipeline، قبل HostFiltering)
  • 2) Health readiness: GET /health/ready ينتظر 200 (DB مع cache مع APIs خارجية جاهزة)
  • 3) Locale switcher TR: GET /?lang=tr ينتظر 200، مع التحقق من <html lang="tr">
  • 4) Locale switcher EN: GET /?lang=en ينتظر 200، مع التحقق من <html lang="en">
  • 5) Locale switcher AR: GET /?lang=ar ينتظر 200، مع التحقق من <html lang="ar" dir="rtl">
  • 6) Rate limiter trigger: 15 طلب POST /api/mailgonder متتالٍ تنتج 12×{200,202} مع 3×429 مع header Retry-After: 60 في الاستجابة الأخيرة
  • 7) Attack probe /.env: GET /.env ينتظر 404 (middleware لحظر dotfile)
  • 8) Attack probe /.git/config: GET /.git/config ينتظر 404
  • 9) Attack probe /web.config: GET /web.config ينتظر 404 (مسار IIS كلاسيكي، غير موجود في ASP.NET Core)
  • 10) Prometheus scrape: GET /metrics ينتظر 200، مع ظهور metrics process_runtime_dotnet_gc_heap_size_bytes وhttp_server_request_duration_seconds_bucket على الأقل

Smoke test bash one-liner:

BASE="https://yourapp.com"
echo "1) /health/live"; curl -sI $BASE/health/live | head -1
echo "2) /health/ready"; curl -sI $BASE/health/ready | head -1
for lang in tr en ar; do
  echo "3-5) /?lang=$lang"; curl -sI "$BASE/?lang=$lang" | head -1
done
echo "6) rate-limit 15x"; for i in {1..15}; do curl -s -o /dev/null -w "%{http_code} " "$BASE/api/mailgonder"; done; echo
for path in .env .git/config web.config; do
  echo "7-9) /$path"; curl -sI "$BASE/$path" | head -1
done
echo "10) /metrics"; curl -s $BASE/metrics | head -5

النتيجة: 10/10 أخضر، في غضون 8 دقائق من الـ build. ووضع هذه القائمة تحت version control كـ smoke-test.sh واحد يقلل عبء التحقق اليدوي بعد deploy من 5 دقائق إلى 30 ثانية.

المراقبة: Prometheus مع OpenTelemetry

«ساعات مختصرة إلى دقائق.» هذا ما تختصره الـ TraceId حين تنقر عليها في Grafana فتفتح distributed trace في Tempo. ويقدم .NET 10 سطحاً أنظف على جانب diagnostic API، خاصة مع System.Diagnostics.Metrics وحزمة OpenTelemetry.Exporter.Prometheus.AspNetCore.

الإعداد القياسي:

builder.Services.AddOpenTelemetry()
    .WithMetrics(metrics => metrics
        .AddAspNetCoreInstrumentation()
        .AddHttpClientInstrumentation()
        .AddMeter("MyApp.Custom")
        .AddPrometheusExporter())
    .WithTracing(tracing => tracing
        .AddAspNetCoreInstrumentation()
        .AddHttpClientInstrumentation()
        .AddOtlpExporter());

app.MapPrometheusScrapingEndpoint(); // /metrics

مثال counter مخصص:

private static readonly Meter Meter = new("MyApp.Custom");
private static readonly Counter<long> AttackProbes =
    Meter.CreateCounter<long>("bilal_attack_probe_blocks_total");

AttackProbes.Add(1, new KeyValuePair<string, object?>("category", "encoded_or_dotfile"));

يسحب Prometheus scrape config هذا metric من endpoint /metrics، ثم يظهر كـ time-series في لوحة Grafana. وهذا هو النمط الذي أستخدمه لـ security telemetry.

محاذاة الـ Sinks: logs المنظمة لـ Serilog، وtrace IDs لـ OpenTelemetry، وcounters لـ Prometheus مع لوحة Grafana، يمكن قراءتها كلها من نافذة واحدة. ويتدفق trace ID تلقائياً مع log enrichment:

Log.Logger = new LoggerConfiguration()
    .Enrich.WithProperty("Application", "MyApp")
    .Enrich.With<TraceIdEnricher>()
    .WriteTo.Console(outputTemplate:
        "[{Timestamp:HH:mm:ss} {Level:u3}] [{TraceId}] {Message:lj}{NewLine}{Exception}")
    .CreateLogger();

وعلى جانب Grafana، النقر على سطر log مع TraceId يفتح distributed trace في Tempo أو Jaeger، خاصة في تدفقات multi-microservice أو OAuth callback مع تبعيات API خارجية، فيوفر ذلك ساعات.

7 خلاصات من هذا الترحيل

بعد يوم من العمل المكثف، بقيت سبعة بنود في دفتري، وهي أشياء يجب أن يعرفها مسبقاً أي شخص يقوم بترحيل .NET 10 التالي:

  1. تعلّم قراءة رسالة خطأ TLS. يلقي تغيير الإعدادات الافتراضية لـ SqlClient 5.x خطأ TLS handshake يبدو كـ «Login failed». ولا تقرأ الكلمة الأولى من سطر الخطأ فقط، بل اقرأه كله. وإذا رأيت SSL Provider, error: 0 فهي مشكلة شهادة وليست credential. والثلاثي Encrypt=True;TrustServerCertificate=True;Connect Timeout=60 هو المعيار.
  2. فرصة لإسقاط rate limiter طرف ثالث. لا يزال AspNetCoreRateLimit يُترجم، غير أنه لا يوجد التزام vendor بالتصحيحات. أمّا Native AddRateLimiter مع SlidingWindow مع URL-pattern routing فقد استغرق نصف يوم وأزال 124 سطر JSON config. ونادراً ما يُغلق الدين بهذا الرخص.
  3. AutoMapper 16 ليس ctor كاسراً، بل test setup كاسر. DI الإنتاج services.AddAutoMapper(...) يحقن ILoggerFactory بالفعل. وخطأ CS7036 يكون 95٪ في فئات قاعدة الاختبار، ويُحلّ بسطر واحد عبر NullLoggerFactory.Instance.
  4. فخ Serilog 4.3 في NU1605 هو transitive. عندما تثبت Serilog.AspNetCore 10، يجب أن ترقّي صراحة Sinks.Console >= 6.1.1 وSinks.File >= 7.0.0، وإلا أوقف خطأ downgrade الـ build.
  5. Kubernetes startup سريع، والتمييز بين probes مهم. Cold start لـ .NET 10 يقارب 2-3 ثوانٍ، لذا يكفي livenessProbe مع /health/live ولا حاجة لـ startupProbe. واترك ترحيلات EFCore في deploy job كـ init container، لا في runtime.
  6. اجعل سلسلة الـ major bump لـ feed المستضاف ذاتياً قابلة للقراءة. عندما تدفع 9 حزم من 1.x إلى 2.0.0، اسرد التغييرات transitive لكل واحدة. ولا يمكن الكتابة فوق نفس الإصدار (HTTP 409)، لذا يجب أن ترقّي الإصدار مرة أخرى بعد كل خطأ.
  7. اختبار smoke لـ 10 endpoints يساوي 30 ثانية من الثقة. بدلاً من «دعني أفتح هذا URL وأتحقق» يدوياً بعد deploy، يختبر bash script تحت version control تلقائياً 10 endpoints. ويغير ذلك قرارات rollback من ساعات إلى ثوانٍ.

أسئلة شائعة

هل عليّ الانتقال إلى .NET 10 الآن، أليس .NET 8 LTS مدعوماً حتى نهاية 2026؟
ينتهي دعم .NET 8 في 10 نوفمبر 2026 (و.NET 9 EOL في نفس التاريخ). والفرق التي تبدأ الآن تكسب 5-7 أشهر من مساحة المناورة، فيما سيتعامل من ينتظرون حتى سبتمبر-أكتوبر مع patch مع ترحيل في نفس أسابيع EOL.

هل يمكنني الاستمرار في استخدام AspNetCoreRateLimit؟
نعم، فلا تزال الحزمة تُترجم على .NET 10. لكنه لا يوجد التزام vendor بالتصحيحات. والانتقال إلى native AddRateLimiter نصف يوم على المدى القصير ويقلل مخاطر CVE على المدى الطويل.

هل ترقية AutoMapper 16 إلزامية؟
لا. AutoMapper 13 و14 و15 لا تزال متوافقة مع .NET 10. غير أنّك إذا كنت تعتمد على حزم مثل IdentityModel.Tokens 8 أو MimeKit 4.14، فقد تأتي transitive. وانتبه لتغيير ctor الـ MapperConfiguration في إعداد الاختبار.

أحصل على خطأ اتصال SqlClient، ماذا أفعل؟
أضف الثلاثي Encrypt=True;TrustServerCertificate=True;Connect Timeout=60 إلى connection string. وهذا إلزامي على SQL Server بشهادات self-signed أو forced encryption.

أي إصدار من Serilog يجب أن أستخدم؟
اعتباراً من مايو 2026، الإصدار المستقر الأحدث هو Serilog 4.3.0 مع Serilog.AspNetCore 10.0.0. وترقية صريحة لـ Sinks.Console >= 6.1.1 وSinks.File >= 7.0.0 إلزامية (خطأ NU1605).

أي اختبارات smoke يجب أن أشغل قبل deploy إلى الإنتاج؟
الحد الأدنى يشمل: /health/live 200، ومبدّل اللغة (?lang=tr|en|ar) 200، واختبار rate limiter 429 (13 طلب POST عبر curl)، ومسارات attack probe (/.env، /.git، /web.config) تعيد 404. ويكفي اختبار 10 endpoints قابل للأتمتة.

أستخدم preview SDK لـ .NET 10، هل سيسبب مشكلة في image الإنتاج؟
إذا ضبطت "rollForward": "latestFeature" و"allowPrerelease": true في global.json، فإن جهاز التطوير لديك يستخدم preview بينما يسحب Docker الإنتاج إصدار GA، أي بدون عدم توافق.

ما هي المدة النموذجية للترحيل؟
لمطور واحد على تطبيق متوسط الحجم (5-7 csproj، نحو 50 حزمة): 4-8 ساعات ترحيل مركّز مع 1-2 ساعة اختبار smoke. ومع فريق اختبار: 1-2 يوم. وفي monorepo كبيرة، قد يمتد ذلك إلى أسابيع.

ما يجب أن تكون خطة الـ rollback؟
Kubernetes kubectl rollout undo deployment/my-app. واحتفظ بـ 3 إصدارات على الأقل من tags الـ Docker image. وإذا كانت لديك ترحيلات DB، فاجعلها idempotent (down migration)، إذ إنّ .NET 10 EFCore 10 سيتصل، غير أنّ الإصدار القديم يعود بمعاملات connection string مختلفة.

المصادر والمراجع

الوثائق الرسمية:

كتابات المجتمع:

سياق الترحيل (البيانات المصدرية لهذه المقالة):

  • تطبيق الويب bilalkose.com.tr، 6 csproj، وترحيل .NET 8 إلى 10 LTS، في 7 مايو 2026
  • 9 حزم خاصة، major bump (1.x إلى 2.0.0) على feed NuGet الخاص nuget.bilalkose.com.tr
  • نتيجة الـ build: 0 خطأ، و18 تحذير، وزمن build نحو 25 ثانية، و10/10 smoke test endpoints

التعليقات (0)

اترك تعليقاً وتقييماً

لا توجد تعليقات بعد. كن أول من يعلق.