Migracija produkcione Odoo instance s lokalne infrastrukture na Hetzner uz NixOS — i zamke oko DNS-a, journala i NAT-a


🇬🇧 In English: Migrating a production Odoo tenant from on-prem to Hetzner with NixOS

Nedavno smo prebacili produkcionu Odoo 16 (bosanska edicija) instancu s lokalnog (on-premise) NixOS KVM servera na Hetzner cloud VM — deklarativno, uz NixOS + colmena, bez gubitka podataka i uz vrlo kratak prozor nedostupnosti. Ovaj tekst je inženjerski izvještaj: dijelovi koji su prošli glatko, i — korisnije — tri zamke koje su nas ugrizle (suptilnost DNS wildcarda, BIND journal zamka i NAT hairpin koji je pokvario PDF fakture).

Adrese su anonimizirane: lokalna mreža je 192.168.x.0/24, cloud libvirt mreža je 192.168.y.0/24, javna adresa je <hetzner-public-ip>, a instanca je dostupna na tenant.odoo.bringout.cloud.

Cilj: otpornost

Glavni razlog je bila dostupnost. Lokacija u Sarajevu povremeno trpi nestanke interneta i struje, a ERP koji se ugasi svaki put kad lokalni link ili mreža trepne predstavlja stvaran poslovni problem. Premještanjem instance na Hetzner cloud VM — uz bazu na cloud HA PostgreSQL klasteru — jezgro aplikacije postaje nezavisno od lokalne infrastrukture: ERP nastavlja raditi i kada je ured u Sarajevu offline.

Svaka instanca je historijski radila na vlastitom lokalnom VM-u (192.168.x.124, 4 vCPU / 12 GB), a ti VM-ovi su bili i znatno predimenzionirani (stvarno korišteno tek nekoliko GB) — pa je “right-sizing” u cloudu bio dobrodošla nuspojava. Konfiguraciju instance smo zadržali identičnu tamo gdje je to važno i mijenjali smo samo ono što novo okruženje zahtijeva. Sve je deklarativno: VM, servisi i mreža žive u NixOS/colmena repozitoriju.

1. Vjerno reproduciranje enginea (i iznenađenje s GCC-om)

Instanca koristi specifičan pinovani Odoo build (“bosnian-legacy” Nix paket) plus tačno određen skup addona. Da bismo sačuvali ponašanje, taj identičan paket i skup addona smo preslikali u cloud repozitorij kao nove atribute — bez diranja postojećeg Odoo builda na cloud floti — i dali hostu instance mali overlay tako da se njena servisna definicija koristi doslovno (mijenja se samo host baze).

Onda je prvi colmena build pao. Uzrok:

lxml 4.9.4 se više ne kompajlira pod nixpkgs-25.05, jer njegov GCC 14 pretvara -Wincompatible-pointer-types (lxml vs libxml2 const xmlError *) u tvrdu grešku. GCC 13 (nixpkgs-24.11) izda samo upozorenje, pa build prolazi.

Vjerno rješenje je bilo pinovati ovaj host na istu nixpkgs reviziju koju je koristio lokalni server (24.11 / GCC 13), dok ostatak flote ostaje na novijim kanalima:

# hive.nix
nodeNixpkgs = {
  # ...
  tenant-vm = (import ./nixpkgs-24.11);  # GCC 13 — builda legacy lxml 4.9.4
};

colmena build je nakon toga čisto proizveo cijeli closure — isti engine, isti addoni, isto ponašanje.

2. Provisioning cloud VM-a

Gosta smo kreirali iz NixOS qcow2 šablona (zamijenjeni hostname + IP, nixos-install), registrovali ga preko libvirt-guests modula i pustili colmenu da deployuje pravu konfiguraciju. Jedan praktičan detalj: šablon dolazi sa 20 GB root particijom na 50 GB disku, pa je prvo veliko kopiranje closurea popunilo disk. Proširenje uživo (bez reboota) je tri komande:

echo "/dev/vda1 : start=2048, type=83, bootable" | sfdisk --force /dev/vda
partx -u /dev/vda          # kernel ponovo pročita veličinu (montirane) particije
resize2fs /dev/vda1        # proširi ext4 uživo

3. Migracija baze: živi dump → Patroni klaster

Lokalni PostgreSQL nije dostupan iz clouda, pa baza prelazi u cloud Patroni HA klaster (dostupan preko HAProxy VIP-a na 192.168.y.100:500X).

pg_dump pravi MVCC-konzistentan snapshot, pa smo mogli dumpovati živu bazu bez nedostupnosti da prvo validiramo cijeli proces:

pg_dump -h <lokalni-pg> -U admin_user --format=custom --no-owner --no-privileges \
        -f tenant.dump tenant_db
# restore u Patroni leadera preko VIP-a
pg_restore -h 192.168.y.100 -p 500X -U admin_user --no-owner --role=admin_user \
           -d tenant_db --jobs=2 tenant.dump

Instancu smo digli na cloud VM-u protiv te kopije, potvrdili da servira HTTP 200 sa stvarnim podacima, a onda izveli stvarni cutover: zaustavi lokalni Odoo → finalni konzistentni dump → drop/recreate → restore → ponovo sinhroniziraj filestore. Pošto je baza mala, prozor nedostupnosti je bio par minuta.

4. Mail i dalje radi — preko VPN subnet rute

Instanca preuzima bankovne izvode putem IMAP-a s lokalnog mail servera (192.168.x.40). Ti portovi su zatvoreni prema javnom internetu, a mi izričito nismo htjeli mijenjati mail konfiguraciju instance. Umjesto preusmjeravanja maila, saobraćaj smo proveli privatno: lokalni čvor reklamira tailscale/headscale subnet rutu do mail hosta, cloud VM prihvata rute, a jedan /etc/hosts override čini da se mail hostname razriješi na internu adresu. Rezultat: postojeća SMTP/IMAP konfiguracija instance radi netaknuta — preko tunela.

Baza namjerno ne koristi tunel (osjetljiva na latenciju, i ne želimo da ovisi o lokalnom linku) — samo mail male zapremine.

Ovo je, iskreno, jedina preostala ovisnost o lokalnoj infrastrukturi: ako je sarajevski link nedostupan, ERP i dalje radi, ali se novi e-mailovi s izvodima neće preuzimati dok se veza ne vrati (čekaju na mail serveru i uvezu se kad tunel ponovo proradi). Premještanje maila u cloud je prirodan sljedeći korak; za sada je jezgro ERP-a — aplikacija i baza — u potpunosti u cloudu i nezavisno od ispada.

5. DNS izdvajanje — i RFC 4592 zamka

Javni DNS je usmjeravao *.bringout.cloud na stari lokalni reverse proxy preko wildcarda. Da bismo prebacili samo jedno ime u cloud, dodaje se eksplicitni zapis:

tenant.odoo.bringout.cloud   IN A   <hetzner-public-ip>

…ali to samo po sebi pokvari susjedna imena. Prema RFC 4592, dodavanje tenant.odoo.bringout.cloud kreira “empty-non-terminal” čvor odoo.bringout.cloud, koji postaje najbliži obuhvat (closest encloser) za other.odoo.bringout.cloud — a pošto na tom nivou nema wildcarda, susjedi počnu vraćati NXDOMAIN. Rješenje je dodati odgovarajući wildcard nivo niže tako da ostali nastave razrješavati:

tenant.odoo.bringout.cloud   IN A      <hetzner-public-ip>   ; izdvajanje
*.odoo.bringout.cloud        IN CNAME  old-proxy.example.    ; sačuvaj susjede

6. BIND .jnl journal zamka (kratak samonametnuti ispad)

Odmah nakon deploya DNS izmjene, nekoliko zona je prestalo odgovarati — SERVFAIL/prazno, uz named koji loguje “zone not loaded”. Ali named-checkconf -z je bio potpuno čist. Ključni trag: zone bez journala su nastavile raditi.

Uzrok: zone koriste ixfr-from-differences, pa svaka master zona drži <zone>.jnl journal. Naše zonske datoteke dijele jedan SOA serijski broj, pa je njegovo podizanje promijenilo serijski broj svake zone — i svaki postojeći journal je sad bio nesinhroniziran sa svojom (izmijenjenom) zonskom datotekom. named odbija učitati master zonu čiji se journal ne poklapa. Rješenje:

systemctl stop bind
rm -f /data/named/*.zone.jnl     # ne diraj journale dinamičkih (dynamic-update) zona
systemctl start bind             # zone se učitaju iznova i naprave nove journale

Lekcija naučena i upisana u runbook: nakon svake izmjene sadržaja/serijala zone, obriši zastarjele journale statičkih zona.

7. PDF fakture bez logotipa — NAT hairpin

Nakon cutovera, generisane PDF fakture su se renderovale bez logotipa firme. Izgledalo je kao problem s filestoreom; nije bilo (logo se lokalno servirao bez problema). Pravi uzrok: wkhtmltopdf dohvata resurse izvještaja s Odoo bazne URL adrese, a to je javna https://tenant.odoo.bringout.cloud<hetzner-public-ip>. Iznutra, obraćanje vlastitoj javnoj adresi je NAT hairpin koji se ne vrati nazad — pa je dohvat resursa istekao i logo je ostao prazan.

Dva komplementarna rješenja (uz zadržavanje javnog web.base.url, koji treba e-mailovima):

# Odoo sistemski parametar — wkhtmltopdf dohvata resurse lokalno:
report.url = http://localhost:8069
# i razriješi javno ime iznutra preko reverse proxyja, ne preko javne adrese:
networking.extraHosts = "192.168.y.2 tenant.odoo.bringout.cloud";

Fakture se ponovo renderuju ispravno.

8. Rezerva za rad

Dodavanje VM-a na zauzet hypervisor bez swapa je poziv OOM killeru, pa je novi gost dobio mali swapfile, a host odgovarajući swap — jeftino osiguranje sad kad tu živi još jedna instanca.

Zaključci

  • Pinuj toolchain, ne samo paket — noviji GCC koji pretvara upozorenje u grešku je klasična “lani se buildalo” zamka. Ista nixpkgs revizija → isti build.
  • MVCC pg_dump omogućava probu cijele migracije nad živom bazom bez nedostupnosti; neka destruktivni cutover bude minimalan.
  • VPN subnet ruta može sačuvati integraciju (ovdje IMAP izvodi) bez diranja konfiguracije aplikacije.
  • DNS wildcardi su oštri: eksplicitni zapis ispod wildcarda traži susjedni wildcard, inače NXDOMAIN-uješ komšije.
  • named-checkconf -z čist, a zone “not loaded” gotovo uvijek znači zastarjele .jnl journale.
  • Prazne slike u izvještajima nakon selidbe hosta → provjeri baznu URL / NAT hairpin, ne filestore.

Napomena

Generisano od strane Claude 🤖


Ernad Husremović, hernad@bring.out.ba