Creiamo un compito per il deploy su GKE senza plugin, sms e registrazione. Diamo una sbirciatina a Jenkins sotto la giacca

Tutto è iniziato quando il team lead di una delle nostre squadre di sviluppo ha chiesto di pubblicare in modalità di test la loro nuova applicazione, che era stata containerizzata il giorno prima. L'ho pubblicata. Circa 20 minuti dopo è arrivata la richiesta di aggiornare l'applicazione, perché avevano aggiunto una funzione molto necessaria. Ho aggiornato. Dopo un paio d'ore... beh, potete immaginare cosa sia successo dopo...

Confesso di essere piuttosto pigro (l'ho già detto, giusto? No?), e considerando il fatto che i team lead hanno accesso a Jenkins, dove abbiamo tutta la CI/CD, ho pensato: che lo faccia lui stesso il deployment, quanto gli pare! Mi è venuto in mente un aneddoto: dai una pesce a un uomo e lui mangia per un giorno; chiamalo Sazioso e mangerà per tutta la vita. E sono andato a creare un job, che potesse eseguire il deployment di un container con l'applicazione di qualsiasi versione compilata con successo e passarvi qualsiasi valore ENV (mio nonno, filologo e ex insegnante di inglese, ora mi guarderebbe con aria scettica e mi darebbe uno sguardo molto significativo dopo aver letto questa frase).

Quindi, in questa nota parlerò di come ho imparato:

  1. Ad aggiornare dinamicamente i job in Jenkins dall'interno di un job o da altri job;
  2. Ad accedere alla console cloud (Cloud shell) da un nodo con l'agente Jenkins installato;
  3. A eseguire il deployment di un carico di lavoro (workload) nel Google Kubernetes Engine.


In realtà, sto un po' esagerando. Si presume che almeno una parte dell'infrastruttura sia nel cloud di Google, e quindi siete utenti e, ovviamente, avete un account GCP. Ma questa nota non riguarda questo.

Questa è un'altra mia guida. Scrivo queste note solo in un caso: avevo un compito, non sapevo inizialmente come risolverlo, la soluzione non era reperibile su Google in forma già pronta, così l'ho cercata a pezzi e alla fine sono riuscito a risolvere il problema. E per far sì che in futuro, quando dimenticherò come l'ho fatto, non debba di nuovo cercarlo a pezzi e ricomporlo, scrivo a me stesso queste guide.

Disclaimer: 1. Questa nota è stata scritta "per me", non ambisce a essere una best practice . Sarei felice di leggere suggerimenti su "sarebbe meglio farlo in questo modo" nei commenti.
2. Se consideriamo la parte pratica della nota come il sale, allora, come tutte le mie note precedenti, questa è una soluzione iposodica.

Aggiornamento dinamico delle impostazioni dei job in Jenkins

Prevedo la tua domanda: e che c'entra l'aggiornamento dinamico del job? Ho inserito manualmente il valore del parametro stringa e avanti!

Rispondo: sono davvero pigro, non mi piace quando lamentano: Misha, il deploy si blocca, è tutto finito! Cominci a guardare, e c'è un errore nel valore di qualche parametro di avvio del job. Pertanto, preferisco fare tutto in modo il più a prova di errore possibile. Se c'è la possibilità di privare l'utente della possibilità di immettere dati direttamente, offrendo invece un elenco di valori tra cui scegliere, allora organizzo la selezione.

Il piano è il seguente: creiamo un job in Jenkins, in cui, prima dell'avvio, si può scegliere una versione da un elenco, specificare i valori per i parametri da passare al contenitore tramite ENV, quindi costruisce il contenitore e lo invia al Container Registry. Successivamente, il contenitore viene avviato in Kubernetes come workload con i parametri definiti nel job.

Non tratteremo il processo di creazione e configurazione del job in Jenkins, è fuori tema. Supponiamo che il job sia pronto. Per implementare un elenco aggiornato delle versioni, abbiamo bisogno di due cose: un elenco di origine già esistente con numeri di versione validi a priori e una variabile di tipo Choice parameter nel job. Nel nostro esempio, supponiamo che la variabile si chiami BUILD_VERSION, su di essa non ci soffermeremo. Ma sul l'elenco di origine facciamo una sosta più dettagliata.

Le opzioni non sono così numerose. Mi vengono in mente due subito:

  • Utilizzare l'API di accesso remoto, che Jenkins offre ai suoi utenti;
  • Richiedere il contenuto di una cartella remota del repository (in questo caso, è JFrog Artifactory, il che non è fondamentale).

API di accesso remoto di Jenkins

Seguendo la bella tradizione, preferisco evitare spiegazioni prolisse.
Mi permetto solo una libera traduzione di un pezzo del primo paragrafo della prima pagina della documentazione sull'API:

Jenkins offre un'API per l'accesso remoto e comprensibile alle macchine alle sue funzionalità. L'accesso remoto è fornito in uno stile simile a REST. Ciò significa che non esiste un unico punto di accesso per tutte le funzionalità, ma al suo posto vengono utilizzati URL del tipo ".../api/", dove "" indica l'oggetto a cui si applicano le funzionalità dell'API.

In altre parole, se il job di deploy di cui stiamo parlando è accessibile all'indirizzo http://jenkins.mybuild.er/view/AweSomeApp/job/AweSomeApp_build, quindi le API per questo compito sono disponibili all'indirizzo http://jenkins.mybuild.er/view/AweSomeApp/job/AweSomeApp_build/api/

Poi abbiamo la scelta di quale formato utilizzare per l'output. Focalizziamoci su XML, poiché le API consentono la filtrazione solo in questo caso.

Proviamo semplicemente a ottenere un elenco di tutte le esecuzioni del compito. Ci interessa solo il nome della build (displayName) e il suo risultato (risultato):

http://jenkins.mybuild.er/view/AweSomeApp/job/AweSomeApp_build/api/xml?tree=allBuilds[displayName,result]

Ce l'abbiamo fatta?

Ora filtriamo solo quelle esecuzioni che alla fine hanno il risultato SUCCESS. Utilizziamo l'argomento &exclude e come parametro passiamo il percorso per il valore diverso da SUCCESS. Sì-sì. La doppia negazione è una affermazione. Escludiamo tutto ciò che non ci interessa:

http://jenkins.mybuild.er/view/AweSomeApp/job/AweSomeApp_build/api/xml?tree=allBuilds[displayName,result]&exclude=freeStyleProject/allBuild[result!='SUCCESS']

Screenshot dell'elenco di successi
Creiamo un compito per il deployment in GKE senza plugin, SMS e registrazioni. Diamo un'occhiata furtiva a Jenkins sotto il vestito

E semplicemente per divertirci, assicuriamoci che il filtro non ci abbia ingannato (i filtri non mentono mai!) e mostriamo l'elenco dei 'non riusciti':

http://jenkins.mybuild.er/view/AweSomeApp/job/AweSomeApp_build/api/xml?tree=allBuilds[displayName,result]&exclude=freeStyleProject/allBuild[result='SUCCESS']

Screenshot dell'elenco dei non riusciti
Creiamo un compito per il deployment in GKE senza plugin, SMS e registrazioni. Diamo un'occhiata furtiva a Jenkins sotto il vestito

Elenco delle versioni nella cartella sul server remoto

C'è anche un secondo modo per ottenere l'elenco delle versioni. Mi piace anche di più rispetto alla chiamata all'API di Jenkins. Bene, perché se l'applicazione si è compilata correttamente, significa che è stata impacchettata e posizionata nel repository nella cartella corrispondente. Tipo, il repository è di default lo stoccaggio delle versioni funzionanti delle applicazioni. Tipo. Quindi chiediamogli quali versioni sono in deposito. Scaricheremo, filtreremo e elaboreremo la cartella remota. Se a qualcuno interessa un one-liner, è sotto spoiler.

Comando in una sola riga
Fai attenzione a due cose: passo le credenziali per la connessione nell'intestazione e non ho bisogno di tutte le versioni dalla cartella, e seleziono solo quelle create nell'ultimo mese. Modifica il comando in base alle tue realtà e necessità:

curl -H "X-JFrog-Art-Api:VeryLongAPIKey" -s http://arts.myre.po/artifactory/awesomeapp/ | sed 's/a href=// ' | grep "$(date +%b)-$(date +%Y)|$(date +%b --date='-1 month')-$(date +%Y)" | awk '{print $1}' | grep -oP 'K[^/]' )

Configurazione dei compiti e file di configurazione del compito in Jenkins

Abbiamo risolto la questione della fonte dell'elenco delle versioni. Ora inseriamo l'elenco ottenuto nel compito. Per me la soluzione ovvia è stata aggiungere un passaggio nel compito di compilazione dell'applicazione. Un passaggio che venga eseguito in caso di risultato 'successo'.

Apriamo le impostazioni del compito di compilazione e scorriamo fino in fondo. Facciamo clic sui pulsanti: Aggiungi passaggio di build -> Passaggio condizionale (singolo). Nelle impostazioni del passaggio, scegliamo la condizione Stato attuale della build, impostiamo il valore SUCCESS, l'azione da eseguire in caso di successo Esegui comando shell.

E ora la parte più interessante. Le configurazioni dei lavori di Jenkins sono memorizzate in file. In formato XML. Al percorso http://percorso-al-lavoro/config.xml Pertanto, è possibile scaricare il file di configurazione, modificarlo come necessario e rimetterlo al posto da cui è stato prelevato.

Ricordate, sopra abbiamo concordato che per l'elenco delle versioni creeremo un parametro. BUILD_VERSION?

Scarichiamo il file di configurazione e diamo un'occhiata all'interno. Solo per assicurarci che il parametro sia presente e sia effettivamente del tipo desiderato.

Screenshot sotto spoiler.

Il frammento di config.xml fornito dovrebbe apparire allo stesso modo da voi. A parte il fatto che il contenuto dell'elemento choices è attualmente assente.
Creiamo un compito per il deployment in GKE senza plugin, SMS e registrazioni. Diamo un'occhiata furtiva a Jenkins sotto il vestito

Vi siete accertati? Bene, ora scriviamo lo script che verrà eseguito in caso di build riuscita.
Lo script otterrà l'elenco delle versioni, scaricherà il file di configurazione, scriverà in esso l'elenco delle versioni nel punto che ci interessa e poi lo rimette a posto. Sì. Esatto. Scrivere l'elenco delle versioni nel XML nel punto già esistente dell'elenco delle versioni (sarà in futuro, dopo il primo avvio dello script). So che nel mondo ci sono ancora appassionati di espressioni regolari. Non mi interessa farne parte. Per favore, installate xmlstarler sulla macchina in cui verrà modificato il config. Mi sembra che non sia una spesa eccessiva per evitare di modificare l'XML usando sed.

Sotto lo spoiler vi fornisco il codice che esegue l'intera sequenza sopra descritta.

Scriviamo nel config l'elenco delle versioni dalla cartella su un server remoto.

#!/bin/bash
############## Скачиваем конфиг
curl -X GET -u username:apiKey http://jenkins.mybuild.er/view/AweSomeApp/job/AweSomeApp_k8s/config.xml -o appConfig.xml

############## Удаляем и заново создаем xml-элемент для списка версий
xmlstarlet ed --inplace -d '/project/properties/hudson.model.ParametersDefinitionProperty/parameterDefinitions/hudson.model.ChoiceParameterDefinition[name="BUILD_VERSION"]/choices[@class="java.util.Arrays$ArrayList"]/a[@class="string-array"]' appConfig.xml

xmlstarlet ed --inplace --subnode '/project/properties/hudson.model.ParametersDefinitionProperty/parameterDefinitions/hudson.model.ChoiceParameterDefinition[name="BUILD_VERSION"]/choices[@class="java.util.Arrays$ArrayList"]' --type elem -n a appConfig.xml

xmlstarlet ed --inplace --insert '/project/properties/hudson.model.ParametersDefinitionProperty/parameterDefinitions/hudson.model.ChoiceParameterDefinition[name="BUILD_VERSION"]/choices[@class="java.util.Arrays$ArrayList"]/a' --type attr -n class -v string-array appConfig.xml

############## Читаем в массив список версий из репозитория
readarray -t vers < <( curl -H "X-JFrog-Art-Api:Api:VeryLongAPIKey" -s http://arts.myre.po/artifactory/awesomeapp/ | sed 's/a href=//' | grep "$(date +%b)-$(date +%Y)|$(date +%b --date='-1 month')-$(date +%Y)" | awk '{print $1}' | grep -oP '>K[^/]+' )

############## Пишем массив элемент за элементом в конфиг
printf '%sn' "${vers[@]}" | sort -r | 
                while IFS= read -r line
                do
                    xmlstarlet ed --inplace --subnode '/project/properties/hudson.model.ParametersDefinitionProperty/parameterDefinitions/hudson.model.ChoiceParameterDefinition[name="BUILD_VERSION"]/choices[@class="java.util.Arrays$ArrayList"]/a[@class="string-array"]' --type elem -n string -v "$line" appConfig.xml
                done

############## Кладем конфиг взад
curl -X POST -u username:apiKey http://jenkins.mybuild.er/view/AweSomeApp/job/AweSomeApp_k8s/config.xml --data-binary @appConfig.xml

############## Приводим рабочее место в порядок
rm -f appConfig.xml

Se preferite l'opzione di ottenere le versioni da Jenkins e siete anche pigri come me, allora sotto spoiler c'è lo stesso codice, ma l'elenco da Jenkins:

Scriviamo nel config l'elenco delle versioni da Jenkins.
Solo notate un aspetto: il mio nome di build è composto da un numero sequenziale e da un numero di versione, separati da due punti. Pertanto, awk rimuove la parte non necessaria. Modificate questa riga secondo le vostre necessità.

#!/bin/bash
############## Скачиваем конфиг
curl -X GET -u username:apiKey http://jenkins.mybuild.er/view/AweSomeApp/job/AweSomeApp_k8s/config.xml -o appConfig.xml

############## Удаляем и заново создаем xml-элемент для списка версий
xmlstarlet ed --inplace -d '/project/properties/hudson.model.ParametersDefinitionProperty/parameterDefinitions/hudson.model.ChoiceParameterDefinition[name="BUILD_VERSION"]/choices[@class="java.util.Arrays$ArrayList"]/a[@class="string-array"]' appConfig.xml

xmlstarlet ed --inplace --subnode '/project/properties/hudson.model.ParametersDefinitionProperty/parameterDefinitions/hudson.model.ChoiceParameterDefinition[name="BUILD_VERSION"]/choices[@class="java.util.Arrays$ArrayList"]' --type elem -n a appConfig.xml

xmlstarlet ed --inplace --insert '/project/properties/hudson.model.ParametersDefinitionProperty/parameterDefinitions/hudson.model.ChoiceParameterDefinition[name="BUILD_VERSION"]/choices[@class="java.util.Arrays$ArrayList"]/a' --type attr -n class -v string-array appConfig.xml

############## Пишем в файл список версий из Jenkins
curl -g -X GET -u username:apiKey 'http://jenkins.mybuild.er/view/AweSomeApp/job/AweSomeApp_build/api/xml?tree=allBuilds[displayName,result]&exclude=freeStyleProject/allBuild[result!=%22SUCCESS%22]&pretty=true' -o builds.xml

############## Читаем в массив список версий из XML
readarray vers < <(xmlstarlet sel -t -v "freeStyleProject/allBuild/displayName" builds.xml | awk -F":" '{print $2}')

############## Пишем массив элемент за элементом в конфиг
printf '%sn' "${vers[@]}" | sort -r | 
                while IFS= read -r line
                do
                    xmlstarlet ed --inplace --subnode '/project/properties/hudson.model.ParametersDefinitionProperty/parameterDefinitions/hudson.model.ChoiceParameterDefinition[name="BUILD_VERSION"]/choices[@class="java.util.Arrays$ArrayList"]/a[@class="string-array"]' --type elem -n string -v "$line" appConfig.xml
                done

############## Кладем конфиг взад
curl -X POST -u username:apiKey http://jenkins.mybuild.er/view/AweSomeApp/job/AweSomeApp_k8s/config.xml --data-binary @appConfig.xml

############## Приводим рабочее место в порядок
rm -f appConfig.xml

In teoria, se avete testato il codice scritto sulla base degli esempi sopra, nel job di deploy dovrebbe già apparire un elenco a discesa con le versioni. Ecco come appare grossomodo nello screenshot sotto spoiler.

Elenco delle versioni correttamente compilato.
Creiamo un compito per il deployment in GKE senza plugin, SMS e registrazioni. Diamo un'occhiata furtiva a Jenkins sotto il vestito

Se tutto ha funzionato, copiate e incollate lo script in Esegui comando shell e salvate le modifiche.

Collegamento a Cloud shell

I nostri raccoglitori sono nei contenitori. Come mezzo di consegna delle applicazioni e gestore di configurazione utilizziamo Ansible. Di conseguenza, quando si parla di costruzione dei contenitori, ci vencono in mente tre opzioni: installare Docker in Docker, installare Docker su una macchina con Ansible, oppure costruire i contenitori nella console cloud. Abbiamo deciso di tacere riguardo ai plugin per Jenkins in questa nota. Ricordate?

Ho deciso: beh, poiché i contenitori "out of the box" possono essere costruiti nella console cloud, perché complicarsi la vita? Keep it clean, giusto? Voglio costruire i contenitori con Jenkins nella console cloud e poi trasferirli direttamente in Kubernetes. Inoltre, all'interno dell'infrastruttura di Google ci sono canali davvero molto veloci, il che avrà un impatto positivo sulla velocità di deploy.

Per connettersi alla console cloud sono necessarie due cose: gcloud e i diritti di accesso a Google Cloud API per l'istanza VM da cui verrà effettuata questa connessione.

Per coloro che pianificano di connettersi da un cloud che non è di Google
Google consente la possibilità di disabilitare l'autenticazione interattiva nei propri servizi. Questo permetterà di connettersi alla console anche da una macchina da caffè, se è sotto *nix e ha una console propria.

Se avete bisogno che approfondisca questo argomento in questa nota — scrivetelo nei commenti. Se si raccoglie un numero sufficiente di voci, scriverò un aggiornamento su questo tema.

Il metodo più semplice per concedere i diritti è tramite l'interfaccia web.

  1. Arrestate l'istanza VM da cui verrà effettuata in seguito la connessione alla console cloud.
  2. Aprite le Informazioni sull'istanza e cliccate su Modificare.
  3. In fondo alla pagina selezionate il raggio d'azione dell'accesso dell'istanza Accesso completo a tutte le Cloud API.

    Screenshot
    Creiamo un compito per il deployment in GKE senza plugin, SMS e registrazioni. Diamo un'occhiata furtiva a Jenkins sotto il vestito

  4. Salvate le modifiche e avviate l'istanza.

Al termine del caricamento dell'istanza VM, connettetevi ad essa via SSH e assicuratevi che la connessione avvenga senza errori. Utilizzate il comando:

gcloud alpha cloud-shell ssh

Una connessione riuscita appare approssimativamente così
Creiamo un compito per il deployment in GKE senza plugin, SMS e registrazioni. Diamo un'occhiata furtiva a Jenkins sotto il vestito

Deploy in GKE

Poiché ci sforziamo di passare completamente all'IaC (Infrastructure as Code), i Dockerfile sono archiviati in Git. Questo da un lato. E il deploy in Kubernetes è descritto da un file yaml, che viene utilizzato solo da questo compito, che a sua volta è anch'esso una sorta di codice. Questo dall'altro lato. In generale, il mio piano è il seguente:

  1. Prendiamo i valori delle variabili BUILD_VERSION e, opzionalmente, i valori delle variabili che saranno passati tramite ENV.
  2. Scarichiamo il Dockerfile da Git.
  3. Generiamo un yaml per il deployment.
  4. Carichiamo entrambi questi file tramite scp nella console cloud.
  5. Costruiamo il container lì e lo pushiamo nel Container registry
  6. Applichiamo il file di deployment del carico nel cluster.

Facciamo un passo più specifico. Poiché abbiamo parlato di ENV, supponiamo che dobbiamo passare valori di due parametri: PARAM1 e PARAM2. Li aggiungiamo al task di deployment, tipo — Parametro Stringa.

Screenshot
Creiamo un compito per il deployment in GKE senza plugin, SMS e registrazioni. Diamo un'occhiata furtiva a Jenkins sotto il vestito

Genereremo il yaml semplicemente reindirizzando echo in un file. Si presuppone, ovviamente, che nel Dockerfile sia presente PARAM1 e PARAM2, che il nome del carico sarà awesomeapp, e che il container costruito con la versione specificata si trova in Container registry al percorso gcr.io/awesomeapp/awesomeapp-$BUILD_VERSION, dove $BUILD_VERSION è stato selezionato dal menu a discesa.

Elenco dei comandi

touch deploy.yaml
echo "apiVersion: apps/v1" >> deploy.yaml
echo "kind: Deployment" >> deploy.yaml
echo "metadata:" >> deploy.yaml
echo "  name: awesomeapp" >> deploy.yaml
echo "spec:" >> deploy.yaml
echo "  replicas: 1" >> deploy.yaml
echo "  selector:" >> deploy.yaml
echo "    matchLabels:" >> deploy.yaml
echo "      run: awesomeapp" >> deploy.yaml
echo "  template:" >> deploy.yaml
echo "    metadata:" >> deploy.yaml
echo "      labels:" >> deploy.yaml
echo "        run: awesomeapp" >> deploy.yaml
echo "    spec:" >> deploy.yaml
echo "      containers:" >> deploy.yaml
echo "      - name: awesomeapp" >> deploy.yaml
echo "        image: gcr.io/awesomeapp/awesomeapp-$BUILD_VERSION:latest" >> deploy.yaml
echo "        env:" >> deploy.yaml
echo "        - name: PARAM1" >> deploy.yaml
echo "          value: $PARAM1" >> deploy.yaml
echo "        - name: PARAM2" >> deploy.yaml
echo "          value: $PARAM2" >> deploy.yaml

Per l'agente Jenkins, dopo la connessione tramite gcloud alpha cloud-shell ssh la modalità interattiva non è disponibile, quindi passiamo i comandi alla console cloud utilizzando il parametro --command.

Puliamo la cartella home nella console cloud dal vecchio Dockerfile:

gcloud alpha cloud-shell ssh --command="rm -f Dockerfile"

Inseriamo il Dockerfile appena scaricato nella cartella home della console cloud utilizzando scp:

gcloud alpha cloud-shell scp localhost:./Dockerfile cloudshell:~

Costruiamo, tagghiamo e pushiamo il container nel Container registry:

gcloud alpha cloud-shell ssh --command="docker build -t awesomeapp-$BUILD_VERSION ./ --build-arg BUILD_VERSION=$BUILD_VERSION --no-cache"
gcloud alpha cloud-shell ssh --command="docker tag awesomeapp-$BUILD_VERSION gcr.io/awesomeapp/awesomeapp-$BUILD_VERSION"
gcloud alpha cloud-shell ssh --command="docker push gcr.io/awesomeapp/awesomeapp-$BUILD_VERSION"

Analogamente procediamo con il file di deployment. Fate attenzione che nei comandi seguenti si utilizzano nomi di cluster fittizi, dove avviene il deployment (awsm-cluster) e il nome del progetto (awesome-project), dove si trova il cluster.

gcloud alpha cloud-shell ssh --command="rm -f deploy.yaml"
gcloud alpha cloud-shell scp localhost:./deploy.yaml cloudshell:~
gcloud alpha cloud-shell ssh --command="gcloud container clusters get-credentials awsm-cluster --zone us-central1-c --project awesome-project && 
kubectl apply -f deploy.yaml"

Avviamo il compito, apriamo l'output della console e speriamo di vedere una costruzione del contenitore riuscita.

Screenshot
Creiamo un compito per il deployment in GKE senza plugin, SMS e registrazioni. Diamo un'occhiata furtiva a Jenkins sotto il vestito

E poi anche un deployment riuscito del contenitore costruito

Screenshot
Creiamo un compito per il deployment in GKE senza plugin, SMS e registrazioni. Diamo un'occhiata furtiva a Jenkins sotto il vestito

Ho deliberatamente omesso la configurazione Ingress. Per un semplice motivo: una volta configurato con un workload nome specificato, sarà funzionante, indipendentemente dal numero di deployment effettuati con quel nome. Inoltre, rientra un po' al di fuori della storia.

Invece delle conclusioni

Tutti i passaggi sopra menzionati probabilmente non erano necessari, avendo semplicemente installato qualche plugin per Jenkins, ce ne sono un milione. Ma per qualche motivo non mi piacciono i plugin. Anzi, li utilizzo solo in caso di estremo bisogno.

E inoltre mi piace semplicemente esplorare un argomento che per me è nuovo. Il testo sopra è anche un modo per condividere le scoperte che ho fatto affrontando la questione descritta all'inizio. Condividere con chi, come me, non è affatto un lupo feroce nel devops. Se almeno qualcuno trova utili le mie scoperte, sarò contento.

Fonte: habr.com

Acquista hosting affidabile per siti web con protezione DDoS, VPS VDS server 🔥 Acquista hosting affidabile per siti web con protezione DDoS, VPS VDS server | ProHoster