Ansible playbook לעומת role: מתי להשתמש בכל אחד
מתי playbook שטוח מספיק, ומתי role מצדיק את מבנה התיקיות שלו: מבנה role, הפקודה ansible-galaxy init, קריאות role וסדר הקדימויות של משתנים.
playbook לעומת role ב־Ansible: מה ההבדל
Ansible playbook הוא הקובץ שמריצים באמצעות ansible-playbook. הוא ממפה קבוצת מארחים לעבודה שעליהם לבצע. Ansible role היא תיקייה במבנה קבוע, שמכילה משימות, תבניות, handlers ומשתני ברירת מחדל, ו־playbook קורא לה בשמה. תחביר המשימות בשני המקרים זהה, ולכן השאלה אינה מה אפשר לבטא באמצעותם. השאלה היא מה אפשר לעשות בהם שימוש חוזר.
התחילו ב־playbook שטוח. קובץ site.yml יחיד שמכיל רשימת tasks: הוא המבנה המתאים לאוטומציה הראשונה שלכם, והוא יישאר מתאים זמן רב יותר מכפי שרוב האנשים מצפים. המירו אותו ל־role כאשר אותו מקטע משימות צריך לפעול עבור קבוצת מארחים נוספת, או כאשר הקובץ גדל מעבר לכ־100 שורות ואי־אפשר עוד למצוא משימה באמצעות גלילה.
אם עדיין לא כתבתם playbook, התחילו ב־playbook ראשון מול VPS יחיד וחזרו לכאן כאשר הוא מתחיל לגדול.
מתי playbook שטוח הוא הבחירה הנכונה
playbook שטוח מתאים כאשר העבודה מתבצעת פעם אחת, על מארח אחד, או כאשר אף אדם אחר לא יקרא אותו. הקצאת שרת יישומים יחיד, או התקנת תיקונים בשרת לפני חלון תחזוקה: אף אחת מהמשימות האלה אינה מצדיקה עץ תיקיות. role מוסיף שבע תיקיות ושכבה אחת של הפניה עקיפה. אם ה־playbook היחיד שקורא לו נמצא בסמוך אליו, ההפניה העקיפה הזאת אינה מוסיפה דבר, אך מחייבת אתכם לדלג בין קבצים בכל פעם שאתם רוצים לקרוא מה באמת מופעל.
ה־playbook השטוח מפסיק להיות הבחירה הנכונה ברגע מסוים, וקל לזהות את הרגע הזה. אתם מעתיקים קבוצת משימות ל־playbook שני. ההעתקה הזאת היא הסימן. מכאן ואילך כל תיקון צריך להתבצע פעמיים, ועד מהרה יגיע היום שבו הוא יתבצע רק פעם אחת.
מה באמת נמצא בתיקיית role
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 מריץ את הקובץ הזה כאשר ה־role נקרא, וכל תיקייה אחרת היא אופציונלית.defaults/main.ymlמכילה את המשתנים שמצופה מהקורא לדרוס. זהו מקור המשתנים בעל העדיפות הנמוכה ביותר ב־Ansible, ולכן כמעט כל מקור אחר גובר עליו.vars/main.ymlמכילה משתנים שלא מצופה מהקורא לדרוס. העדיפות שלה גבוהה מזו של inventory, וזו קביעה מחייבת. השתמשו בה לעיתים רחוקות.handlers/main.ymlמכילה משימות שמופעלות על ידיnotify. handler פועל בסוף ה־play, פעם אחת בלבד, בלי קשר למספר המשימות שהודיעו עליו.files/מכילה קבצים שמועתקים כפי שהם באמצעות המודולcopy, ו־templates/מכילה תבניות Jinja2 שעוברות עיבוד באמצעות המודולtemplate. בתוך role מפנים לשניהם באמצעות שם קובץ בלבד, ללא נתיב, משום ש־Ansible מחפש תחילה בתיקיות של ה־role עצמו.meta/main.ymlמצהירה על תלויות ה־role ועל המטא־נתונים ש־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. Handler שמציין את היחידה השגויה נכשל רק כאשר משהו אכן משתנה בתבנית. לכן הבעיה מתגלה בדרך כלל רק כעבור כמה שבועות.
השורה 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 קורא ל־role
# 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 בסיכום ההרצה. העבירו פרמטרים בנקודת הקריאה באמצעות הצורה המורחבת. כך אותו role יכול לשרת שתי קבוצות של hosts:
roles:
- role: common
common_admin_group: ops
common_permit_root_login: prohibit-passwordיש כאן כלל סדר שמפתיע כמעט את כולם. play יכול לכלול pre_tasks, roles, tasks ו־post_tasks, ו־Ansible מריץ אותם בסדר הזה, בלי קשר לסדר שבו כתבתם אותם בקובץ. הציבו את tasks: מעל roles:, וה־roles עדיין יורצו תחילה. לכן, אם פעולה מסוימת חייבת להתבצע לפני role, מקומה ב־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כדי לקרוא ל־role מתוך רשימת משימות במקום להשתמש במפתח 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 קורא את ה־role בזמן ניתוח הקובץ, והמשימות שלו הופכות לחלק מה־play. לכן ansible-playbook --list-tasks site.yml מציג אותן, ו־tag שמוגדר בייבוא חל על כל המשימות שבתוכו. include_role הוא דינמי. שום דבר אינו נקרא עד שהמשימה רצה, ולכן אפשר לקבוע את שם ה־role מתוך משתנה או לולאה. החיסרון הוא שהמשימות האלה אינן גלויות ל־--list-tasks ול־--start-at-task.
יש כאן מלכודת אחת. when: במשימת include_role נבדק לפני ש־defaults/main.yml של ה־role הכלול נמצא בתחום. אם תכתבו when: common_packages | length > 0 ב־include, ההרצה תיעצר עם 'common_packages' is undefined, אף שהמשתנה מוגדר בתוך ה־role שאתם כוללים. הפתרון הוא להעביר את המתג אל מחוץ ל־role: הציבו אותו ב־group_vars/all.yml, שם הוא נמצא בתחום בכל מקום, והשאירו את ברירות המחדל של ה־role עבור ערכים שה־role עצמו צורך.
איזו משתנה גוברת: ברירות מחדל, group_vars, vars, extra vars
Ansible מתעד יותר מעשרים רמות של קדימות משתנים. ארבע מהן מכריעות כמעט כל מחלוקת מעשית, והנה הן מהחלשה לחזקה.
roles/<name>/defaults/main.ymlנמצאת קרוב לתחתית. כמעט כל ערך שתגדירו במקום אחר יגבר עליה, ולכן זהו המקום המתאים לפרמטרים הניתנים לכוונון של role.group_vars/ו־host_vars/נמצאות באמצע. כאן צריכים להופיע הערכים הספציפיים לאתר שלכם, והם עוקפים באופן נקי את ברירות המחדל של ה־role.roles/<name>/vars/main.ymlנמצאת מעלhost_vars. אי אפשר לעקוף ערך שהוגדר כאן באמצעות inventory. שמרו אותה לערכים שה־role חייב לשמור על עקביות פנימית, כגון שם חבילה שחייב להתאים לשם שירות.- פרמטר של role שהועבר בנקודת הקריאה גובר על
vars/main.yml, ו־-eבשורת הפקודה גובר על הכול, כולל פרמטרים של role.
אפשר לראות את תהליך ההכרעה בתוך כדקה. הגדירו ל־role קטן ברירת מחדל אחת ומשתנה role אחד, ולאחר מכן הגדירו את אותם שמות ב־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. ה־inventory גבר על ברירת המחדל של ה־role, אך הפסיד למשתנה ה־role. ההרצה השנייה מדפיסה internal=from-cli, משום ש־extra vars נמצאים בראש הסולם ושום ערך ברמה נמוכה יותר אינו יכול לגבור עליהם. לכן -e מתאים להרצה חד־פעמית, אך שגוי בסקריפט שנשמר ומשתמשים בו שוב: הוא גובר בשקט על כל החלטה רלוונטית במאגר שלכם.
הכלל המעשי: אם אתם רוצים לאפשר הגדרה של ערך, מקמו אותו ב־defaults/. הצבתו ב־vars/ מודיעה לכל משתמש עתידי של ה־role שאסור ל־inventory לשנות אותו. לפעמים זו אכן הכוונה, אך בדרך כלל מדובר בטעות.
הוכיחו שה־role אידמפוטנטי: הריצו אותו פעמיים
הרצת 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=0changed=0 פירושו שכל מודול בדק את המצב הנוכחי ומצא שהפעולה כבר בוצעה. 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 מספק למודול תוצר גלוי שאותו הוא יכול לחפש תחילה. כאשר פקודה אינה משאירה תוצר כזה, שמרו את הפלט שלה והחליטו בעצמכם באמצעות changed_when.
ansible-playbook --check --diff site.yml חוזה שינויים בלי לבצע אותם, ו־--diff מציג את השורות המדויקות ש־template היה כותב מחדש. קראו את הפלט תוך התחשבות בסייג אחד: משימות shell ו־command מדולגות במצב בדיקה, ולכן תוכנית שנראית נקייה עדיין עשויה להסתיר פעולות שיבוצעו.
מדוע Ansible מציין שהתפקיד לא נמצא
Ansible מחפש תיקיית roles/ לצד קובץ ה־playbook, ולאחר מכן ב־roles_path. החיפוש מתבצע לפי ה־playbook, ולא לפי ה־shell שממנו הפעלתם את הפקודה.
ERROR! the role 'common' was not found in /home/deploy/lonely/roles:/home/deploy/lonelyהודעה זו פירושה שהנתיבים site.yml ו־roles/ אינם תואמים עוד. Ansible מציג גם את הנתיבים שבהם ניסה לחפש. שמרו את שני הקבצים באותה תיקייה. אפשר להפעיל את הפקודה מתיקיית אב, משום שהנתיב של ה־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. בלעדיו תקבלו את מה שנמצא ב־default branch ביום שבו תריצו את הפקודה. לכן deployment שעבד בחודש שעבר עלול להיכשל בלי שביצעתם שינוי כלשהו ב־repository שלכם. הגדירו את roles_path לתיקיית ההורדות, והחריגו את התיקייה הזו מ־git:
# ansible.cfg
[defaults]
inventory = inventory.ini
roles_path = ./galaxy_rolesתפקידים שנמצאים ב־roles/ לצד ה־playbook עדיין יימצאו, משום שהנתיב הזה תמיד נבדק בנוסף ל־roles_path. כך התפקידים שלכם נשארים מתועדים ב־commit ועוברים review, ואילו תפקידים של צד שלישי הם הורדות שניתן לשחזר, המקובעות ל־tag.
היכן התפקידים מפסיקים להיות הפתרון
תפקיד הוא יחידת שימוש חוזר בתוך הרצה אחת של Ansible. הוא אינו יוצר שרתים או רשומות DNS אצל ספק התשתית שלכם, וניסיון לגרום לו לעשות זאת הופך את ה־playbooks למשהו שאיש אינו רוצה לתחזק. כדאי לקרוא את חלוקת העבודה בין Ansible ל־Terraform לפני שמתחילים. תפקיד גם אינו מחליף תכנון inventory: לאחר שעוברים מספר קטן של מכונות, האופן שבו מקבצים את השרתים האלה וניגשים אליהם חשוב יותר מהאופן שבו מחלקים את המשימות לקבצים.
גם להקשחה שמתקין התפקיד common יש החלטות משלה. קובץ ה־drop-in שלמעלה מגדיר שתי directives בלבד, ולכן כדאי לקרוא את הגדרות SSH שבאמת כדאי לשנות ואת האופן שבו גורמים ל־Ubuntu להחיל עדכוני אבטחה באופן עצמאי לפני שמחליטים מה ייכלל בתפקיד עבור כל מארח שבניהולכם.
FAQ
מתי כדאי להפוך Ansible playbook ל־role?
כאשר אותו מקטע משימות צריך לפעול ב־play נוסף או מול קבוצת hosts נוספת. העתקת משימות בין playbooks היא הסימן לכך, משום שמאותו רגע צריך להחיל כל תיקון פעמיים, ובשלב מסוים הוא יוחל רק פעם אחת. 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 ולא על תיקיית העבודה של ה־shell. אם אתם מסתמכים על 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 אכן מבצעים פעולה.