
Settembre 2025, due settimane sul cluster OpenSearch su LXC che oggi serve la search di una buona fetta del traffico editoriale che gira su AI Multisite. Tre nodi data+master coordinator su LXC Proxmox, dashboard separata, deploy Ansible idempotente, install via tarball ufficiale. Le note tecniche le ho tenute aperte per tutto il deploy: questo pezzo le rimette in ordine, errori inclusi. Vedi anche gli altri post di questa rubrica: Production.
Perché OpenSearch e non Elasticsearch
Domanda inevitabile. La risposta breve: licenza. Dalla 7.11 in poi Elasticsearch è SSPL/Elastic License v2, OpenSearch è ASL 2.0. Per un prodotto SaaS multi-tenant come AI Multisite, ASL 2.0 è la scelta che riduce attrito legale a zero. Niente discussione su sublicensing, ridistribuzione, fork.
La risposta lunga: OpenSearch 3.5 mantiene compatibilità API con la maggior parte degli script Elasticsearch 7.10, ha un security plugin nativo (non a parte come prima), e il pacchetto Dashboards è la stessa esperienza di Kibana con qualche divergenza estetica. Per il caso editoriale (full-text su milioni di articoli, faceted search per redazione, ranking custom per data e popolarità) tutto quello che mi serviva era già lì.
Architettura
Tre nodi di tipo data+master+ingest, una dashboard separata, un Caddy davanti per esporre dashboard e API verso esterno con TLS gestito.
Subnet privata di gestione. Quattro container LXC numerati progressivamente: tre nodi del cluster (data+master+ingest) e un container dedicato alle Dashboards.
Node-1 ─┐
Node-2 ─┼── cluster: opensearch-prod
Node-3 ─┘
Dashboards: container LXC dedicato
Public:
endpoint Dashboards dedicato dietro VPN → :5601
endpoint API dedicato dietro VPN → round-robin 3 nodes :9200
Tre nodi è il minimo per fault tolerance reale (quorum di majority). Con un solo nodo perdi tutto se cade, con due hai split-brain in caso di partizione di rete, con tre puoi perderne uno senza interruzione di servizio. Configurazione cluster.initial_master_nodes sui tre, discovery.seed_hosts derivati dall’inventory Ansible.
LXC su Proxmox: perché non VM
Domanda altrettanto inevitabile. Le VM (KVM) hanno isolamento più forte ma costano: kernel separato, overhead di I/O, cold start lento, ogni nodo si porta dietro 2-3 GB di RAM solo per essere “vivo” prima di servire query. LXC condivide il kernel dell’host, montaggio diretto dei volumi ZFS, start in 2 secondi.
Per OpenSearch ci sono tre cose da gestire diversamente che su VM:
-
vm.max_map_count. OpenSearch richiede minimo 262144. Su Proxmox il default è1048576, già abbondante. Va comunque controllato e impostato esplicitamente nel container. Su LXC il sysctl si propaga dall’host se hai il container “unprivileged”, il che è il caso di default. -
memlock. OpenSearch chiede di bloccare la heap in memoria per evitare swap. In LXCswapoffnon funziona dal container, maLimitMEMLOCK=infinitynel systemd unit è sufficiente, e l’host Proxmox è configurato conswappiness=10di default, basso ma non zero. Per il caso editoriale, dove il traffico è prevedibile e la heap dimensionata correttamente, non ho mai visto un nodo swappare in produzione. -
systemd unit override. Il pacchetto OpenSearch tarball viene con un service file pensato per VM. Su LXC ho aggiunto un dropin
/etc/systemd/system/opensearch.service.d/override.confconLimitMEMLOCK=infinity,LimitNOFILE=65535,Restart=on-failure,RestartSec=10.
Tarball, non pacchetto
Decisione consapevole. Il pacchetto deb di OpenSearch installa in /usr/share/opensearch, scrive config in /etc/opensearch, dipende da policy systemd che non sempre giocano bene con LXC. Il tarball lo scarico in /opt/opensearch-3.5.0, simlink /opt/opensearch -> /opt/opensearch-3.5.0, configurazione in /opt/opensearch/config. Upgrade futuro: scarico il nuovo tarball, sposto il simlink, restart. Rollback: sposto il simlink indietro. Niente apt che gestisce file di configurazione che potrebbero divergere.
Il vantaggio reale è il controllo della versione. Apt potrebbe portarmi a una 3.5.1 quando io voglio testare prima la nuova versione su staging. Tarball con sha256 verificato in Ansible: scelgo io quando passo.
jvm.options: il pezzo che fa la differenza
Questo è il file che decide se il cluster regge i picchi serali o si pianta. Le decisioni:
Heap size: 8 GB su nodi da 16 GB di RAM. Regola classica: 50% della RAM al massimo, mai sopra 32 GB (compressed oops break). I nostri nodi LXC hanno 16 GB ciascuno, heap a 8 GB, il resto al filesystem cache che OpenSearch usa pesantemente per i segmenti Lucene.
-Xms8g
-Xmx8g
-Xms uguale a -Xmx: heap fissa, niente resize a runtime. Su Java moderni il resize è efficiente ma su un servizio long-running con pattern di carico noti è semplicemente rumore.
Garbage collector: G1GC, default su OpenJDK 17+, va bene così. ZGC era tentazione ma per il nostro working set (heap 8 GB) G1GC è ampiamente sufficiente, ha tooling più maturo, e i tempi di pausa medi che vedo in produzione stanno sotto i 50ms in p99.
Heap dump on OOM: attivato. -XX:+HeapDumpOnOutOfMemoryError con path /var/lib/opensearch/heapdumps. L’ho usato due volte in due mesi per capire un leak in un plugin di test che non è andato in produzione.
Logging GC: ridotto rispetto al default. Il default di OpenSearch logga il GC con rotazione su 32 file da 64 MB ciascuno, il che significa 2 GB di log GC sempre lì. Ho ridotto a 8 file da 32 MB, equivalente a circa 250 MB. La cronologia GC del giorno mi basta.
Indici: shard e replica policy
La regola più mal applicata di Elasticsearch/OpenSearch è “tanti shard è meglio”. Falso. Ogni shard è un Lucene index a sé, costa overhead, e oltre un certo numero il cluster passa più tempo a coordinare che a cercare.
Per il caso editoriale ho strutturato così:
- Un indice per testata, non un indice unico globale. Permette retention policy diverse, ranking diverso, e cancellazione pulita se una testata esce dal tenant.
- Numero di shard primari per indice: 3 (uno per nodo, distribuzione naturale). Riconsiderato per indici grandi (>50 GB) con un solo shard primario e replica 2: per la maggior parte delle query editoriali un solo shard primario è più veloce di tre shard piccoli, perché evita il fan-out coordinator -> shard.
- Replica: 1. Con tre nodi e replica 1 tollero la perdita di un nodo senza data loss. Replica 2 sarebbe overkill su tre nodi (tutte le repliche sui restanti due, doppio uso di disco senza ulteriore safety).
- Refresh interval: 30 secondi sugli indici editoriali (default è 1s). Per la search di articoli che vengono pubblicati ogni qualche minuto, 30s di latenza sull’indicizzazione è invisibile e riduce drasticamente il churn dei segmenti.
Query patterns
Tre tipi di query, in ordine di volume:
Full-text search sull’articolo. Match su title^3 e body^1, con multi_match di tipo best_fields. Highlighting attivo sul body, snippet di 200 caratteri. Filtri impliciti su published_at (ultimi N anni) e su tenant_id.
Faceted search per redazione. Aggregations su autore, categoria, tag, data. Il caso d’uso è il pannello editoriale che mostra “articoli simili” o “altri articoli dello stesso autore”. Performance critica: queste query devono restituire sotto i 100ms p95.
Semantic search opzionale via embeddings. Per i casi in cui la full-text classica non basta (ricerca per concetto, non per parola), un microservizio FastAPI calcola embeddings con un modello sentence-transformer e popola un campo embedding_v (dense_vector, 384 dimensioni) sull’indice. Query con knn di OpenSearch. Per ora attivo solo su una redazione pilota, perché il costo di calcolo degli embeddings per il backfill di 200k articoli non è banale.
Issue risolti durante il deploy
Tre cose mi hanno fatto perdere ore:
Sysctl in LXC unprivileged. Inizialmente avevo provato a settare vm.max_map_count dentro il container con sysctl -w. Errore: in unprivileged LXC il /proc/sys è in sola lettura per parametri kernel-globali. Soluzione: settarlo sull’host Proxmox (/etc/sysctl.d/90-opensearch.conf) e verificare che si propaghi al container. Aggiunta una task Ansible che fa proprio questo check.
jvm.options path. La 3.5 cerca jvm.options in /opt/opensearch/config/jvm.options ma legge anche dropin in /opt/opensearch/config/jvm.options.d/*.options. Il template Ansible inizialmente sovrascriveva il file principale rompendo i default. Refactor: il file principale lo lasciamo intoccato, le custom vanno in <99-custom>.options come dropin. Più pulito, soprattutto per upgrade.
systemd dropin per LimitMEMLOCK. Il service file del tarball ha già LimitMEMLOCK=infinity ma se modifichi il file con systemctl edit senza override, ti ritrovi col file originale sovrascritto a ogni ansible-playbook. Dropin in /etc/systemd/system/opensearch.service.d/limits.conf, gestito come template Ansible separato.
Deploy: due comandi
L’Ansible project è in un repo runbook interno dedicato. Provisioning prima, configurazione e avvio poi:
ansible-playbook playbooks/provision.yml -v -e "@group_vars/vault.yml"
ansible-playbook playbooks/site.yml -v -e "@group_vars/vault.yml"
Il provisioning crea i container LXC via tteck community scripts (più affidabili di pct create raw, gestiscono cose come template upgrade automatico), genera chiavi TLS PKCS#8 (con openssl genpkey, non genrsa che è deprecato), pulisce le known_hosts locali per evitare i conflitti dopo recreate, e attende SSH ready con un check robusto (perché pct list | awk ha trailing whitespace fastidiosi).
Il site playbook installa il tarball, applica configurazioni, gestisce il security plugin (con bcrypt hash precalcolato in vault.yml per non ricalcolare a ogni run), copia i certificati, avvia i nodi nell’ordine giusto (non in parallelo: il primo deve eleggersi master, gli altri si uniscono).
L’inventory è il single point of configuration. IPs, risorse (cores, RAM), nomi dei nodi: tutto deriva da inventory/hosts.yml. seed_hosts, nodes_dn per TLS, backend di Dashboards: derivati dinamicamente. Aggiungere un quarto nodo significa una riga in inventory e re-run del playbook.
Backup e snapshot
Snapshot OpenSearch su repository S3-compatible (Cloudflare R2 nel nostro caso, perché il pricing per egress è zero e bastano le retention policy native). Snapshot incrementale notturno alle 3:00 UTC, retention 30 giorni, lifecycle policy che cancella i più vecchi.
Il restore l’ho testato due volte: la prima per validare la procedura, la seconda durante un deploy fallito di un plugin custom. In entrambi i casi il restore di un indice grande (~40 GB) ha richiesto sotto i 10 minuti, accettabile.
I numeri di esercizio
| Metrica | Valore |
|---|---|
| Nodi data+master+ingest | 3 |
| Nodi Dashboards | 1 |
| RAM per nodo | 16 GB |
| Heap JVM per nodo | 8 GB |
| Versione OpenSearch | 3.5.0 |
| Indici | ~45 (uno per testata) |
| Documenti totali | ~3,2M articoli |
| Storage usato | ~210 GB (con replica) |
| Query p95 full-text | sotto 80 ms |
| Query p95 faceted | sotto 50 ms |
| Uptime ultimi 90 giorni | 100% |
I numeri sono realistici per il caso. Non sono cluster da hyperscaler, sono cluster che fanno il lavoro di una software house piccola con clienti editoriali italiani: pochi nodi, molto bene tarati, deploy ripetibile. Le partnership tecniche, di nuovo, sono il vero R&D: la maggior parte delle decisioni di tuning le ho prese guardando le metriche reali del cliente storico, non leggendo guide best-practice in astratto.
Quello che mi sono portato a casa
Prima. OpenSearch su LXC funziona, e funziona meglio di OpenSearch su VM nella maggior parte dei casi small/medium. Il prezzo è tre cose specifiche da gestire: sysctl propagation, memlock, systemd dropin. Una volta che le hai in Ansible, sparisce.
Seconda. Tarball install batte pacchetto deb per servizi long-running che vuoi controllare in versione. Apt è fatto per pacchetti di sistema, non per database e search engine. Una versione minore inattesa può cambiare il comportamento del tuo stack: meglio pinarla.
Terza. Tre nodi con shard 3 primari, replica 1, refresh 30s: questa è la configurazione di partenza per la maggior parte dei casi editoriali. Non è la configurazione “scientifica”, è la configurazione che ho visto reggere senza tuning ulteriore per mesi su un caso editoriale italiano in produzione. La documentazione ufficiale di OpenSearch sul sizing parte da numeri molto più grandi: la realtà di un cluster da 200k a 5M documenti è quella che ho descritto qui.
Settembre 2025 il cluster è entrato in produzione, da allora ha incassato due nuovi tenant senza tuning aggiuntivo. È la dimostrazione che una buona astrazione di deploy (Ansible idempotente, inventory single-source) vale più di qualunque ottimizzazione singola: ti permette di aggiungere capacità senza riscrivere niente.
