GitHub Actions: i minuti gratuiti non bastano? Due Self-hosted Runner su un VPS: configurazione e isolamento
Schema dalla reale architettura a due Runner, Job riusciti in passato e impostazioni systemd ispezionate. L’animazione prima dimostra l’instradamento dei task e i confini di risorse, poi conserva solo un debole bagliore di connessione a bassa frequenza; quando è attiva l’impostazione di sistema “riduci movimento”, viene mostrato un diagramma di architettura statico.
Quando la CI gira molto su un repository privato, i minuti gratuiti del piano GitHub Actions diventano in fretta un numero da tenere d’occhio. Se hai già un VPS acceso tutto il giorno, puoi lasciare a GitHub la gestione dei workflow e affidare le build e i test veri a questa macchina?
Sì. Due workflow che mantengo hanno già affidato parte dei loro task deterministici allo stesso VPS: un runner si occupa di build e deploy del blog, l’altro esegue i controlli degli strumenti di ingegneria. GitHub si occupa di trigger e log; il calcolo avviene sul mio server. GitHub attualmente non addebita minuti di calcolo per i job dei Self-hosted Runner; ma questo non significa che il VPS sia gratis — né che si debba buttare ogni PR sulla macchina di produzione.
Se convenga farlo, io partirei da tre domande:
- I minuti gratuiti stanno davvero finendo? Se ce n’è ancora in abbondanza, la comodità di GitHub-hosted potrebbe valere più del fai-da-te.
- Il VPS deve stare online tutto il giorno comunque? Riutilizzare capacità esistente è un conto diverso dal noleggiare una macchina nuova solo per la CI.
- Quanto è affidabile il codice da eseguire? Task fissi su un main branch protetto si possono valutare per l’infrastruttura propria; i PR non affidabili vanno prima in un ambiente di esecuzione isolato.
Le prime due domande decidono se lo sforzo vale la pena; la terza decide se per risparmiare qualche minuto si finisca per sacrificare anche l’ambiente di produzione.
Con i Self-hosted sono gratis i minuti di calcolo, non l’intero conto
La documentazione sulla fatturazione di GitHub Actions dice attualmente: i job dei Self-hosted Runner sono gratuiti; i runner standard GitHub-hosted sui repository privati consumano prima i minuti inclusi nel piano, e l’eccedenza si paga. Secondo le quote ufficiali attuali, GitHub Free include 2000 minuti al mese, Pro e Team 3000 minuti — la cifra esatta dipende comunque dall’account del proprietario del repository. Prendendo come esempio un runner standard Linux x64 a 2 vCPU, il prezzo ufficiale di listino è di $0.006 al minuto, e i job ospitati arrotondano per eccesso le frazioni di minuto.
Va scomposto in tre voci:
| Costo | Cosa succede dopo lo spostamento su un VPS |
|---|---|
| Minuti di calcolo GitHub-hosted | I job Self-hosted spostati smettono di consumare questi minuti ospitati |
| Calcolo e manutenzione del VPS | CPU, memoria, disco, banda e costi operativi sono a tuo carico |
| Altri SKU GitHub | Storage extra di Artifact/Cache e altri job ospitati continuano a essere conteggiati separatamente |
C’è anche un momento facile da fraintendere per via di vecchie notizie. GitHub aveva annunciato in origine una tariffa di piattaforma di $0.002 al minuto per i Self-hosted Runner, aggiornando poi l’annuncio per rimandarne l’applicazione. Il “fatturazione da marzo 2026” di un vecchio articolo non può essere preso come regola vigente; viceversa, non c’è garanzia che non cambierà mai.
I miei tre job storici si prestano bene a un’ipotesi: se avessero girato su runner ospitati Linux standard a 2 vCPU per esattamente la stessa durata, verrebbero arrotondati a 9, 13 e 2 minuti, per un totale di 24 minuti. Solo se tutti questi minuti fossero eccedenze fatturate, il prezzo di riferimento di listino sarebbe $0.144. Questa è aritmetica contro i prezzi di listino, non un risparmio reale; fattura e uso incluso nel piano dello stesso ciclo vanno verificati nello Usage del tuo account.
Un VPS, due runner: non basta cambiare runs-on
Un Self-hosted Runner è un processo che gira sulla tua macchina e si connette attivamente a GitHub per prendere i job — non è GitHub che accede al VPS via SSH. La documentazione ufficiale dei runner spiega il meccanismo completo.
Dai miei record esistenti dei job di GitHub Actions ho preso tre esecuzioni andate a buon fine:
| Task reale | Ruolo del runner | Risultato | Intervallo tra inizio e fine del log del job |
|---|---|---|---|
| Build del blog e deploy in produzione | Runner di produzione | Success | 8 min 32 s |
| Validazione contenuti di un PR del blog (3º tentativo) | Lo stesso runner di produzione | Success | 12 min 12 s |
| Controlli di sicurezza e contratto degli strumenti di ingegneria | Runner CI dedicato | Success | 1 min 27 s |
I due runner hanno identità distinte su GitHub, e i log dei job puntano allo stesso VPS. Questo almeno dimostra che due repository consumano davvero capacità di calcolo propria, non solo un YAML ben scritto. I tempi nella tabella sono gli intervalli dei log dei job storici — buoni per confrontare i record di esecuzione; le questioni di capacità richiedono comunque limiti di risorse e disciplina operativa.
Su un VPS non molto potente, il problema più frequente è semmai la contesa di risorse: la CI avvia una build grossa e il servizio di produzione, sempre acceso, diventa improvvisamente lento. Per questo tratto separatamente le identità dei runner, i limiti dell’host e l’ammissione dei workflow.
Due etichette dei runner non bastano: è systemd che limita davvero le risorse
Ciascuno dei due repository può creare il proprio runner a livello di repository in Settings → Actions → Runners, prendendo dalla pagina GitHub attuale l’URL di download, il checksum e il token di registrazione a breve durata. Produzione e CI girano con account Linux non root diversi, directory di lavoro indipendenti e privilegi minimi, e ricevono etichette proprie, ad esempio:
# 已审查的生产部署
runs-on: [self-hosted, linux, x64, deploy-prod]
# 受信任的工程检查
runs-on: [self-hosted, linux, x64, ci-lab]
Le etichette scelgono solo chi esegue, non isolano. Se entrambi i runner possono leggere i segreti di produzione, o entrambi hanno sudo illimitato o accesso al socket Docker, nomi diversi non proteggono nulla sul VPS.
Lo script di installazione che ho scritto per il runner CI a bassa priorità contiene in realtà questo drop-in di servizio di systemd. Questo è il set iniziale di limiti che uso su un piccolo VPS condiviso da 2 vCPU e circa 2.4 GiB di memoria:
[Service]
CPUQuota=75%
MemoryHigh=700M
MemoryMax=1050M
OOMScoreAdjust=400
Nice=10
Qui c’è un dettaglio facile da fraintendere. Secondo la documentazione sul controllo delle risorse di systemd, CPUQuota=75% significa il 75% del tempo di CPU di un singolo core, non il 75% dell’intero VPS dual-core. MemoryHigh=700M è una soglia morbida da cui la pressione di recupero memoria inizia a salire; MemoryMax=1050M è un confine duro — superarlo può scatenare OOM e far fallire il job CI. Nice=10 e OOMScoreAdjust=400 rendono questo CI più incline a cedere il passo ai servizi critici, ma non possono promettere che la produzione non sarà mai toccata.
Il prerequisito è un host con cgroup v2 con controller di memoria e una versione di systemd corrispondente. Esegui prima test -f /sys/fs/cgroup/cgroup.controllers && grep -qw memory /sys/fs/cgroup/cgroup.controllers && systemctl --version per confermare la gerarchia unificata, il controller di memoria e la versione di systemd; non copiarlo tal quale in un ambiente vecchio e dare per scontato che i limiti abbiano effetto.
Il ./config.sh del runner registra soltanto; non crea automaticamente un servizio di systemd. Seguendo i passaggi ufficiali del servizio Linux di GitHub, entra in ciascuna directory di installazione registrata dei runner di produzione e CI, e con privilegi di amministratore esegui install <utente dedicato> su ciascuno svc.sh, poi start. Ad esempio, eseguilo dentro la directory di installazione del runner CI (registrata, con svc.sh presente):
sudo ./svc.sh install ci-runner
sudo ./svc.sh start
sudo ./svc.sh status
ci-runner è un account di esempio — non riusare l’account di produzione; se c’è già un servizio in esecuzione, non fare install due volte. Poi trova la vera unit CI e imposta i limiti in una finestra di manutenzione:
systemctl list-unit-files 'actions.runner.*.service'
# 替换为上一行显示的真实 CI unit,切勿照抄占位名称
UNIT='actions.runner.EXAMPLE-REPO.ci-01.service'
sudo systemctl edit "$UNIT"
# 在编辑器里保存上面的 [Service] 配置
sudo systemctl daemon-reload
sudo systemctl restart "$UNIT"
systemctl cat "$UNIT"
systemctl show "$UNIT" -p CPUQuotaPerSecUSec -p MemoryHigh -p MemoryMax
restart interromperà il job in esecuzione su questo runner — prima assicurati che sia inattivo, e non riavviare per errore il runner di produzione. Dopo il riavvio, conferma nella pagina Runners del repository che sia tornato Online, e verifica i valori effettivi con systemctl show. Al rollback, rimuovi solo il drop-in CI aggiunto questa volta — non cancellare di passaggio personalizzazioni di servizio esistenti. Codici di registrazione, nomi host, account di servizio e directory di produzione non devono mai finire nei workflow, né dovrebbero apparire in un articolo pubblico.
Ricontrolla le risorse ancora una volta prima che il workflow parta
systemd può mettere dei confini al gruppo di processi di un servizio, ma non può creare spazio libero su disco dal nulla. La mia CI di ingegneria ha anche un gate di ammissione risorse: legge la memoria disponibile dell’host e lo spazio libero sul filesystem della directory di lavoro. Sotto una ricetta Node.js ridotta per un main branch affidabile:
name: Trusted main validation
on:
push:
branches: [main]
permissions:
contents: read
concurrency:
group: trusted-main-validation
cancel-in-progress: false
jobs:
test:
runs-on: [self-hosted, linux, x64, ci-lab]
timeout-minutes: 12
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- name: Check available resources
shell: bash
run: |
set -euo pipefail
mem_kib="$(awk '/^MemAvailable:/ {print $2}' /proc/meminfo)"
disk_kib="$(df -Pk "$GITHUB_WORKSPACE" | awk 'NR==2 {print $4}')"
echo "available memory=${mem_kib} KiB, disk=${disk_kib} KiB"
if [ "$mem_kib" -lt 600000 ]; then
echo "::error::Memory below 600000 KiB: ${mem_kib} KiB"
exit 1
fi
if [ "$disk_kib" -lt 5242880 ]; then
echo "::error::Disk below 5242880 KiB: ${disk_kib} KiB"
exit 1
fi
- uses: actions/setup-node@v7
with:
node-version: 24
- run: npm ci
- run: npm test
Questo esempio usa actions/checkout@v7 e actions/setup-node@v7; usano Node.js 24 come runtime dell’azione e richiedono almeno la versione v2.327.1 di Actions Runner. Controlla la versione del runner prima di migrare macchine esistenti — non limitarti ad aggiornare lo YAML. checkout v7 e setup-node v7 hanno le note corrispondenti.
Circa 586 MiB di memoria disponibile e 5 GiB di disco libero sono le soglie di ammissione di questo piccolo VPS. Lo script stampa le quantità restanti e il motivo del fallimento, così è facile distinguere mancanza di memoria da mancanza di disco.
Questo passo può solo verificare, non prenotare. I gruppi concurrency di due repository non sono un lock globale tra repository. La mia scelta è tenere la CI di ingegneria sotto i tetti di CPU/memoria del suo cgroup systemd — fallendo quando le risorse mancano invece di crescere senza limiti; il servizio di produzione mantiene i suoi permessi e confini di risorse. Per build grosse, o casi in cui i due repository non devono mai girare insieme, l’opzione più affidabile è passare a un’unica coda di task controllata o a un VPS indipendente, con un unico scheduler che distribuisce il lavoro in serie; non scambiare un concurrency omonimo tra repository per un mutex globale. Se vuoi una vera serializzazione a livello di host, puoi progettare un executor wrapper flock revisionato a parte — ma deve bloccare l’ingresso di tutti i task intensivi di risorse; bloccare solo prima di un job non serve; questo approccio non è in uso sulla mia macchina. Il costo è che i task fanno coda; non si può mantenere il throughput concorrente di entrambi i repository.
Se devi davvero serializzare i passi pesanti dei due repository sullo stesso host, puoi scegliere una ricetta di file-lock condiviso revisionata a parte. Prima, fai creare da un amministratore un gruppo di sistema solo per il lock, aggiungi entrambi gli utenti runner affidabili al gruppo, e configura il file di lock condiviso (solo per coordinare le build; non dare al gruppo i permessi sulla directory di produzione):
sudo groupadd --system ci-queue
sudo usermod -aG ci-queue runner-a
sudo usermod -aG ci-queue runner-b
sudo install -d -o root -g ci-queue -m 3770 /var/lock/ci-queue
sudo install -o root -g ci-queue -m 0660 /dev/null /var/lock/ci-queue/build.lock
I nomi account qui sono segnaposto; dopo aver aggiunto nuovi gruppi conviene riavviare i servizi runner interessati in una finestra di manutenzione perché raccolgano i nuovi gruppi supplementari — i job in esecuzione non vanno interrotti alla leggera. Poi entrambi i repository devono mettere i propri comandi intensivi di risorse nell’ambito dello stesso lock:
flock -w 900 /var/lock/ci-queue/build.lock \
bash -e -c 'npm ci && npm test'
Così il secondo partecipante aspetta al massimo 900 secondi e poi fallisce, invece di restare appeso per sempre. Protegge solo i comandi avvolti dallo stesso lock — non serializza automaticamente tutti i passi di un job GitHub, né può impedire a un workflow non affidabile di saltare il lock di proposito. Anche per questo, i miei runner duali esistenti non usano questo schema di lock; è una ricetta replicabile che richiede valutazione indipendente, non un risultato misurato di un setup in produzione.
Non alzare MemoryMax senza limiti solo per far diventare verde una CI; i task che superano cronicamente il margine appartengono a una macchina separata.
PR e produzione sullo stesso runner: un altro conto di rischi
Guardando i workflow esistenti del blog, ho scoperto che la validazione dei contenuti dei PR e il deploy in produzione usano ancora le stesse etichette del runner di produzione. In un repository privato con solo maintainer affidabili può essere tollerabile per ora — ma non va insegnato come tutorial di sicurezza per la collaborazione aperta.
La guida alla sicurezza di GitHub avverte: i Self-hosted Runner persistenti che eseguono codice non affidabile possono lasciare contaminazione persistente tra job, o permettere agli script di toccare file e permessi dell’host. Se in futuro collaboratori esterni, PR da fork o altro codice non affidabile entrano nella catena di validazione, vanno separati dalla macchina di produzione:
不受信任 PR -> GitHub-hosted / 全新隔离 Runner -> 审查
受保护 main -> 专用生产 Self-hosted Runner -> 部署
Questo significa che parte della validazione dei PR potrebbe continuare a spendere minuti GitHub-hosted. Ma permettere a script sconosciuti di avvicinarsi alle credenziali di deploy per risparmiare qualche minuto è un cattivo scambio. Anche in un repository privato, bisogna guardare i permessi reali dei collaboratori, la protezione dei branch e chi può modificare i workflow.
Runner caduto o VPS in avaria: come ripristinare il deploy
Il costo dei runner propri non è solo l’affitto mensile — include anche downtime, aggiornamenti e tempo di ripristino. Una differenza molto pratica: quando un Self-hosted Runner va offline, i job con le sue etichette si accodano; non ricadono automaticamente su ubuntu-latest. La documentazione di GitHub dice che un job senza runner disponibile attende fino a circa 24 ore prima di fallire.
L’ordine di ripristino può essere semplice: prima guarda nella pagina Runners del repository GitHub se è Online, poi controlla il ./svc.sh status della CI, journalctl -u <unit-reale> -n 100 --no-pager e lo spazio libero su disco; se c’è un OOM di cgroup, prima riduci il carico o aspetta che sia inattivo, invece di togliere il limite direttamente. L’applicazione del runner di solito si autoaggiorna, ma se hai disattivato l’autoaggiornamento devi seguire tu le versioni; secondo le regole ufficiali, un runner fuori dalla finestra di aggiornamento — o privo di un aggiornamento di sicurezza critico — potrebbe smettere di ricevere job.
Il deploy in produzione dovrebbe avere un canale di ripristino separato e chiaramente definito: ad esempio, una build GitHub-hosted approvata separatamente + passo di deploy controllato dietro un punto di ingresso di pubblicazione protetto, oppure riprovare dopo aver ripristinato il VPS tramite il tuo canale operativo abituale. I runner GitHub-hosted per impostazione non possono accedere alle directory locali del VPS né conservare le credenziali originali di deploy di produzione, quindi cambiare solo runs-on in ubuntu-latest non è un fallback funzionante. Finché non l’hai provato a parte, non descrivere questa riserva come un disaster recovery automatico verificato.
Se si risparmia davvero, lo si decide alla fine nello Usage
Gli Actions verdi provano solo che i job sono finiti; non fanno la tua contabilità mensile. In Billing & licensing → Usage di GitHub, controlla l’uso di Actions per ciclo di fatturazione; per il dettaglio, esporta un report secondo il metodo ufficiale. Separa i minuti ospitati oltre quota, lo storage di Artifact/Cache, l’affitto extra del VPS e i costi di manutenzione.
Quando guardo questo tipo di migrazioni, il mio ordine è sempre: prima vedere se i minuti del piano sono davvero tirati, poi vedere se il VPS era già sempre acceso, e infine confrontare il confine di fiducia del codice eseguito. Build stabili, frequenti e affidabili su un VPS esistente sono buoni candidati per i Self-hosted; i progetti con poca CI e minuti gratuiti inutilizzati forse escono meglio con GitHub-hosted.
Il calcolo dei costi ammette direttamente i tuoi numeri: minuti ospitati migrabili oltre piano al mese × prezzo unitario dello SKU corrispondente − costo nuovo di VPS/operazioni. Ad esempio, supponi 1000 minuti tutti come eccedenza di Linux 2-core standard: prezzo di listino $6; se erano dentro la quota gratuita, il conto marginale può essere $0. Vale la pena spostare solo i task affidabili, manutenibili e ripristinabili.
Il precedente percorso pratico ChatGPT Web + GitHub + Self-hosted Runner trattava del punto di ingresso dello sviluppo e dell’uscita del deploy; questo risponde a un’altra domanda: come risparmiare i minuti GitHub-hosted, e come evitare che più runner su un VPS si ostacolino a vicenda.
Nota sui tempi: fatturazione e annunci ufficiali verificati al 19 ottobre 2026; i tempi dei job provengono da record di esecuzione esistenti, i valori di systemd da script di installazione esistenti — non da nuovi stress test dell’host.