Vodiči

Reproducible Linux Server Provisioning: Scripting, Validation, and Audit Trails

Reproducibilno postavljanje Linux poslužitelja: skriptiranje, provjera valjanosti i revizijski tragovi

Poslužitelj koji se može ponovno izgraditi vrjedniji je od poslužitelja koji je tek preživio. Ručni popravci gomilaju nevidljive pretpostavke: paket instaliran tijekom incidenta, dozvola promijenjena bez zapisa ili konfiguracija kopirana s drugog poslužitelja. Mjesecima kasnije stroj radi, ali nitko ne može objasniti zašto.

Ovaj vodič gradi mali, produkcijski orijentiran sustav za pripremu namjenskog poslužitelja Debian 12 ili Ubuntu 24.04. Instalira Nginx, postavlja verzioniranu zdravstvenu krajnju točku na 127.0.0.1:8080, provjerava rezultat, bilježi revizijski trag i podržava vraćanje na prethodno stanje. Ponovljena pokretanja približavaju se istom stanju bez nepotrebnog ponovnog pokretanja zdravih usluga.

Preduvjeti i operativne granice

Upotrijebite testni virtualni stroj prije nego što ciljate stvarni poslužitelj. Skripte namjerno upravljaju zadanom Nginx stranicom, stoga cilj mora biti namjenski poslužitelj bez postojećih Nginx radnih opterećenja.

  • Poslužitelj Debian 12 ili Ubuntu 24.04 sa sustavom systemd
  • Root pristup putem sudo
  • Ispravni repozitoriji paketa i DNS
  • Odlazni HTTP ili HTTPS pristup za instalaciju paketa
  • Postojeći, neovisno testiran SSH put pristupa

Sve naredbe u nastavku su naredbe za poslužitelj, a ne naredbe za spremnik. Krajnja točka veže se samo na povratnu petlju, pa ne zahtijeva otvaranje dolaznog vatrozida. Nemojte je izlagati promjenom adrese za slušanje dok se ne osmisle autentikacija, TLS, ograničenja stope i izričita pravila vatrozida.

Arhitektura i kompromisi

Sustav odvaja nepromjenjiva izdanja od promjenjivog stanja aktivacije. Svako izdanje nalazi se pod /opt/repro-node/releases. Simbolička poveznica /opt/repro-node/current odabire aktivno izdanje, omogućujući atomsku promjenu preimenovanjem unutar istog datotečnog sustava.

Svako pokretanje stvara transakciju pod /var/lib/reprovision/transactions. Bilježi stare i nove ciljeve izdanja te sigurnosne kopije konfiguracije promijenjene tijekom tog pokretanja. Neblokirajući flock sprječava istodobne alate za pripremu da se natječu oko aktivne simboličke poveznice.

Vraćanje na prethodno stanje obuhvaća izdanje aplikacije i upravljanu Nginx konfiguraciju. Namjerno ne deinstalira pakete: uklanjanje paketa tijekom oporavka može pokrenuti skripte održavatelja, uklanjanje ovisnosti i nepovezane prekide usluga. Instalacija paketa stoga je trajna promjena na razini poslužitelja, dok aktivacija izdanja ostaje reverzibilna.

Datoteka revizije je samo prema konvenciji nadopisiva, a nije kriptografski zaštićena od neovlaštenih izmjena. Root je može izmijeniti. Za snažnije dokaze proslijedite odgovarajuće događaje dnevnika u udaljeni sustav zapisnika s ograničenim kontrolama zadržavanja.

Struktura projekta

Stvorite ovaj projekt na radnoj stanici za administraciju ili izravno u pripremnom direktoriju u vlasništvu roota:

reprovision/
├── provision.sh
├── rollback.sh
├── validate.sh
└── files/
    ├── nginx-repro-node.conf
    └── public/
        └── health.json

Zdravstveni dokument namjerno je statičan. Provjera pripreme ne bi trebala ovisiti o bazi podataka, vanjskom API-ju ili vremenu izvođenja koje uvodi nepovezane načine kvara.

{"status":"ok","service":"repro-node"}

Definirajte uslugu Nginx

Spremite sljedeće kao files/nginx-repro-node.conf. Vezanje na povratnu petlju važna je sigurnosna granica, a ne samo praktična zadana postavka.

server {
    listen 127.0.0.1:8080;
    server_name _;
    server_tokens off;

    root /opt/repro-node/current/public;

    access_log /var/log/nginx/repro-node.access.log;
    error_log  /var/log/nginx/repro-node.error.log warn;

    location = /healthz {
        default_type application/json;
        add_header Cache-Control "no-store" always;
        add_header X-Content-Type-Options "nosniff" always;
        try_files /health.json =503;
    }

    location / {
        return 404;
    }
}

try_files vraća 503 ako aktivacija pokazuje na nepotpuno izdanje. Zahtjevi prema svakoj drugoj putanji dobivaju 404, čime se smanjuje slučajno izlaganje.

Implementirajte idempotentnu pripremu

Spremite ovu skriptu kao provision.sh. Njezin identifikator izdanja izveden je iz upravljanih datoteka, pa nepromijenjeni unos ponovno koristi isti nepromjenjivi direktorij. Metapodaci paketa osvježavaju se samo kada nedostaje potreban paket.

#!/usr/bin/env bash
set -Eeuo pipefail

[[ $EUID -eq 0 ]] || { echo "Run as root" >&2; exit 1; }

SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
BASE=/opt/repro-node
RELEASES=$BASE/releases
CURRENT=$BASE/current
TX_ROOT=/var/lib/reprovision/transactions
CONF=/etc/nginx/conf.d/repro-node.conf
DEFAULT_SITE=/etc/nginx/sites-enabled/default
AUDIT=/var/log/reprovision/audit.tsv
RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)-$$"
TX=$TX_ROOT/$RUN_ID
ARMED=0
STAGE=""

exec 9>/run/lock/reprovision.lock
flock -n 9 || { echo "Another provisioning run is active" >&2; exit 1; }

install -d -m 0755 "$BASE" "$RELEASES"
install -d -m 0700 "$TX_ROOT" "$TX"
install -d -m 0750 -o root -g adm "$(dirname "$AUDIT")"
touch "$AUDIT"
chown root:adm "$AUDIT"
chmod 0640 "$AUDIT"

audit() {
    printf '%s\t%s\t%s\t%s\t%s\n' \
        "$(date -u +%FT%TZ)" "$RUN_ID" "$1" "$2" "$3" >>"$AUDIT"
    logger -t reprovision -- "run=$RUN_ID action=$1 status=$2 detail=$3"
}

on_error() {
    local rc=$1
    trap - ERR
    set +e
    audit provision failure "exit-$rc"
    if [[ $ARMED -eq 1 ]]; then
        REPROVISION_LOCK_HELD=1 "$SCRIPT_DIR/rollback.sh" "$RUN_ID" --automatic
    fi
    if [[ -n $STAGE && $STAGE == "$RELEASES"/.staging.* ]]; then
        rm -rf -- "$STAGE"
    fi
    exit "$rc"
}
trap 'on_error $?' ERR

audit provision start requested

missing=()
for package in nginx curl; do
    dpkg-query -W -f='${Status}' "$package" 2>/dev/null |
        grep -qx 'install ok installed' || missing+=("$package")
done

if ((${#missing[@]})); then
    apt-get update
    DEBIAN_FRONTEND=noninteractive apt-get install -y \
        --no-install-recommends "${missing[@]}"
    audit packages changed "${missing[*]}"
else
    audit packages unchanged present
fi

RELEASE_ID="$(
    cd "$SCRIPT_DIR/files"
    sha256sum public/health.json nginx-repro-node.conf |
        sha256sum | awk '{print $1}'
)"
DEST=$RELEASES/$RELEASE_ID

if [[ ! -d $DEST ]]; then
    STAGE="$(mktemp -d "$RELEASES/.staging.XXXXXX")"
    install -d -m 0755 "$STAGE/public"
    install -m 0644 "$SCRIPT_DIR/files/public/health.json" \
        "$STAGE/public/health.json"
    mv -- "$STAGE" "$DEST"
    STAGE=""
    audit release created "$RELEASE_ID"
else
    audit release unchanged "$RELEASE_ID"
fi

if [[ -e $CURRENT && ! -L $CURRENT ]]; then
    echo "$CURRENT exists but is not a symlink" >&2
    exit 1
fi

previous="$(readlink -f "$CURRENT" 2>/dev/null || true)"
if [[ -n $previous && $previous != "$RELEASES"/* ]]; then
    echo "Refusing unmanaged current target: $previous" >&2
    exit 1
fi

printf '%s\n' "$previous" >"$TX/previous_target"
printf '%s\n' "$DEST" >"$TX/new_target"

if [[ -e $CONF ]]; then
    if cmp -s "$SCRIPT_DIR/files/nginx-repro-node.conf" "$CONF"; then
        touch "$TX/config.unchanged"
    else
        cp -a -- "$CONF" "$TX/nginx.conf"
        touch "$TX/config.changed"
    fi
else
    touch "$TX/config.absent"
fi

if [[ -L $DEFAULT_SITE ]]; then
    cp -a --no-dereference "$DEFAULT_SITE" "$TX/default-site"
    touch "$TX/default.changed"
elif [[ -e $DEFAULT_SITE ]]; then
    echo "Refusing to remove non-symlink $DEFAULT_SITE" >&2
    exit 1
fi

ARMED=1
install -m 0644 "$SCRIPT_DIR/files/nginx-repro-node.conf" "$CONF"

if [[ -f $TX/default.changed ]]; then
    rm -- "$DEFAULT_SITE"
fi

link_tmp=$BASE/.current-$RUN_ID
ln -s "$DEST" "$link_tmp"
mv -Tf -- "$link_tmp" "$CURRENT"
touch "$TX/activation.done"

nginx -t
if systemctl is-active --quiet nginx; then
    systemctl reload nginx
else
    systemctl enable --now nginx
fi

"$SCRIPT_DIR/validate.sh"
touch "$TX/success"
audit provision success "$RELEASE_ID"
trap - ERR
printf 'Provisioned release %s with transaction %s\n' "$RELEASE_ID" "$RUN_ID"

Zamka pogreške aktivira se tek nakon što transakcija ima dovoljno informacija za oporavak. Kvarovi tijekom instalacije paketa ili izgradnje izdanja ostavljaju prethodno aktivnu uslugu netaknutom.

Dodajte kontrolirano vraćanje na prethodno stanje

Spremite ovo kao rollback.sh. Ručno vraćanje na prethodno stanje dopušteno je samo kada aktivno izdanje i dalje odgovara izdanju postavljenom tom transakcijom. Ta zaštita sprječava da stara transakcija neprimjetno prepiše novije postavljanje.

#!/usr/bin/env bash
set -Eeuo pipefail

[[ $EUID -eq 0 ]] || { echo "Run as root" >&2; exit 1; }
[[ $# -ge 1 ]] || { echo "Usage: rollback.sh RUN_ID" >&2; exit 2; }

RUN_ID=$1
[[ $RUN_ID != *[^A-Za-z0-9._-]* ]] || { echo "Invalid run ID" >&2; exit 2; }

BASE=/opt/repro-node
CURRENT=$BASE/current
RELEASES=$BASE/releases
TX=/var/lib/reprovision/transactions/$RUN_ID
CONF=/etc/nginx/conf.d/repro-node.conf
DEFAULT_SITE=/etc/nginx/sites-enabled/default
AUDIT=/var/log/reprovision/audit.tsv

[[ -d $TX ]] || { echo "Unknown transaction: $RUN_ID" >&2; exit 1; }

if [[ ${REPROVISION_LOCK_HELD:-0} != 1 ]]; then
    exec 9>/run/lock/reprovision.lock
    flock -n 9 || { echo "Provisioning is active" >&2; exit 1; }
fi

previous="$(cat "$TX/previous_target")"
deployed="$(cat "$TX/new_target")"
current="$(readlink -f "$CURRENT" 2>/dev/null || true)"

if [[ -f $TX/activation.done ]]; then
    [[ $current == "$deployed" ]] || {
        echo "Active release no longer matches transaction" >&2
        exit 1
    }

    if [[ -n $previous ]]; then
        [[ $previous == "$RELEASES"/* && -d $previous ]] || {
            echo "Previous release is unavailable" >&2
            exit 1
        }
        tmp=$BASE/.rollback-$RUN_ID
        ln -s "$previous" "$tmp"
        mv -Tf -- "$tmp" "$CURRENT"
    else
        rm -- "$CURRENT"
    fi
fi

if [[ -f $TX/config.changed ]]; then
    install -m 0644 "$TX/nginx.conf" "$CONF"
elif [[ -f $TX/config.absent ]]; then
    rm -f -- "$CONF"
fi

if [[ -f $TX/default.changed ]]; then
    rm -f -- "$DEFAULT_SITE"
    cp -a --no-dereference "$TX/default-site" "$DEFAULT_SITE"
fi

nginx -t
if systemctl is-active --quiet nginx; then
    systemctl reload nginx
fi

printf '%s\t%s\trollback\tsuccess\t%s\n' \
    "$(date -u +%FT%TZ)" "$RUN_ID" "${previous:-no-release}" >>"$AUDIT"
logger -t reprovision -- "run=$RUN_ID action=rollback status=success"
printf 'Rolled back transaction %s\n' "$RUN_ID"

Nginx konfiguracija testira se prije ponovnog učitavanja. Neuspješan test ostavlja pokrenuti Nginx proces na njegovoj prethodnoj konfiguraciji u memoriji, iako datotečni sustav i dalje treba istražiti. Transakcijske direktorije držite u vlasništvu roota i nikada ne prihvaćajte arhive transakcija iz nepouzdanih izvora.

Provjerite usklađeno stanje

Spremite ovo kao validate.sh. Provjera provjerava sintaksu konfiguracije, stanje usluge, zadržavanje unutar izdanja, vlasništvo, dozvole i stvarni HTTP odgovor.

#!/usr/bin/env bash
set -Eeuo pipefail

CURRENT=/opt/repro-node/current
expected='{"status":"ok","service":"repro-node"}'

[[ -L $CURRENT ]] || { echo "Current release is not a symlink" >&2; exit 1; }
target="$(readlink -f "$CURRENT")"
[[ $target == /opt/repro-node/releases/* ]] || {
    echo "Current target escapes the releases directory" >&2
    exit 1
}
[[ -f $target/public/health.json ]] || {
    echo "Health document is missing" >&2
    exit 1
}

[[ "$(stat -c '%U:%G:%a' "$target/public/health.json")" == "root:root:644" ]] || {
    echo "Unexpected health document ownership or mode" >&2
    exit 1
}

nginx -t
systemctl is-active --quiet nginx

body="$(curl --fail --silent --show-error \
    --connect-timeout 2 --max-time 5 \
    http://127.0.0.1:8080/healthz)"

[[ $body == "$expected" ]] || {
    echo "Unexpected health response: $body" >&2
    exit 1
}

printf 'Validation passed for %s\n' "$target"

Vremensko ograničenje povezivanja ograničava uspostavljanje TCP veze; --max-time neovisno ograničava cijeli zahtjev. Jedno nije zamjena za drugo.

Postavite i testirajte

Iz direktorija projekta na poslužitelju instalirajte datoteke na mjesto u vlasništvu roota i pokrenite alat za pripremu:

sudo install -d -m 0755 /srv/reprovision/files/public
sudo install -m 0755 provision.sh rollback.sh validate.sh /srv/reprovision/
sudo install -m 0644 files/nginx-repro-node.conf /srv/reprovision/files/
sudo install -m 0644 files/public/health.json /srv/reprovision/files/public/

sudo /srv/reprovision/provision.sh
curl --fail --silent --show-error http://127.0.0.1:8080/healthz
sudo /srv/reprovision/provision.sh
sudo tail -n 20 /var/log/reprovision/audit.tsv
sudo journalctl -t reprovision --since today

Drugo pokretanje trebalo bi prijaviti nepromijenjeni skup paketa i izdanje, a ipak izvršiti provjeru. To je koristan test idempotentnosti: uspjeh znači usklađivanje, a ne „ništa nije izvršeno”.

Testirajte vraćanje na prethodno stanje na virtualnom stroju za jednokratnu upotrebu tako da promijenite health.json, ponovno izvršite pripremu i rezultirajući identifikator transakcije proslijedite naredbi sudo /srv/reprovision/rollback.sh RUN_ID. Zatim pokrenite validate.sh. Nemojte prvo simulirati kvar na produkcijskom poslužitelju.

Sigurnost, vidljivost i performanse

Neka u /srv/reprovision može pisati samo root ili strogo kontrolirani račun za postavljanje. Svatko tko može mijenjati ove skripte može ostvariti učinke na razini roota tijekom sljedećeg pokretanja. Pregledajte promjene prije izvršavanja i razmotrite potpisivanje artefakata postavljanja u većem sustavu.

Osluškivač povratne petlje zadržava krajnju točku izvan mrežnog vatrozida. Ako vanjski pristup postane potreban, dodajte usko ograničeno pravilo vatrozida bez brisanja postojećeg skupa pravila poslužitelja. Zadržite uspostavljene veze i potvrdite SSH pravilo dopuštanja prije omogućavanja pravila zadanog odbacivanja. Radije izložite Nginx kroz autentificirani obrnuti proxy nego da izravno otvorite port 8080.

Nginx zapisnici pristupa i pogrešaka pružaju vidljivost na razini zahtjeva; TSV datoteka i dnevnik pružaju događaje pripreme. Upozoravajte na neuspjela pokretanja pripreme, ponovljena vraćanja na prethodno stanje, neuspjele Nginx provjere i latenciju zdravstvene provjere. Prosljeđujte zapisnike izvan poslužitelja jer lokalni dokazi nestaju zajedno s poslužiteljem.

Izdanja adresirana sadržajem izbjegavaju ponovno kopiranje nepromijenjenih artefakata, a provjere paketa izbjegavaju nepotrebna osvježavanja repozitorija. Globalno zaključavanje serijalizira rijetku putanju koja mijenja stanje. Čišćenje izdanja trebalo bi biti zaseban, pregledan zadatak zadržavanja koji nikada ne uklanja trenutačni cilj ni bilo koje izdanje na koje upućuju zadržane transakcije.

Uobičajeni načini kvara

  • Repozitoriji paketa nisu dostupni: pokretanje se zaustavlja prije aktivacije, ostavljajući trenutačno izdanje netaknutim.
  • Nginx već poslužuje aplikacije: zaustavite se i prilagodite model vlasništva konfiguracije; ovaj vodič pretpostavlja namjenski poslužitelj.
  • Trenutačna putanja nije simbolička poveznica: skripta odbija zamijeniti neupravljani direktorij.
  • Provjera vraća odbijenu vezu: pregledajte systemctl status nginx i Nginx zapisnik pogrešaka.
  • Vraćanje na prethodno stanje odbija transakciju: aktivno je novije postavljanje ili je prethodno izdanje uklonjeno.
  • Odstupanje dozvola: provjera ne uspijeva prije proglašenja uspjeha, čineći odstupanje vidljivim u revizijskom tragu.

Završni kontrolni popis provjere

  • Alat za pripremu uspijeva dvaput s istim unosima.
  • /opt/repro-node/current pokazuje unutar direktorija izdanja.
  • nginx -t uspijeva i Nginx je aktivan.
  • Zdravstvena krajnja točka odgovara samo na 127.0.0.1:8080.
  • Odgovor se točno podudara s očekivanim JSON-om.
  • Revizijski TSV i dnevnik sadržavaju događaje početka, promjene, uspjeha i vraćanja na prethodno stanje.
  • Testirana transakcija može vratiti prethodno izdanje.
  • Datoteke projekta, transakcije i ovlasti za postavljanje ograničene su.
  • Pristup vatrozidu i SSH-u neovisno je pregledan.

Ponovljivost se ne postiže stavljanjem naredbi u datoteku ljuske. Proizlazi iz izričitog vlasništva, determinističkih ulaza, atomske aktivacije, ograničene provjere, iskrenih granica vraćanja na prethodno stanje i dokaza svakog prijelaza. Kada su ta svojstva osmišljena zajedno, ponovna izgradnja poslužitelja prestaje biti čin arheologije i postaje rutinska operacija koju je moguće pregledati.

Portret autora bloga

Mihajlo

Ja sam Mihajlo — programer vođen znatiželjom, disciplinom i stalnom željom da stvorim nešto smisleno. Dijelim uvide, tutorijale i besplatne usluge kako bih pomogao drugima da pojednostave svoj rad i rastu u svijetu softvera i umjetne inteligencije koji se neprestano razvija.