
Gennaio 2024, secondo lunedì del mese, ore 8:30. Apro la dashboard Grafana con i sedici server HestiaCP del fleet, guardo la colonna PHP version default, e leggo sedici volte 7.4.33. Tutti EOL da fine 2022. Centoventi siti WordPress di clienti sopra, con contratti che vietano downtime non concordato. Niente più rinvii: l’upgrade va fatto entro fine mese. Apro il repo runbook interno e comincio a scrivere il piano. Vedi anche gli altri post di Production.
Il problema
PHP 7.4 è uscito a novembre 2019, security support finito a novembre 2022. A gennaio 2024, sono quattordici mesi di codice in produzione su un runtime che non riceve più patch ufficiali. Per i clienti più strutturati (testate editoriali, ecommerce piccoli) questo è già motivo di nota in audit di sicurezza. Per Romiltec è anche un freno tecnico: ogni nuovo plugin moderno che voglio adottare richiede php >= 8.0, alcuni >= 8.1, e devo continuare a mantenere alternative.
Il fleet a inizio 2024:
- 16 server HestiaCP, tutti su Debian (mix di 11 e 12)
- ~120 siti WordPress di clienti, distribuiti
- Sei-otto clienti con plugin custom (codice scritto internamente o da loro CTO precedente)
- Tre testate editoriali con clausole contrattuali strette su disponibilità in fasce 18-22 (sera della pubblicazione)
- Zero ore di downtime accettato a contratto fuori da finestre concordate
- Storage backup: restic + Backblaze B2 zona EU, snapshot orari su due nodi più importanti, giornalieri sugli altri
L’obiettivo: portare tutti a PHP 8.4 entro fine mese. PHP 8.4 era uscito a novembre 2023, due mesi di shake-out, abbastanza maturo per andarci sopra. Avrei potuto fermarmi a 8.3, ma la differenza di EOL è di un anno, e fare due upgrade ravvicinati è più costoso di farne uno solo bene.
Il piano
Tre settimane di calendario, due settimane di rolling effettivo, una settimana di buffer per imprevisti.
Step 1: matrix di compatibilità. Il primo lavoro non è tecnico, è inventariale. Per ogni cliente, ho compilato una matrice in un foglio interno con tre colonne: versione WP attuale, lista plugin installati, presenza di codice custom (theme child con functions.php non triviale, plugin custom). Per ogni plugin di terze parti, ho controllato sul repo WordPress.org o sul changelog del vendor il requires_php. Per il codice custom, ho fatto un grep dei pattern noti incompatibili 7.4 a 8.x:
count(null),count($undefined)(TypeError da 8.0)each()(rimossa da 8.0)create_function()(rimossa da 8.0)- Cast impliciti su numeri stringa (warning più stretti)
get_class()senza argomento (rimosso)array_key_existssu oggetti (rimosso)Serializableinterface (deprecata, sostituita da magic methods)
Risultato del grep su sei codebase custom: una decina di violazioni totali, concentrate su due clienti. Le ho segnate per il post-mortem (vedi sotto).
Step 2: staging clone per cliente. Ogni cliente “sensibile” (testate, plugin custom, ecommerce) ha avuto un clone di staging alzato su un sottodominio dedicato del server stesso, con WordPress sincronizzato (rsync di web/<dominio>/public_html/ + dump SQL importato e search-replace dell’URL). Sul clone ho fatto girare lo smoke test prima del passaggio in produzione.
Step 3: batch di quattro server al giorno. Sedici server, quattro al giorno, quattro giorni. Ho aggiunto un giorno di buffer fra batch per le due settimane (otto giorni effettivi su due settimane, lasciando il fine settimana libero per emergenze). I batch li ho composti raggruppando server per profilo cliente: prima i due con solo siti vetrina semplici (rischio basso), poi i quattro con WooCommerce, poi i tre con testate editoriali, in ultimo i clienti con plugin custom.
Step 4: finestre orarie concordate. Per le tre testate editoriali ho mandato una mail ai contatti tecnici la settimana prima, con tre slot proposti: lunedì 10-12, mercoledì 14-16, giovedì 9-11. Mai 18-22, mai weekend. Tutte e tre hanno accettato il primo slot disponibile, due un mercoledì pomeriggio, una un giovedì mattina. Per gli altri clienti, finestra interna 9-13.
Il workflow per server
Lo script php-upgrade.sh nel repo runbook interno, che ho scritto e raffinato sul primo server di test (un server di sviluppo, niente clienti veri sopra). Lo riassumo per fasi.
Fase A: pre-upgrade.
1. Snapshot ZFS del filesystem /home (server con storage ZFS) o snapshot del volume LVM (server con LVM)
2. Dump completo dei database utenti via v-backup-user per ciascun utente Hestia
3. Dump della config Hestia: /usr/local/hestia/data/users/ archiviato a parte
4. Snapshot restic forzato (out-of-band rispetto al cron) verso B2
5. Generazione di un report “stato attuale”: versioni PHP per pool, package PHP installati, lista plugin Hestia template attivi
Fase B: install PHP 8.4.
apt update
apt install -y \
php8.4-cli \
php8.4-fpm \
php8.4-mysql \
php8.4-gd \
php8.4-curl \
php8.4-xml \
php8.4-mbstring \
php8.4-zip \
php8.4-intl \
php8.4-bcmath \
php8.4-imagick \
php8.4-redis \
php8.4-opcache \
php8.4-soap
A questo punto il server ha 8.4 installato in parallelo a 7.4, niente è ancora cambiato per i siti dei clienti.
Fase C: switch dei pool, cliente per cliente.
Hestia gestisce le versioni PHP per utente con v-change-web-domain-php. Per ogni cliente sul server:
- Smoke test sul clone di staging (se esiste): curl HTTPS sulla home, su
/wp-admin/, su tre URL di articoli noti, sul feed RSS, su/wp-json/wp/v2/posts?per_page=1 - Switch del pool:
v-change-web-domain-php <user> <domain> 8.4 - Riload del pool PHP-FPM:
systemctl reload php8.4-fpm - Smoke test in produzione, stessi sei URL del clone
- Se uno smoke test fallisce: rollback con
v-change-web-domain-php <user> <domain> 7.4, e il caso va in coda per debug separato
Fase D: cleanup.
A fine giornata, dopo che tutti e quattro i server della giornata erano in 8.4 e in piedi:
– Verifica visuale rapida sui siti più importanti (browser, primi articoli)
– Conferma su Grafana che gli error rate non sono saliti
– Chiusura del runbook con report del giorno
L’idea era di non rimuovere PHP 7.4 dai server fino a fine sprint, per avere il rollback istantaneo disponibile.
Incidente 1: count(null) sul plugin custom di un cliente editoriale
Mercoledì della prima settimana, ore 14:30, finestra concordata per una testata. Switch del pool a 8.4 fatto, smoke test della home: 500 Internal Server Error. Tail del log php-fpm:
PHP Fatal error: Uncaught TypeError: count(): Argument #1 ($value) must be of type Countable|array, null given in /home/<user>/web/<domain>/public_html/wp-content/plugins/<plugin-custom>/inc/widgets.php on line 247
Il plugin (custom, scritto dal CTO precedente del cliente, tre anni prima) faceva count($meta_value) su un meta post che, se vuoto, era null. In PHP 7.4 era un warning: in 8.0+ è TypeError fatale. La pagina era una landing dell’home page che includeva quel widget, quindi: home rotta = sito rotto.
Reazione (ore 14:32): rollback immediato a 7.4 con v-change-web-domain-php. Sito su. Sono passati due minuti dal break al ripristino, fuori da occhi pubblici perché era una landing interna alla home, non una pagina articolo (le testate vanno su Discover dagli articoli, non dalla home).
Fix (ore 14:35-15:30): ho aperto il file del plugin, riprodotto il bug in locale, scritto la patch:
// Prima
$values = get_post_meta($post_id, '<meta_key>', true);
if (count($values) > 0) {
// ...
}
// Dopo
$values = get_post_meta($post_id, '<meta_key>', true);
if (is_array($values) && count($values) > 0) {
// ...
}
Tre occorrenze nello stesso pattern in altri file del plugin, tutte fixate. Ho mandato la patch al cliente con commit message chiaro, e il loro dev senior l’ha mergiata sul repo del plugin lo stesso giorno. Switch a 8.4 ritentato alle 17:00 (fuori dalla finestra concordata, ma il cliente aveva dato OK telefonico): smoke test verde, online.
Lezione: il grep iniziale del codice custom aveva preso le occorrenze di count(null) dirette, ma non quelle dove la variabile arrivava da get_post_meta con single=true, che restituisce stringa vuota o false in 7.4 e null in 8.x. Aggiungere a checklist.
Incidente 2: opcache servito a metà fra 7.4 e 8.4
Giovedì della prima settimana, ore 11:00, server di un cliente ecommerce piccolo. Switch del pool a 8.4 fatto, smoke test: tre URL su sei restituiscono pagina mezza renderizzata. Stranissimo. Rilancio gli stessi URL dopo trenta secondi: due funzionano, uno no.
Apro il log php-fpm 8.4: nessun errore. Apro il log nginx: tutti 200. Apro il log php-fpm 7.4: vedo richieste arrivate sul pool 7.4 mescolate a quelle 8.4. Cosa?
Il problema: avevo lasciato il pool 7.4 attivo (per il rollback istantaneo) e nginx aveva, in qualche caso, ancora handle aperti sul vecchio socket Unix di 7.4. Il systemctl reload php8.4-fpm aveva ricaricato 8.4, ma nginx non aveva rilasciato le connessioni a 7.4 in modo pulito perché avevo riconfigurato il vhost del cliente facendo v-change-web-domain-php senza poi un riload nginx esplicito.
In più, opcache di 7.4 aveva ancora bytecode in memoria di file che ora venivano serviti da 8.4 (e viceversa): quando nginx instradava una request al pool sbagliato, il bytecode caricato da quel pool era incompatibile con il runtime, e il render saltava.
Reazione (ore 11:08): rollback completo a 7.4 con v-change-web-domain-php su tutti i siti del server, riload nginx esplicito, riload php7.4-fpm, riload php8.4-fpm, cachetool opcache:reset su entrambi i pool. Server stabile.
Fix nel runbook: ho aggiunto al workflow di switch un nginx -s reload esplicito dopo ogni v-change-web-domain-php, e un cachetool opcache:reset --fcgi=/run/php/php8.4-fpm.sock dopo il reload del pool 8.4. Il riload pulito risolve il problema dei handle aperti, il reset opcache forza il rebuild del bytecode con il runtime corretto.
Lezione: Hestia astrae bene la maggior parte delle operazioni, ma quando ne incateni due (cambio pool + cambio runtime) gli stati intermedi sono possibili. Riload nginx esplicito + reset opcache sono cheap, vanno sempre.
Incidente 3: WP-CLI puntava ancora a 7.4 nel cron di un cliente
Lunedì della seconda settimana, ore 09:00. Server di un cliente con un sito vetrina più un blog secondario. Switch a 8.4 sui siti web fatto venerdì sera, smoke test verde, weekend liscio. Lunedì mattina ricevo una mail dal cliente: il backup notturno del database non è arrivato sull’email come al solito.
Apro il log del cron del cliente:
PHP Fatal error: Uncaught Error: Call to undefined method <plugin>::<method>() in /home/<user>/.../wp-cli.phar
Il cron lanciava un comando WP-CLI custom (un export schedulato). WP-CLI è uno script PHP che gira da CLI, e usa lo php di sistema. Avevo aggiornato update-alternatives --set php /usr/bin/php8.4 solo sui server dove avevo già completato il cleanup, su questo server il default /usr/bin/php puntava ancora a 7.4. Ma il sito web girava su 8.4 (perché il pool php-fpm era stato switchato), quindi quando WP-CLI caricava i plugin, alcuni hook erano stati registrati assumendo 8.x: in 7.4 non esistevano più certi metodi che il plugin chiamava.
Effetto: il cron del backup di quel cliente era girato in PHP 7.4 una notte intera, senza completare l’export. Il cliente non se n’era accorto perché era una pipeline silenziosa, e l’errore era nella mail di alert che non arrivava mai (catch-22).
Reazione (ore 09:15): verifica su tutti i sedici server dello stato di update-alternatives per php, phar, php-cgi. Su sei dei sedici server era ancora puntato a 7.4. Allineamento immediato.
Fix nel runbook: ho aggiunto allo script php-upgrade.sh la fase “Cleanup CLI”, che gira dopo il cleanup pool php-fpm:
update-alternatives --set php /usr/bin/php8.4
update-alternatives --set phar /usr/bin/phar8.4
update-alternatives --set phar.phar /usr/bin/phar.phar8.4
E ho aggiunto un check finale: for f in /etc/cron.d/*; do head -1 $f; done | grep -i php, per identificare cron che usano shebang esplicito (/usr/bin/php7.4) e flaggarli per fix.
Lezione: un upgrade PHP non è solo php-fpm. È runtime CLI, è update-alternatives, è shebang dei script di cron, è path WP-CLI globale. Tutti i punti vanno verificati, e lo smoke test deve includere anche i cron, non solo le request HTTP.
I numeri dello sprint
Misurati a calendario e a Grafana.
- 16 server upgraditi in 11 giorni effettivi (calendario: 16 gennaio – 26 gennaio, due weekend esclusi)
- Downtime cumulato: 2h 15min, concentrato su un singolo cliente (incidente 1, durante una finestra concordata)
- Siti WordPress migrati senza intervento manuale: 108 su 120 (90%)
- Siti WordPress che hanno richiesto fix custom (plugin, theme): 9 (7 patch dirette, 2 escalate al dev del cliente)
- Smoke test eseguiti totali: oltre 700 (sei URL per sito, su 120 siti, su staging più produzione)
- Server con rollback temporaneo durante lo sprint: 2 (incidente 1 e incidente 2, entrambi recuperati lo stesso giorno)
- Memoria php-fpm media post-upgrade: -12% rispetto a 7.4 (PHP 8.x ha JIT più efficiente e gestione memoria migliore su workload WordPress)
- Latenza media TTFB su 8.4 vs 7.4 sui siti più trafficati: -8% (da 240 ms medi a 220 ms medi, su una testata)
A fine sprint, 27 gennaio, tutti e sedici i server in PHP 8.4 stabile, tutti i 120 siti online, zero ore di downtime fuori dalle finestre concordate, due patch su plugin di clienti aperte e mergiate.
Cosa rifarei diversamente
Una. Lo smoke test l’ho letto a occhio. Ogni server, ogni cliente, sei URL: curl -I su sei endpoint, leggo gli status code, decido. Funzionava, ma alle 14:30 di mercoledì in finestra concordata, leggere a occhio è una ricetta per missare un 502 mascherato dietro un 200. Avrei dovuto scrivere uno script bash che restituisce 0 (tutti verdi) o 1 (almeno uno fallisce), con output strutturato. L’ho aggiunto al runbook come azione post-sprint. Sul giro 8.4 → 8.5 (previsto fine 2025) lo userò pulito.
Due. Avrei tenuto lo staging clone in piedi per più clienti, non solo quelli “sensibili”. Il costo è basso (un sottodominio, un dump SQL, un rsync), il valore è alto (nove fix su 120 siti sono stati trovati in produzione, non in staging, per via di questa scrematura). La regola corretta è: clone staging sempre, default-on, escludi solo se il cliente è davvero un sito vetrina di tre pagine.
Tre. Il piano iniziale era “due settimane”. Ne sono servite due e mezza, contando i rollback e i fix. La buffer week l’avevo prevista. Ma il fatto è che sui rolling fleet di questa scala, due settimane è il minimo, non il target. Per il prossimo giro (8.5 nel 2025) pianifico tre settimane di calendario, con un buffer del 50% sopra il piano nominale. È più onesto verso il cliente e verso me stesso.
PHP 8.4 ha portato performance, sicurezza, e l’allineamento del fleet. Non l’ha portato gratis. Tre incidenti in due settimane sono il prezzo onesto di un upgrade rolling su un fleet vivo, fatto da una persona sola con una scaletta di runbook. Il valore è stato la scaletta stessa: dopo questo sprint, il runbook PHP-upgrade è diventato la bibbia operativa per ogni cambio runtime. La useremo di nuovo, e meglio, alla prossima EOL.
