Mi stvaramo zadatak implementacije u GKE-u bez dodataka, SMS-a ili registracije. Zavirimo ispod Jenkinsove jakne

Sve je počelo kada nas je voditelj jednog od naših razvojnih timova zamolio da testiramo njihovu novu aplikaciju, koja je dan ranije bila kontejnerizirana. Ja sam to objavio. Nakon 20-tak minuta stigao je zahtjev za ažuriranje aplikacije, jer je tu dodana vrlo potrebna stvar. obnovio sam. Nakon još par sati... pa, možete pretpostaviti što se dalje počelo događati...

Moram priznati, prilično sam lijen (nisam li to ranije priznao? Ne?), a s obzirom na činjenicu da voditelji timova imaju pristup Jenkinsu, u kojem imamo sve CI/CD, pomislio sam: neka se rasporedi kao koliko hoće! Sjetio sam se vica: daj čovjeku ribu i jest će jedan dan; nazovite osobu Fed i bit će Fed cijeli život. I otišao igraj trikove na poslu, koji bi mogao implementirati spremnik koji sadrži aplikaciju bilo koje uspješno ugrađene verzije u Kuber i prenijeti sve vrijednosti u njega ENV (moj djed, filolog, nekadašnji profesor engleskog, sada bi nakon čitanja ove rečenice vrtio prstom po sljepoočnici i vrlo ekspresno me pogledao).

Dakle, u ovoj ću vam bilješci reći kako sam naučio:

  1. Dinamički ažurirajte poslove u Jenkinsu iz samog posla ili iz drugih poslova;
  2. Povežite se s konzolom u oblaku (Cloud shell) iz čvora s instaliranim Jenkinsovim agentom;
  3. Implementacija radnog opterećenja na Google Kubernetes Engine.


Zapravo, ja sam, naravno, pomalo neiskren. Pretpostavlja se da imate barem dio infrastrukture u Google oblaku, te ste, prema tome, njegov korisnik i, naravno, imate GCP račun. Ali nije o tome riječ u ovoj bilješci.

Ovo je moja sljedeća varalica. Takve bilješke želim napisati samo u jednom slučaju: suočio sam se s problemom, u početku nisam znao kako ga riješiti, rješenje nije guglano gotovo, pa sam guglao u dijelovima i na kraju riješio problem. I da ubuduće, kada zaboravim kako sam to napravio, ne moram opet guglati sve dio po dio i sastavljati zajedno, pišem si takve varalice.

Odricanje: 1. Bilješka je napisana “za sebe”, za ulogu najbolje prakse ne primjenjuje. Drago mi je čitati opcije "bilo bi bolje da je ovako" u komentarima.
2. Ako se naneseni dio note smatra soli, onda je, kao i sve moje prethodne note, i ova slaba otopina soli.

Dinamičko ažuriranje postavki posla u Jenkinsu

Predviđam vaše pitanje: kakve veze s tim ima dinamičko ažuriranje poslova? Unesite vrijednost string parametra ručno i krenite!

Odgovaram: stvarno sam lijen, ne volim kad se žale: Misha, implementacija se ruši, sve je nestalo! Počnete tražiti i postoji greška u vrijednosti nekog parametra pokretanja zadatka. Stoga radije sve radim što učinkovitije. Ako je moguće spriječiti korisnika da izravno unese podatke davanjem popisa vrijednosti između kojih se mogu birati, tada ja organiziram odabir.

Plan je sljedeći: stvaramo posao u Jenkinsu, u kojem bismo prije pokretanja mogli odabrati verziju s popisa, odrediti vrijednosti za parametre proslijeđene u spremnik putem ENV, zatim skuplja spremnik i gura ga u registar spremnika. Zatim se od tamo spremnik lansira u cuber as posla s parametrima navedenim u poslu.

Nećemo razmatrati proces stvaranja i postavljanja posla u Jenkinsu, ovo nije tema. Pretpostavit ćemo da je zadatak spreman. Da bismo implementirali ažurirani popis s verzijama, potrebne su nam dvije stvari: postojeći izvorni popis s a priori valjanim brojevima verzija i varijabla poput Parametar izbora u zadatku. U našem primjeru neka je varijabla imenovana BUILD_VERSION, na njemu se nećemo detaljnije zadržavati. Ali pogledajmo pobliže popis izvora.

Nema toliko opcija. Dvije stvari su mi odmah pale na pamet:

  • Koristite API za daljinski pristup koji Jenkins nudi svojim korisnicima;
  • Zatražite sadržaj mape udaljenog repozitorija (u našem slučaju to je JFrog Artifactory, što nije važno).

Jenkinsov API za daljinski pristup

Po ustaljenoj izvrsnoj tradiciji, radije bih izbjegao duga objašnjenja.
Dopustit ću si samo slobodan prijevod dijela prvog paragrafa prva stranica API dokumentacije:

Jenkins pruža API za daljinski strojno čitljiv pristup njegovoj funkcionalnosti. <…> Udaljeni pristup nudi se u stilu sličnom REST-u. To znači da ne postoji jedinstvena ulazna točka za sve značajke, već URL poput ".../api/", Gdje "..." označava objekt na koji se primjenjuju API mogućnosti.

Drugim riječima, ako je zadatak implementacije o kojem trenutno govorimo dostupan na http://jenkins.mybuild.er/view/AweSomeApp/job/AweSomeApp_build, onda su API zviždaljke za ovaj zadatak dostupne na http://jenkins.mybuild.er/view/AweSomeApp/job/AweSomeApp_build/api/

Zatim imamo izbor u kojem ćemo obliku primiti izlaz. Usredotočimo se na XML, budući da API dopušta filtriranje samo u ovom slučaju.

Pokušajmo dobiti popis svih izvođenja poslova. Zanima nas samo naziv sklopa (displayName) i njegov rezultat (rezultirati):

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

Ispalo je?

Sada filtrirajmo samo ona izvođenja koja završe s rezultatom USPJEH. Iskoristimo argument &isključiti a kao parametar ćemo mu proslijediti put do vrijednosti koja nije jednaka USPJEH. Da da. Dvostruki negativ je izjava. Isključujemo sve što nas ne zanima:

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

Snimka zaslona popisa uspješnih
Mi stvaramo zadatak implementacije u GKE-u bez dodataka, SMS-a ili registracije. Zavirimo ispod Jenkinsove jakne

Pa, samo zabave radi, uvjerimo se da nas filtar nije prevario (filtri nikad ne lažu!) i prikažimo popis "neuspješnih":

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

Snimka zaslona popisa neuspješnih
Mi stvaramo zadatak implementacije u GKE-u bez dodataka, SMS-a ili registracije. Zavirimo ispod Jenkinsove jakne

Popis verzija iz mape na udaljenom poslužitelju

Postoji drugi način za dobivanje popisa verzija. Sviđa mi se čak i više od pristupa Jenkins API-ju. Pa, jer ako je aplikacija uspješno izgrađena, to znači da je zapakirana i smještena u repozitorij u odgovarajuću mapu. Kao, repozitorij je zadana pohrana radnih verzija aplikacija. Kao. Pa, pitajmo ga koje su verzije u skladištu. Udaljenu mapu ćemo curl, grep i awk. Ako nekoga zanima oneliner, onda je ispod spojlera.

Jednolinijska naredba
Obratite pažnju na dvije stvari: prosljeđujem detalje veze u zaglavlju i ne trebaju mi ​​sve verzije iz mape i odabirem samo one koje su stvorene unutar mjesec dana. Uredite naredbu tako da odgovara vašoj stvarnosti i potrebama:

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

Postavljanje poslova i konfiguracijske datoteke poslova u Jenkinsu

Shvatili smo izvor popisa verzija. Uključimo sada dobiveni popis u zadatak. Za mene je očito rješenje bilo dodavanje koraka u zadatak izrade aplikacije. Korak koji bi se izvršio da je rezultat bio "uspjeh".

Otvorite postavke zadatka sklapanja i pomaknite se do samog dna. Kliknite na gumbe: Dodaj korak izgradnje -> Uvjetni korak (jedan). U postavkama koraka odaberite uvjet Trenutačni status izrade, postavite vrijednost USPJEH, radnja koja će se izvršiti ako bude uspješna Pokreni naredbu ljuske.

A sada zabavni dio. Jenkins pohranjuje konfiguracije poslova u datoteke. U XML formatu. Putem http://путь-до-задания/config.xml U skladu s tim, možete preuzeti konfiguracijsku datoteku, urediti je po potrebi i vratiti je tamo gdje ste je preuzeli.

Zapamtite, gore smo se dogovorili da ćemo izraditi parametar za popis verzija BUILD_VERSION?

Preuzmite konfiguracijsku datoteku i zavirimo u nju. Samo da budemo sigurni da je parametar na mjestu i željenog tipa.

Snimka zaslona ispod spojlera.

Vaš bi fragment config.xml trebao izgledati isto. Osim što još nedostaje sadržaj elementa izbora
Mi stvaramo zadatak implementacije u GKE-u bez dodataka, SMS-a ili registracije. Zavirimo ispod Jenkinsove jakne

Jesi li siguran? To je to, napišimo skriptu koja će se izvršiti ako je build uspješan.
Skripta će primiti popis verzija, preuzeti konfiguracijsku datoteku, upisati popis verzija u nju na mjesto koje nam je potrebno, a zatim je vratiti. Da. Tako je. Napišite popis verzija u XML-u na mjesto gdje već postoji popis verzija (bit će u budućnosti, nakon prvog pokretanja skripte). Znam da u svijetu još uvijek postoje žestoki ljubitelji regularnih izraza. Ja ne pripadam njima. Molimo instalirajte xmlstarler na stroj na kojem će se urediti konfiguracija. Čini mi se da to nije tako velika cijena za izbjegavanje uređivanja XML-a pomoću sed-a.

Ispod spojlera predstavljam kod koji u cijelosti izvodi gornju sekvencu.

Napišite popis verzija iz mape na udaljenom poslužitelju u 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

Ako više volite opciju dobivanja verzija od Jenkinsa, a lijeni ste kao i ja, onda je ispod spojlera isti kod, ali popis od Jenkinsa:

Napišite popis verzija iz Jenkinsa u konfiguraciju
Samo imajte ovo na umu: naziv mog sklopa sastoji se od rednog broja i broja verzije, odvojenih dvotočkom. Sukladno tome, awk odsiječe nepotrebni dio. Za sebe, promijenite ovu liniju kako bi odgovarala vašim potrebama.

#!/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

U teoriji, ako ste testirali kod napisan na temelju gornjih primjera, tada biste u zadatku postavljanja već trebali imati padajući popis s verzijama. Kao na slici ispod spojlera.

Ispravno popunjen popis verzija
Mi stvaramo zadatak implementacije u GKE-u bez dodataka, SMS-a ili registracije. Zavirimo ispod Jenkinsove jakne

Ako je sve radilo, kopirajte i zalijepite skriptu u Pokreni naredbu ljuske i spremite promjene.

Povezivanje s Cloud shellom

Imamo sakupljače u kontejnere. Koristimo Ansible kao naš alat za isporuku aplikacija i upravitelj konfiguracije. U skladu s tim, kada se radi o izgradnji kontejnera, tri opcije padaju na pamet: instalirati Docker u Docker, instalirati Docker na stroj koji pokreće Ansible ili izgraditi kontejnere u konzoli u oblaku. Dogovorili smo se da ćemo šutjeti o dodacima za Jenkins u ovom članku. Zapamtiti?

Odlučio sam: dobro, budući da se spremnici "iz kutije" mogu skupljati u konzoli u oblaku, zašto se onda truditi? Neka bude čisto, zar ne? Želim prikupiti Jenkinsove spremnike u konzoli u oblaku, a zatim ih odatle pokrenuti u cuber. Štoviše, Google ima vrlo bogate kanale unutar svoje infrastrukture, što će povoljno utjecati na brzinu implementacije.

Za povezivanje s konzolom u oblaku potrebne su vam dvije stvari: gcloud i prava pristupa Google Cloud API za VM instancu s kojom će se uspostaviti ta ista veza.

Za one koji se uopće ne planiraju spajati s Google oblaka
Google dopušta mogućnost onemogućavanja interaktivne autorizacije u svojim uslugama. To će vam omogućiti da se povežete na konzolu čak i iz aparata za kavu, ako radi na *nixu i ima samu konzolu.

Ukoliko postoji potreba da ovu problematiku detaljnije obradim u okviru ove bilješke, napišite u komentarima. Ako dobijemo dovoljno glasova, napisat ću ažuriranje o ovoj temi.

Najlakši način dodjele prava je putem web sučelja.

  1. Zaustavite VM instancu s koje ćete se naknadno spojiti na konzolu u oblaku.
  2. Otvorite pojedinosti instance i kliknite ispraviti.
  3. Na samom dnu stranice odaberite opseg pristupa instanci Potpuni pristup svim Cloud API-jima.

    zaslona
    Mi stvaramo zadatak implementacije u GKE-u bez dodataka, SMS-a ili registracije. Zavirimo ispod Jenkinsove jakne

  4. Spremite promjene i pokrenite instancu.

Nakon što se VM završi s učitavanjem, povežite se s njim putem SSH-a i provjerite da se veza odvija bez pogreške. Koristite naredbu:

gcloud alpha cloud-shell ssh

Uspješna veza izgleda otprilike ovako
Mi stvaramo zadatak implementacije u GKE-u bez dodataka, SMS-a ili registracije. Zavirimo ispod Jenkinsove jakne

Implementiraj u GKE

Budući da na sve moguće načine nastojimo u potpunosti prijeći na IaC (Infrastucture as a Code), naše docker datoteke pohranjene su u Gitu. Ovo je s jedne strane. A implementacija u kubernetesu opisana je yaml datotekom, koju koristi samo ovaj zadatak, koji je sam također poput koda. Ovo je s druge strane. Općenito, mislim, plan je sljedeći:

  1. Uzimamo vrijednosti varijabli BUILD_VERSION i, po izboru, vrijednosti varijabli koje će biti proslijeđene ENV.
  2. Preuzmite docker datoteku s Gita.
  3. Generirajte yaml za implementaciju.
  4. Obje ove datoteke prenosimo putem scp-a na konzolu u oblaku.
  5. Tamo gradimo spremnik i guramo ga u registar spremnika
  6. Primjenjujemo datoteku postavljanja opterećenja na cuber.

Budimo konkretniji. Jednom kada smo počeli razgovarati o ENV, onda pretpostavimo da trebamo proslijediti vrijednosti dva parametra: PARAM1 и PARAM2. Dodajemo njihov zadatak za implementaciju, upišite - Parametar niza.

zaslona
Mi stvaramo zadatak implementacije u GKE-u bez dodataka, SMS-a ili registracije. Zavirimo ispod Jenkinsove jakne

Generirati ćemo yaml jednostavnim preusmjeravanjem odjek podnijeti. Pretpostavlja se, naravno, da imate u svojoj docker datoteci PARAM1 и PARAM2da će naziv opterećenja biti superapp, a montirana posuda s primjenom navedene izvedbe leži u Registar kontejnera na putu gcr.io/awesomeapp/awesomeapp-$BUILD_VERSIONGdje $BUILD_VERSION je upravo odabran s padajućeg popisa.

Popis momčadi

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

Jenkins agent nakon povezivanja pomoću gcloud alpha cloud-shell ssh interaktivni način rada nije dostupan, pa šaljemo naredbe konzoli u oblaku pomoću parametra --naredba.

Čistimo početnu mapu u konzoli u oblaku iz stare docker datoteke:

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

Smjestite svježe preuzetu docker datoteku u početnu mapu konzole u oblaku koristeći scp:

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

Prikupljamo, označavamo i guramo spremnik u registar spremnika:

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"

Isto radimo s datotekom za implementaciju. Imajte na umu da naredbe u nastavku koriste fiktivna imena klastera u kojem se odvija implementacija (awsm-klaster) i naziv projekta (strašan-projekt), gdje se klaster nalazi.

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"

Pokrećemo zadatak, otvaramo izlaz konzole i nadamo se da ćemo vidjeti uspješnu montažu spremnika.

zaslona
Mi stvaramo zadatak implementacije u GKE-u bez dodataka, SMS-a ili registracije. Zavirimo ispod Jenkinsove jakne

A zatim uspješno postavljanje sastavljenog kontejnera

zaslona
Mi stvaramo zadatak implementacije u GKE-u bez dodataka, SMS-a ili registracije. Zavirimo ispod Jenkinsove jakne

Namjerno sam zanemario postavku Ulaz. Iz jednog jednostavnog razloga: nakon što ga postavite posla s danim imenom, ostat će operativan, bez obzira koliko implementacija s ovim imenom izvršite. Pa, općenito, ovo je malo izvan okvira povijesti.

Umjesto zaključaka

Svi gore navedeni koraci vjerojatno se nisu mogli napraviti, već jednostavno instalirati neki dodatak za Jenkins, njihov muuulion. Ali iz nekog razloga ne volim dodatke. Pa, točnije, pribjegavam im samo iz očaja.

I jednostavno volim odabrati neku novu temu za sebe. Gornji tekst također je način da podijelim saznanja do kojih sam došao rješavajući problem opisan na samom početku. Podijelite s onima koji, poput njega, nisu nimalo strašni vuk u devopsu. Ako moji nalazi pomognu barem nekome, bit ću sretan.

Izvor: www.habr.com

Dodajte komentar