GitHub Actions nu ajunge la VPN-ul tău: runner propriu sau tunel
Un runner găzduit este o VM nouă în afara rețelei tale. Fie pui un runner în acea rețea, fie deschizi un tunel din job și aștepți o rută reală.
Pasul tău de deploy se blochează pe un nume de host intern în timp ce aceeași comandă răspunde instantaneu de pe laptop, iar buildul clientului este identic pe ambele mașini. Jobul rulează pe un runner găzduit de GitHub: o mașină virtuală creată pentru acel singur job în cloudul GitHub, fără rută către spațiul tău de adrese privat, fără intrare de resolver pentru zona internă și fără certificat pe care endpointul privat să îl accepte. Doar două arhitecturi se potrivesc acestei constrângeri, și niciuna nu poate fi activată mai târziu fără a reconstrui workflowul. Alege între ele înainte de prima linie de YAML, pentru că migrarea ulterioară costă mai mult decât prima încercare.
Alegerea este între un runner plasat unde rețeaua se termină deja și un tunel care există pe durata unui job. Ambele susțin deployuri de producție de ani buni, și eșuează în moduri diferite.
Unde se află de fapt granița
Tot ce instalezi pe laptop se oprește la laptop, iar tot ce instalezi în timpul unui job dispare odată cu runnerul. Nu există un namespace de rețea persistent de care un client să rămână atașat între rulări, așa că fiecare rulare pornește din aceeași stare inaccesibilă. Azure documentează această graniță pentru agenții găzduiți de Microsoft, unde legăturile VPN și ExpressRoute către rețele private nu sunt acceptate, iar runnerii găzduiți de GitHub poartă aceeași formă de restricție în loc de un mecanism separat. Citește asta ca o proprietate a produsului, nu ca un defect de ocolit.
Nicio setare a repository-ului nu o relaxează. Eticheta jobului decide ce pool preia munca, iar o etichetă găzduită înseamnă o mașină în afara perimetrului tău pe toată durata rulării.
Accesibilitatea privată trebuie deci să vină din ceva ce deții. Un runner plasat unde rețeaua se termină deja, sau un tunel care trăiește un singur job. Restul articolului tratează plata deliberată pentru una dintre cele două, în loc de a descoperi factura în producție.
Decide după frecvența rulărilor, nu după familiaritate
Apelează la un runner propriu în VPC sau în rețeaua biroului când deployurile sosesc de mai multe ori pe zi, când jobul atinge mai mult de un port privat sau când o echipă de platformă se ocupă deja de patchuri și de rotația credențialelor. Un job pe acea mașină rezolvă și rutează ca orice alt host din spatele firewallului. Munca de rețea se face o singură dată, la instalare, în loc de la fiecare rulare.
Construiește tunelul în interiorul jobului când accesul este ocazional și nu vrei un agent de lungă durată care ține o credențială spre rețeaua ta. Jobul devine propriul client de rețea pentru câteva minute și nu lasă nimic în urmă, ceea ce este adesea tot scopul.
Estimează costul tunelului înainte de a-l alege. Fiecare rulare plătește materializarea profilului, pornirea clientului, așteptarea disponibilității și demontarea. Un tunel care urcă dar nu este niciodată verificat produce un deploy care eșuează la jumătatea drumului și lasă starea privată într-o formă pe care nimeni nu a planificat-o. Reîncercarea de după aceea este rar curată, iar al doilea eșec este de obicei pus pe seama versiunii clientului.
Scrierea profilului astfel încât pasul să nu poată aborta
Ține profilurile și credențialele în afara repository-ului și materializează-le din secrete în pasul care are nevoie de ele. Ordinea decide dacă jobul supraviețuiește: un fișier creat sub sudo aparține lui root, iar shellul fără privilegii al pasului nu mai poate scrie în el.
- 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"Ambele detalii din acel bloc există pentru pașii următori. Cu umask 077 profilul ajunge la modul 0600 și aparține utilizatorului runnerului, sudo îl poate citi în continuare, iar clientul își scrie logul într-un fișier pe care runnerul îl deține deja, așa că bucla de disponibilitate poate citi acel log fără un al doilea sudo. --writepid înregistrează PIDul pe care îl va viza demontarea.
Un profil care se autentifică cu un certificat de client are nevoie de cheia lui scrisă la fel, iar un profil cu provocare statică are nevoie de modul de autentificare potrivit. Profilurile care împing opțiuni DNS au nevoie de openvpn-systemd-resolved pe Ubuntu și Debian, pentru că împingerea singură nu schimbă nimic pe o imagine de bază.
Disponibilitatea este o sondă, nu o pauză
Un sleep fix rezistă exact până când concentratorul devine lent, iar apoi pasul de deploy rulează înainte ca ruta să existe. Eșecul apare apoi în instrumentul de deploy ca timeout către hostul privat, ceea ce nu spune nimic despre tunel.
- 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 1Bucla urmărește două lucruri pentru că oricare dintre ele poate fi jumătatea defectă. getent hosts dovedește că resolverul împins răspunde pentru acel nume, iar deschiderea socketului dovedește că ceva ascultă pe portul pe care îl va folosi deployul; un profil doar cu rută are nevoie de adresa literală și de linia getent ștearsă.
Când bucla expiră, logul este singurul lucru care separă trei defecte diferite: un certificat respins, un mod de autentificare greșit și o rută care nu a sosit niciodată. Filtrarea înainte de tail păstrează acea ieșire suficient de scurtă pentru a fi citită în timp ce jobul încă eșuează. Citește prima eroare în loc de ultima linie, pentru că un client care nu se poate autentifica se repetă până când bucla renunță. ip -4 route show dev tun0 răspunde la întrebarea mai simplă dacă s-a împins vreo rută, și costă o linie în aceeași buclă.
Demontare și întrebarea despre artefact
Închide jobul cu un pas protejat de if: always() care rulează sudo kill "$(sudo cat "$RUNNER_TEMP/openvpn.pid")" și nimic altceva. Pe un runner propriu partajat, nu omorî niciodată după numele procesului: alt job poate deține un client cu același binar și argumente similare. Eșecul cade atunci pe jobul care a rulat al doilea, iar logul lui indică un workflow care nu a greșit cu nimic.
Protecția contează pentru că un deploy eșuat lasă totuși un tunel viu. Fără always() pasul de curățare este sărit exact când este necesar, iar rularea următoare pornește împotriva unui client deja conectat.
Rămâne o întrebare înainte de a adopta oricare dintre arhitecturi: are etapa de build nevoie de o rută privată? Un agent de deployment care stă deja în rețea poate descărca un artefact aprobat, ceea ce ține buildurile obișnuite departe de calea privată. Mai puține joburi care țin un tunel înseamnă mai puține locuri unde se poate ascunde o rută învechită.