Playbook أم Role في Ansible: متى تستخدم كلًّا منهما؟
تعرّف إلى متى يكفي Playbook مسطّح، ومتى تحتاج Role ببنيته ذات المجلدات السبعة، مع ansible-galaxy init واستدعاء الأدوار وأسبقية المتغيرات.
الـplaybook مقابل role في Ansible: ما الفرق بينهما؟
ملف Ansible playbook هو الملف الذي تشغّله باستخدام ansible-playbook. وهو يربط مجموعة من المضيفين بالمهام التي تحتاج إلى تنفيذها عليها. أما Ansible role فهو مجلد ذو بنية ثابتة يحتوي على المهام والقوالب وhandlers والمتغيرات الافتراضية، ويستدعيه playbook باسمه. صياغة المهام داخل كل منهما متطابقة، لذا لا يتعلق الأمر بما يمكنك التعبير عنه. بل يتعلق بإمكانية إعادة الاستخدام.
ابدأ بـplaybook مسطّح. يُعد ملف site.yml واحد يحتوي على قائمة tasks: الشكل المناسب لأول عملية أتمتة تنفذها، ويظل مناسباً لفترة أطول مما يتوقع معظم الأشخاص. حوّل المهام إلى role عندما تحتاج كتلة المهام نفسها إلى التنفيذ على مجموعة ثانية من المضيفين، أو عندما يتجاوز الملف نحو 100 سطر ولا تعود قادراً على العثور على مهمة بمجرد التمرير.
إذا لم تكتب واحداً بعد، ابدأ بإنشاء playbook أول مقابل VPS واحد ثم عُد عندما يبدأ حجمه بالازدياد.
عندما يكون الـplaybook المسطّح هو الخيار الصحيح
يكون الـplaybook المسطّح مناسباً عندما تُنفَّذ المهمة مرة واحدة، أو على خادم واحد، أو عندما لا يقرأه أي شخص آخر. لا يحتاج تجهيز خادم تطبيق واحد أو تثبيت التحديثات على خادم قبل نافذة صيانة إلى شجرة مجلدات. يضيف الـrole سبعة مجلدات وطبقة من الإحالة غير المباشرة. إذا كان المستدعي الوحيد هو الـplaybook الموجود بجانبه، فلن تفيدك هذه الإحالة غير المباشرة، بل ستضطرك إلى الانتقال بين الملفات في كل مرة تريد فيها قراءة ما يُنفَّذ فعلياً.
يتوقف الـplaybook المسطّح عن كونه الخيار الصحيح في لحظة محددة، ومن السهل ملاحظتها. تنسخ مجموعة من المهام إلى playbook ثانٍ. هذه النسخة هي الإشارة. منذ ذلك الحين، يجب تطبيق كل إصلاح مرتين، إلى أن يأتي يوم يُطبَّق فيه مرة واحدة فقط.
ما الذي يحتويه دليل الدور فعلياً
roles/common/
defaults/main.yml
vars/main.yml
tasks/main.yml
handlers/main.yml
templates/99-hardening.conf.j2
files/
meta/main.ymltasks/main.ymlهو نقطة الدخول. يشغّل Ansible هذا الملف عند استدعاء الدور، وجميع الأدلة الأخرى اختيارية.defaults/main.ymlيحتوي على المتغيرات التي يُتوقع من المستدعي تجاوزها. وهو مصدر المتغيرات ذي الأولوية الأدنى في Ansible، لذلك تتغلب عليه تقريباً أي مصادر أخرى.vars/main.ymlيحتوي على المتغيرات التي لا يُتوقع من المستدعي تجاوزها. وتكون أولويته أعلى من أولوية الجرد، وهذا تصريح قوي. استخدمه نادراً.handlers/main.ymlيحتوي على المهام التي يفعّلهاnotify. يعمل المعالج في نهاية play، ومرة واحدة، بغض النظر عن عدد المهام التي أبلغته.files/يحتوي على الملفات التي تنسخها وحدةcopyكما هي، بينما يحتويtemplates/على قوالب Jinja2 التي تعرضها وحدةtemplate. داخل الدور، تشير إلى كليهما باسم الملف المجرد من دون مسار، لأن Ansible يبحث أولاً في أدلة الدور نفسه.meta/main.ymlيعرّف تبعيات الدور والبيانات الوصفية التي يقرأها Ansible Galaxy.
هذا التخطيط ليس تفضيلاً أسلوبياً. يبحث Ansible في هذه المسارات المحددة، لذلك لا يعثر مطلقاً على قالب تضعه في roles/common/template/ (بصيغة المفرد).
إنشاء الدور المشترك باستخدام ansible-galaxy init
mkdir -p ~/infra/roles
cd ~/infra
ansible-galaxy init --init-path roles commonيكتب ذلك الهيكل الكامل ضمن roles/common، بما في ذلك أدلة لن تستخدمها وملفات main.yml أولية لا تحتوي إلا على ---. احذف العناصر التي ستتركها فارغة. وجود vars/main.yml فارغ لا يسبب مشكلة لـAnsible، لكنه يخفي الملفات المهمة فعلياً في الدور.
املأ الآن الملفات التي تنفذ العمل. ابدأ بالقيم الافتراضية، لأنها الواجهة العامة للدور.
# roles/common/defaults/main.yml
---
common_packages:
- ufw
- fail2ban
- unattended-upgrades
common_admin_group: admins
common_permit_root_login: "no"
common_password_authentication: "no"ضع "no" و"yes" بين علامتي اقتباس. يحلل Ansible لغة YAML باستخدام PyYAML، الذي يقرأ no غير الموضوع بين علامتي اقتباس على أنه القيمة المنطقية false، ولذلك يصبح سطر الإعداد الناتج PermitRootLogin False، ويرفضه sshd. تحافظ علامات الاقتباس على القيمة كسلسلة نصية.
# roles/common/tasks/main.yml
---
- name: Install the base packages
ansible.builtin.apt:
name: "{{ common_packages }}"
state: present
update_cache: true
cache_valid_time: 3600
- name: Create the admin group
ansible.builtin.group:
name: "{{ common_admin_group }}"
state: present
- name: Install the sshd hardening drop-in
ansible.builtin.template:
src: 99-hardening.conf.j2
dest: /etc/ssh/sshd_config.d/99-hardening.conf
owner: root
group: root
mode: "0644"
validate: /usr/sbin/sshd -t -f %s
notify: Restart sshd# roles/common/handlers/main.yml
---
- name: Restart sshd
ansible.builtin.service:
name: ssh
state: restarted# roles/common/templates/99-hardening.conf.j2
# Managed by Ansible. Local edits are overwritten on the next run.
PermitRootLogin {{ common_permit_root_login }}
PasswordAuthentication {{ common_password_authentication }}في Debian وUbuntu، تُسمى وحدة systemd ssh، بينما تُسمى في أنظمة عائلة RHEL sshd. يفشل المعالج الذي يذكر الاسم الخطأ فقط عند حدوث تغيير فعلي في القالب، ولذلك لا تظهر المشكلة عادةً إلا بعد أسابيع.
يُعد السطر validate أهم جزء في هذه المهمة. يعرض Ansible القالب في ملف مؤقت، ويستبدل %s بمسار ذلك الملف، ثم ينفذ الأمر. لا يُستبدل الملف الهدف إلا إذا انتهى الأمر بالرمز 0. ضع توجيهاً غير صالح في القالب وشغّل المهمة مرة أخرى: تفشل المهمة مع failed to validate، ويبقى /etc/ssh/sshd_config.d/99-hardening.conf الفعلي دون تغيير، ويظل بإمكانك تسجيل الدخول إلى الخادم. انتبه إلى أن الفحص يختبر أكثر من صحة بناء الجملة. إذا تعذر على sshd -t قراءة مفاتيح المضيف، فسينتهي بالرمز sshd: no hostkeys available -- exiting.، وسيبلغ Ansible عن failed to validate نفسه. لذلك اقرأ msg الخاص بالوحدة قبل أن تلقي اللوم على القالب.
كيفية استدعاء دور من playbook
# site.yml
---
- name: Base configuration for every server
hosts: all
become: true
roles:
- common# inventory.ini
[local]
localhost ansible_connection=localansible-playbook -i inventory.ini site.ymlيجب أن تنتهي الـplay بـfailed=0 في الملخص. مرّر المعلمات في موضع الاستدعاء باستخدام الصيغة الموسعة. بهذه الطريقة يخدم الدور نفسه مجموعتين من المضيفين:
roles:
- role: common
common_admin_group: ops
common_permit_root_login: prohibit-passwordهناك قاعدة ترتيب تفاجئ معظم المستخدمين. يمكن أن تحتوي الـplay على pre_tasks وroles وtasks وpost_tasks، ويشغّلها Ansible بهذا الترتيب مهما كان ترتيب كتابتها في الملف. ضع tasks: فوق roles:، وستظل الأدوار تعمل أولاً. لذلك، إذا كان يجب تنفيذ شيء قبل أحد الأدوار، فضعه في pre_tasks:، وليس في بداية tasks:.
- name: Ordering demonstration
hosts: local
gather_facts: false
pre_tasks:
- name: Runs first
ansible.builtin.debug:
msg: pre
roles:
- common
tasks:
- name: Runs after the role
ansible.builtin.debug:
msg: task
post_tasks:
- name: Runs last
ansible.builtin.debug:
msg: postلاستدعاء دور من داخل قائمة مهام بدلاً من المفتاح roles:، استخدم import_role أو include_role.
tasks:
- name: Static, read when the playbook is parsed
ansible.builtin.import_role:
name: common
- name: Dynamic, resolved when the task runs
ansible.builtin.include_role:
name: postgres
when: "'db' in group_names"إنّ import_role ثابت. يقرأ Ansible الدور في وقت التحليل، وتصبح مهامه جزءاً من الـplay، لذلك يعرض ansible-playbook --list-tasks site.yml هذه المهام، وتطبَّق العلامة الموجودة على الاستيراد على كل مهمة داخله. أمّا include_role فهو ديناميكي. لا يقرأ Ansible شيئاً حتى تُنفَّذ المهمة، وهذا ما يتيح لك تحديد اسم الدور من متغير أو من حلقة. لكن هذه المهام لا تظهر في --list-tasks ولا في --start-at-task.
توجد هنا نقطة شائعة تسبب الأخطاء. يُقيَّم when: في مهمة include_role قبل أن يصبح defaults/main.yml الخاص بالدور المُضمَّن ضمن النطاق. إذا كتبت when: common_packages | length > 0 في عملية التضمين، يتوقف التنفيذ مع 'common_packages' is undefined، رغم أن هذا المتغير معرّف داخل الدور الذي تُضمّنه نفسه. الحل هو نقل مفتاح التفعيل خارج الدور: ضعه في group_vars/all.yml، حيث يكون متاحاً ضمن النطاق في كل المواضع، واترك القيم الافتراضية للدور للقيم التي يستخدمها الدور نفسه.
أي متغيّر تكون له الأولوية: الإعدادات الافتراضية، أم roles/<name>/defaults/main.yml، أم group_vars/، أم host_vars/؟
تُوثّق Ansible أكثر من عشرين مستوى لأولوية المتغيّرات. أربعة منها تحسم معظم الحالات العملية، وهي مرتبة هنا من الأضعف إلى الأقوى.
- يوجد
roles/<name>/defaults/main.ymlبالقرب من أسفل الترتيب. تتغلب عليه تقريباً أي قيمة تضبطها في موضع آخر، ولذلك فهو المكان المناسب لإعدادات الدور القابلة للضبط. - يقع
group_vars/وhost_vars/في الوسط. هنا تضع القيم الخاصة بموقعك، وهما يتغلبان بوضوح على الإعدادات الافتراضية للدور. - يأتي
roles/<name>/vars/main.ymlفوقhost_vars. لا يمكن تجاوز قيمة تضعها هنا من خلال الجرد. احجزها للقيم التي يجب أن يحافظ الدور على اتساقها الداخلي، مثل اسم حزمة يجب أن يطابق اسم خدمة. - تتغلب معلمة الدور التي تمررها عند استدعائه على
vars/main.yml، كما تتغلب-eفي سطر الأوامر على كل شيء، بما في ذلك معلمات الدور.
يمكنك رؤية كيفية حسم هذه الأولويات خلال نحو دقيقة. امنح دوراً صغيراً قيمة افتراضية واحدة ومتغيّر دور واحداً، ثم اضبط الاسمين نفسيهما في host_vars.
# roles/prec/defaults/main.yml
---
prec_tunable: from-defaults
prec_internal: from-defaults# roles/prec/vars/main.yml
---
prec_internal: from-rolevars# host_vars/localhost.yml
---
prec_tunable: from-hostvars
prec_internal: from-hostvars# roles/prec/tasks/main.yml
---
- name: Show which value survived
ansible.builtin.debug:
msg: "tunable={{ prec_tunable }} internal={{ prec_internal }}"ansible-playbook -i inventory.ini prec.yml
ansible-playbook -i inventory.ini prec.yml -e prec_internal=from-cliتطبع عملية التشغيل الأولى tunable=from-hostvars internal=from-rolevars. فقد تغلب الجرد على الإعداد الافتراضي للدور، لكنه خسر أمام متغيّر الدور. وتطبع عملية التشغيل الثانية internal=from-cli، لأن المتغيّرات الإضافية تقع في أعلى الترتيب، ولا يمكن لأي مستوى أدنى تجاوزها. ولهذا السبب يكون -e مناسباً لعملية تشغيل لمرة واحدة، لكنه غير مناسب في script تحتفظ به: إذ يتغلب بصمت على كل قرار ذي صلة في مستودعك.
القاعدة العملية: إذا أردت أن تكون القيمة قابلة للضبط، فضعها في defaults/. ووضعها في vars/ يخبر كل مستخدم لاحق للدور بأن الجرد لا يمكنه تغييرها. قد يكون هذا مقصوداً أحياناً، لكنه يكون في الغالب خطأً غير مقصود.
أثبت أن الدور idempotent: شغّله مرتين
ينتج تشغيل Ansible الذي يمكن الوثوق به النتيجة نفسها في المرة الثانية، ويُبلغ عن عدم تغيير أي شيء. شغّل playbook مرتين واقرأ الملخص.
ansible-playbook -i inventory.ini site.yml
ansible-playbook -i inventory.ini site.ymlيجب أن يبدو الملخص الثاني كما يلي:
PLAY RECAP *********************************************************************
localhost : ok=4 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0يعني changed=0 أن كل module فحص الحالة الحالية ووجد أن العمل قد أُنجز مسبقاً. ويعني changed=2 في التشغيل الثاني أن مهمتين لا تستطيعان تمييز الحالة، ولذلك ستواصلان إعادة كتابة الملفات وإعادة تشغيل الخدمات إلى ما لا نهاية. والسبب المعتاد هو command أو shell، لأن Ansible لا يملك طريقة لمعرفة ما نفّذه أمر عشوائي.
# traps.yml
---
- name: Command modules do not know what they changed
hosts: local
gather_facts: false
tasks:
- name: This appends a line on every run
ansible.builtin.shell: "echo run >> /tmp/grow.txt"
- name: This appends a line only once
ansible.builtin.shell: "echo run >> /tmp/guarded.txt"
args:
creates: /tmp/guarded.txtشغّل ذلك playbook مرتين، ثم عدّ الأسطر التي تحتوي على wc -l /tmp/grow.txt /tmp/guarded.txt. يحتوي /tmp/grow.txt على سطرين، ويحتوي /tmp/guarded.txt على سطر واحد. في التشغيل الثاني، لم تُنفّذ المهمة المحمية إطلاقاً، وتحمل نتيجتها الرسالة skipped, since /tmp/guarded.txt exists، لأن creates يزوّد module بمنتج ظاهر يبحث عنه أولاً. عندما لا يترك الأمر هذا المنتج، سجّل ناتجه واتخذ القرار بنفسك باستخدام changed_when.
يتنبأ ansible-playbook --check --diff site.yml بالتغييرات من دون تنفيذها، ويطبع --diff الأسطر الدقيقة التي سيعيد template كتابتها. اقرأ الناتج مع مراعاة قيد واحد: يتم تخطي مهمتَي shell وcommand في check mode، لذلك قد يخفي المخطط الذي يبدو نظيفاً عملاً ما زال مطلوباً.
يستحق عمود آخر في ذلك الملخص القدر نفسه من الانتباه: يُحتسب المضيف الذي تعذر على Ansible الاتصال به ضمن unreachable بدلاً من failed، ولم تُنفّذ أي من مهامه، لذلك حدّد مسبقاً ما إذا كان يجب لمضيف واحد يتعذر الوصول إليه إيقاف التشغيل بالكامل قبل توجيه هذا الدور إلى أكثر من جهازين.
لماذا يقول Ansible إن الدور غير موجود
يبحث Ansible عن مجلد roles/ بجوار ملف playbook، ثم في roles_path. ويتبع البحث مسار playbook، وليس الصدفة التي شغّلت منها الأمر.
ERROR! the role 'common' was not found in /home/deploy/lonely/roles:/home/deploy/lonelyتعني هذه الرسالة أن site.yml وroles/ لم يعودا متوافقين، وتطبع المسارات التي حاول استخدامها. ضع الاثنين في الدليل نفسه. لا بأس من التشغيل من دليل أب، لأن مسار playbook هو العامل المحدد:
ansible-playbook -i infra/inventory.ini infra/site.ymlتوجد نسخة أقل وضوحاً من المشكلة نفسها. يتجاهل Ansible ملف ansible.cfg في الدليل الحالي عندما يكون هذا الدليل قابلاً للكتابة من جميع المستخدمين، لأن أي مستخدم على الخادم يمكنه وضع إعدادات فيه وتغيير سلوك التشغيل.
[WARNING]: Ansible is being run in a world writable directory (/tmp/infra), ignoring it as an ansible.cfg source.عندئذٍ تصبح إعدادات roles_path وinventory غائبة بصمت، ويفشل البحث عن الدور لسبب لا علاقة له بالأدوار. يطبع ansible --version ملف config file الذي حمّله فعلياً، بينما يطبع ansible-config dump --only-changed كل إعداد يختلف عن القيم الافتراضية المضمّنة. تحقّق من كليهما كلما تصرّف التشغيل كما لو أن إعداداتك غير موجودة.
مشاركة الأدوار: requirements.yml وإصدار مثبت
يُثبَّت الدور الذي كتبه شخص آخر، ولا يُنسخ. صرّح عنه مرة واحدة:
# requirements.yml
---
roles:
- name: postgres
src: https://github.com/example/ansible-role-postgres
scm: git
version: v1.4.0ansible-galaxy install -r requirements.yml -p galaxy_rolesاضبط version دائماً. من دونه، ستحصل على محتوى الفرع الافتراضي في يوم تنفيذ الأمر. لذلك قد يفشل نشر نجح الشهر الماضي من دون أي تغيير في مستودعك نفسه. وجّه roles_path إلى دليل التنزيل، وأبقِ هذا الدليل خارج git:
# ansible.cfg
[defaults]
inventory = inventory.ini
roles_path = ./galaxy_rolesتظل الأدوار الموجودة في roles/ بجانب ملف playbook قابلة للعثور عليها، لأن هذا المسار يُبحث فيه دائماً بالإضافة إلى roles_path. لذلك تبقى أدوارك الخاصة محفوظة في المستودع وخاضعة للمراجعة، بينما تكون أدوار الجهات الخارجية تنزيلات قابلة لإعادة الإنتاج ومثبتة على tag.
متى لا يعود الدور هو الحل
الدور هو وحدة لإعادة الاستخدام ضمن تنفيذ واحد لـAnsible. لا ينشئ خوادم أو سجلات DNS لدى مزود الخدمة، ومحاولة استخدامه لهذا الغرض هي ما يحوّل ملفات playbook إلى شيء لا يرغب أحد في صيانته. من المفيد قراءة تقسيم العمل بين Ansible وTerraform قبل أن تبدأ. لا يحل الدور أيضاً محل تصميم inventory: بعد تجاوز عدد محدود من الأجهزة، تصبح طريقة تجميع تلك الخوادم والوصول إليها أهم من طريقة توزيع المهام على الملفات.
تستحق إجراءات التقوية التي يثبّتها هذا الدور common قرارات مستقلة أيضاً. يضبط ملف drop-in أعلاه توجيهين فقط ولا يزيد عليهما، لذلك اقرأ إعدادات SSH التي تستحق التغيير فعلاً وكيفية جعل Ubuntu يطبّق تحديثات الأمان تلقائياً قبل أن تقرر ما يجب أن يندرج في الدور لكل مضيف تملكه.
FAQ
متى أحوّل Ansible playbook إلى role؟
عندما تحتاج الكتلة نفسها من المهام إلى التنفيذ في play ثانية أو على مجموعة ثانية من المضيفين. يُعد نسخ المهام بين ملفات playbook إشارة واضحة، لأن كل إصلاح سيجب تطبيقه مرتين منذ تلك اللحظة، وسيأتي يوم يُطبَّق فيه مرة واحدة فقط. لا تستفيد playbook واحدة يقل طولها تقريباً عن 100 سطر وتستهدف دائماً مجموعة واحدة من role، كما أن الأدلة الإضافية تجعل قراءتها أصعب.
هل تُنفَّذ roles قبل المهام الموجودة في play نفسها؟
نعم. ينفّذ Ansible pre_tasks، ثم كل ما هو مدرج ضمن roles:، ثم tasks:، ثم post_tasks:، ويتجاهل ترتيب ظهور هذه المفاتيح في ملفك. إن كتابة tasks: فوق roles: لا تجعل تلك المهام تُنفَّذ أولاً. إذا كان يجب تنفيذ شيء قبل role، فضعه في pre_tasks:.
لماذا لا تتجاوز قيمة group_vars قيمة المتغير في role؟
تحقق مما إذا كان المتغير مضبوطاً في vars/main.yml ضمن role بدلاً من defaults/main.yml. يحتل vars/ أولوية أعلى من group_vars وhost_vars في ترتيب أولوية Ansible، لذلك لا يستطيع inventory تجاوز قيمته. انقل المتغير إلى defaults/main.yml، فهو قريب من أدنى ترتيب الأولوية، وهو المكان الصحيح لأي قيمة يجب أن يتمكن المستدعي من تغييرها. للتأكد من أن أولوية المتغير هي السبب وليس خطأً مطبعياً، شغّل التنفيذ مرة واحدة باستخدام -e name=value، الذي تتجاوز أولويته أي مصدر آخر.
لماذا يقول Ansible إن role غير موجود؟
يبدأ البحث بجوار ملف playbook، لذلك يجب أن يكون site.yml وroles/ في الدليل نفسه. يعرض الخطأ المسارات التي حاول البحث فيها، كما في the role 'common' was not found in /home/deploy/lonely/roles:/home/deploy/lonely. لا مشكلة في تشغيل playbook من دليل أب، لأن البحث يتبع مسار playbook وليس دليل العمل الحالي في الصدفة. إذا كنت تعتمد على roles_path من ansible.cfg، فتحقق من تحميل ذلك الملف باستخدام ansible --version، لأن Ansible يتجاهل دليل العمل القابل للكتابة عالمياً.
هل أحتاج إلى ansible-galaxy init لإنشاء role؟
لا. تتكون role من أدلة تحمل الأسماء المتوقعة فقط، لذلك يكفي mkdir -p roles/common/tasks مع tasks/main.yml للحصول على role عاملة. يوفّر ansible-galaxy init --init-path roles common وقت الكتابة ويمنحك الهيكل الكامل، بما في ذلك meta/main.yml ومسودة README. احذف الأدلة التي تتركها فارغة، لأن vars/main.yml الفارغ يخفي الملفات التي تنفّذ عملاً فعلياً في role.