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.4se više ne kompajlira pod nixpkgs-25.05, jer njegov GCC 14 pretvara-Wincompatible-pointer-types(lxml vs libxml2const 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_dumpomoguć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.jnljournale.- 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