Parchi server misti: una procedura unica di patching PHP su RHEL, Debian e SUSE con Ansible

Un inventario per famiglie di distribuzioni, variabili per pacchetti e servizi e un playbook Ansible che aggiorna PHP a ondate con dnf, apt e zypper, controlla il sito dopo ogni server e torna alle versioni precedenti in caso di errore.

Copertina con il logo php∞, la categoria Distribuzioni Linux, il titolo Patching PHP con Ansible e il numero 25

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:

AspettoRHEL e derivateDebian e UbuntuSUSE
Gestore dei pacchettidnfaptzypper
Modulo Ansibleansible.builtin.dnfansible.builtin.aptcommunity.general.zypper
Nomi tipici dei pacchettiphp-fpm, php-cliphp7.4-fpm, php8.2-cliphp8, php8-fpm
Valore di os_familyRedHatDebianSuse
Tornare indietroallow_downgradeallow_downgradeoldpackage
I nomi dei pacchetti dipendono dalla versione e dal repository in uso. Fonte: documentazione di Ansible.

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.

Schema di un playbook unico, patch-php.yml, con serial, block e rescue, che esegue quattro passi comuni: versioni prima dell’intervento, aggiornamento, riavvio di PHP-FPM e controllo HTTP. Solo il passo di aggiornamento cambia, scelto dal fatto ansible_facts os_family. Tre schede: RedHat, per RHEL, Rocky Linux e AlmaLinux, con il modulo ansible.builtin.dnf, i pacchetti di esempio php-fpm, php-cli e php-common e il servizio php-fpm; Debian, per Debian e Ubuntu, con il modulo ansible.builtin.apt, i pacchetti di esempio php7.4-fpm, php7.4-cli e php7.4-common e il servizio php7.4-fpm; Suse, per SLES, con il modulo community.general.zypper, i pacchetti di esempio php8 e php8-fpm e il servizio php-fpm. Una nota precisa che nomi di pacchetti e servizi sono illustrativi e stanno nelle variabili di gruppo
Le differenze stanno nelle variabili, la procedura resta una.

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 serial impostato 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 uri chiede una pagina che usa davvero PHP e il database, e ripete il tentativo qualche volta prima di dichiarare l’errore.
Esempio illustrativo di rilascio a ondate su 12 server con serial impostato a 1, 25% e 100%: il lotto 1 contiene un solo server canarino, il lotto 2 tre server, pari al 25% del totale, il lotto 3 gli otto server rimanenti. Su ogni server del lotto la procedura aggiorna i pacchetti PHP, riavvia PHP-FPM e fa un controllo HTTP del sito: con risposta 200 si passa al server o al lotto seguente, in caso di errore il blocco rescue reinstalla le versioni precedenti, riavvia e ferma tutta la procedura. Una nota spiega che con max_fail_percentage a 0 un solo errore interrompe i lotti successivi e i server non ancora toccati restano come prima
Un errore sul primo server ferma tutto prima che tocchi il resto del parco.

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 con ansible.builtin.copy, secondo le regole viste nell’articolo sul pinning APT;
  • su RHEL e derivate, l’opzione exclude nella definizione dei repository che non devono fornire PHP, oppure il parametro exclude del modulo dnf;
  • 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.

IL PROSSIMO PASSO

Mettiamo
in sicurezza.

Indicaci versione di PHP, sistema operativo e cosa ti preoccupa. Ti risponderemo con una valutazione dell’esposizione e una proposta di intervento.

Richiedi supporto

Ti porta al modulo di richiesta, in quattro passi.

PHP LEGACY · MANAGED SERVER S.R.L.info@phplegacy.it +39 02 5656 9681

Lunedì – venerdì · 09:30 – 19:30

Urgenza in corso? Reperibilità H24
+39 340 985 4556 ↗