Kis méretű Docker image készítése Go nyelven

Borító kép zöld GO gombbal a pixabay.com-ról powerpointtal szerkesztve

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

[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

../../bin/build.sh

parancsot futtatva az összes használható paramétert megtalálod a hoszton való binárisok generálásához. A

../../bin/build-image.sh

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.

go build -o build/main src/main.go

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.

package main

func main() {
  println("Hello Go!")
}

A program futtatása:

./build/main

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:

FROM golang:1.16.5-buster as build
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ó

docker build -t localhost/go-examples/hello:main -f build/Dockerfile.main .

Majd pedig indulhat is az első, már konténerben futó, jelenleg hasznosan hasztalan Go programunk:

docker run --rm -it localhost/go-examples/hello:main

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:

package main

// 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:

go build -o build/mainc src/mainc.go

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:

 standard_init_linux.go:228: exec user process caused: no such file or directory

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.

go build -o build/mainc -ldflags '-extldflags "-static" ' src/mainc.go

A fenti utasítást használja tehát a "build" mappába generált "Dockerfile.mainc-static"

FROM golang:1.16.5-buster as build
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"]

docker build -t localhost/go-examples/hello:mainc-static -f build/Dockerfile.mainc-static .

A kimenet most már a következő módon indítva a konténert ismét a "Hello Go! 2" lesz.

docker run --rm -it localhost/go-examples/hello:mainc-static

A következő paranccsal meg lehet nézni a "hello" projekt Docker image-eit a verzió tag-ek és a méreteik alapján:

docker image ls localhost/go-examples/hello:* --format '{{ .Tag }} {{ .Size }}' | column -t | sort
main            1.22MB
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:

go build -o build/mainc -ldflags '-extldflags "-static" -s ' src/mainc.go

A "Dockerfile.main-static-st0" a "build" mappában pedig:

FROM golang:1.16.5-buster as build
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 build -t localhost/go-examples/hello:mainc-static-st0 -f build/Dockerfile.mainc-static-st0 .
docker run --rm -it localhost/go-examples/hello:mainc-static-st0

A kimenet nem változik, de nézzük az image méreteket:

docker image ls localhost/go-examples/hello:* --format '{{ .Tag }} {{ .Size }}' | column -t | sort
main              1.22MB
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.

cd ../hello-time

Az "src" mappában most a "main.go" így néz ki:

package main

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

go build -o build/main src/main.go

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.

FROM golang:1.16.5-buster as build
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 build -t localhost/go-examples/hello-time:main -f build/Dockerfile.main
docker run --rm -it localhost/go-examples/hello-time:main

A hibaüzenet

panic: time: missing Location in call to Date

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:

go build -o build/main -tags timetzdata src/main.go

A Docker buildhez szükséges fájlok és parancsok pedig a következők a "build/Dockerfile.main-tz" fájllal kezdve

FROM golang:1.16.5-buster as build
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 build -t localhost/go-examples/hello-time:main-tz -f build/Dockerfile.main-tz .
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.

docker image ls localhost/go-examples/hello-time:* --format '{{ .Tag }} {{ .Size }}' | column -t | sort

Kimenet

main     1.42MB
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

go: no Go source files

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.

cd ../hello-web
package main

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.

FROM golang:1.16.5-buster as build
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 build -t localhost/go-examples/hello-web:main -f build/Dockerfile.main .
docker run --rm -it -p 8180:8180 localhost/go-examples/hello-web:main

A hibaüzenet pedig

standard_init_linux.go:228: exec user process caused: no such file or directory

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:

strings ./build/main | grep '\.so\.'

Eredmény

/lib64/ld-linux-x86-64.so.2
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:

FROM golang:1.16.5-buster as build
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 build -t localhost/go-examples/hello-web:main-cgo0 -f build/Dockerfile.main-cgo0 .
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:

docker image ls localhost/go-examples/hello-web:* --format '{{ .Tag }} {{ .Size }}' | column -t | sort
main       6.18MB
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.

CGO_ENABLED=0 go build -o path/to/output -ldflags '-extldflags "-static" -s' -tags timetzdatta path/to/source-code.go

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.

[Tartalom]

Kategóriák: 
Megosztás/Mentés