Molte aziende non hanno un solo tipo di server. Il gestionale gira su RHEL perché lo richiede il fornitore, il sito istituzionale su Debian, l’e-commerce su Ubuntu, un portale su SUSE perché sta accanto a SAP. Quando esce una vulnerabilità di PHP, la stessa correzione va installata su sistemi con gestori di pacchetti, nomi dei pacchetti e servizi diversi. Se ogni famiglia ha la sua procedura manuale, l’intervento dura giorni, e qualche server resta indietro senza che nessuno se ne accorga.
In questo articolo costruiamo una procedura unica con Ansible: un inventario che separa le famiglie di distribuzioni, variabili che raccolgono le differenze, un playbook che aggiorna PHP a ondate, controlla il sito dopo ogni server e torna alle versioni precedenti se qualcosa va storto. Il codice è un punto di partenza, da adattare al proprio parco e da provare prima in un ambiente di prova.
Cosa cambia tra RHEL, Debian e SUSE
La sequenza delle operazioni è la stessa ovunque: annotare le versioni installate, aggiornare i pacchetti, riavviare PHP-FPM, verificare che il sito risponda. Quello che cambia sono i dettagli:
| Aspetto | RHEL e derivate | Debian e Ubuntu | SUSE |
|---|---|---|---|
| Gestore dei pacchetti | dnf | apt | zypper |
| Modulo Ansible | ansible.builtin.dnf | ansible.builtin.apt | community.general.zypper |
| Nomi tipici dei pacchetti | php-fpm, php-cli | php7.4-fpm, php8.2-cli | php8, php8-fpm |
Valore di os_family | RedHat | Debian | Suse |
| Tornare indietro | allow_downgrade | allow_downgrade | oldpackage |
Ansible raccoglie all’inizio di ogni esecuzione i fatti del server, tra cui ansible_facts['os_family'], che vale RedHat per RHEL, CentOS, Rocky Linux e AlmaLinux, Debian per Debian e Ubuntu, Suse per SLES. È la variabile che permette di scegliere il modulo giusto senza scrivere tre playbook.
Inventario e variabili
Il primo passo è un inventario con un gruppo per famiglia e un gruppo padre che li contiene tutti. Le differenze, cioè quali pacchetti aggiornare, come si chiama il servizio di PHP-FPM e quale indirizzo usare per il controllo, vanno nelle variabili di gruppo o di host. I nomi dei server e gli indirizzi qui sotto sono esempi.
# inventario.ini
[php_rhel]
gestionale-01.example.com
[php_debian]
sito-01.example.com
shop-01.example.com
[php_suse]
portale-01.example.com
[php:children]
php_rhel
php_debian
php_suse
# group_vars/php_debian.yml (su Debian e Ubuntu il ramo è nel nome dei pacchetti)
php_pacchetti: [php7.4-fpm, php7.4-cli, php7.4-common, php7.4-mysql]
php_servizio_fpm: php7.4-fpm
# group_vars/php_rhel.yml
php_pacchetti: [php-fpm, php-cli, php-common, php-mysqlnd]
php_servizio_fpm: php-fpm
# group_vars/php_suse.yml
php_pacchetti: [php8, php8-fpm, php8-mysql]
php_servizio_fpm: php-fpm
# host_vars/shop-01.example.com.yml (una pagina leggera che usa PHP e il database)
php_url_controllo: https://shop.example.com/controllo.php
Le variabili vanno ricavate dai server reali, con un inventario dei pacchetti installati, prima di scrivere il playbook: ogni pacchetto dell’elenco deve essere già installato, perché il playbook ne annota la versione. Un elenco sbagliato aggiorna solo una parte dei pacchetti di un ramo, e le dipendenze tra php-fpm e php-common bloccano l’aggiornamento o, peggio, lasciano versioni miste.
Il playbook
Il playbook lavora a lotti con serial: prima un server, poi un quarto del parco, poi tutti gli altri. Per ogni server annota le versioni installate, aggiorna i pacchetti con il modulo della sua famiglia, riavvia PHP-FPM e controlla il sito. Se un passo fallisce, il blocco rescue reinstalla le versioni precedenti e ferma la procedura.
# patch-php.yml
- name: Patching PHP su un parco misto
hosts: php
become: true
serial:
- 1 # il primo server da solo
- "25%"
- "100%"
max_fail_percentage: 0 # un solo errore ferma i lotti successivi
tasks:
- name: Leggi i pacchetti installati
ansible.builtin.package_facts:
- name: Annota le versioni attuali, per un eventuale ritorno indietro
ansible.builtin.set_fact:
php_precedenti: "{{ php_precedenti | default([]) + [item ~ sep ~ ver] }}"
vars:
pkg: "{{ ansible_facts.packages[item][0] }}"
sep: "{{ '-' if ansible_facts['os_family'] == 'RedHat' else '=' }}"
ver: "{{ pkg.version if ansible_facts['os_family'] == 'Debian' else pkg.version ~ '-' ~ pkg.release }}"
loop: "{{ php_pacchetti }}"
- name: Aggiorna PHP e verifica il sito
block:
- name: Aggiorna i pacchetti (RHEL e derivate)
ansible.builtin.dnf:
name: "{{ php_pacchetti }}"
state: latest
update_only: true
when: ansible_facts['os_family'] == 'RedHat'
- name: Aggiorna i pacchetti (Debian e Ubuntu)
ansible.builtin.apt:
name: "{{ php_pacchetti }}"
state: latest
only_upgrade: true
update_cache: true
when: ansible_facts['os_family'] == 'Debian'
- name: Aggiorna i pacchetti (SUSE)
community.general.zypper:
name: "{{ php_pacchetti }}"
state: latest
update_cache: true
when: ansible_facts['os_family'] == 'Suse'
- name: Riavvia PHP-FPM
ansible.builtin.service:
name: "{{ php_servizio_fpm }}"
state: restarted
- name: Controlla che il sito risponda
ansible.builtin.uri:
url: "{{ php_url_controllo }}"
status_code: 200
register: risposta
retries: 5
delay: 3
until: risposta.status == 200
rescue:
- name: Reinstalla le versioni precedenti (RHEL e derivate)
ansible.builtin.dnf:
name: "{{ php_precedenti }}"
state: present
allow_downgrade: true
when: ansible_facts['os_family'] == 'RedHat'
- name: Reinstalla le versioni precedenti (Debian e Ubuntu)
ansible.builtin.apt:
name: "{{ php_precedenti }}"
state: present
allow_downgrade: true
when: ansible_facts['os_family'] == 'Debian'
- name: Reinstalla le versioni precedenti (SUSE)
community.general.zypper:
name: "{{ php_precedenti }}"
state: present
oldpackage: true
when: ansible_facts['os_family'] == 'Suse'
- name: Riavvia PHP-FPM con le versioni precedenti
ansible.builtin.service:
name: "{{ php_servizio_fpm }}"
state: restarted
- name: Ferma la procedura
ansible.builtin.fail:
msg: "Aggiornamento annullato su {{ inventory_hostname }}, versioni precedenti reinstallate"
Il modulo per SUSE fa parte della collezione community.general, da installare sul nodo di controllo con ansible-galaxy collection install community.general se non è già presente. Le versioni annotate hanno il formato che ogni gestore si aspetta: nome=versione per apt, nome-versione-release per dnf, nome=versione-release per zypper.
Ondate, controlli e ritorno indietro
Tre scelte del playbook servono a limitare i danni di un aggiornamento che non va come previsto.
- Lotti progressivi: con
serialimpostato a 1, 25% e 100%, Ansible completa tutto il playbook sul primo server prima di toccare gli altri. Un problema si vede su una macchina sola; - soglia di errore a zero: secondo la documentazione di Ansible la procedura si ferma quando la percentuale di server falliti supera il valore di
max_fail_percentage. Con 0 basta un solo server fallito per fermare i lotti successivi; - controllo applicativo: il riavvio riuscito di PHP-FPM dice poco. Il modulo
urichiede una pagina che usa davvero PHP e il database, e ripete il tentativo qualche volta prima di dichiarare l’errore.
Il ritorno indietro ha una condizione che va verificata prima, non durante l’emergenza: le versioni precedenti devono essere ancora disponibili in un repository raggiungibile dal server. Sui repository di RHEL e SUSE le versioni precedenti di solito restano pubblicate. Sui repository di sicurezza di Debian e Ubuntu, e su deb.sury.org, spesso resta solo l’ultima versione. Per questo conviene tenere i pacchetti in uso in un repository interno, che è anche il posto naturale per i pacchetti ricostruiti con le patch.
Proteggere i pacchetti corretti
Se una parte del parco usa pacchetti PHP ricostruiti, la stessa procedura deve garantire che un aggiornamento non li sostituisca con quelli di un altro repository. Conviene distribuire con Ansible anche le regole che li proteggono, così regole e pacchetti restano allineati:
- su Debian e Ubuntu, un file in
/etc/apt/preferences.d/copiato conansible.builtin.copy, secondo le regole viste nell’articolo sul pinning APT; - su RHEL e derivate, l’opzione
excludenella definizione dei repository che non devono fornire PHP, oppure il parametroexcludedel modulodnf; - su SUSE, un campo Vendor proprio nei pacchetti ricostruiti, che zypper per default non sostituisce con quelli di un altro fornitore, come spiegato nell’articolo su PHP su SUSE Linux Enterprise.
Server vecchi e versioni di Ansible
I parchi con PHP legacy hanno spesso anche sistemi operativi legacy, e questo tocca Ansible stesso. Dalla versione 2.17 di ansible-core i server gestiti devono avere almeno Python 3.7: Python 2.7 e 3.6 non sono più supportati. Su CentOS 7, RHEL 7 o Debian 9 serve quindi una versione precedente di ansible-core, in un ambiente separato sul nodo di controllo, oppure un Python più recente sul server. Con la stessa versione 2.17 il modulo yum è stato rimosso e reindirizzato a dnf.
Va considerato anche il nodo di controllo. Se il parco va da CentOS 7 a RHEL 10, una sola versione di Ansible difficilmente copre tutto, e conviene tenere due ambienti, ciascuno con la propria versione, che eseguono lo stesso playbook su gruppi diversi dell’inventario.
Come eseguirlo la prima volta
Prima di usarlo in produzione, il playbook va provato per gradi:
# Verifica la sintassi
ansible-playbook -i inventario.ini patch-php.yml --syntax-check
# Mostra cosa cambierebbe, senza installare nulla (il controllo HTTP viene saltato)
ansible-playbook -i inventario.ini patch-php.yml --check --diff
# Esecuzione reale su un solo server di prova
ansible-playbook -i inventario.ini patch-php.yml --limit sito-01.example.com
# Esecuzione su tutto il parco, a ondate
ansible-playbook -i inventario.ini patch-php.yml
La prova più importante è quella del ritorno indietro: su un server di prova, si rende volutamente irraggiungibile la pagina di controllo e si verifica che il blocco rescue reinstalli davvero le versioni precedenti. È lo stesso approccio che usiamo nei nostri interventi di backport, dove ogni rilascio con Ansible ha un piano di rollback provato in un ambiente di prova con l’applicazione reale.
In conclusione
Un parco con RHEL, Debian, Ubuntu e SUSE può avere una sola procedura di patching PHP. L’inventario separa le famiglie, le variabili raccolgono pacchetti, servizi e indirizzi di controllo, e ansible_facts['os_family'] sceglie tra dnf, apt e zypper. Il playbook aggiorna a ondate con serial, si ferma al primo errore con max_fail_percentage a zero, controlla il sito con una richiesta HTTP e, se qualcosa va storto, reinstalla le versioni annotate prima dell’intervento. Perché il ritorno indietro funzioni servono le versioni precedenti in un repository interno, e sui sistemi più vecchi una versione di ansible-core compatibile con il loro Python. Provato per gradi, diventa il modo più rapido per portare la stessa correzione su tutto il parco.