تأمين حاويات Docker
أمن الحاويات موضوع بالغ الأهمية. إليك الممارسات الأساسية لحماية تطبيقاتك العاملة داخل حاويات.
الحاوية ليست آلة افتراضية: جميع الحاويات على المضيف نفسه تتشارك نواة Linux واحدة. يعتمد العزل على namespaces وcgroups وcapabilities وملفات seccomp. هذا العزل فعّال، لكن كل إعداد متساهل أكثر من اللازم (عملية تعمل بصلاحيات root، أو capabilities زائدة، أو socket لـ Docker مركّب داخل الحاوية، أو منفذ مفتوح) يوسّع ما يستطيع المهاجم فعله بعد اختراق تطبيق ما. الهدف إذن هو تقليص سطح الهجوم طبقةً بعد طبقة: الصورة، والمستخدم، والصلاحيات أثناء التشغيل، والشبكة، والأسرار.
مبدأ أدنى الصلاحيات
# NEVER run as root
FROM php:8.3-fpm-alpine
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
WORKDIR /app
COPY --chown=appuser:appgroup . .
افتراضياً، تعمل العملية داخل الحاوية بصلاحيات root. إذا اختُرق التطبيق (حقن أوامر، أو اعتمادية مصابة بثغرة)، يصبح المهاجم root داخل الحاوية، فيستطيع تعديل الشيفرة وتثبيت أدوات، ويكون في موقع أفضل لمحاولة الهروب نحو المضيف. مع USER تعمل العملية الرئيسية بمستخدم نظام بلا صلاحيات، أُنشئ هنا بخيارات -S الخاصة بـ Alpine.
تفصيلتان بخصوص PHP-FPM: بما أن العملية الرئيسية لم تعد root، فلا يمكنها تغيير المستخدم، وتُتجاهل تعليمات user/group الخاصة بالـ pool (مع مجرد تحذير في السجلات). ويجب أيضاً أن تستمع على منفذ أعلى من 1024، وهو حال المنفذ الافتراضي 9000. وأخيراً، يمنح COPY --chown ملكية الملفات لمستخدم التطبيق؛ وإذا لم تكن الشيفرة بحاجة إلى أن تكون قابلة للتعديل، فالأفضل تركها مملوكة لـ root وعدم فتح الكتابة إلا لمجلدات الـ cache والسجلات.
ينطبق مبدأ أدنى الصلاحيات أيضاً أثناء التشغيل. يمنح Docker افتراضياً مجموعة من capabilities الخاصة بـ Linux لا يحتاجها تطبيق ويب تقريباً أبداً. انزعها كلها، وامنع رفع الصلاحيات، واجعل نظام الملفات للقراءة فقط:
services:
app:
image: registry.example.com/myapp:1.4.2
user: "1000:1000"
read_only: true
tmpfs:
- /tmp
- /app/var
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
يمنع الخيار no-new-privileges أي ملف تنفيذي من نوع setuid من رفع صلاحيات العملية. ويمنع وضع read_only الكتابة في كل مكان باستثناء نقاط tmpfs المصرّح بها: هنا /tmp ومجلد var/ الخاص بـ Symfony (الـ cache والسجلات). إذا كان تطبيقك يكتب في مكان آخر فسيفشل عند الإقلاع، وهي طريقة ممتازة لاكتشاف ما يفعله فعلاً. لا تشغّل أبداً حاوية بالخيار --privileged في الإنتاج: فهذا الخيار يعطّل معظم العزل.
حماية socket الخاص بـ Docker
تركيب /var/run/docker.sock داخل حاوية يعني منحها السيطرة الكاملة على المضيف: إذ يمكنها تشغيل حاوية ذات صلاحيات كاملة تركّب نظام الملفات الجذري. احصره في الأدوات النادرة التي تحتاجه فعلاً (وكيل عكسي، وكيل مراقبة)، للقراءة فقط، ويُفضَّل خلف وكيل socket يرشّح الاستدعاءات المسموح بها. وللسبب نفسه، إضافة مستخدم إلى المجموعة docker تعادل منحه صلاحيات root. أما وضع rootless في Docker، حيث تعمل الخدمة نفسها دون صلاحيات، فيقلّص هذا الأثر كثيراً.
فحص الثغرات
أدمج أداة لفحص الثغرات في خط الـ CI الخاص بك:
# With Trivy
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
aquasec/trivy image myapp:latest
# With Docker Scout
docker scout cves myapp:latest
تقارن هذه الأدوات حزم النظام واعتماديات التطبيق (بما فيها composer.lock) بقواعد بيانات الثغرات المعروفة (CVE). ولكي يكون الفحص مفيداً، يجب أن يوقف خط الـ CI عندما يجد ثغرة خطيرة. مع Trivy يمكن ضبط رمز الخروج:
trivy image --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 \
registry.example.com/myapp:1.4.2
يستبعد الخيار --ignore-unfixed الثغرات التي لا يوجد لها إصلاح بعد، حتى لا يتوقف خط الـ CI بسبب تنبيهات لا يمكن معالجتها. افحص أيضاً بشكل دوري الصور العاملة في الإنتاج: تُنشر ثغرات جديدة كل يوم لحزم كانت سليمة لحظة البناء. وأعد بناء صورك بانتظام للحصول على إصلاحات الصورة الأساسية.
الشبكة والعزل
- أنشئ شبكات مخصصة لعزل الخدمات
- لا تكشف أبداً منافذ قواعد البيانات
- استخدم أسرار Docker بدلاً من متغيرات البيئة للبيانات الحساسة
- فعّل وضع القراءة فقط (read-only) للحاويات عديمة الحالة
الشبكة المعرّفة بـ internal: true ليس لها أي وصول إلى الخارج: يمكن لقاعدة البيانات أن تتحاور عليها مع التطبيق، لكن لا يمكن الوصول إليها من الإنترنت ولا يمكنها فتح اتصال صادر. الوكيل العكسي وحده منشور على المضيف:
services:
proxy:
image: nginx:1.27-alpine
ports:
- "443:443"
networks: [frontend]
app:
image: registry.example.com/myapp:1.4.2
networks: [frontend, backend]
db:
image: mysql:8.4
networks: [backend]
networks:
frontend:
backend:
internal: true
تذكّر أن المنافذ التي ينشرها Docker تُفتح عبر قواعد iptables تُقيَّم غالباً قبل قواعد UFW: المنفذ المنشور يمكن الوصول إليه حتى لو بدا أن الجدار الناري يحجبه. إذا كان يجب الوصول إلى خدمة من المضيف فقط، فانشرها على 127.0.0.1. طريقة عمل الشبكات مفصّلة في دليل شبكات Docker.
إدارة الأسرار
services:
app:
secrets:
- db_password
- api_key
secrets:
db_password:
file: ./secrets/db_password.txt
api_key:
external: true
تتسرّب متغيرات البيئة بسهولة: فهي تظهر في docker inspect، وترثها كل العمليات الفرعية، وتنتهي أحياناً في تقارير الأخطاء. أما السر فيُركَّب كملف في /run/secrets/<name>، ولا يمكن قراءته إلا في الحاويات التي تصرّح به. يُقرأ سر من نوع file من المضيف (ويجب بالطبع ألا يُرفع الملف إلى المستودع)؛ أما السر من نوع external فيجب أن يكون موجوداً مسبقاً في العنقود (docker secret create)، وهذا يفترض استخدام Docker Swarm.
تقبل كثير من الصور الرسمية متغيرات تنتهي باللاحقة _FILE (مثل MYSQL_PASSWORD_FILE). ومن جهة Symfony، تقرأ معالجات متغيرات البيئة file وtrim الملف مباشرة:
# config/packages/doctrine.yaml
parameters:
env(DB_PASSWORD_FILE): '/run/secrets/db_password'
doctrine:
dbal:
driver: pdo_mysql
host: db
dbname: app
user: app
password: '%env(trim:file:DB_PASSWORD_FILE)%'
انتبه أيضاً إلى الأسرار اللازمة أثناء البناء (رمز وصول إلى مستودع Composer خاص مثلاً). يبقى ARG أو ENV ظاهراً في سجلّ الصورة. استخدم بدلاً من ذلك تركيب سر عبر BuildKit، والذي لا يُكتب أبداً في أي طبقة:
# syntax=docker/dockerfile:1
FROM composer:2 AS vendor
WORKDIR /app
COPY composer.json composer.lock ./
RUN --mount=type=secret,id=composer_auth,target=/app/auth.json \
composer install --no-dev --no-scripts --prefer-dist
# Build: docker build --secret id=composer_auth,src=$HOME/.composer/auth.json .
صور موثوقة
استخدم فقط الصور الرسمية أو الموثّقة. ثبّت الإصدارات بدقة بدلاً من استخدام latest. وقّع صورك باستخدام Docker Content Trust.
الوسم مثل php:8.3-fpm-alpine متحرك: يشير إلى صورة جديدة مع كل تحديث. لقابلية إعادة إنتاج كاملة، ثبّت الـ digest (FROM php:8.3-fpm-alpine@sha256:…) ودع أداة مثل Renovate أو Dependabot تقترح التحديثات. أما التوقيع، فيعتمد Docker Content Trust على Notary v1 الذي تتخلى عنه Docker تدريجياً؛ ولتوقيع صورك الخاصة، يُعدّ Sigstore Cosign اليوم الخيار الأكثر انتشاراً:
# Find out an image's digest
docker buildx imagetools inspect php:8.3-fpm-alpine
# Sign then verify an image with Cosign
cosign sign --key cosign.key registry.example.com/myapp:1.4.2
cosign verify --key cosign.pub registry.example.com/myapp:1.4.2
وأخيراً، اختر صوراً أساسية مصغّرة (Alpine أو نسخ slim أو صور distroless): كل حزمة غائبة هي ثغرة محتملة أقل.
قائمة التحقق
- صورة أساسية مصغّرة، بإصدار محدد، يُعاد بناؤها بانتظام
- عملية لا تعمل بصلاحيات root، مع نزع الـ capabilities و
no-new-privileges - نظام ملفات للقراءة فقط للحاويات عديمة الحالة
- فحص ثغرات يوقف خط الـ CI
- شبكات منفصلة، وقواعد بيانات غير منشورة
- أسرار على شكل ملفات، لا في الصورة ولا في المستودع أبداً
- لا يُركَّب socket الخاص بـ Docker أبداً في حاوية تطبيق
لا يكفي أيٌّ من هذه الإجراءات وحده، لكن اجتماعها يحوّل اختراق تطبيق ما إلى حادث محصور بدلاً من سيطرة كاملة على الخادم.