chore(): Adds Multi-stage docker container build post
This commit is contained in:
parent
a96c0e5b82
commit
68a0200f01
2 changed files with 417 additions and 0 deletions
|
@ -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
|
/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
|
deployment of an /SSL/ cert. That may come in the future, time permitting. Stay
|
||||||
safe, until the next one !
|
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-04-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:
|
** K3s :@k3s:
|
||||||
*** DONE Building k3s on a Pi :arm:kubernetes:
|
*** DONE Building k3s on a Pi :arm:kubernetes:
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
|
|
212
content/posts/multi-stage-docker-container-build.md
Normal file
212
content/posts/multi-stage-docker-container-build.md
Normal file
|
@ -0,0 +1,212 @@
|
||||||
|
+++
|
||||||
|
title = "Multi-Stage Docker container Build"
|
||||||
|
author = ["Elia el Lazkani"]
|
||||||
|
date = 2025-04-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.
|
Loading…
Add table
Reference in a new issue