Vai al contenuto

OpenSearch 3.5 su LXC: tre nodi che gestiscono la search di mezzo editorial italiano

OpenSearch 3.5 su LXC: tre nodi che gestiscono la search di mezzo editorial italiano

OpenSearch 3.5 su LXC: tre nodi che gestiscono la search di mezzo editorial italiano

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:

  1. 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.

  2. memlock. OpenSearch chiede di bloccare la heap in memoria per evitare swap. In LXC swapoff non funziona dal container, ma LimitMEMLOCK=infinity nel systemd unit è sufficiente, e l’host Proxmox è configurato con swappiness=10 di 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.

  3. 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.conf con LimitMEMLOCK=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.