A Docker konténerekkel találkozva nem telik el sok idő, míg szembesülünk Go nyelvvel vagy legalább a Go template-tel, mivel a Docker is Go nyelven készült és a Docker kliensnél is használhatunk Go template-et bizonyos kimenetek formázására. Talán már hallottál arról is, hogy lehet készíteni Go-ban olyan programokat is, amikből egyetlen bináris generálható, ami aztán könnyedén használható konténerben is. Ha minden függőségünk benne van ebben a fájlban, mivel a legtöbb esetben a konténerben levő alap Linux fájlrendszert úgysem használjuk ki, azt akár teljesen el is hagyhatjuk és egy "scratch" image-re építve elkészíthetjük az egyetlen binárist tartalmazó Docker image-ünket, így egy nagyon kis méretű image-et eredményezve. Mutatom, hogy build-elheted a legegyszerűbb Go programot, mik azok, amikre valószínűleg még szükséged lehet és mi kell ahhoz, hogy Docker konténerben is működjön a build.
Tartalomjegyzék
- Környezet beállítása
- A legegyszerűbb program függőségek nélkül
- C függvénydefiníció beágyazása Go forráskódba
- Image méreteket csökkentő paraméterek
- Munka dátumokkal
- Mi az a CGO és mi köze a C forráskódhoz?
- Végszó
- Kapcsolódó linkek
Környezet beállítása
[Tartalom]
A példákban a hoszt operációs rendszeren és Docker image-ben is fogok Go programokat build-elni. Builder Docker image-nek a golang:1.16.5-buster
-t használom. A hoszton is érdemes ennek megfelelő Go-t telepíteni, de újabbal is lehet próbálkozni.
Az itt bemutatott példák mind megtalálhatók a GitHub-on a Docker Go Examples repository-ban. A cikkben bemutatott verzióra közvetlen link:
https://github.com/itsziget/docker-go-examples/tree/v1.0.0
A projekt tartalmaz segédszkripteket is, amivel legenerálhatók a Go build parancsok és a Docker image készítéséhez, illetve a konténerek indításához szükséges utasítások is. A cikkben már csak a generált utasításokat fogom mutatni a releváns forráskódokkal, amik lefedik a téma megértéséhez szükséges paramétereket. Ha mégis a szeretnél további példákat kipróbálni, az egyes példaprojektekbe belépve a letölthető forráskódban a
parancsot futtatva az összes használható paramétert megtalálod a hoszton való binárisok generálásához. A
pedig ezek Docker konténerben való futtatásában segít azonos paraméterezéssel.
A legegyszerűbb program függőségek nélkül
[Tartalom]
Először nyiss egy terminált és lépj be a letöltött projekten belül a projects/hello
mappába. Egy darabig minden parancsot innen kell majd kiadni. A legegyszerűbb Go build parancs a forrásfájl és a generálandó kimeneti fájl megadásából áll.
Ez persze nem igaz, mert a forrás és a kimeneti fájl útvonala is elhagyható, de akkor más tényezőket kellene még figyelembe venni, ezért nevezzük inkább a legtisztább parancsnak. A forrásfájl ebben az esetben az "src" mappában levő "main.go", aminek a tartalma a következő, kizárólag a "Hello Go!" szöveget kiíró forráskód, mindenféle függőség, import nélkül.
func main() {
println("Hello Go!")
}
A program futtatása:
Ez persze tényleg a világ legegyszerűbb Go programja volt, így a világ egyik legegyszerűbb dolga ezt Docker image-be is betenni. A "Dockerfile.main" a "build" mappában a következőképpen néz ki:
COPY src /src
WORKDIR /
RUN go build -o build/main src/main.go
FROM scratch
WORKDIR /
COPY --from=build /build/main /run
STOPSIGNAL SIGINT
CMD ["/run"]
A fenti kód egy multi-stage build forráskódja, ahol az első stage, azaz az első FROM kulcsszóval kezdődő rész egy Debian Buster-re épülő, Go 1.16.5-öt tartalmazó Docker image-et használ, majd a gyökérkönyvtárból (WORKDIR) futtatja a már ismert GO build utasítást. A második rész viszont már a "scratch" image-ből származik,, ami egy teljesen üres image, így csak az kerül bele, amit belemásolunk. Mivel itt shellünk sincs, a RUN direktíva használatának itt nem sok értelme van, hacsak nem egy már felmásolt bináris után. A STOPSIGNAL direktíváról már volt szó korábban. Csak azért állítom SIGINT-re, hogy így egyből működjön a CTRL+C -vel is az előtérben futtatott konténer leállítása, miközben a Docker stop paranccsal is működne a leállítás.
A Docker build az alábbi módon indítható
Majd pedig indulhat is az első, már konténerben futó, jelenleg hasznosan hasztalan Go programunk:
A kimenet továbbra is "Hello Go!". Persze azért ennyire üres forráskód nem nagyon létezik, szóval bonyolítsuk kicsit.
C függvénydefiníció beágyazása Go forráskódba
[Tartalom]
Nézzük a következő forrráskódot az "src" mappában "mainc.go" néven:
// int number() {
// return 2;
// }
import "C"
func main() {
println("Hello Go!", C.number())
}
Itt már kommentben egy C függvényt is definiáltam, amire a "C" modul importálása miatt tudok is hivatkozni Goból. A hoszton futtatva konténer nélkül ez is hibátlanul működik az előző példában használt build paranccsal buildelve:
A Docker build verziója viszont már nem ilyen hibátlan és természetesen a "main" elnevezést mindenhol "mainc"-re cserélve a Dockerfile fájlban a konténer futtatásakor mégis az alábbi hibaüzenetet kapnánk:
Nem, a hiba nem a futtatandó bináris megtalálása, hanem a Docker által használt runc a már megtalált bináris futtatásakor ütközik a hiányzó programkönyvtárak hibájába a beágyazott C függvény miatt. A -ldflags '-extldflags "-static" '
hozzáadásával a build parancshoz utasítjuk a Go buildet, hogy fordítsa bele a binárisba ezeket. Ha valakit érdekelne, az "LD" az "ldflags"-ben itt a linker direktívát jelenti, az "ext" pedig az "external linkerre" utal, mint a gcc.
A fenti utasítást használja tehát a "build" mappába generált "Dockerfile.mainc-static"
COPY src /src
WORKDIR /
RUN go build -o build/mainc -ldflags '-extldflags "-static" ' src/mainc.go
FROM scratch
WORKDIR /
COPY --from=build /build/mainc /run
STOPSIGNAL SIGINT
CMD ["/run"]
A kimenet most már a következő módon indítva a konténert ismét a "Hello Go! 2" lesz.
A következő paranccsal meg lehet nézni a "hello" projekt Docker image-eit a verzió tag-ek és a méreteik alapján:
mainc 1.3MB
mainc-static 2.36MB
A "mainc-static" verzió méretéből is látszik, hogy változatlan forráskóddal több minden került bele a binárisba, nagyobb lett a mérete, de erre itt szükség is volt.
Image méreteket csökkentő paraméterek
[Tartalom]
A lefordított binárisokba bizonyos szimbolúmok is bekerülnek, amik nem közvetlenül a program futásában játszanak szerepet, de információkat jelentenek és például debugoláskor hasznosak. Ennek kikapcsolására a Go buildnél két flag is létezik. A -w
, ami csak a debug szimbolúmokat (DWARF) kapcsolja ki és a -s
, ami minden szimbólumot. Minden szimbolúm kikapcsolásával tehát így tudnánk csökkenteni az image méretet továbbra is a "mainc.go"-ból kiindulva:
A "Dockerfile.main-static-st0" a "build" mappában pedig:
COPY src /src
WORKDIR /
RUN go build -o build/mainc -ldflags '-extldflags "-static" -s ' src/mainc.go
FROM scratch
WORKDIR /
COPY --from=build /build/mainc /run
STOPSIGNAL SIGINT
CMD ["/run"]
És a Docker build, majd a konténer indítása
docker run --rm -it localhost/go-examples/hello:mainc-static-st0
A kimenet nem változik, de nézzük az image méreteket:
mainc 1.3MB
mainc-static 2.36MB
mainc-static-st0 1.67MB
A szombúlum tábla kikapcsolásával ismét közelebb kerültünk az "mainc" image méretünkhöz. Ha még ráadásul ugyanezt nem a "mainc" hanem a "C" forráskódot nem tartalmazó "main" változattal tettük volna, a programkörnyvtárak hiányában egy 836 kilobájtos image méretet értünk volna el. Oké, ez nem igazán számít, mivel gyakorlatilag semmit nem csinál az image, de szemlélteti a paraméterek jelentőségét, illetve azok forráskódokkal való kapcsolatát. Az sem számít, hogy használjuk-e a "-static" opciót akkor is, amikor nincs rá szükség, mert akkor legfeljebb nem változtat az eredményen. A szombúlum tábla kikapcsolása viszont mindig csökkenti a méretet és debugolás nélkül a működésben sem okoz problémát. Szükség esetén pedig lehet készíteni egy direkt debugolásra használható verziót ugyanabból a forráskódból.
Munka dátumokkal
[Tartalom]
A dátumok kezelése nem ritka igény és időzónák figyelembe vétele nélkül ma már nehéz élni. A "hello-time" nevű példa projekt a "projects/hello-time" mappában ezt demonstrálja. Első lépés tehát a "hello"-ból ki-, a "hello-time" mappába belépés.
Az "src" mappában most a "main.go" így néz ki:
import "time"
func main() {
loc, _ := time.LoadLocation("Europe/Budapest")
println(
"Hello Go!",
time.Date(2021, time.August, 20, 12, 0, 0, 0, loc).Format(time.RFC3339),
)
}
A fordítás a már ismert paranccsal történik
A ./build/main
futtatása a Hello Go! 2021-08-20T12:00:00+02:00
kimenetet adja, tehát budapesti időzónában megkapom a 2021. augusztus 20. déli 12 órát. Ám ugyanezt az alábbi "build/Dockerfile.main"-nel buildelve egy hibás konténert kapok.
COPY src /src
WORKDIR /
RUN go build -o build/main src/main.go
FROM scratch
WORKDIR /
COPY --from=build /build/main /run
STOPSIGNAL SIGINT
CMD ["/run"]
A build
docker run --rm -it localhost/go-examples/hello-time:main
A hibaüzenet
goroutine 1 [running]:
time.Date(0x7e5, 0x8, 0x14, 0xc, 0x0, 0x0, 0x0, 0x0, 0x414801, 0x0, ...)
/usr/local/go/src/time/time.go:1344 +0x5f1
main.main()
/src/main.go:7 +0x7d
A "Location" megint csak nem a futtatott bináris helyére utal, hanem az időzónára. Nem találja az időzónák kezeléséhez szükséges információkat, amiket egy Ubuntu hoszton a /usr/share/zoneinfo/
mappa tartalmazna. Léteznek viszont a go forráskódokban a tag-ek. Meg lehet adni, hogy egy bizonyos forráskód csak akkor legyen lefordítva, ha a build időben átadunk egy megfelelő tag-et. Ezt láthatjuk a time modul embed.go fájljában, ami feltételesen importálja az időzóna információkat. Nincs más dolgunk, mint használni a tag-et:
A Docker buildhez szükséges fájlok és parancsok pedig a következők a "build/Dockerfile.main-tz" fájllal kezdve
COPY src /src
WORKDIR /
RUN go build -o build/main -tags timetzdata src/main.go
FROM scratch
WORKDIR /
COPY --from=build /build/main /run
STOPSIGNAL SIGINT
CMD ["/run"]
Build és futtatás
docker run --rm -it localhost/go-examples/hello-time:main-tz
És máris egy működő konténer az eredmény. Ellenőrízhetjük még az image-ek méretét, de igazából a "main" változat úgysem működött, így nincs különösebb jelentősége, hogy a "main-tz" nagyobb, de látszik, hogy valóban belekerült valami, ami azelőtt nem volt benne.
Kimenet
main-tz 1.85MB
Mi az a CGO és mi köze a C forráskódhoz?
[Tartalom]
Gyakran lehet olvasni a Docker konténerekben használt Go binárisokkal kapcsolatban, hogy a CGO_ENABLED
környezeti változót is 0-ra kell állítani. De a kérdés, hogy mégis miért. Egyáltalán mi az a CGO, és hogy lehet, hogy eddig egyszer sem használtuk a build-hez és mégis működtek a konténerek? A CGO valójában a "C" és a "GO" nyelv összeolvasztása. Ha ezt kikapcsoltam volna azoknál a példáknál a változó beállításával, amikben beágyazott C forráskódom is volt, nem futott volna le a build. Pontosabban lefutottt volna a parancs, de azt az üzenetet kaptuk volna, hogy
Ahogy a tag-ek használata, úgy a C forráskód beágyazása is úgy működiik, hogy a megfelelő feltételek nélkül egyszerűen nem történik meg a "go" fájl fordítása. Ahol ugyanis eddig egy fix fájlt adtunk meg útvonalként, ott egy minta is használható lenne az összes "go" kiterjesztésű fájlra hivatkozva. A hibaüzenet tehát inkább azt jelenti, hogy "Nincs olyan GO forráskód a megadott mintában, ami megfelel a feltételeknek". De mi van akkor, ha egy modul tartalmaz olyan fájlokat, amik igénylik a CGO engedélyezését, nekünk viszont nincs rá szükségünk, mégis belekerül a lefordított binárisba? Ilyenkor hasznos, ha letilthatjuk a CGO-t. Nézzük a következő példát a "projects/hello-web" példaprojektben az "src/main.go" forráskóddal.
import (
"fmt"
"log"
"net/http"
)
func HelloServer(response http.ResponseWriter, request *http.Request) {
fmt.Fprintf(response, "Hello, you requested: %s\n", request.URL.Path)
log.Printf("Received request for path: %s", request.URL.Path)
}
func main() {
var addr string = ":8180"
handler := http.HandlerFunc(HelloServer)
err := http.ListenAndServe(addr, handler)
if err != nil {
log.Fatalf("Could not listen on port %s %v", addr, err)
}
}
A hoszton a szokásos go build -o build/main src/main.go
parancs továbbra is működő binárist eredményez. A ./build/main
futtatásával elindul egy webszerver, ami a 8180-as porton figyell. De nézzük a Docker build változatot az alábbi "build/Dockerfile.main" fájl használatával.
COPY src /src
WORKDIR /
RUN go build -o build/main src/main.go
FROM scratch
WORKDIR /
COPY --from=build /build/main /run
STOPSIGNAL SIGINT
CMD ["/run"]
Build és indítás
docker run --rm -it -p 8180:8180 localhost/go-examples/hello-web:main
A hibaüzenet pedig
Várjunk csak! Ezt már ismerjük. Tehát csak a "-static" opciót kellene megint használni, igaz? Nem. Most nem az a gond, hogy hiányoznak a binárisból programkönyvtárak, hanem hogy belekerültek a binárisba számunkra szükségtelen referenciák, amik nincsenek a konténerben. A következő paranccsal bele is lehet nézni a binárisba, milyen referenciákról van szó, amit a korábbi példákban nem találhattunk volna meg:
Eredmény
libpthread.so.0
libc.so.6
libc.so.6
libpthread.so.0
Gyanúsan C-re utaló referenciák vannak itt, mint a "libc". Oké, tiltsuk le a CGO-t az alábbi "build/Dockerfile.main-cgo0" használatával:
COPY src /src
WORKDIR /
RUN CGO_ENABLED=0 go build -o build/main src/main.go
FROM scratch
WORKDIR /
COPY --from=build /build/main /run
STOPSIGNAL SIGINT
CMD ["/run"]
Build és indítás
docker run --rm -it -p 8180:8180 localhost/go-examples/hello-web:main-cgo0
Most már akár böngészőből, akár curl-lel parancssorból lekérdezhető a "localhost:8180"-as webcím. Működk a Go webszerver. Végül pedig azért nézzük meg ismét az image méreteket:
main-cgo0 6.13MB
Itt az is feltűnhet, hogy az image mérete is kisebb lett ettől, tehát valóban van szerepe az image méretekben is, ami hasznos, de még hasznosabb, hogy működik is a konténer.
Végszó
[Tartalom]
Vannak tehát különböző paramétereink, amik egy része csökkenti, egy része pedig növeli az image méreteket. Utóbbiak viszont szükségesek, de még így is kisebb lesz az image, mintha egy bármilyen Linux alap fájlrendszert tettünk volna alá a scatch image helyett. Több problémát is megoldottunk, de persze lehetnek olyanok, amiket az itt leírtak nem oldanak meg, de azt gondolom, egy jó kezdés lehet. Ezután talán a Google Container Tools "Distroless" image-e is érdekelhet, ami nem teljesen scratch image, de nincs messze attól és olyan függőségeket tartalmazhat, mint például a tanúsítványok vagy a felhasználók neveit és azonosítóit összepárosító "/etc/passwd" fájl, ám ezekre a legegyszerűbb esetekben nincs is szükség. Ha egy olyan go build parancsot keresel, ami az esetek többségében tehát működni fog és kis méretet eredményez beágyazott C forráskód engedélyezése nélkül, akkor a következőt használhatod, még ha egyes flag-ek nem is mindig szükségesek.
Ez persze nem nyújt 100%-os garanciát és akár további tag-ekre is szükség lehet, ezért minden esetben utána kell járni a programok függőségeinek. Ez viszont általában is igaz a konténerizáció folyamatára.
Ha úgy érzed, segített a cikk vagy a videó a Go programok konténerben használatának megértésében, jelezd egy kommenttel, like-kal vagy egy feliratkozással a Facebook és/vagy Youtube csatornára.
Kapcsolódó linkek
[Tartalom]