الإعدادات
صورة واحدة لعدة بيئات — وما لا تعنيه كلمة «Secret».
ConfigMaps وSecrets
يجب أن يكون بالإمكان نشر صورة الحاوية في بيئة التطوير وبيئة ما قبل الإنتاج وبيئة الإنتاج دون إعادة بنائها. هذا هو المبدأ الذي يبرر هذا الدرس كله، وهو ليس مبدأً نظريًا: فإذا كان عنوان قاعدة البيانات مكتوبًا داخل الصورة، فإن الحزمة (artifact) التي تختبرونها ليست هي التي تسلّمونها، ولم يعد الاختبار يثبت شيئًا.
أما النصف الآخر من المشكلة فهو أشد إزعاجًا. فكلمة المرور الموضوعة في صورة تبقى فيها إلى الأبد: الطبقات غير قابلة للتعديل، وحذفها في طبقة لاحقة لا يزيلها من السجل التاريخي، ويستطيع كل من يملك صلاحية الوصول إلى المستودع (registry) قراءتها. السر الذي يُسلَّم داخل صورة ليس سرًا، بل هو تسريب مؤجَّل.
لذلك يفصل Kubernetes بين كائنين لاستخدامين مختلفين: ConfigMap تحمل الإعدادات غير الحساسة، وSecret تحمل البيانات السرية. ويُحقن الاثنان في الحاويات بالطريقة نفسها — متغيرات بيئة أو ملفات مُركَّبة — لكن معاملة العنقود (cluster) لهما تختلف.
وهذا تنبيه يجب إيراده فورًا قبل أي صياغة، لأنه أكثر سوء فهم شيوعًا في هذا النظام البيئي: كائن Secret في Kubernetes ليس مشفَّرًا افتراضيًا. إنه مُرمَّز بصيغة base64، وهي صيغة نقل وليست حماية. سنرى بالضبط ما يترتب على ذلك، وما ينبغي إضافته لتستحق كلمة «secret» اسمها.
ConfigMap
الـ ConfigMap قاموس من أزواج مفتاح-قيمة، أو مجموعة من الملفات، مخزَّن في العنقود.
apiVersion: v1
kind: ConfigMap
metadata:
name: app-config
namespace: atelier
data:
# قيم بسيطة: مفتاح واحد، سلسلة نصية
APP_ENV: "production"
LOG_LEVEL: "info"
API_TIMEOUT: "30"
FEATURE_NOUVEAU_PANIER: "true"
# ملف كامل: يصبح المفتاح اسم الملف عند التركيب كوحدة (volume)
application.properties: |
server.port=8080
spring.jpa.show-sql=false
logging.level.root=INFO
nginx.conf: |
server {
listen 80;
location /api/ {
proxy_pass http://api:8080/;
}
}
هناك تفصيلان في الصياغة يكلّفان وقتًا عند تجاهلهما. أولًا، جميع قيم data سلاسل نصية: فـ API_TIMEOUT: 30 بلا علامات اقتباس يرفضه المخطط (schema)، وtrue بلا علامات اقتباس تُفسَّر على أنها قيمة منطقية في YAML. اعتادوا وضع علامات الاقتباس دائمًا. ثانيًا، الرمز | في YAML يحافظ على فواصل الأسطر، وهذا يتيح تضمين ملف إعدادات كما هو.
الصيغ الأمرية (imperative) مفيدة للاستكشاف:
# انطلاقًا من قيم حرفية
kubectl create configmap app-config \
--from-literal=APP_ENV=production \
--from-literal=LOG_LEVEL=info
# انطلاقًا من ملف: المفتاح هو اسم الملف
kubectl create configmap nginx-config --from-file=nginx.conf
# انطلاقًا من مجلد كامل: مفتاح لكل ملف
kubectl create configmap tous-configs --from-file=./config/
# انطلاقًا من ملف .env: مفتاح لكل سطر
kubectl create configmap app-config --from-env-file=.env
kubectl get configmaps
kubectl describe configmap app-config # يعرض القيم بنص صريح
kubectl get configmap app-config -o yaml
Secret
بنيته مطابقة تقريبًا، مع حقل إضافي وترميز متوقَّع.
apiVersion: v1
kind: Secret
metadata:
name: db-credentials
namespace: atelier
type: Opaque
stringData:
# stringData: تكتبون بنص صريح، ويقوم Kubernetes بالترميز عند الحفظ
DB_USER: "app_user"
DB_PASSWORD: "S3cr3t-a-changer"
DATABASE_URL: "postgresql://app_user:S3cr3t-a-changer@postgres:5432/appdb"
يكاد stringData يكون دائمًا الخيار الصحيح للملف الذي يُكتب يدويًا: فهو يجنّبكم الترميز بأنفسكم. أما الحقل data فيتوقع قيمًا مُرمَّزة مسبقًا بصيغة base64:
apiVersion: v1
kind: Secret
metadata:
name: db-credentials
type: Opaque
data:
DB_USER: YXBwX3VzZXI= # echo -n 'app_user' | base64
DB_PASSWORD: UzNjcjN0LWEtY2hhbmdlcg==
# الإنشاء بالأسلوب الأمري: يتم الترميز نيابةً عنكم
kubectl create secret generic db-credentials \
--from-literal=DB_USER=app_user \
--from-literal=DB_PASSWORD='S3cr3t-a-changer'
# انطلاقًا من ملف (شهادة، مفتاح خاص، ملف بيانات اعتماد)
kubectl create secret generic tls-keys --from-file=./server.key
# Secret مخصص للمستودعات الخاصة، لاستخدامه مع imagePullSecrets
kubectl create secret docker-registry registre-prive \
--docker-server=registry.example.com \
--docker-username=deploiement \
--docker-password='jeton-du-registre'
أكثر أنواع Secret استخدامًا:
type | الاستخدام |
|---|---|
Opaque | بيانات اعتباطية: الحالة العامة |
kubernetes.io/dockerconfigjson | المصادقة على مستودع صور خاص |
kubernetes.io/tls | شهادة ومفتاح خاص، تستهلكهما كائنات Ingress |
kubernetes.io/service-account-token | رمز (token) حساب خدمة |
ما الذي يحميه «Secret» فعلًا
إليكم التجربة التي ينبغي إجراؤها مرة واحدة كي لا تُنسى أبدًا:
# المحتوى «المحمي»
kubectl get secret db-credentials -o jsonpath='{.data.DB_PASSWORD}' | base64 -d
# S3cr3t-a-changer
لا كلمة مرور، ولا مفتاح، ولا فك تشفير. لم يكن base64 يومًا آلية أمان — فهو يُستخدم لنقل البايتات الثنائية داخل حقل نصي. وهذا يعني عمليًا ما يلي:
- كل من يُسمح له بقراءة Secrets في namespace ما يقرأ جميع كلمات المرور في ذلك الـ namespace بنص صريح.
- كل Secret يُدرَج في Git هو سر مكشوف، حتى لو كان مُرمَّزًا بـ base64.
- افتراضيًا، تُخزَّن Secrets بنص صريح في etcd. ونسخة احتياطية من etcd غير محمية جيدًا تكشف كل شيء.
ما يجعل Secret سريًّا فعلًا هو ثلاثة إجراءات تُضاف:
- RBAC. هي الحماية الوحيدة التي تعمل افتراضيًا: تقييد من يستطيع قراءة Secrets. لا يكون Secret سريًّا إلا إذا كانت صلاحيات القراءة سرية.
- التشفير أثناء التخزين (encryption at rest). يُفعَّل على مستوى خادم API عبر
EncryptionConfiguration، بمفتاح محلي أو بخدمة إدارة مفاتيح (KMS). وهو غير مفعَّل تلقائيًا في عنقود تديرونه بأنفسكم؛ أما العروض المُدارة (managed) فتوفّره غالبًا، ويجب التحقق من ذلك في كل حالة. - مدير أسرار خارجي. مثل HashiCorp Vault وAWS Secrets Manager وGoogle Secret Manager، مع مشغِّل (operator) يزامن الأسرار إلى العنقود. عندها تصبح عملية تدوير الأسرار (rotation) ممكنة ويتوفر التدقيق (audit)، وهو ما لا يقدمه Kubernetes وحده.
وبالمقارنة مع متغير بيئة مكتوب في ملف Deployment، يقدّم Secret مع ذلك مكسبين حقيقيين: لا تظهر القيمة في kubectl describe deployment، ويمكن إدارتها بمعزل عن النشر. إنه تقدّم، وليس ضمانًا.
الحقن عبر متغيرات البيئة
الطريقة الأكثر مباشرة، مفتاحًا بمفتاح:
apiVersion: apps/v1
kind: Deployment
metadata:
name: api
spec:
replicas: 2
selector:
matchLabels:
app: api
template:
metadata:
labels:
app: api
spec:
containers:
- name: api
image: mon-api:1.0.0
ports:
- containerPort: 8080
env:
# مفتاح من ConfigMap إلى متغير، مع إمكانية إعادة التسمية
- name: ENVIRONNEMENT
valueFrom:
configMapKeyRef:
name: app-config
key: APP_ENV
# مفتاح من Secret إلى متغير
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: db-credentials
key: DB_PASSWORD
# قيمة حرفية، لما ليس مشتركًا ولا حساسًا
- name: PORT
value: "8080"
لاستيراد كل شيء دفعة واحدة دون سرد المفاتيح:
envFrom:
- configMapRef:
name: app-config
- secretRef:
name: db-credentials
يتميز envFrom بالإيجاز، لكن له مقابلًا ينبغي تقديره: تصبح أسماء المتغيرات ضمنية. فعند قراءة ملف Deployment لم يعد بالإمكان معرفة المتغيرات التي يتلقاها التطبيق. وبالنسبة إلى تطبيق لا تتحكمون في شيفرته، يبقى السرد الصريح أفضل.
وأخيرًا أضيفوا optional: true حين يكون غياب مفتاح ما أمرًا مشروعًا، وإلا بقي الـ Pod عالقًا في الحالة CreateContainerConfigError:
- name: FONCTION_EXPERIMENTALE
valueFrom:
configMapKeyRef:
name: app-config
key: FEATURE_NOUVEAU_PANIER
optional: true
الحد الأساسي لمتغيرات البيئة
إنه حد بنيوي: متغيرات البيئة تُثبَّت عند بدء العملية (process). فتعديل ConfigMap لا يغيّر شيئًا في الـ Pods التي تعمل بالفعل، ولا يطلق أي إعادة نشر. وعندها تحصلون على عنقود تقول فيه ConfigMap شيئًا ويفعل التطبيق شيئًا آخر — تناقض صامت يصعب جدًا اكتشافه.
هناك حلّان ممكنان بحسب الحاجة.
إجبار إعادة النشر عند كل تغيير، بجعل القالب (template) يعتمد على بصمة للإعدادات:
metadata:
labels:
app: api
annotations:
# يُعاد حسابه عند كل تسليم: أي تعديل في القالب
# يطلق تحديثًا تدريجيًا للـ Pods.
checksum/config: "9f2b7c1e4a"
أو، بأسلوب أبسط في التشغيل اليومي:
# إعادة تشغيل تدريجية للـ Deployment دون تغيير الصورة
kubectl rollout restart deployment/api
الحقن عبر الوحدات (volumes)
تركيب ConfigMap كمجلد يعطي ملفًا لكل مفتاح. وهو الأسلوب الإلزامي لملف إعدادات كامل، والوحيد الذي يسمح بالتحديث دون إعادة تشغيل.
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
spec:
replicas: 2
selector:
matchLabels:
app: web
template:
metadata:
labels:
app: web
spec:
containers:
- name: nginx
image: nginx:1.27-alpine
ports:
- containerPort: 80
volumeMounts:
# جميع مفاتيح ConfigMap تصبح ملفات هنا
- name: config-volume
mountPath: /etc/app/config
readOnly: true
# Secret مُركَّب: أذونات مقيِّدة، ولا يظهر أبدًا في سجل (log)
- name: secrets-volume
mountPath: /etc/app/secrets
readOnly: true
volumes:
- name: config-volume
configMap:
name: app-config
items:
# بدون «items» تُركَّب كل المفاتيح.
# ومعه نختار بدقة، ونعيد التسمية.
- key: nginx.conf
path: default.conf
- name: secrets-volume
secret:
secretName: db-credentials
defaultMode: 0400 # قراءة فقط للمالك
مع هذا الملف، ترى الحاوية الملف /etc/app/config/default.conf الذي يحتوي قيمة المفتاح nginx.conf. وإعادة التسمية عبر items هي ما يتيح وضع ملف في المكان الذي ينتظره التطبيق دون التفافات.
ولاستبدال ملف واحد في مجلد موجود دون إخفاء بقية محتواه، يكون subPath هو المفتاح:
volumeMounts:
- name: config-volume
mountPath: /etc/nginx/conf.d/default.conf
subPath: default.conf # يستبدل ملفًا واحدًا، لا المجلد
بدون subPath، فإن التركيب على /etc/nginx/conf.d/ سيُخفي كل المحتوى الأصلي للمجلد. وهذا سبب كلاسيكي لرفض حاوية البدء بعد إضافة إعدادات.
التحديث الفوري، بشروطه
ConfigMaps المُركَّبة كوحدات تُحدَّث فعلًا داخل الحاوية العاملة، دون إعادة تشغيل. وثمة ثلاثة تحفظات ينبغي معرفتها:
- يستغرق الانتشار حتى نحو دقيقة، بحسب فترة المزامنة لدى kubelet.
- يجب أن يعرف التطبيق كيف يعيد تحميل ملفه. يحتاج nginx إلى
nginx -s reload، أما تطبيق Spring Boot فلا يعيد قراءة خصائصه من تلقاء نفسه. يتغير الملف، ولا يتغير السلوك. - التركيب باستخدام
subPathلا يُحدَّث أبدًا. وهذه هي المفاضلة بالضبط في هذا الخيار: دقة التركيب مقابل فقدان إعادة التحميل.
# تعديل ConfigMap في مكانها ثم ملاحظة الملف داخل الـ Pod
kubectl edit configmap app-config
kubectl exec -it deploy/web -- cat /etc/app/config/default.conf
متغيرات البيئة أم الوحدة: أيهما نختار
| المعيار | متغيرات البيئة | الوحدة (Volume) |
|---|---|---|
| قيم بسيطة وقصيرة | مناسبة | مطوَّلة |
| ملف إعدادات كامل | غير مناسبة | الخيار الوحيد |
| التحديث دون إعادة تشغيل | مستحيل | ممكن، مع تحفظات |
الظهور في /proc وسجلات الانهيار | مكشوفة | مقتصر على قارئي الملف |
مخرجات kubectl describe pod | أسماء المتغيرات ظاهرة | مسار التركيب فقط |
بالنسبة إلى الأسرار، تكون الوحدة أكثر أمانًا، ولسبب ملموس يمكنكم التحقق منه بأنفسكم:
# بيئة العملية الرئيسية قابلة للقراءة في /proc.
kubectl exec -it deploy/api -- sh -c 'tr "\0" "\n" < /proc/1/environ | grep DB_'
# DB_PASSWORD=S3cr3t-a-changer
كما تظهر متغيرات البيئة في تتبعات الاستثناءات (exception traces) وفي تقارير الانهيار، وترثها كل عملية فرعية. أما الملف المُركَّب بالأذونات 0400 فمساحة انكشافه أضيق بكثير.
ConfigMaps غير القابلة للتعديل (immutable)
وسم ConfigMap أو Secret بأنه غير قابل للتعديل يمنع أي تعديل لاحق، ويخفف في الوقت نفسه عبء المراقبة على خادم API في العناقيد الكبيرة.
apiVersion: v1
kind: ConfigMap
metadata:
name: app-config-v3
immutable: true
data:
APP_ENV: "production"
LOG_LEVEL: "warn"
وهذا يفرض انضباطًا مفيدًا: فبدلًا من تعديل app-config، تنشئون app-config-v4 وتجعلون الـ Deployment يشير إليها. فيصبح تغيير الإعدادات تعديلًا مُصدَّرًا (versioned) في الـ Deployment، وبالتالي ظاهرًا في kubectl rollout history وقابلًا للتراجع بواسطة kubectl rollout undo. أما ConfigMap المعدَّلة في مكانها فلا تترك أي أثر يمكن الاستفادة منه.
الخلاصة
- يجب أن تكون الصورة مستقلة عن بيئتها. تحمل ConfigMap الإعدادات، ويحمل Secret البيانات الحساسة، ويمكن حقن كليهما عبر متغيرات البيئة أو عبر وحدة تخزين (volume).
- الـ Secret ليس مشفّرًا: فهو مُرمَّز بصيغة base64، ويمكن قراءته بأمر
base64 -d، ويُخزَّن افتراضيًا كنص صريح في etcd. ما يحميه فعليًا هو RBAC، والتشفير أثناء التخزين (encryption at rest) المُهيَّأ على جانب خادم API، ومدير أسرار خارجي. - متغيرات البيئة تُثبَّت عند بدء التشغيل: تعديل ConfigMap لا يغيّر شيئًا في Pods الجارية. أجبر على التحديث عبر annotation تحتوي على checksum أو عبر
kubectl rollout restart. - وحدات التخزين (volumes) تُحدَّث مباشرة دون إعادة تشغيل (بفارق دقيقة تقريبًا)، بشرط أن يعيد التطبيق تحميل ملفه، ولا تُحدَّث أبدًا عند استخدام
subPathالذي يجمّد نقطة الربط. - فضّل استخدام volume للأسرار: فمتغيرات البيئة تظهر في
/procوفي تتبعات الأخطاء وعند جميع العمليات الفرعية.
نصيحة: أعطِ ConfigMaps إصدارات (
app-config-v3) واجعلها غير قابلة للتغيير (immutable). عندئذٍ يصبح تغيير الإعدادات مراجعة (revision) جديدة للـ Deployment، وبالتالي قابلًا للتتبع والتراجع عنه مثل أي عملية نشر أخرى.
التعليقات
التعليقات مدعومة عبر GitHub Discussions
سجّل الدخول باستخدام GitHub للمشاركة في النقاش