بناء الخدمات المصغّرة بـ Spring Boot

تحويل الخدمة المصغّرة إلى حاوية

18 دقيقة الدرس 9 من 12

تحويل الخدمة المصغّرة إلى حاوية

يمنحك تغليف خدمة Spring Boot كصورة Docker وحدةً مكتفيةً بذاتها وقابلةً للاستنساخ، تعمل بشكل متطابق على حاسوب المطوّر وعلى عامل CI وعلى عنقود Kubernetes. في هذا الدرس ستتعلم كيف تكتب Dockerfile بمستوى الإنتاج، وكيف تبني الصورة وتُعلّمها، وتشغّلها محليًا، وتدير الإعداد والأسرار التي تحتاجها الخدمة المُحوسَبة — دون أن تُدرج بيانات الاعتماد داخل طبقات الصورة.

لماذا الحاويات للخدمات المصغّرة؟

لا تكون الخدمة المصغّرة قابلةً للنقل إلا بقدر قابلية البيئة التي تعتمد عليها للنقل. بدون حاويات تحتاج كل مضيف إلى إصدار JDK صحيح وقيم application.properties مناسبة ومكتبات نظام تشغيل متطابقة. تحل الحاويات المشاكل الثلاث بتجميع بيئة JRE وملف JAR الضخم في وحدة واحدة غير قابلة للتغيير. أبرز الفوائد:

  • قابلية الاستنساخ: الصورة المبنية في CI هي بالضبط ما يُروَّج إلى الإنتاج.
  • العزل: لكل خدمة مسار أصناف (classpath) منفصل ومنفذ ومتغيرات بيئة خاصة — لا يوجد JVM مشترك أو تعارض منافذ على نفس المضيف.
  • التوسع الأفقي السريع: يستطيع المنسّق (Kubernetes أو ECS) تشغيل عشر نسخ إضافية من الصورة في ثوانٍ.
  • النشر الثابت: لا تُرقِّع حاويةً جارية أبدًا؛ بل تبني تاج صورة جديدًا وتطرحه.

كتابة Dockerfile بمستوى الإنتاج

يعمل Dockerfile البسيط الذي ينسخ ملف JAR الضخم ويشغّله، لكنه يُفسد آلية تخزين طبقات Docker مؤقتًا: كل تغيير في الكود يُعيد بناء طبقة حجمها 60 ميغابايت. الحل المعياري هو نمط الطبقات المتعددة (أو JAR المفكّك)، الذي تدعمه ملحقات بناء Spring Boot بشكل أصيل.

أولًا، ابنِ تخطيط JAR المفكّك. مع Maven:

mvn package -DskipTests java -Djarmode=layertools -jar target/order-service-0.0.1-SNAPSHOT.jar extract --destination target/extracted

ينتج هذا أربعة مجلدات فرعية تحت target/extracted/: dependencies/، وspring-boot-loader/، وsnapshot-dependencies/، وapplication/. يرتّبها Spring Boot حسب تكرار التغيير: الكود التطبيقي يتغيّر أكثر، أما JAR الجهات الخارجية فنادرًا ما تتغيّر.

اكتب الآن Dockerfile في جذر المشروع:

# ---- مرحلة البناء (اختيارية؛ تُبقي JDK خارج الصورة النهائية) ---- FROM eclipse-temurin:21-jdk-alpine AS builder WORKDIR /build COPY . . RUN ./mvnw package -DskipTests RUN java -Djarmode=layertools \ -jar target/order-service-0.0.1-SNAPSHOT.jar \ extract --destination target/extracted # ---- مرحلة التشغيل ---- FROM eclipse-temurin:21-jre-alpine WORKDIR /app # إنشاء مستخدم غير جذري لتشغيل الخدمة RUN addgroup -S spring && adduser -S spring -G spring USER spring:spring # نسخ الطبقات من مرحلة البناء بترتيب تصاعدي لتكرار التغيير COPY --chown=spring:spring --from=builder /build/target/extracted/dependencies ./ COPY --chown=spring:spring --from=builder /build/target/extracted/spring-boot-loader ./ COPY --chown=spring:spring --from=builder /build/target/extracted/snapshot-dependencies ./ COPY --chown=spring:spring --from=builder /build/target/extracted/application ./ EXPOSE 8081 ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]
البنايات ثنائية المراحل مهمة للأمان. تحتاج مرحلة البناء إلى JDK كامل (أدوات التحويل والاختبار). أما مرحلة التشغيل فتحتاج فقط إلى JRE. إبقاء JDK خارج الصورة النهائية يُقلص سطح الهجوم — لا javac، ولا أدوات بناء، ولا ثنائيات إضافية يمكن لعملية حاوية مخترقة استغلالها.

التشغيل كمستخدم غير جذري

افتراضيًا تعمل حاويات Docker بصلاحيات root داخل فضاء اسم الحاوية. إذا كانت في خدمتك ثغرة تنفيذ كود عن بُعد، يستطيع المهاجم الذي يعمل بصلاحيات root داخل الحاوية — في بعض التكوينات — الخروج إلى المضيف. الإصلاح بسيط: أنشئ دائمًا مستخدم نظام مخصصًا وانتقل إليه باستخدام USER. يضمن الخيار --chown في كل COPY امتلاك ذلك المستخدم لملفات التطبيق حتى يستطيع Spring Boot قراءة مسار الأصناف في وقت التشغيل.

بناء الصورة وتعليمها

من جذر المشروع (حيث يوجد Dockerfile):

# البناء بتاج دلالي docker build -t order-service:1.0.0 . # تعليمها أيضًا بـ latest لسهولة الاستخدام المحلي docker tag order-service:1.0.0 order-service:latest # الرفع إلى سجل (استبدل ببادئة سجلك) docker tag order-service:1.0.0 ghcr.io/myorg/order-service:1.0.0 docker push ghcr.io/myorg/order-service:1.0.0
لا تستخدم التاج latest في بيانيات الإنتاج أبدًا. التاج latest قابل للتغيير — يُعيد الرفع الجديد الكتابة عليه وتفقد قابلية التتبع. علّم كل إصدار برقم نسخة (1.0.0) أو بتجزئة إيداع Git (git rev-parse --short HEAD)، ثم أشر إلى ذلك التاج الثابت في ملفات Kubernetes أو Compose.

تمرير الإعداد والأسرار

يجب أن تكون صورة الحاوية مستقلةً عن البيئة. ترميز spring.datasource.url=jdbc:mysql://prod-db:3306/orders داخل الصورة يربطها بهدف نشر واحد ويُسرّب اسم المضيف إلى سجل الصور. الطريقة الصحيحة هي حقن جميع القيم الخاصة بالبيئة في وقت التشغيل عبر متغيرات البيئة، التي يُعيّنها Spring Boot إلى الخصائص وفق اصطلاح محدد:

# اسم الخاصية متغير البيئة المكافئ # spring.datasource.url SPRING_DATASOURCE_URL # server.port SERVER_PORT # app.jwt.secret APP_JWT_SECRET

شغّل الحاوية محليًا بمتغيرات البيئة:

docker run --rm \ -p 8081:8081 \ -e SPRING_DATASOURCE_URL=jdbc:mysql://host.docker.internal:3306/orders \ -e SPRING_DATASOURCE_USERNAME=orders_user \ -e SPRING_DATASOURCE_PASSWORD=secret \ -e SPRING_PROFILES_ACTIVE=docker \ order-service:1.0.0

استخدم host.docker.internal على macOS/Windows للوصول إلى خدمة تعمل على جهازك المضيف. على Linux استخدم عنوان IP لبوابة شبكة الجسر (172.17.0.1 افتراضيًا) أو أنشئ شبكة Docker مشتركة.

لا تستخدم خيارات -e للأسرار في CI/CD أو الإنتاج أبدًا. متغيرات البيئة مرئية في قوائم العمليات (ps aux) وفي مخرجات docker inspect. استخدم مدير أسرار (Kubernetes Secrets مع تشفير في حالة الراحة، أو AWS Secrets Manager، أو HashiCorp Vault) وثبّت الأسرار كملفات أو أحقنها عبر بُنية أساسية واعية بالأسرار.

فحوصات الصحة

يستطيع Docker استطلاع نقطة نهاية صحة خدمتك وإعادة تشغيل الحاويات غير السليمة تلقائيًا. يعرض Spring Boot Actuator مسار /actuator/health جاهزًا. أضف تعليمة HEALTHCHECK إلى Dockerfile:

HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \ CMD wget -qO- http://localhost:8081/actuator/health | grep -q '"status":"UP"' || exit 1

يمنح --start-period=40s وقتًا كافيًا لبدء تشغيل JVM قبل أن يبدأ Docker في عدّ المحاولات. الحاوية التي تفشل ثلاث فحوصات متتالية تُعلَّم بـ unhealthy ويستبدلها المنسّق.

Spring Boot Buildpacks (بديل عن Dockerfile)

يأتي Spring Boot 3 مع دعم مدمج لـ Cloud Native Buildpacks. لا حاجة لـ Dockerfile:

./mvnw spring-boot:build-image -Dspring-boot.build-image.imageName=order-service:1.0.0

تُطبّق Buildpacks تلقائيًا مستخدمًا غير جذري، وتُطبّق طبقات JAR، وتُضيف عامل JVM لحساب الذاكرة، وتتبع أفضل الممارسات الافتراضية. المفاضلة: تحكم أقل في صورة الأساس الدقيقة وبناء أول أبطأ (يُنزّل مكدس Buildpack). فرق العمل التي تُفضّل الاصطلاح على الإعداد ستجد Buildpacks ممتازة. أما الفرق ذات متطلبات صور أساسية مخصصة (شهادات CA مؤسسية، صور نظام تشغيل مُصلَّبة) فـDockerfile المكتوبة يدويًا توفر تحكمًا أكبر.

ضبط ذاكرة JVM داخل الحاوية

بدون حدود صريحة، يُحدّد JVM حجم الكومة (heap) نسبةً إلى إجمالي ذاكرة RAM للمضيف، التي قد تبلغ مئات الغيغابايت في بيئة سحابية. يُفضي هذا إلى قتل حاويتك من قِبل OOM-killer عند تجاوز حد ذاكرة مجموعة التحكم (cgroup). حدّد دائمًا حدود الكومة بشكل صريح:

ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75.0", "-XX:InitialRAMPercentage=50.0", "-XX:+UseContainerSupport", "org.springframework.boot.loader.launch.JarLauncher"]

يجعل UseContainerSupport (مُفعَّل افتراضيًا منذ JDK 10) JVM يقرأ حد ذاكرة cgroup بدلًا من إجمالي ذاكرة المضيف. يُحدّد MaxRAMPercentage=75.0 سقف الكومة عند 75% من حد الحاوية، تاركًا هامشًا لـ Metaspace ومكدسات الخيوط والذاكرة الأصيلة.

الخلاصة

يعني تحويل خدمة Spring Boot المصغّرة إلى حاوية كتابة Dockerfile متعددة المراحل تفصل بين أدوات البناء وصورة التشغيل، والاستفادة من استخراج طبقات Spring Boot لتعظيم مخبأ Docker، والتشغيل كمستخدم غير جذري، وحقن جميع الإعدادات الخاصة بالبيئة في وقت التشغيل بدلًا من تضمينها في الصورة. أضف HEALTHCHECK حتى تتمكن المنسّقات من اكتشاف الحالات المعطوبة واستبدالها تلقائيًا. بمجرد أن تصبح خدمتك صورةً ثابتةً ومُعلَّمة، ستمتلك القطعة التي تحتاجها في الدرس الأخير: ربط خدمتين معًا في نظام متكامل.