GitHub Actions non raggiunge la tua VPN: runner self-hosted o tunnel
Un runner ospitato è una VM nuova fuori dalla tua rete. Metti un runner dentro quella rete, oppure apri un tunnel dal job e attendi una rotta reale.
Il tuo passo di deploy si blocca su un nome host interno mentre lo stesso comando risponde all’istante dal tuo portatile, e la build del client sulle due macchine è identica. Il job gira su un runner ospitato da GitHub: una macchina virtuale creata per quel singolo job nella cloud di GitHub, senza rotta verso il tuo spazio di indirizzi privato, senza voce di resolver per la zona interna e senza certificato che l’endpoint privato accetterà. Solo due progetti stanno dentro quel vincolo, e nessuno dei due si può accendere dopo senza ricostruire il workflow. Scegli prima della prima riga di YAML, perché migrare a posteriori costa più del primo tentativo.
La scelta è tra un runner collocato dove la rete finisce già e un tunnel che esiste per la durata di un job. Entrambi reggono deploy di produzione da anni, e falliscono in modi diversi.
Dove si trova davvero il confine
Tutto ciò che installi sul portatile si ferma al portatile, e tutto ciò che installi durante un job sparisce con il runner. Non esiste un namespace di rete persistente a cui un client possa restare agganciato tra un’esecuzione e l’altra, quindi ogni esecuzione riparte dallo stesso stato irraggiungibile. Azure documenta questo confine per gli agenti ospitati da Microsoft, dove i collegamenti VPN ed ExpressRoute verso reti private non sono supportati, e i runner ospitati da GitHub portano quella stessa forma di restrizione invece di un meccanismo separato. Leggilo come una proprietà del prodotto, non come un difetto da aggirare.
Nessuna impostazione del repository lo allenta. L’etichetta del job decide quale pool prende il lavoro, e un’etichetta ospitata significa una macchina fuori dal tuo perimetro per tutta l’esecuzione.
La raggiungibilità privata deve quindi venire da qualcosa che possiedi. Un runner collocato dove la rete finisce già, o un tunnel che vive un job. Il resto dell’articolo riguarda il pagare una delle due di proposito invece di scoprire la fattura in produzione.
Decidi in base alla frequenza dei run, non alla familiarità
Scegli un runner self-hosted dentro la VPC o la rete dell’ufficio quando i deploy arrivano più volte al giorno, quando il job tocca più di una porta privata o quando un team di piattaforma gestisce già patch e rotazione delle credenziali. Un job su quella macchina risolve e instrada come qualsiasi altro host dietro il firewall. Il lavoro di rete avviene una volta, all’installazione, invece che a ogni esecuzione.
Costruisci il tunnel dentro il job quando l’accesso è occasionale e non vuoi un agente di lunga durata che tenga una credenziale verso la tua rete. Il job diventa il proprio client di rete per qualche minuto e non lascia nulla indietro, che spesso è tutto il punto.
Stima il costo del tunnel prima di sceglierlo. Ogni esecuzione paga la materializzazione del profilo, l’avvio del client, l’attesa della disponibilità e lo smontaggio. Un tunnel che sale ma non viene mai verificato produce un deploy che fallisce a metà e lascia lo stato privato in una forma che nessuno aveva pianificato. Il tentativo successivo è di rado pulito, e il secondo errore di solito viene attribuito alla versione del client.
Scrivere il profilo perché il passo non possa abortire
Tieni profili e credenziali fuori dal repository e materializzali dai secret nel passo che ne ha bisogno. L’ordine decide se il job sopravvive: un file creato sotto sudo appartiene a root, e la shell senza privilegi del passo non può più scriverci.
- name: Bring up OpenVPN
run: |
umask 077
profile="$RUNNER_TEMP/client.ovpn"
printf '%s\n' "$VPN_PROFILE" > "$profile"
: > "$RUNNER_TEMP/openvpn.log"
printf '%s\n' "$VPN_CREDENTIALS" > "$RUNNER_TEMP/credentials.txt"
sudo openvpn --daemon openvpn-job \
--config "$profile" \
--auth-user-pass "$RUNNER_TEMP/credentials.txt" \
--writepid "$RUNNER_TEMP/openvpn.pid" \
--log "$RUNNER_TEMP/openvpn.log"Entrambi i dettagli di quel blocco esistono per i passi successivi. Con umask 077 il profilo arriva in modalità 0600 di proprietà dell’utente del runner, sudo può ancora leggerlo, e il client scrive il suo log in un file che il runner possiede già, così il ciclo di disponibilità può leggere quel log senza un secondo sudo. --writepid registra il PID che lo smontaggio prenderà di mira.
Un profilo che si autentica con un certificato client ha bisogno della chiave scritta allo stesso modo, e un profilo static-challenge ha bisogno della modalità di autenticazione corrispondente. I profili che spingono opzioni DNS hanno bisogno di openvpn-systemd-resolved su Ubuntu e Debian, perché la spinta da sola non cambia nulla su un’immagine base.
La disponibilità è una sonda, non una pausa
Un sleep fisso tiene fino a quando il concentratore rallenta, e allora il passo di deploy gira prima che la rotta esista. L’errore emerge poi nello strumento di deploy come timeout verso l’host privato, il che non dice nulla sul tunnel.
- name: Await private endpoint
run: |
for attempt in $(seq 1 30); do
if getent hosts private.example.internal > /dev/null && \
timeout 3 bash -c 'exec 3<>/dev/tcp/private.example.internal/8443' 2>/dev/null; then
echo "endpoint answered after $attempt attempts"
exit 0
fi
sleep 2
done
grep -Ei 'error|verify|auth' "$RUNNER_TEMP/openvpn.log" | tail -40
exit 1Il ciclo osserva due cose perché ciascuna può essere la metà rotta. getent hosts dimostra che il resolver spinto risponde per quel nome, e l’apertura del socket dimostra che qualcosa ascolta sulla porta che il deploy userà; un profilo di sola rotta ha bisogno dell’indirizzo letterale e della riga getent cancellata.
Quando il ciclo scade, il log è l’unica cosa che separa tre guasti diversi: un certificato rifiutato, una modalità di autenticazione sbagliata e una rotta mai arrivata. Filtrare prima del tail mantiene quell’output abbastanza corto da leggere mentre il job sta ancora fallendo. Leggi il primo errore invece dell’ultima riga, perché un client che non riesce ad autenticarsi si ripete finché il ciclo non si arrende. ip -4 route show dev tun0 risponde alla domanda più semplice: se una rotta è stata spinta o no, e costa una riga dentro lo stesso ciclo.
Smontaggio e la questione dell’artefatto
Chiudi il job con un passo protetto da if: always() che esegue sudo kill "$(sudo cat "$RUNNER_TEMP/openvpn.pid")" e nient’altro. Su un runner self-hosted condiviso, non uccidere mai per nome di processo: un altro job può possedere un client con lo stesso binario e argomenti simili. Il guasto cade allora sul job che è partito secondo, e il suo log punta a un workflow che non ha fatto nulla di male.
La protezione conta perché un deploy fallito lascia comunque un tunnel vivo. Senza always() il passo di pulizia viene saltato proprio quando serve, e l’esecuzione successiva parte contro un client già connesso.
Resta una domanda prima di adottare uno dei due progetti: la fase di build ha davvero bisogno di una rotta privata? Un agente di deploy che sta già dentro la rete può scaricare un artefatto approvato, il che tiene le build ordinarie lontane dal percorso privato. Meno job che tengono un tunnel significa meno posti dove nascondere una rotta obsoleta.