chore(): Adds Multi-stage docker container build post
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is passing

This commit is contained in:
Elia el Lazkani 2025-03-05 23:43:42 +01:00
parent a96c0e5b82
commit a04bb96f6e
2 changed files with 417 additions and 0 deletions

View file

@ -3029,6 +3029,211 @@ On these small Raspberry Pis, the cluster seems to be working very well. The
/DNS/. There's a few improvements that can be done to this setup, mainly the
deployment of an /SSL/ cert. That may come in the future, time permitting. Stay
safe, until the next one !
*** DONE Multi-Stage Docker container Build :docker:linux:container:multi_stage:podman:dockerfile:
:PROPERTIES:
:EXPORT_HUGO_LASTMOD: 2025-03-05
:EXPORT_DATE: 2025-03-05
:EXPORT_FILE_NAME: multi-stage-docker-container-build
:CUSTOM_ID: multi-stage-docker-container-build
:END:
One of the hidden gems of /Docker containers/ is /multi-stage/ builds. If it
never made any sense to you, you've heard of it but have no clue what it is or
just passing along... We're going to use it in a practical example.
#+hugo: more
**** go-cmw
A while ago, I wrote a small utility in /golang/ which fetches the weather for
me and displays it in the terminal. /[[https://scm.project42.io/elia/go-cmw][go-cmw]]/ is, basically, a [[https://wttr.in/][wttr.in]] terminal
client. It simplifies the usage of the *API* and makes it easier to integrate,
for me, into other terminal tools. Who's not a big a fan of the shell huh !
Am I right !
**** Let's containerize it
Let's say we would like to write a =Dockerfile= for the project to build the
code and create a container for it.
The =Dockerfile= would probably look something like this.
#+begin_src dockerfile
# Yes, we're smart, we used a small image because it's all we need
FROM docker.io/library/golang:alpine
# Copy the directory of the code into /cmw
ADD . /cmw
# Install git as a dependency
RUN apk add git && \
# Navigate to the directory where we copied the code to
cd /cmw && \
# Get the dependencies of the project
go get -u . && \
# Build that bad boy !
go build -o cmw && \
# Move it into a path we know is in $PATH
mv cmw /usr/bin/cmw && \
# Clean up, we have security in mind
cd / && rm -rf /cmw
# Aight run it over one day !
CMD ["cmw", "-o"]
#+end_src
We've tried to use as few layers as possible to keep this image small. Let's
take a look at the image we built.
#+begin_src shell
$ podman build -t cmw .
$ podman run -e GO_CMW_LOCATION="Dublin" cmw
Weather report: Dublin
\ / Partly cloudy
_ /"".-. +12(11) °C
\_( ). ↑ 16 km/h
/(___(__) 10 km
0.0 mm
┌──────────────────────────────┬───────────────────────┤ Wed 05 Mar ├───────────────────────┬──────────────────────────────┐
│ Morning │ Noon └──────┬──────┘ Evening │ Night │
├──────────────────────────────┼──────────────────────────────┼──────────────────────────────┼──────────────────────────────┤
│ \ / Sunny │ \ / Sunny │ \ / Sunny │ \ / Clear │
│ .-. +9(7) °C │ .-. +12(10) °C │ .-. +8(6) °C │ .-. +6(3) °C │
│ ― ( ) ― ↑ 14-20 km/h │ ― ( ) ― ↗ 19-22 km/h │ ― ( ) ― ↑ 14-29 km/h │ ― ( ) ― ↑ 14-29 km/h │
│ `- 10 km │ `- 10 km │ `- 10 km │ `- 10 km │
/ \ 0.0 mm | 0% │ / \ 0.0 mm | 0% │ / \ 0.0 mm | 0% │ / \ 0.0 mm | 0% │
└──────────────────────────────┴──────────────────────────────┴──────────────────────────────┴──────────────────────────────┘
Location: Dublin, County Dublin, Leinster, Ireland [53.3497645,-6.2602731]
#+end_src
Okay, it works. So what now ? Well now, we look deeper.
Let's look at the size...
#+begin_src shell
$ podman images cmw
REPOSITORY TAG IMAGE ID CREATED SIZE
localhost/cmw latest db1730d690ed 2 minutes ago 398 MB
#+end_src
Ah! That's quite big for a small tiny client that shouldn't be bigger than a few
MBs. This is going to take forever to download on a server to run constantly (hypothetically).
How many layers does it have...
#+begin_src shell
$ podman inspect cmw | jq .[].RootFS.Layers[] | wc -l
7
#+end_src
That's quite a few layers. The more we have to download, the slower the download is.
And finally a quick vulnerability scan...
#+begin_src shell
$ trivy image localhost/cmw
...
#+end_src
Okay the /Trivy/ output is quite big but the summary is *2 Critical*, *1 High*
and *2 Medium* severity vulnerabilities all coming from the image even though
we're using the latest available.
**** Multi-stage build
We saw a few issues in the previous part of this post, let's see if we can fix
them.
The first thing I'm going to do is start thinking about my application. My
client is written in /golang/ which means that the binary should work without
any dependencies. I could build the binary on my machine and /then/ *copy* it to
the container. This path will definitely reduce our layer number but this path
is not easily packaged and reproduced on a different machine.
Besides, we said we're using /containers/ for this.
Another thing to think about are the vulnerabilities in the built image. All of
the vulnerabilities identified are related to /golang/, which makes sense. We're
using a /golang/ container image after all, even though the image is based on
the hardened /alpine/ distribution. We can do better, we can go with a container
that contains almost nothing, it should definitely be more secure.
#+begin_src dockerfile
FROM docker.io/library/golang:alpine as builder
ADD . /cmw
RUN apk add git && \
cd /cmw && \
go get -u . && \
go build -o cmw
FROM docker.io/library/alpine:latest
COPY --from=builder /cmw/cmw /cmw
CMD ["/cmw", "-o"]
#+end_src
Let me explain a bit what changed. The first change is that we named our /first
stage/ to *builder* to make it easier to reference it later. The /dependency/
installations and the code builds stay exactly the same. The cleanups were
removed as they have no purpose anymore.
The /second/ =FROM= is where the magic starts to happen. We're using, in this
/second stage/ a plain =alpine= image. This container does not have any /golang/
compiler, library or dependencies. We, /then/, =COPY= the =cmw= /binary/ from
the *builder* container and into our /alpine/ container. The rest does basically
the same.
Now, let's take a deeper look at the image.
#+begin_src shell
$ podman images cmw
REPOSITORY TAG IMAGE ID CREATED SIZE
localhost/cmw latest 978342ca6735 8 minutes ago 19.5 MB
#+end_src
The difference, in size, between the old image and this new one is *extremely
significant*, down from ~398 MB~ to /just/ ~20 MB~.
And the layers...
#+begin_src shell
$ podman inspect cmw | jq .[].RootFS.Layers[] | wc -l
2
#+end_src
Only ~2~, all the way down from ~7~.
And finally, the icing on the cake...
#+begin_src shell
$ trivy image localhost/cmw
localhost/cmw (alpine 3.21.3)
Total: 0 (UNKNOWN: 0, LOW: 0, MEDIUM: 0, HIGH: 0, CRITICAL: 0)
#+end_src
That's right, no vulnerabilities at all.
#+BEGIN_EXPORT html
<div class="admonition warning">
<p class="admonition-title">warning</p>
#+END_EXPORT
There are no vulnerabilities at the time of building this image. This does not
mean that this image will stay this way. Over time, vulnerabilities will
eventually be found. This is the reason why it is advisable to rebuild your images
frequently to keep them updated.
#+BEGIN_EXPORT html
</div>
#+END_EXPORT
**** Conclusion
~Docker~ container /multi-stage build/ is not a hard concept to grasp. As you
can see, it helps a lot in creating a small and safe container. More stages can
be built on top, some to build the frontend written in /TypeScript/ for example.
This opens up a wide range of features and opportunities for us to use to our advantage.
** K3s :@k3s:
*** DONE Building k3s on a Pi :arm:kubernetes:
:PROPERTIES:

View file

@ -0,0 +1,212 @@
+++
title = "Multi-Stage Docker container Build"
author = ["Elia el Lazkani"]
date = 2025-03-05
lastmod = 2025-03-05
tags = ["docker", "linux", "container", "multi-stage", "podman", "dockerfile"]
categories = ["container"]
draft = false
+++
One of the hidden gems of _Docker containers_ is _multi-stage_ builds. If it
never made any sense to you, you've heard of it but have no clue what it is or
just passing along... We're going to use it in a practical example.
<!--more-->
## go-cmw {#go-cmw}
A while ago, I wrote a small utility in _golang_ which fetches the weather for
me and displays it in the terminal. _[go-cmw](https://scm.project42.io/elia/go-cmw)_ is, basically, a [wttr.in](https://wttr.in/) terminal
client. It simplifies the usage of the **API** and makes it easier to integrate,
for me, into other terminal tools. Who's not a big a fan of the shell huh !
Am I right !
## Let's containerize it {#let-s-containerize-it}
Let's say we would like to write a `Dockerfile` for the project to build the
code and create a container for it.
The `Dockerfile` would probably look something like this.
```dockerfile
# Yes, we're smart, we used a small image because it's all we need
FROM docker.io/library/golang:alpine
# Copy the directory of the code into /cmw
ADD . /cmw
# Install git as a dependency
RUN apk add git && \
# Navigate to the directory where we copied the code to
cd /cmw && \
# Get the dependencies of the project
go get -u . && \
# Build that bad boy !
go build -o cmw && \
# Move it into a path we know is in $PATH
mv cmw /usr/bin/cmw && \
# Clean up, we have security in mind
cd / && rm -rf /cmw
# Aight run it over one day !
CMD ["cmw", "-o"]
```
We've tried to use as few layers as possible to keep this image small. Let's
take a look at the image we built.
```shell
$ podman build -t cmw .
$ podman run -e GO_CMW_LOCATION="Dublin" cmw
Weather report: Dublin
\ / Partly cloudy
_ /"".-. +12(11) °C
\_( ). ↑ 16 km/h
/(___(__) 10 km
0.0 mm
┌──────────────────────────────┬───────────────────────┤ Wed 05 Mar ├───────────────────────┬──────────────────────────────┐
│ Morning │ Noon └──────┬──────┘ Evening │ Night │
├──────────────────────────────┼──────────────────────────────┼──────────────────────────────┼──────────────────────────────┤
│ \ / Sunny │ \ / Sunny │ \ / Sunny │ \ / Clear │
│ .-. +9(7) °C │ .-. +12(10) °C │ .-. +8(6) °C │ .-. +6(3) °C │
│ ― ( ) ― ↑ 14-20 km/h │ ― ( ) ― ↗ 19-22 km/h │ ― ( ) ― ↑ 14-29 km/h │ ― ( ) ― ↑ 14-29 km/h │
`- 10 km │ `- 10 km │ `- 10 km │ `- 10 km │
│ / \ 0.0 mm | 0% │ / \ 0.0 mm | 0% │ / \ 0.0 mm | 0% │ / \ 0.0 mm | 0% │
└──────────────────────────────┴──────────────────────────────┴──────────────────────────────┴──────────────────────────────┘
Location: Dublin, County Dublin, Leinster, Ireland [53.3497645,-6.2602731]
```
Okay, it works. So what now ? Well now, we look deeper.
Let's look at the size...
```shell
$ podman images cmw
REPOSITORY TAG IMAGE ID CREATED SIZE
localhost/cmw latest db1730d690ed 2 minutes ago 398 MB
```
Ah! That's quite big for a small tiny client that shouldn't be bigger than a few
MBs. This is going to take forever to download on a server to run constantly (hypothetically).
How many layers does it have...
```shell
$ podman inspect cmw | jq .[].RootFS.Layers[] | wc -l
7
```
That's quite a few layers. The more we have to download, the slower the download is.
And finally a quick vulnerability scan...
```shell
$ trivy image localhost/cmw
...
```
Okay the _Trivy_ output is quite big but the summary is **2 Critical**, **1 High**
and **2 Medium** severity vulnerabilities all coming from the image even though
we're using the latest available.
## Multi-stage build {#multi-stage-build}
We saw a few issues in the previous part of this post, let's see if we can fix
them.
The first thing I'm going to do is start thinking about my application. My
client is written in _golang_ which means that the binary should work without
any dependencies. I could build the binary on my machine and _then_ **copy** it to
the container. This path will definitely reduce our layer number but this path
is not easily packaged and reproduced on a different machine.
Besides, we said we're using _containers_ for this.
Another thing to think about are the vulnerabilities in the built image. All of
the vulnerabilities identified are related to _golang_, which makes sense. We're
using a _golang_ container image after all, even though the image is based on
the hardened _alpine_ distribution. We can do better, we can go with a container
that contains almost nothing, it should definitely be more secure.
```dockerfile
FROM docker.io/library/golang:alpine as builder
ADD . /cmw
RUN apk add git && \
cd /cmw && \
go get -u . && \
go build -o cmw
FROM docker.io/library/alpine:latest
COPY --from=builder /cmw/cmw /cmw
CMD ["/cmw", "-o"]
```
Let me explain a bit what changed. The first change is that we named our _first
stage_ to **builder** to make it easier to reference it later. The _dependency_
installations and the code builds stay exactly the same. The cleanups were
removed as they have no purpose anymore.
The _second_ `FROM` is where the magic starts to happen. We're using, in this
_second stage_ a plain `alpine` image. This container does not have any _golang_
compiler, library or dependencies. We, _then_, `COPY` the `cmw` _binary_ from
the **builder** container and into our _alpine_ container. The rest does basically
the same.
Now, let's take a deeper look at the image.
```shell
$ podman images cmw
REPOSITORY TAG IMAGE ID CREATED SIZE
localhost/cmw latest 978342ca6735 8 minutes ago 19.5 MB
```
The difference, in size, between the old image and this new one is **extremely
significant**, down from `398 MB` to _just_ `20 MB`.
And the layers...
```shell
$ podman inspect cmw | jq .[].RootFS.Layers[] | wc -l
2
```
Only `2`, all the way down from `7`.
And finally, the icing on the cake...
```shell
$ trivy image localhost/cmw
localhost/cmw (alpine 3.21.3)
Total: 0 (UNKNOWN: 0, LOW: 0, MEDIUM: 0, HIGH: 0, CRITICAL: 0)
```
That's right, no vulnerabilities at all.
<div class="admonition warning">
<p class="admonition-title">warning</p>
There are no vulnerabilities at the time of building this image. This does not
mean that this image will stay this way. Over time, vulnerabilities will
eventually be found. This is the reason why it is advisable to rebuild your images
frequently to keep them updated.
</div>
## Conclusion {#conclusion}
`Docker` container _multi-stage build_ is not a hard concept to grasp. As you
can see, it helps a lot in creating a small and safe container. More stages can
be built on top, some to build the frontend written in _TypeScript_ for example.
This opens up a wide range of features and opportunities for us to use to our advantage.