Creiamo un'attività di distribuzione in GKE senza plug-in, SMS o registrazione. Diamo una sbirciatina sotto la giacca di Jenkins

Tutto è iniziato quando il responsabile di uno dei nostri team di sviluppo ci ha chiesto di testare la loro nuova applicazione, che era stata containerizzata il giorno prima. L'ho pubblicato. Dopo circa 20 minuti è arrivata la richiesta di aggiornare l'applicazione, perché lì era stata aggiunta una cosa molto necessaria. Ho rinnovato. Dopo un altro paio d'ore... beh, potete immaginare cosa cominciò a succedere dopo...

Devo ammettere che sono piuttosto pigro (non l'ho ammesso prima? No?), e dato che i leader del team hanno accesso a Jenkins, in cui abbiamo tutti CI/CD, ho pensato: lascialo schierare come quanto vuole! Mi sono ricordata di una barzelletta: dai un pesce a un uomo e mangerà per un giorno; chiama una persona Fed e sarà nutrita per tutta la vita. E andò fare brutti scherzi sul lavoro, che sarebbe in grado di distribuire in Kuber un contenitore contenente l'applicazione di qualsiasi versione assemblata con successo e trasferirvi qualsiasi valore ENV (mio nonno, filologo, un tempo insegnante di inglese, ora si girava il dito sulla tempia e mi guardava in modo molto espressivo dopo aver letto questa frase).

Quindi, in questa nota ti racconterò come ho imparato:

  1. Aggiorna dinamicamente i lavori in Jenkins dal lavoro stesso o da altri lavori;
  2. Connettersi alla console cloud (Cloud shell) da un nodo su cui è installato l'agente Jenkins;
  3. Distribuisci il carico di lavoro a Google Kubernetes Engine.


In effetti, ovviamente, sono un po' falso. Si presuppone che tu abbia almeno parte dell'infrastruttura nel cloud di Google e, quindi, sei il suo utente e, ovviamente, hai un account GCP. Ma non è di questo che tratta questa nota.

Questo è il mio prossimo foglietto illustrativo. Voglio scrivere tali note solo in un caso: mi sono trovato di fronte a un problema, inizialmente non sapevo come risolverlo, la soluzione non era già pronta su Google, quindi l'ho cercata su Google in alcune parti e alla fine ho risolto il problema. E così in futuro, quando dimenticherò come l'ho fatto, non dovrò cercare di nuovo tutto su Google pezzo per pezzo e compilarlo insieme, mi scrivo questi cheat sheet.

Disclaimer: 1. La nota è stata scritta “per me”, per il ruolo le migliori pratiche Non si applica. Sono felice di leggere le opzioni “sarebbe stato meglio fare così” nei commenti.
2. Se la parte applicata della nota è considerata sale, allora, come tutte le mie note precedenti, questa è una soluzione salina debole.

Aggiornamento dinamico delle impostazioni del lavoro in Jenkins

Prevedo la tua domanda: cosa c'entra l'aggiornamento dinamico del lavoro? Inserisci manualmente il valore del parametro stringa e il gioco è fatto!

Rispondo: sono davvero pigro, non mi piace quando si lamentano: Misha, l'implementazione va in crash, è andato tutto! Inizi a cercare e c'è un errore di battitura nel valore di alcuni parametri di avvio dell'attività. Pertanto, preferisco fare tutto nel modo più efficiente possibile. Se è possibile impedire all'utente di inserire direttamente i dati dando invece una lista di valori tra cui scegliere, allora organizzo la selezione.

Il piano è questo: creiamo un lavoro in Jenkins, in cui, prima del lancio, potremmo selezionare una versione dall'elenco, specificare i valori per i parametri passati al contenitore tramite ENV, quindi raccoglie il contenitore e lo inserisce nel Container Registry. Quindi da lì il contenitore viene lanciato in cuber as carico di lavoro con i parametri specificati nel lavoro.

Non prenderemo in considerazione il processo di creazione e impostazione di un lavoro in Jenkins, questo è fuori tema. Daremo per scontato che l'attività sia pronta. Per implementare un elenco aggiornato con le versioni, abbiamo bisogno di due cose: un elenco di fonti esistente con numeri di versione validi a priori e una variabile come Parametro di scelta nel compito. Nel nostro esempio, diamo un nome alla variabile BUILD_VERSIONE, non ci soffermeremo su questo in dettaglio. Ma diamo un'occhiata più da vicino all'elenco delle fonti.

Non ci sono molte opzioni. Mi sono subito venute in mente due cose:

  • Utilizzare l'API di accesso remoto che Jenkins offre ai suoi utenti;
  • Richiedi il contenuto della cartella del repository remoto (nel nostro caso si tratta di JFrog Artifactory, che non è importante).

API di accesso remoto Jenkins

Come da ottima tradizione consolidata, preferirei evitare lunghe spiegazioni.
Mi permetto solo la libera traduzione di un pezzo del primo paragrafo prima pagina della documentazione API:

Jenkins fornisce un'API per l'accesso remoto leggibile dalla macchina alle sue funzionalità. <…> L'accesso remoto è offerto in uno stile simile a REST. Ciò significa che non esiste un unico punto di accesso a tutte le funzionalità, ma piuttosto un URL come ".../api/", Dove "..." indica l'oggetto a cui vengono applicate le funzionalità API.

In altre parole, se l'attività di distribuzione di cui stiamo attualmente parlando è disponibile su http://jenkins.mybuild.er/view/AweSomeApp/job/AweSomeApp_build, i fischi API per questa attività sono disponibili all'indirizzo http://jenkins.mybuild.er/view/AweSomeApp/job/AweSomeApp_build/api/

Successivamente, possiamo scegliere in quale forma ricevere l'output. Concentriamoci su XML, poiché l'API consente il filtraggio solo in questo caso.

Proviamo semplicemente a ottenere un elenco di tutte le esecuzioni dei lavori. Siamo interessati solo al nome dell'assembly (nome da visualizzare) e il suo risultato (colpevole):

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

Capito?

Ora filtriamo solo le esecuzioni che terminano con il risultato SUCCESSO. Usiamo l'argomento &escludere e come parametro gli passeremo il percorso ad un valore diverso da SUCCESSO. Si si. Una doppia negazione è un'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 dei successi
Creiamo un'attività di distribuzione in GKE senza plug-in, SMS o registrazione. Diamo una sbirciatina sotto la giacca di Jenkins

Bene, giusto per divertimento, assicuriamoci che il filtro non ci abbia ingannato (i filtri non mentono mai!) e visualizziamo un elenco di quelli “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 di quelli non riusciti
Creiamo un'attività di distribuzione in GKE senza plug-in, SMS o registrazione. Diamo una sbirciatina sotto la giacca di Jenkins

Elenco delle versioni da una cartella su un server remoto

Esiste un secondo modo per ottenere un elenco di versioni. Mi piace ancora più dell'accesso all'API Jenkins. Beh, perché se l'applicazione è stata creata con successo, significa che è stata pacchettizzata e inserita nel repository nella cartella apposita. Ad esempio, un repository è l'archivio predefinito delle versioni funzionanti delle applicazioni. Come. Bene, chiediamogli quali versioni sono in archivio. Arricciaremo, grep e awk la cartella remota. Se qualcuno è interessato al oneliner, allora è sotto lo spoiler.

Comando su una riga
Tieni presente due cose: passo i dettagli della connessione nell'intestazione e non ho bisogno di tutte le versioni dalla cartella e seleziono solo quelle che sono state create entro un mese. Modifica il comando in base alle tue realtà e esigenze:

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[^/]+' )

Impostazione dei lavori e file di configurazione dei lavori in Jenkins

Abbiamo scoperto la fonte dell'elenco delle versioni. Incorporiamo ora l'elenco risultante nell'attività. Per me, la soluzione ovvia era aggiungere un passaggio all'attività di creazione dell'applicazione. Il passaggio che verrebbe eseguito se il risultato fosse "successo".

Apri le impostazioni dell'attività di assemblaggio e scorri fino in fondo. Fare clic sui pulsanti: Aggiungi passaggio di creazione -> Passaggio condizionale (singolo). Nelle impostazioni del passaggio, seleziona la condizione Stato attuale della build, impostare il valore SUCCESSO, l'azione da eseguire in caso di successo Esegui il comando della shell.

E ora la parte divertente. Jenkins memorizza le configurazioni del lavoro in file. In formato XML. Lungo la strada http://путь-до-задания/config.xml Di conseguenza, puoi scaricare il file di configurazione, modificarlo secondo necessità e rimetterlo dove l'hai preso.

Ricorda, abbiamo concordato sopra che creeremo un parametro per l'elenco delle versioni BUILD_VERSIONE?

Scarichiamo il file di configurazione e diamo un'occhiata al suo interno. Giusto per assicurarsi che il parametro sia a posto e del tipo desiderato.

Schermata sotto spoiler.

Il tuo frammento config.xml dovrebbe avere lo stesso aspetto. Solo che mancano ancora i contenuti dell'elemento scelte
Creiamo un'attività di distribuzione in GKE senza plug-in, SMS o registrazione. Diamo una sbirciatina sotto la giacca di Jenkins

Sei sicuro? Questo è tutto, scriviamo uno script che verrà eseguito se la compilazione avrà successo.
Lo script riceverà un elenco di versioni, scaricherà il file di configurazione, scriverà l'elenco delle versioni nel posto in cui ci serve e poi lo ripristinerà. SÌ. Giusto. Scrivi un elenco di versioni in XML nel luogo in cui esiste già un elenco di versioni (sarà in futuro, dopo il primo avvio dello script). So che nel mondo ci sono ancora accaniti fan delle espressioni regolari. Non appartengo a loro. Si prega di installare xmlstarler alla macchina in cui verrà modificata la configurazione. Mi sembra che questo non sia un prezzo così alto da pagare per evitare di modificare XML usando sed.

Sotto lo spoiler presento il codice che esegue integralmente la sequenza sopra riportata.

Scrivi un elenco di versioni da una cartella sul server remoto nel file config

#!/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 preferisci la possibilità di ottenere versioni da Jenkins e sei pigro come me, sotto lo spoiler c'è lo stesso codice, ma un elenco da Jenkins:

Scrivi un elenco di versioni da Jenkins nel file config
Tienilo a mente: il nome del mio assembly è costituito da un numero di sequenza e un numero di versione, separati da due punti. Di conseguenza, awk elimina la parte non necessaria. Per te, cambia questa linea in base alle tue esigenze.

#!/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 hai testato il codice scritto in base agli esempi sopra, nell'attività di distribuzione dovresti già avere un elenco a discesa con le versioni. È come nello screenshot sotto lo spoiler.

Elenco delle versioni compilato correttamente
Creiamo un'attività di distribuzione in GKE senza plug-in, SMS o registrazione. Diamo una sbirciatina sotto la giacca di Jenkins

Se tutto ha funzionato, copia e incolla lo script in Esegui il comando della shell e salvare le modifiche.

Connessione a Cloud Shell

Abbiamo collezionisti in contenitori. Utilizziamo Ansible come strumento di distribuzione delle applicazioni e gestore della configurazione. Di conseguenza, quando si tratta di creare contenitori, vengono in mente tre opzioni: installare Docker in Docker, installare Docker su una macchina che esegue Ansible o creare contenitori in una console cloud. Abbiamo concordato di non parlare dei plugin per Jenkins in questo articolo. Ricordare?

Ho deciso: beh, poiché i contenitori "out of the box" possono essere raccolti nella console cloud, allora perché preoccuparsi? Tienilo pulito, giusto? Desidero raccogliere i contenitori Jenkins nella console cloud e quindi avviarli nel cuber da lì. Inoltre, Google dispone di canali molto ricchi all'interno della sua infrastruttura, il che avrà un effetto benefico sulla velocità di implementazione.

Per connetterti alla console cloud, hai bisogno di due cose: gcloud e diritti di accesso a Google Cloud API per l'istanza VM da cui verrà effettuata la stessa connessione.

Per coloro che intendono connettersi non da Google Cloud
Google consente la possibilità di disattivare l'autorizzazione interattiva nei propri servizi. Ciò ti consentirà di connetterti alla console anche da una macchina da caffè, se esegue *nix e dispone di una console stessa.

Se è necessario che io tratti questo problema in modo più dettagliato nell'ambito di questa nota, scrivi nei commenti. Se ottengo abbastanza voti, scriverò un aggiornamento su questo argomento.

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

  1. Arresta l'istanza VM da cui ti connetterai successivamente alla console cloud.
  2. Apri Dettagli istanza e fai clic su Emendare.
  3. Nella parte inferiore della pagina, seleziona l'ambito di accesso all'istanza Accesso completo a tutte le API Cloud.

    Screenshot
    Creiamo un'attività di distribuzione in GKE senza plug-in, SMS o registrazione. Diamo una sbirciatina sotto la giacca di Jenkins

  4. Salva le modifiche e avvia l'istanza.

Una volta terminato il caricamento della VM, connettiti ad essa tramite SSH e assicurati che la connessione avvenga senza errori. Utilizza il comando:

gcloud alpha cloud-shell ssh

Una connessione riuscita è simile a questa
Creiamo un'attività di distribuzione in GKE senza plug-in, SMS o registrazione. Diamo una sbirciatina sotto la giacca di Jenkins

Distribuisci su GKE

Poiché stiamo cercando in ogni modo possibile di passare completamente a IaC (Infrastucture as a Code), i nostri file docker sono archiviati in Git. Questo è da un lato. E la distribuzione in Kubernetes è descritta da un file yaml, che viene utilizzato solo da questa attività, che a sua volta è come il codice. Questo viene dall'altra parte. In generale, voglio dire, il piano è questo:

  1. Prendiamo i valori delle variabili BUILD_VERSIONE e, facoltativamente, i valori delle variabili che verranno passate ENV.
  2. Scarica il dockerfile da Git.
  3. Genera yaml per la distribuzione.
  4. Carichiamo entrambi questi file tramite scp sulla console cloud.
  5. Costruiamo lì un contenitore e lo inseriamo nel registro dei contenitori
  6. Applichiamo il file di distribuzione del carico al cuber.

Cerchiamo di essere più specifici. Una volta abbiamo iniziato a parlare di ENV, supponiamo allora di dover passare i valori di due parametri: PARAM1 и PARAM2. Aggiungiamo la loro attività per la distribuzione, digitiamo: Parametro stringa.

Screenshot
Creiamo un'attività di distribuzione in GKE senza plug-in, SMS o registrazione. Diamo una sbirciatina sotto la giacca di Jenkins

Genereremo yaml con un semplice reindirizzamento eco archiviare. Si presuppone, ovviamente, che tu abbia nel tuo dockerfile PARAM1 и PARAM2che sarà il nome del carico app fantasticae si trova il contenitore assemblato con l'applicazione della versione specificata Registro dei contenitori lungo la strada gcr.io/awesomeapp/awesomeapp-$BUILD_VERSIONDove $BUILD_VERSION è stato appena selezionato dall'elenco a discesa.

Elenco delle squadre

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

Agente Jenkins dopo la connessione utilizzando gcloud alpha cloud shell ssh la modalità interattiva non è disponibile, quindi inviamo comandi alla console cloud utilizzando il parametro --comando.

Puliamo la cartella home nella console cloud dal vecchio dockerfile:

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

Inserisci il dockerfile appena scaricato nella cartella home della console cloud utilizzando scp:

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

Raccogliamo, tagghiamo e inviamo il contenitore al registro dei contenitori:

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"

Facciamo lo stesso con il file di distribuzione. Tieni presente che i comandi seguenti utilizzano nomi fittizi del cluster in cui avviene la distribuzione (awsm-cluster) e il nome del progetto (progetto fantastico), 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"

Eseguiamo l'attività, apriamo l'output della console e speriamo di vedere il corretto assemblaggio del contenitore.

Screenshot
Creiamo un'attività di distribuzione in GKE senza plug-in, SMS o registrazione. Diamo una sbirciatina sotto la giacca di Jenkins

E poi il successo dell'implementazione del container assemblato

Screenshot
Creiamo un'attività di distribuzione in GKE senza plug-in, SMS o registrazione. Diamo una sbirciatina sotto la giacca di Jenkins

Ho deliberatamente ignorato l'ambientazione Ingresso. Per un semplice motivo: una volta configurato carico di lavoro con un determinato nome, rimarrà operativo, indipendentemente dal numero di distribuzioni eseguite con questo nome. Ebbene, in generale, questo va un po' oltre lo scopo della storia.

Invece di conclusioni

Probabilmente non sarebbe stato possibile eseguire tutti i passaggi precedenti, ma è stato sufficiente installare qualche plug-in per Jenkins, il loro muuulion. Ma per qualche motivo non mi piacciono i plugin. Ebbene, più precisamente, vi ricorro solo per disperazione.

E mi piace semplicemente prendere qualche nuovo argomento per me. Il testo sopra è anche un modo per condividere le scoperte che ho fatto risolvendo il problema descritto all'inizio. Condividi con chi, come lui, non è affatto un lupo feroce nel devops. Se le mie scoperte aiutano almeno qualcuno, sarò felice.

Fonte: habr.com

Aggiungi un commento