data-demo wpadał w pętlę restartów na k3s-agent3: RuntimeError: NumPy was built with baseline optimizations: (X86_V2) but your machine doesn't support: (X86_V2). DIAGNOZA — to NIE jest różnica sprzętu. Wszystkie cztery węzły to maszyny wirtualne. Trzy widzą procesor jako „QEMU Virtual CPU 2.5+" i mają komplet rozszerzeń x86-64-v2; agent3 widzi „Common KVM processor" (kvm64) i nie ma popcnt, ssse3, sse4_1 ani sse4_2. Fizyczny i7-3770 pod spodem obsługuje nawet x86-64-v3 — te flagi są MASKOWANE przez domyślny model CPU w Proxmoksie. agent3 ma 14 dni, pozostałe 52. Został dołożony później i pominięto przy nim krok `--cpu host`, odnotowany w pamięci projektu 2 sierpnia. Nikt tego nie zauważył, dopóki scheduler nie postawił tam akurat warstwy danych — jedynej, która ciągnie pandas, a przez nią NumPy. ZABEZPIECZENIE: data i data-demo wymagają etykiety `astrololo.czernobog.pl/cpu-x86-64-v2`. Świadomie ETYKIETA, a nie wykluczenie agent3 po nazwie: wykluczanie po nazwie znaczyłoby, że każdy KOLEJNY źle postawiony węzeł znów zbiera się przez awarię. Nowy węzeł jest domyślnie nieoznaczony, więc nie dostanie tych podów, dopóki ktoś go nie sprawdzi i nie oznaczy. Pending z czytelnym powodem bije pętlę restartów z komunikatem o „baseline optimizations". KOLEJNOŚĆ: węzły trzeba oznaczyć PRZED wdrożeniem tej zmiany. Dziś etykietę ma zero węzłów, więc samo zmergowanie posłałoby data w Pending. RUNBOOK: skrypt sprawdzający węzły, naprawa u źródła (`qm set --cpu host` plus pełne wyłączenie i włączenie maszyny — sam restart z gościa nie wystarcza), oraz zastrzeżenie, że `--cpu host` blokuje migrację na żywo między hostami o różnych procesorach. PUŁAPKA PRZY CZYTANIU FLAG, udokumentowana bo kosztowała fałszywą diagnozę: Linux raportuje SSE3 jako „pni", nie „sse3". Pierwsza wersja kontroli wskazała przez to trzy zdrowe węzły jako niesprawne. Przy okazji: lista węzłów w eksportach NFS obejmuje wszystkie cztery. Runbooki wymieniały trzy, a klaster ma cztery — ten sam mechanizm, ta sama przyczyna. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
18 KiB
astrololo — deploy (namespace astrololo)
Manifesty k8s składane Kustomize. Obrazy podbija automatycznie image-updater
(kustomization.yaml → images: newTag) po każdym buildzie z repo aplikacji.
Warstwy: presentation (wejście z przeglądarki przez Ingress po https) →
logic → data (pliki Excel montowane z NFS, nie z obrazu).
Adres aplikacji: https://astrololo.czernobog.pl — patrz TLS.
Co śledzi image-updater — image-updater.yaml
Lista obrazów podbijanych automatycznie jest jawna i żyje w CRD ImageUpdater
(ns argocd). Obraz, którego na niej nie ma, nigdy się nie podbije, choćby CI
go budowało — tak przez chwilę wisiał render na :latest, zanim dopisaliśmy go do
listy. Dodając nową usługę, dopisz jej wpis w image-updater.yaml i nałóż:
kubectl apply -f astrololo/image-updater.yaml
Ten plik celowo nie jest w kustomization.yaml: resource stoi w ns argocd
(poza namespace docelowym aplikacji), a to konfiguracja kontrolera, który wdraża tę
aplikację — nakładamy go ręcznie, w repo trzymamy dla odtwarzalności i historii.
Pliki baz — udział otwarty na zapis (DAN-27)
Runbook: README-pliki.md. Ekran „Pliki" wymaga zapisu do udziału z bazami, więc znosi gwarancję z DAN-25, że baz nie da się zmienić przez NFS. Co zostaje z zabezpieczeń i jak to wdrożyć — w runbooku.
astrodemo — wersja demonstracyjna (PRE-28/29)
Osobna usługa o dwóch funkcjach: dodanie pliku i pytanie o interpretację urodzeniową. Runbook: README-astrodemo.md.
Ma własną warstwę danych i logiki (astrodemo-stack.yaml) oraz własny udział
NFS, a każde konto dostaje w nim odrębną, niewidoczną dla pozostałych pulę plików
(PRE-29). Nie sięga do produkcyjnych baz.
Konta są osobne (sekret astrololo-astrodemo), więc usługę odcina się jedną
zmianą, bez ruszania kont głównej aplikacji. Wchodzi się przez własny host
astrodemo.czernobog.pl — nie przez ścieżkę pod adresem astrololo, bo wspólne
pochodzenie oznaczałoby wspólne ciasteczka i wzajemne wylogowywanie.
⚠️ Wymóg węzłów: klient NFS
Wszystkie warstwy montują udziały z NAS-a, więc każdy węzeł, na którym może wylądować pod, musi mieć klienta NFS. Bez niego kubelet nie zamontuje wolumenu:
mount: ... bad option; for several filesystems (e.g. nfs, cifs) you might need
a /sbin/mount.<type> helper program
Ten komunikat nie oznacza problemu z udziałem ani z uprawnieniami — serwer
w ogóle nie został zapytany. Jądro nie znalazło programu pomocniczego
/sbin/mount.nfs.
Sprawdzenie
for N in 192.168.1.73 192.168.1.80 192.168.1.81 192.168.1.82; do
printf "%-15s " "$N"
ssh hammer@$N 'test -x /sbin/mount.nfs && echo MA-KLIENTA || echo BRAK-KLIENTA'
done
Instalacja
ssh hammer@<węzeł> 'sudo apt-get update && sudo apt-get install -y nfs-common'
Na WSZYSTKICH węzłach, nie tylko na tym, gdzie pod stoi teraz. Braku na pozostałych nie widać, dopóki scheduler tam czegoś nie przeniesie — a wtedy awaria wygląda na nagłą, choć przyczyna leżała od dawna. Dokładnie tak wyszło za pierwszym razem: pody działały miesiącami na jednym węźle, aż zejście do zera replik przy innej naprawie przeplanowało je gdzie indziej.
Skąd wiadomo, że to TO
| komunikat | co znaczy |
|---|---|
bad option ... mount.<type> helper program |
brak nfs-common na węźle — serwer niepytany |
access denied by server while mounting |
serwer odmawia: eksport nieprzeładowany, węzła nie ma na liście hosts, albo udział wyłączony |
Permission denied przy zapisie |
montowanie działa, brakuje praw — patrz mapall_user i właściciel katalogu |
⚠️ Wymóg węzłów: rozszerzenia procesora (x86-64-v2)
Warstwa danych używa pandas, a przez nią NumPy. Koła NumPy z PyPI są budowane z bazą x86-64-v2, więc na węźle bez tych rozszerzeń kontener nie wstaje:
RuntimeError: NumPy was built with baseline optimizations:
(X86_V2) but your machine doesn't support: (X86_V2).
Komunikat mówi o „optymalizacjach", czyli o niczym, po czym dałoby się poznać, że chodzi o węzeł, a nie o obraz. Pod wpada w pętlę restartów i wygląda to na zepsuty build.
To NIE jest ograniczenie sprzętu
Węzły k3s są maszynami wirtualnymi. Proxmox z domyślnym modelem CPU (kvm64,
„Common KVM processor") maskuje flagi procesora — gość nie widzi SSE4.2 ani
POPCNT, mimo że fizyczny i7-3770 obsługuje nawet x86-64-v3. Stąd bierze się
złudzenie, że „węzły mają różne procesory": mają ten sam sprzęt i różne maski.
Sprawdzenie: które węzły to udźwigną
for N in $(kubectl get nodes -o jsonpath='{.items[*].metadata.name}'); do
kubectl run cpucheck-$N --image=busybox --restart=Never --rm -i --quiet \
--overrides="{\"spec\":{\"nodeName\":\"$N\",\"tolerations\":[{\"operator\":\"Exists\"}]}}" \
--command -- sh -c '
F=$(grep -m1 ^flags /proc/cpuinfo); BRAK=""
for f in cx16 lahf_lm popcnt pni ssse3 sse4_1 sse4_2; do
echo "$F" | grep -qw "$f" || BRAK="$BRAK $f"
done
echo "$(grep -m1 "model name" /proc/cpuinfo | cut -d: -f2-)"
[ -z "$BRAK" ] && echo " x86-64-v2: TAK" || echo " x86-64-v2: NIE —$BRAK"
' 2>/dev/null | sed "s/^/$N /"
done
Pułapka przy czytaniu flag: Linux raportuje SSE3 jako pni (Prescott New
Instructions), nie sse3. Szukanie sse3 daje fałszywy alarm na maszynie, która
SSE3 ma — pierwsza wersja tej kontroli wskazała w ten sposób trzy zdrowe węzły
jako niesprawne.
Naprawa u źródła (Proxmox)
Na węźle Proxmox, dla każdej maszyny k3s:
qm set <VMID> --cpu host
qm stop <VMID> && qm start <VMID>
Sam restart z wnętrza gościa nie wystarczy — zmiana modelu CPU wchodzi dopiero przy pełnym wyłączeniu i włączeniu maszyny.
--cpu host przekazuje pełny zestaw rozszerzeń i daje przy okazji realnie
szybszą arytmetykę. Ma jeden koszt: blokuje migrację na żywo między hostami
o różnych procesorach. Jeśli kiedyś zaczniemy migrować maszyny między pve2
a NUC-iem, właściwym wyborem będzie wspólny mianownik x86-64-v2-AES zamiast
host.
Zabezpieczenie: etykieta zamiast wykluczania po nazwie
data i data-demo wymagają etykiety astrololo.czernobog.pl/cpu-x86-64-v2.
Nowy węzeł jest domyślnie nieoznaczony, więc nie dostanie tych podów, dopóki
ktoś go nie sprawdzi i nie oznaczy świadomie:
kubectl label node <węzeł> astrololo.czernobog.pl/cpu-x86-64-v2=true
Wykluczanie konkretnego węzła po nazwie znaczyłoby, że każdy KOLEJNY źle
postawiony węzeł znów zbierze się przez awarię. Tak wyszedł agent3: dołożony
14 dni po pozostałych, bez kroku --cpu host, i nikt tego nie zauważył, dopóki
scheduler nie postawił tam akurat warstwy danych.
KOLEJNOŚĆ MA ZNACZENIE. Oznacz węzły ZANIM to wdrożysz — bez etykiet data
nie ma się gdzie uruchomić i pójdzie w Pending.
⚠️ Sekret astrololo-auth — utwórz PRZED wdrożeniem
Aplikacja wystawia treść oryginalnych baz interpretacyjnych, dlatego wymaga
logowania i tokenu międzywarstwowego (LOG-32). Manifesty odwołują się do sekretu
astrololo-auth i celowo nie zawierają jego wartości — to repo GitOps, więc
cokolwiek by tu wpadło, zostałoby w historii gita na zawsze.
To ta sama konwencja co gitea-registry: sekret tworzymy poza repo.
Pody nie wstaną bez tego sekretu — i tak ma być. Wolimy widoczną awarię niż cichy start aplikacji bez ochrony.
# hasło wczytane bez zapisu w historii powłoki
read -rs -p "Hasło do aplikacji (APP_PASSWORD): " APP_PASSWORD; echo
kubectl -n astrololo create secret generic astrololo-auth \
--from-literal=APP_PASSWORD="$APP_PASSWORD" \
--from-literal=INTERNAL_TOKEN="$(openssl rand -hex 32)" \
--from-literal=SESSION_SECRET="$(openssl rand -hex 32)"
unset APP_PASSWORD
SESSION_SECRET podpisuje ciasteczka sesji (LOG-34). Pod bez niego celowo nie
wstanie: usługa z kontami, ale bez klucza, nie odróżniłaby ważnej sesji od
podrobionej. Nikt go nigdy nie musi oglądać.
⚠️ Masz już sekret sprzed LOG-34? Dołóż klucz, nie twórz od nowa
Komenda wyżej zakłada sekret od zera. Jeśli astrololo-auth już istnieje,
merge manifestu sam klucza nie dołoży — pod zgłosi wtedy:
Error: couldn't find key SESSION_SECRET in Secret astrololo/astrololo-auth
Dokładamy klucz, nie ruszając pozostałych:
kubectl -n astrololo patch secret astrololo-auth --type=merge \
-p "{\"stringData\":{\"SESSION_SECRET\":\"$(openssl rand -hex 32)\"}}"
kubectl -n astrololo rollout restart deploy/presentation
Sprawdzenie, że komplet kluczy jest na miejscu (bez pokazywania wartości):
kubectl -n astrololo get secret astrololo-auth -o jsonpath='{.data}' \
| tr ',' '\n' | grep -o '"[A-Z_]*"'
Oczekiwane: APP_PASSWORD, INTERNAL_TOKEN, SESSION_SECRET.
--type=mergezestringDatadokłada albo nadpisuje i nie wymaga, żeby klucz wcześniej istniał — w odróżnieniu od JSON Patch zop: replace, który na brakującej ścieżce po prostu odmawia. Ta sama komenda służy więc i do dołożenia, i do rotacji.
Rotacja klucza sesji
Wylogowuje WSZYSTKICH. To nie usterka, tylko awaryjny wyłącznik: gdy podejrzewasz, że ktoś przechwycił cudzą sesję, podmiana klucza unieważnia je wszystkie naraz. Komenda ta sama, co dołożenie wyżej.
INTERNAL_TOKEN jest losowany i nikt go nigdy nie musi oglądać — służy tylko
usługom do rozmowy między sobą. APP_PASSWORD wpisujesz w przeglądarce
(użytkownik: astrololo, zmienny przez APP_USER w presentation.yaml).
Zmiana hasła
read -rs -p "Nowe hasło: " NEW; echo
kubectl -n astrololo create secret generic astrololo-auth \
--from-literal=APP_PASSWORD="$NEW" \
--from-literal=INTERNAL_TOKEN="$(kubectl -n astrololo get secret astrololo-auth \
-o jsonpath='{.data.INTERNAL_TOKEN}' | base64 -d)" \
--dry-run=client -o yaml | kubectl apply -f -
kubectl -n astrololo rollout restart deploy/presentation
unset NEW
(Zachowujemy istniejący INTERNAL_TOKEN; jego zmiana wymaga restartu wszystkich
trzech usług naraz, inaczej przestaną się dogadywać.)
Sprawdzenie po wdrożeniu
kubectl -n astrololo rollout status deploy/presentation deploy/logic deploy/data
curl -s -o /dev/null -w "bez hasła: %{http_code}\n" https://astrololo.czernobog.pl/
curl -s -o /dev/null -w "z hasłem: %{http_code}\n" -u astrololo:'<hasło>' https://astrololo.czernobog.pl/
Oczekiwane: 401 bez hasła, 200 z hasłem. /health zostaje publiczny (sondy k8s).
🔒 TLS — wymagane kroki przed wdrożeniem
Do tej pory Basic Auth szedł siecią po zwykłym http, czyli hasło dało się podsłuchać (base64 to nie szyfrowanie). Poza tym http blokował dwie funkcje, bo przeglądarki udostępniają je wyłącznie w tzw. secure context: geolokalizacja („Tu i teraz") i kopiowanie do schowka. Certyfikat załatwia oba tematy naraz.
Certyfikat wystawia własne CA trzymane w klastrze (cert-manager). Let's Encrypt odpada, bo klaster stoi w LAN — walidacja HTTP-01 nie ma jak do niego dojść z internetu, a DNS-01 wymagałby trzymania tu tokena API do domeny.
Kolejność ma znaczenie — oba kroki PRZED synchronizacją ArgoCD.
1. cert-manager (jednorazowo, cały klaster)
kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.21.0/cert-manager.yaml
kubectl -n cert-manager rollout status deploy/cert-manager deploy/cert-manager-webhook
Bez tego API odrzuci Certificate i Issuer jako nieznane rodzaje zasobów,
a ArgoCD pokaże aplikację jako niezsynchronizowaną.
2. DNS: astrololo.czernobog.pl → adres Traefika
kubectl -n kube-system get svc traefik -o jsonpath='{.status.loadBalancer.ingress[*].ip}'; echo
Wpis w routerze / lokalnym DNS-ie (albo doraźnie /etc/hosts). Service
presentation jest teraz ClusterIP — NodePort był drugą, nieszyfrowaną drogą
do aplikacji, więc został zdjęty. Zanim DNS zacznie odpowiadać, jedyne wejście to:
kubectl -n astrololo port-forward svc/presentation 8000:8000 # awaryjnie, http://localhost:8000
3. Zaufanie do własnego CA (raz na urządzenie)
Bez tego przeglądarka pokaże ostrzeżenie o certyfikacie. Korzeń ważny 10 lat, więc import robisz raz:
kubectl -n astrololo get secret astrololo-ca -o jsonpath='{.data.ca\.crt}' \
| base64 -d > astrololo-ca.crt
# macOS — do systemowego zaufania (poprosi o hasło administratora)
sudo security add-trusted-cert -d -r trustRoot \
-k /Library/Keychains/System.keychain astrololo-ca.crt
# Linux (Debian/Ubuntu)
sudo cp astrololo-ca.crt /usr/local/share/ca-certificates/ && sudo update-ca-certificates
Firefox ma własny magazyn — tam import przez Ustawienia → Prywatność → Wyświetl certyfikaty → Organy certyfikacji.
Sprawdzenie samego TLS
# certyfikat wystawiony i gotowy
kubectl -n astrololo get certificate
# oczekiwane: astrololo-ca True, astrololo-tls True
# http odsyła na https
curl -sI http://astrololo.czernobog.pl/ | head -2 # 301 + Location: https://...
# HSTS obecny
curl -skI -u astrololo:'<hasło>' https://astrololo.czernobog.pl/ | grep -i strict-transport
Odnawianie
Certyfikat serwera żyje 90 dni i odnawia się sam 30 dni przed końcem. Termin jest krótki celowo — odnawianie sprawdza się wtedy w praktyce co kwartał, a nie dopiero za dziesięć lat, gdy nikt nie będzie pamiętał, jak to było skonfigurowane.
⚠️ Sekret astrololo-link — szyfrowanie między warstwami
Token międzywarstwowy mówi kto pyta, ale nie ukrywa czego dotyczy odpowiedź — a płyną nią surowe wiersze oryginalnych baz. Kto podsłuchał ruch wewnątrz sieci (drugi pod, mirror portu na switchu, zrzut z węzła), miał je w całości. Łącza są więc szyfrowane AES-256-GCM, osobnym kluczem na każdą parę rozmówców.
kubectl -n astrololo create secret generic astrololo-link \
--from-literal=LINK_KEY_PRESENTATION_LOGIC="$(openssl rand -hex 32)" \
--from-literal=LINK_KEY_LOGIC_DATA="$(openssl rand -hex 32)"
Kluczy nikt nigdy nie musi oglądać — służą tylko usługom. Rozdzielenie jest
celowe: przejęcie klucza prezentacji nie daje dostępu do warstwy danych, gdzie
leżą całe bazy. Logika trzyma oba, bo rozmawia w obie strony; prezentacja i dane
dostają przez secretKeyRef wyłącznie swój.
Pody nie wstaną bez tych kluczy (LINK_ENCRYPTION_REQUIRED=true) — i tak ma
być. Usługa, która wstała i po cichu nie szyfruje, jest gorsza niż pod w CrashLoop,
bo awarii nie widać.
Wymiana kluczy
kubectl -n astrololo create secret generic astrololo-link \
--from-literal=LINK_KEY_PRESENTATION_LOGIC="$(openssl rand -hex 32)" \
--from-literal=LINK_KEY_LOGIC_DATA="$(openssl rand -hex 32)" \
--dry-run=client -o yaml | kubectl apply -f -
kubectl -n astrololo rollout restart deploy/presentation deploy/logic deploy/data
Restart wszystkich trzech naraz — w trakcie wymiany warstwy chwilowo się nie dogadują (klucze muszą być zgodne po obu stronach łącza).
Klucze do modeli w chmurze — sekret astrololo-llm (opcjonalny)
Domyślnie działa model lokalny i nic nie opuszcza sieci. Żeby móc wybrać w UI
OpenAI lub Anthropic, potrzebne są ich klucze. Ten sekret jest opcjonalny
(optional: true) — bez niego pody startują normalnie, tylko chmura jest niedostępna.
read -rs -p "OPENAI_API_KEY (Enter = pomiń): " OPENAI_KEY; echo
read -rs -p "ANTHROPIC_API_KEY (Enter = pomiń): " ANTHROPIC_KEY; echo
kubectl -n astrololo create secret generic astrololo-llm \
--from-literal=OPENAI_API_KEY="$OPENAI_KEY" \
--from-literal=ANTHROPIC_API_KEY="$ANTHROPIC_KEY"
unset OPENAI_KEY ANTHROPIC_KEY
kubectl -n astrololo rollout restart deploy/logic # klucze wstrzykują się przy starcie
Podmiana pojedynczego klucza (bez kasowania drugiego):
kubectl -n astrololo create secret generic astrololo-llm \
--from-literal=OPENAI_API_KEY="$(kubectl -n astrololo get secret astrololo-llm \
-o jsonpath='{.data.OPENAI_API_KEY}' | base64 -d)" \
--from-literal=ANTHROPIC_API_KEY='NOWY-KLUCZ' \
--dry-run=client -o yaml | kubectl apply -f -
kubectl -n astrololo rollout restart deploy/logic
Adres modelu lokalnego ustawia LOCAL_BASE_URL w logic.yaml — dopasuj do miejsca,
gdzie faktycznie stoi Ollama/vLLM. Konfiguracja jest per dostawca
(LOCAL_*, OPENAI_*, ANTHROPIC_*), więc ustawienia lokalnego modelu nie przejmują
żądań do chmury.
Wybór dostawcy w chmurze oznacza, że oryginalne opisy z baz opuszczają naszą sieć. Prompt można obejrzeć przed wysłaniem. Warto zawnioskować u dostawcy o Zero Data Retention — patrz LOG-32.
Czego to nie załatwia
- Własne CA to nie to samo co publiczne zaufanie — każde nowe urządzenie wymaga importu korzenia (krok 3 powyżej). Gdyby aplikacja miała kiedyś wyjść na świat, właściwą drogą jest Let's Encrypt przez DNS-01.
- Szyfrowane są ciała żądań, nie nagłówki — ścieżka (
/search) i token międzywarstwowy jadą czytelnie. Sam token nikomu nic nie daje: bez klucza łącza każde żądanie kończy się odmową. Pełne ukrycie metadanych wymagałoby mTLS. - NFS
192.168.1.34:/mnt/Tank1/astrololo— kto ma dostęp do share'u, bierze pliki baz z pominięciem całej aplikacji. Do zamknięcia po stronie infrastruktury (eksport tylko dla IP węzłów,root_squash, najlepiej read-only). - Sekret w etcd jest tylko zakodowany base64. Docelowo: szyfrowanie etcd at-rest albo Sealed Secrets / SOPS, jeśli chcecie trzymać sekrety deklaratywnie w repo.
Profil ze swissephem
Wariant z silnikiem B: ../astrololo-swisseph/.
Dziedziczy powyższe zmienne z tej bazy, więc też wymaga sekretu astrololo-auth.