Thanks to my sponsors: Chirag Jain, Johan Andersson, Cass, Kamran Khan, Katie Janzen, anichno, Nyefan, Moritz Lammerich, Josh Triplett, David Cornu, Borys Minaiev, Lyssieth, Thor Kamphefner, Paige Ruten, Lucille Blumire, Matěj Volf, Daniel Silverstone, Johan Saf, Geoff Cant, Marco Carmosino and 244 more
Writing a Dockerfile for catscii
Now that our service is production-ready, it's time to deploy it somewhere.
There's a lot of ways to approach this: what we are going to do, though, is build a docker image. Or, I should say, an OCI image.
This is still a series about Nix, but again: because the best way to see the benefits of Nix is to do it without Nix first, we'll use only Docker's tooling to build the image.
Installing Docker on Ubuntu
Docker has its own docs about that. Many times I've followed all the instructions, only to realize near the very end that they actually have a convenience script.
Cool bear's hot tip
There's also docker packages in the Ubuntu repositories, but it tends to lag behind official releases by quite a bit, so we won't bother with it.
From VS Code's integrated terminal, we can run the convenience script:
amos@miles:~/catscii$ cd ~
direnv: unloading
amos@miles:~$ curl -fsSL https://get.docker.com -o get-docker.sh
amos@miles:~$ sudo sh get-docker.sh
[sudo] password for amos:
# Executing docker install script, commit: 4f282167c425347a931ccfd95cc91fab041d414f
+ sh -c apt-get update -qq >/dev/null
+ sh -c DEBIAN_FRONTEND=noninteractive apt-get install -y -qq apt-transport-https ca-certificates curl >/dev/null
+ sh -c mkdir -p /etc/apt/keyrings && chmod -R 0755 /etc/apt/keyrings
+ sh -c curl -fsSL "https://download.docker.com/linux/ubuntu/gpg" | gpg --dearmor --yes -o /etc/apt/keyrings/docker.gpg
+ sh -c chmod a+r /etc/apt/keyrings/docker.gpg
+ sh -c echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu jammy stable" > /etc/apt/sources.list.d/docker.list
+ sh -c apt-get update -qq >/dev/null
+ sh -c DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends docker-ce docker-ce-cli containerd.io docker-compose-plugin docker-scan-plugin >/dev/null
+ version_gte 20.10
+ [ -z ]
+ return 0
+ sh -c DEBIAN_FRONTEND=noninteractive apt-get install -y -qq docker-ce-rootless-extras >/dev/null
+ sh -c docker version
Client: Docker Engine - Community
Version: 23.0.6
API version: 1.41
Go version: go1.18.7
Git commit: baeda1f
Built: Tue Oct 25 18:01:58 2022
OS/Arch: linux/amd64
Context: default
Experimental: true
Server: Docker Engine - Community
Engine:
Version: 23.0.6
API version: 1.41 (minimum version 1.12)
Go version: go1.18.7
Git commit: 3056208
Built: Tue Oct 25 17:59:49 2022
OS/Arch: linux/amd64
Experimental: false
containerd:
Version: 1.6.10
GitCommit: 770bd0108c32f3fb5c73ae1264f7e503fe7b2661
runc:
Version: 1.1.4
GitCommit: v1.1.4-0-g5fd4c4d
docker-init:
Version: 0.19.0
GitCommit: de40ad0
================================================================================
To run Docker as a non-privileged user, consider setting up the
Docker daemon in rootless mode for your user:
dockerd-rootless-setuptool.sh install
Visit https://docs.docker.com/go/rootless/ to learn about rootless mode.
To run the Docker daemon as a fully privileged service, but granting non-root
users access, refer to https://docs.docker.com/go/daemon-access/
WARNING: Access to the remote API on a privileged Docker daemon is equivalent
to root access on the host. Refer to the 'Docker daemon attack surface'
documentation for details: https://docs.docker.com/go/attack-surface/
================================================================================
Once that's done, we can follow the Linux post-install steps:
amos@miles:~$ sudo usermod -aG docker $USER
Cool bear's hot tip
As mentioned in the Docker docs, The docker group grants root-level privileges to the user. For details on how this impacts security in your system, see Docker Daemon Attack Surface.
After that, we can reboot the virtual machine, or, until we do, we can make sure
we run newgrp docker
in any terminal we want to use docker in.
That should allow us to run docker images.
amos@miles:~$ newgrp docker
amos@miles:~$ docker run hello-world
Unable to find image 'hello-world:latest' locally
latest: Pulling from library/hello-world
2db29710123e: Pull complete
Digest: sha256:faa03e786c97f07ef34423fccceeec2398ec8a5759259f94d99078f264e9d7af
Status: Downloaded newer image for hello-world:latest
Hello from Docker!
This message shows that your installation appears to be working correctly.
To generate this message, Docker took the following steps:
1. The Docker client contacted the Docker daemon.
2. The Docker daemon pulled the "hello-world" image from the Docker Hub.
(amd64)
3. The Docker daemon created a new container from that image which runs the
executable that produces the output you are currently reading.
4. The Docker daemon streamed that output to the Docker client, which sent it
to your terminal.
To try something more ambitious, you can run an Ubuntu container with:
$ docker run -it ubuntu bash
Share images, automate workflows, and more with a free Docker ID:
https://hub.docker.com/
For more examples and ideas, visit:
https://docs.docker.com/get-started/
All good, time to write a Dockerfile
!
A basic Dockerfile
to build our service
We've developed our service on Ubuntu, and we can use it as a base for our image, too.
I forget, what version of Ubuntu are we using again?
Ah well, we can use lsb_release
to find out:
amos@miles:~/catscii$ lsb_release --all
No LSB modules are available.
Distributor ID: Ubuntu
Description: Ubuntu 22.04.1 LTS
Release: 22.04
Codename: jammy
But if we didn't have it (since it doesn't ship in all distributions by default),
we could just read the contents of /etc/os-release
:
amos@miles:~/catscii$ cat /etc/os-release
PRETTY_NAME="Ubuntu 22.04.1 LTS"
NAME="Ubuntu"
VERSION_ID="22.04"
VERSION="22.04.1 LTS (Jammy Jellyfish)"
VERSION_CODENAME=jammy
ID=ubuntu
ID_LIKE=debian
HOME_URL="https://www.ubuntu.com/"
SUPPORT_URL="https://help.ubuntu.com/"
BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/"
PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy"
UBUNTU_CODENAME=jammy
So, let's create a Dockerfile
in the root of our repository, and... try to
install Rust in there, for a start:
FROM ubuntu:22.04
# Install rustup
RUN set -eux; \
curl --location --fail \
"https://static.rust-lang.org/rustup/dist/x86_64-unknown-linux-gnu/rustup-init" \
--output rustup-init; \
chmod +x rustup-init; \
./rustup-init -y --no-modify-path --default-toolchain stable; \
rm rustup-init;
# Add rustup to path, check that it works
ENV PATH=${PATH}:/root/.cargo/bin
RUN set -eux; \
rustup --version;
Note, if you encounter an error related to rustup not being able to choose a version of cargo during the cargo build --release step, you might need to ensure that the rustup cache is available during the rustup installation. Modify the rustup installation command in your Dockerfile as follows:
RUN --mount=type=cache,target=/root/.rustup \
set -eux; \
curl --location --fail \
"https://static.rust-lang.org/rustup/dist/x86_64-unknown-linux-gnu/rustup-init" \
--output rustup-init; \
chmod +x rustup-init; \
./rustup-init -y --no-modify-path --default-toolchain stable; \
rm rustup-init;
This ensures that the rustup cache is mounted, potentially resolving the issue.
Cool bear's hot tip
set -eux
allows running multiple commands in a single RUN
statement (which
then counts as a single layer), while printing all commands (-x
), erroring if
a variable we use isn't bound (-u
), and failing if one of the commands fail
(-e
).
And try to build it:
amos@miles:~/catscii$ docker build --tag catscii .
^Cnding build context to Docker daemon 187.7MB
No, wait, abort! (Seriously, hit Ctrl-C). It's transferring hundreds of megabytes to the Docker daemon, something's wrong.
But... we don't have hundreds of megabytes of source code?
No we don't. But we have a few gigabytes of artifacts in target/
:
amos@miles:~/catscii$ du --summarize --human-readable target/
3.6G target/
Oh wow. Debug info is heavy! target/debug/
is 2.6G just by itself.
Yeah. Some of it is just due to iterating though — doing cargo clean && cargo build
brings it down to "just" 1.8G, but yes, debug info is heavy.
Anyway we don't want that target
directory to be part of Docker's build
context, so, much like we ignore it in .gitignore
, we can ignore it in
.dockerignore
:
# in `.dockerignore`
/target
amos@miles:~/catscii$ docker build --tag catscii .
Sending build context to Docker daemon 447.5kB
Step 1/4 : FROM ubuntu:20.04
20.04: Pulling from library/ubuntu
eaead16dc43b: Pull complete
Digest: sha256:450e066588f42ebe1551f3b1a535034b6aa46cd936fe7f2c6b0d72997ec61dbd
Status: Downloaded newer image for ubuntu:20.04
---> 680e5dfb52c7
Step 2/4 : RUN set -eux; curl --location --fail "https://static.rust-lang.org/rustup/dist/x86_64-unknown-linux-gnu/rustup-init" --output rustup-init; chmod +x rustup-init; ./rustup-init -y --no-modify-path --default-toolchain stable; rm rustup-init;
---> Running in ce4072a4edf5
+ curl --location --fail https://static.rust-lang.org/rustup/dist/x86_64-unknown-linux-gnu/rustup-init --output rustup-init
/bin/sh: 1: curl: not found
The command '/bin/sh -c set -eux; curl --location --fail "https://static.rust-lang.org/rustup/dist/x86_64-unknown-linux-gnu/rustup-init" --output rustup-init; chmod +x rustup-init; ./rustup-init -y --no-modify-path --default-toolchain stable; rm rustup-init;' returned a non-zero code: 127
Ah but of course. The base ubuntu:20.04
image doesn't have curl.
Well, no matter, let's install it:
FROM ubuntu:20.04
# Install curl
RUN set -eux; \
apt install -y curl;
# Install rustup
# (cut: same as before)
$ docker build --tag catscii .
Sending build context to Docker daemon 453.1kB
Step 1/5 : FROM ubuntu:20.04
---> 680e5dfb52c7
Step 2/5 : RUN set -eux; apt install -y curl;
---> Running in 7291a0f7bfa6
+ apt install -y curl
WARNING: apt does not have a stable CLI interface. Use with caution in scripts.
Reading package lists...
Building dependency tree...
Reading state information...
E: Unable to locate package curl
The command '/bin/sh -c set -eux; apt install -y curl;' returned a non-zero code: 100
Ah yes. The ubuntu docker image doesn't actually have APT repository information: that's been culled to reduce the image size. So we have to run "apt update".
Cool bear's hot tip
"apt" is a different (more modern) command than "apt-get", and the former warns to "use with caution in scripts", since its output isn't stable. But we're not parsing the output of either command, so it's fine.
Let's amend our Dockerfile
to:
# Install curl
RUN set -eux; \
apt update; \
apt install -y curl;
This time, we get this:
$ docker build --tag catscii .
Sending build context to Docker daemon 453.1kB
Step 1/5 : FROM ubuntu:20.04
---> d5447fc01ae6
Step 2/5 : RUN set -eux; apt update; apt install -y curl;
---> Running in 13081cf88d73
+ apt update
WARNING: apt does not have a stable CLI interface. Use with caution in scripts.
Get:1 http://archive.ubuntu.com/ubuntu focal InRelease [265 kB]
(cut)
Get:18 http://security.ubuntu.com/ubuntu focal-security/main amd64 Packages [2358 kB]
Fetched 24.7 MB in 10s (2567 kB/s)
Reading package lists...
Building dependency tree...
Reading state information...
All packages are up to date.
+ apt install -y curl
WARNING: apt does not have a stable CLI interface. Use with caution in scripts.
Reading package lists...
Building dependency tree...
Reading state information...
The following additional packages will be installed:
ca-certificates krb5-locales libasn1-8-heimdal libbrotli1 libcurl4
libgssapi-krb5-2 libgssapi3-heimdal libhcrypto4-heimdal libheimbase1-heimdal
libheimntlm0-heimdal libhx509-5-heimdal libk5crypto3 libkeyutils1
libkrb5-26-heimdal libkrb5-3 libkrb5support0 libldap-2.4-2 libldap-common
libnghttp2-14 libpsl5 libroken18-heimdal librtmp1 libsasl2-2
libsasl2-modules libsasl2-modules-db libsqlite3-0 libssh-4 libssl1.1
libwind0-heimdal openssl publicsuffix
Suggested packages:
krb5-doc krb5-user libsasl2-modules-gssapi-mit
| libsasl2-modules-gssapi-heimdal libsasl2-modules-ldap libsasl2-modules-otp
libsasl2-modules-sql
The following NEW packages will be installed:
ca-certificates curl krb5-locales libasn1-8-heimdal libbrotli1 libcurl4
libgssapi-krb5-2 libgssapi3-heimdal libhcrypto4-heimdal libheimbase1-heimdal
libheimntlm0-heimdal libhx509-5-heimdal libk5crypto3 libkeyutils1
libkrb5-26-heimdal libkrb5-3 libkrb5support0 libldap-2.4-2 libldap-common
libnghttp2-14 libpsl5 libroken18-heimdal librtmp1 libsasl2-2
libsasl2-modules libsasl2-modules-db libsqlite3-0 libssh-4 libssl1.1
libwind0-heimdal openssl publicsuffix
0 upgraded, 32 newly installed, 0 to remove and 0 not upgraded.
Need to get 5443 kB of archives.
After this operation, 16.7 MB of additional disk space will be used.
Get:1 http://archive.ubuntu.com/ubuntu focal-updates/main amd64 libssl1.1 amd64 1.1.1f-1ubuntu2.16 [1321 kB]
(cut)
Get:32 http://archive.ubuntu.com/ubuntu focal-updates/main amd64 libsasl2-modules amd64 2.1.27+dfsg-2ubuntu0.1 [48.8 kB]
debconf: delaying package configuration, since apt-utils is not installed
Fetched 5443 kB in 3s (1879 kB/s)
Selecting previously unselected package libssl1.1:amd64.
(Reading database ... 4126 files and directories currently installed.)
Preparing to unpack .../00-libssl1.1_1.1.1f-1ubuntu2.16_amd64.deb ...
Unpacking libssl1.1:amd64 (1.1.1f-1ubuntu2.16) ...
Selecting previously unselected package openssl.
(cut)
Setting up curl (7.68.0-1ubuntu2.14) ...
Processing triggers for libc-bin (2.31-0ubuntu9.9) ...
Processing triggers for ca-certificates (20211016ubuntu0.20.04.1) ...
Updating certificates in /etc/ssl/certs...
0 added, 0 removed; done.
Running hooks in /etc/ca-certificates/update.d...
done.
Removing intermediate container 13081cf88d73
---> 01eb9703cd62
Step 3/5 : RUN set -eux; curl --location --fail "https://static.rust-lang.org/rustup/dist/x86_64-unknown-linux-gnu/rustup-init" --output rustup-init; chmod +x rustup-init; ./rustup-init -y --no-modify-path --default-toolchain stable; rm rustup-init;
---> Running in 47b200bdfc62
+ curl --location --fail https://static.rust-lang.org/rustup/dist/x86_64-unknown-linux-gnu/rustup-init --output rustup-init
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 15.0M 100 15.0M 0 0 13.8M 0 0:00:01 0:00:01 --:--:-- 13.8M
+ chmod +x rustup-init
+ ./rustup-init -y --no-modify-path --default-toolchain stable
info: profile set to 'default'
info: default host triple is x86_64-unknown-linux-gnu
info: syncing channel updates for 'stable-x86_64-unknown-linux-gnu'
info: latest update on 2022-12-15, rust version 1.66.0 (69f9c33d7 2022-12-12)
info: downloading component 'cargo'
info: downloading component 'clippy'
info: downloading component 'rust-docs'
info: downloading component 'rust-std'
info: downloading component 'rustc'
info: downloading component 'rustfmt'
info: installing component 'cargo'
info: installing component 'clippy'
info: installing component 'rust-docs'
info: installing component 'rust-std'
info: installing component 'rustc'
info: installing component 'rustfmt'
info: default toolchain set to 'stable-x86_64-unknown-linux-gnu'
stable-x86_64-unknown-linux-gnu installed - rustc 1.66.0 (69f9c33d7 2022-12-12)
Rust is installed now. Great!
To get started you need Cargo's bin directory ($HOME/.cargo/bin) in your PATH
environment variable. This has not been done automatically.
To configure your current shell, run:
source "$HOME/.cargo/env"
+ rm rustup-init
Removing intermediate container 47b200bdfc62
---> 7609ba153931
Step 4/5 : ENV PATH=${PATH}:/root/.cargo/bin
---> Running in 9cadc26748ba
Removing intermediate container 9cadc26748ba
---> 09f5615bbe83
Step 5/5 : RUN set -eux; rustup --version;
---> Running in 1ab7d2570fc1
+ rustup --version
info: This is the version for the rustup toolchain manager, not the rustc compiler.
rustup 1.25.1 (bb60b1e89 2022-07-12)
info: The currently active `rustc` version is `rustc 1.66.0 (69f9c33d7 2022-12-12)`
Removing intermediate container 1ab7d2570fc1
---> 90d38ea8bd14
Successfully built 90d38ea8bd14
Successfully tagged catscii:latest
So now, in that docker image, we have rustc!
$ docker run --rm catscii rustc --version
rustc 1.66.0 (69f9c33d7 2022-12-12)
Which is good and bad. It's good, because we need rustc to build our service, but it's bad, because we don't need rustc at runtime. We'll get to that later.
First let's get the service building at all:
# (After adding rustup to path)
# Copy sources and build them
WORKDIR /app
COPY src src
COPY Cargo.toml Cargo.lock ./
RUN set -eux; \
cargo build --release;
Cool bear's hot tip
The ADD
command can also copy stuff from the "build context" to the "builder",
but Docker documentation recommends against that.
The COPY
command has somewhat surprising behavior when passed directories. The
following:
COPY src Cargo.toml Cargo.lock ./
...doesn't create a src/
directory in the builder. It copies the contents of
the src
folder there. Hence the two separate COPY
invocations.
$ docker build --tag catscii .
(cut)
error: linker `cc` not found
|
= note: No such file or directory (os error 2)
So, again there's two problems here. The long-term problem is that when compiling, cargo needs to fetch the crates.io index. And because of the way the Dockerfile is set up, it'll have to do so from scratch every time.
Also, at the time of this writing, sparse registries are not the default (even though they work pretty well already), so in concrete terms, that means cloning a large Git repository every time.
But we'll get to that later: the main problem is that we're getting an error: it
says it can't find the cc
linker.
Fine then, let's try installing gcc
at the same time we install curl
:
# Install required dependencies
RUN set -eux; \
apt update; \
apt install -y curl gcc;
# (cut: everything else)
Because the layer we're changing is at the very beginning of our Dockerfile
,
it's going to do everything again, including installing rustup, cloning the
crates.io registry, etc.
Hope you have a fast internet connection!
Cool bear's hot tip
The fact that we're playing "Docker golf" right now (because there's so many things to get right) explains why there's a bit of a copy-paste culture around Dockerfiles.
If you have a Dockerfile that works, it's tempting to copy it for other projects, and the end result is large files, with a lot of parts that nobody's quite sure why.
Installing fewer packages
I've had time to check all my messages and it's still downloading gcc and friends. I guess I'll cancel the build and show you a little APT tip: APT has "recommended" and "suggested" packages. By default, it installs the "recommended" packages, which in this case, is a lot of packages.
We can disable that behavior with --no-install-recommends
:
# Install required dependencies
RUN set -eux; \
apt update; \
apt install -y --no-install-recommends \
curl gcc \
;
Cool bear's hot tip
This tip is Ubuntu-specific. Other distributions have other package managers that work differently. But Ubuntu is a typical enough server-side target that it's not that surprising that this series focuses on it.
Reclaiming Docker disk space
Note that at this point, because we've built the image a bunch of times, Docker is using a bunch of disk space for nothing: since we've changed the first layer, we're not going to be re-using anything in cache.
$ docker system df
TYPE TOTAL ACTIVE SIZE RECLAIMABLE
Images 4 3 1.286GB 1.286GB (99%)
Containers 5 0 251.7MB 251.7MB (100%)
Local Volumes 0 0 0B 0B
Build Cache 0 0 0B 0B
We can reclaim that space with docker system prune
:
$ docker system prune
(cut)
deleted: sha256:492b6da4cea745e019df452f2c305a5b0569dc2c1451966171db9aa702f7418c
Total reclaimed space: 251.8MB
And even more space with --all
, or -a
for short (which deletes all unused
images, not just dangling ones):
$ docker system prune -a
(cut)
Total reclaimed space: 1.285GB
Building the service for real
Now, a lot fewer packages are installed, but it fails again with:
$ docker build --tag catscii .
(cut)
---> Running in c6070d98b7e2
+ curl --location --fail https://static.rust-lang.org/rustup/dist/x86_64-unknown-linux-gnu/rustup-init --output rustup-init
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
0 0 0 0 0 0 0 0 --:--:-- --:--:-- --:--:-- 0
curl: (77) error setting certificate verify locations:
CAfile: /etc/ssl/certs/ca-certificates.crt
CApath: /etc/ssl/certs
This occurs because we're using curl
to download the rustup
installer over
HTTPS, and in order to verify the certificate, curl must consult a "root
certificate bundle".
There's such a bundle in Ubuntu's ca-certificates
package, but it's not a hard
dependency of curl
, merely a recommendation.
So... let's add it:
# Install required dependencies
RUN set -eux; \
apt update; \
apt install -y --no-install-recommends \
curl ca-certificates gcc \
;
At this point, it gets as far as downloading the latest rust stable using
rustup-init
, but.. we should probably be able to cache that, right? It doesn't
sound right to have to download that every time we change an earlier layer.
Caching some directories
BuildKit, the newer Docker builder backend, lets us mount some folders as "cache".
This is a "newer" Docker feature, so we'll need to opt into it within our
Dockerfile
with a load-bearing comment. Here's our full updated Dockerfile
:
# syntax = docker/dockerfile:1.4
# 👆 that there is the magic, load-bearing comment to opt into new features
# note using Docker 23.0.6 does not require the magic comment
FROM ubuntu:20.04
# Install required dependencies
RUN set -eux; \
apt update; \
apt install -y --no-install-recommends \
curl ca-certificates gcc \
;
# Install rustup
RUN set -eux; \
curl --location --fail \
"https://static.rust-lang.org/rustup/dist/x86_64-unknown-linux-gnu/rustup-init" \
--output rustup-init; \
chmod +x rustup-init; \
./rustup-init -y --no-modify-path --default-toolchain stable; \
rm rustup-init;
# Add rustup to path, check that it works
ENV PATH=${PATH}:/root/.cargo/bin
RUN set -eux; \
rustup --version;
# Copy sources and build them
WORKDIR /app
COPY src src
COPY Cargo.toml Cargo.lock ./
# 👇 and here's some caches!
RUN --mount=type=cache,target=/root/.rustup \
--mount=type=cache,target=/root/.cargo/registry \
--mount=type=cache,target=/root/.cargo/git \
--mount=type=cache,target=/app/target \
set -eux; \
cargo build --release;
Running this doesn't work:
$ docker build --tag catscii .
Sending build context to Docker daemon 453.6kB
Step 1/9 : FROM ubuntu:20.04
---> d5447fc01ae6
Step 2/9 : RUN set -eux; apt update; apt install -y --no-install-recommends curl ca-certificates gcc ;
---> Using cache
---> d7d6c858dda7
Step 3/9 : RUN --mount=type=cache,target=/root/.cargo --mount=type=cache,target=/root/.rustup set -eux; curl --location --fail "https://static.rust-lang.org/rustup/dist/x86_64-unknown-linux-gnu/rustup-init" --output rustup-init; chmod +x rustup-init; ./rustup-init -y --no-modify-path --default-toolchain stable; rm rustup-init;
the --mount option requires BuildKit. Refer to https://docs.docker.com/go/buildkit/ to learn how to build images with BuildKit enabled
Because, at least on the version of docker we installed into our Linux VM, BuildKit isn't enabled by default.
We can override this globally, or every time we run docker build
:
Note, if you are running Docker 23.0.6 you can omit DOCKER_BUILDKIT=1
$ DOCKER_BUILDKIT=1 docker build --tag catscii .
(continued in next section)
More missing dependencies
$ DOCKER_BUILDKIT=1 docker build --tag catscii .
(cut)
ecstack" "-L" "/root/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/lib/rustlib/x86_64-unknown-linux-gnu/lib" "-o" "/app/target/release/build/serde-387b017df630b297/build_script_build-387b017df630b297" "-Wl,--gc-sections" "-pie" "-Wl,-zrelro,-znow" "-nodefaultlibs"
#16 26.26 = note: /usr/bin/ld: cannot find Scrt1.o: No such file or directory
#16 26.26 /usr/bin/ld: cannot find crti.o: No such file or directory
#16 26.26 collect2: error: ld returned 1 exit status
#16 26.26
#16 26.26
#16 26.26 error: could not compile `serde` due to previous error
------
executor failed running [/bin/sh -c set -eux; cargo build --release;]: exit code: 101
Well... that's also caused by --no-install-recommends
. Did you know GCC has a
runtime component? And that it's not a direct dependency of the gcc
package
on Ubuntu, but rather a "recommendation"? Yeah.
More golfing!
# Install required dependencies
RUN set -eux; \
apt update; \
apt install -y --no-install-recommends \
# 👇 new!
curl ca-certificates gcc libc6-dev \
;
Our next error is...
$ DOCKER_BUILDKIT=1 docker build --tag catscii .
(cut)
#16 2.196 run pkg_config fail: "Could not run `\"pkg-config\" \"--libs\" \"--cflags\" \"openssl\"`\nThe pkg-config command could not be found.\n\nMost likely, you need to install a pkg-config package for your OS.\nTry `apt install pkg-config`, or `yum install pkg-config`,\nor `pkg install pkg-config`, or `apk add pkgconfig` depending on your distribution.\n\nIf you've already installed it, ensure the pkg-config command is one of the\ndirectories in the PATH environment variable.\n\nIf you did not expect this build to link to a pre-installed system library,\nthen check documentation of the openssl-sys crate for an option to\nbuild the library from source, or disable features or dependencies\nthat require pkg-config."
#16 2.196
#16 2.196 --- stderr
#16 2.196 thread 'main' panicked at '
#16 2.196
#16 2.196 Could not find directory of OpenSSL installation, and this `-sys` crate cannot
#16 2.196 proceed without this knowledge. If OpenSSL is installed and this crate had
#16 2.196 trouble finding it, you can set the `OPENSSL_DIR` environment variable for the
#16 2.196 compilation process.
#16 2.196
#16 2.196 Make sure you also have the development packages of openssl installed.
#16 2.196 For example, `libssl-dev` on Ubuntu or `openssl-devel` on Fedora.
#16 2.196
#16 2.196 If you're in a situation where you think the directory *should* be found
#16 2.196 automatically, please open a bug at https://github.com/sfackler/rust-openssl
#16 2.196 and include information about your system as well as this message.
#16 2.196
#16 2.196 $HOST = x86_64-unknown-linux-gnu
#16 2.196 $TARGET = x86_64-unknown-linux-gnu
#16 2.196 openssl-sys = 0.9.78
#16 2.196
#16 2.196
#16 2.196 It looks like you're compiling on Linux and also targeting Linux. Currently this
#16 2.196 requires the `pkg-config` utility to find OpenSSL but unfortunately `pkg-config`
#16 2.196 could not be found. If you have OpenSSL installed you can likely fix this by
#16 2.196 installing `pkg-config`.
#16 2.196
#16 2.196 ', /root/.cargo/registry/src/github.com-1ecc6299db9ec823/openssl-sys-0.9.78/build/find_normal.rs:191:5
#16 2.196 note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
#16 2.196 warning: build failed, waiting for other jobs to finish...
There's two problems here: we don't have pkg-config
and we don't have the
OpenSSL development package.
We could be golfing all day, let me fast forward to this point:
# Install required dependencies
RUN set -eux; \
apt update; \
apt install -y --no-install-recommends \
curl ca-certificates gcc libc6-dev pkg-config libssl-dev \
;
Quick rant about the "Ubuntu CDN"
One of the reasons this is taking forever for me, by the way, is because it's
grabbing everything from archive.ubuntu.com
. It seems like they're doing DNS
load balancing, but your luck may vary WILDLY from one run to the other.
Lucky run (landed somewhere in the UK):
$ ping archive.ubuntu.com
PING archive.ubuntu.com (185.125.190.39) 56(84) bytes of data.
64 bytes from aerodent.canonical.com (185.125.190.39): icmp_seq=1 ttl=55 time=65.5 ms
64 bytes from aerodent.canonical.com (185.125.190.39): icmp_seq=2 ttl=55 time=20.3 ms
64 bytes from aerodent.canonical.com (185.125.190.39): icmp_seq=3 ttl=55 time=19.4 ms
64 bytes from aerodent.canonical.com (185.125.190.39): icmp_seq=4 ttl=55 time=26.2 ms
^C
--- archive.ubuntu.com ping statistics ---
4 packets transmitted, 4 received, 0% packet loss, time 3011ms
rtt min/avg/max/mdev = 19.400/32.853/65.472/19.014 ms
Unlucky run (landed in Boston Massachusetts, USA):
$ ping archive.ubuntu.com
PING archive.ubuntu.com (91.189.91.38) 56(84) bytes of data.
64 bytes from banjo.canonical.com (91.189.91.38): icmp_seq=1 ttl=54 time=194 ms
64 bytes from banjo.canonical.com (91.189.91.38): icmp_seq=2 ttl=54 time=107 ms
64 bytes from banjo.canonical.com (91.189.91.38): icmp_seq=3 ttl=54 time=126 ms
^C
--- archive.ubuntu.com ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2009ms
rtt min/avg/max/mdev = 107.417/142.492/194.316/37.400 ms
These are all just Apache servers by the way:
$ curl --head --silent archive.ubuntu.com | grep --ignore-case server
Server: Apache/2.4.29 (Ubuntu)
Well, at least they're dogfooding.
Running our application with Docker
Anyway, with the last changes, our application builds!
It, however, doesn't run:
$ docker run --rm catscii /app/target/release/catscii
docker: Error response from daemon: failed to create shim task: OCI runtime create failed: runc create failed: unable to start container process: exec: "/app/target/release/catscii": stat /app/target/release/catscii: no such file or directory: unknown.
That seems.. suspicious, let's start a shell inside the container to look around:
$ docker run -it --rm catscii /bin/sh
# ls /app
Cargo.lock Cargo.toml src target
# ls /app/target
#
Wasn't /target
in a cache-mount?
Oh. Right 🙃 That explains why it doesn't actually end up in the final image.
It's also a bit annoying to have to specify the command we want to run, so
we can just set a default in the Dockerfile
:
# (cut: magic syntax comment, installing dependencies, etc.)
# Copy sources and build them
WORKDIR /app
COPY src src
COPY Cargo.toml Cargo.lock ./
RUN --mount=type=cache,target=/root/.rustup \
--mount=type=cache,target=/root/.cargo/registry \
--mount=type=cache,target=/root/.cargo/git \
--mount=type=cache,target=/app/target \
set -eux; \
cargo build --release; \
cp target/release/catscii .
CMD ["/app/catscii"]
$ docker build etc.
(cut)
$ docker run --rm catscii
thread 'main' panicked at '$SENTRY_DSN must be set: NotPresent', src/main.rs:37:37
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
Oh hey! It runs! Let's pass it a few environment variables. They're already in
our local environment, due to our .envrc
/ direnv
setup, so we can just mention them by name:
$ docker run --env SENTRY_DSN --env HONEYCOMB_API_KEY --rm catscii
{"timestamp":"2022-12-23T16:41:45.195202Z","level":"INFO","fields":{"message":"Creating honey client","log.target":"libhoney::client","log.module_path":"libhoney::client","log.file":"/root/.cargo/git/checkouts/libhoney-rust-3b5a30a6076b74c0/9871051/src/client.rs","log.line":78},"target":"libhoney::client"}
{"timestamp":"2022-12-23T16:41:45.195238Z","level":"INFO","fields":{"message":"transmission starting","log.target":"libhoney::transmission","log.module_path":"libhoney::transmission","log.file":"/root/.cargo/git/checkouts/libhoney-rust-3b5a30a6076b74c0/9871051/src/transmission.rs","log.line":124},"target":"libhoney::transmission"}
{"timestamp":"2022-12-23T16:41:45.199169Z","level":"INFO","fields":{"message":"Listening on 0.0.0.0:8080"},"target":"catscii"}
^C^C^C
And now it listens on some port! Unfortunately:
- That port is only accessible from within the Docker container right now
- Doing "Ctrl-C" to kill the container doesn't work.
So first off, let's kill our running container:
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
d09a63ca7640 catscii "/app/catscii" About a minute ago Up About a minute flamboyant_chaplygin
# note: unique prefixes work here
$ docker kill d09
d09
Ctrl-C doesn't exit my docker container
If you look up "Ctrl-C docker not worky send help" online, you'll find tons of advice that doesn't work.
Ctrl+C from a Linux shell, sends the "INT" (interrupt) signal, and the default handler for that exits the process. It certainly works outside the container:
$ cargo run --quiet
{"timestamp":"2022-12-23T16:56:47.798601Z","level":"INFO","fields":{"message":"Creating honey client","log.target":"libhoney::client","log.module_path":"libhoney::client","log.file":"/home/amos/.cargo/git/checkouts/libhoney-rust-3b5a30a6076b74c0/9871051/src/client.rs","log.line":78},"target":"libhoney::client"}
{"timestamp":"2022-12-23T16:56:47.798708Z","level":"INFO","fields":{"message":"transmission starting","log.target":"libhoney::transmission","log.module_path":"libhoney::transmission","log.file":"/home/amos/.cargo/git/checkouts/libhoney-rust-3b5a30a6076b74c0/9871051/src/transmission.rs","log.line":124},"target":"libhoney::transmission"}
{"timestamp":"2022-12-23T16:56:47.846784Z","level":"INFO","fields":{"message":"Listening on 0.0.0.0:8080"},"target":"catscii"}
^C
The explanation lies in the Docker docs:
A process running as PID 1 inside a container is treated specially by Linux: it ignores any signal with the default action. As a result, the process will not terminate on SIGINT or SIGTERM unless it is coded to do so.
We could add an explicit handler for SIGINT with something like
ctrlc, and it'd work. Or we could simply have
another process as PID 1, like /bin/sh
:
# quick and dirty fix, consider not doing that:
CMD ["/bin/sh", "-c", "/app/catscii"]
But because I feel bad even suggesting it, let's implement "the proper way" instead.
Implementing graceful shutdown
Not only will we handle SIGINT
, we'll also make it so it does graceful
shutdown, which means it'll finish handling in-flight requests before stopping.
We don't actually need the ctrlc
crate, because
tokio provides an asynchronous way to deal with
signals.
The end of our main
function becomes:
// in `src/main.rs`
#[tokio::main]
async fn main() {
// omitted: all sorts of setup
let quit_sig = async {
_ = tokio::signal::ctrl_c().await;
warn!("Initiating graceful shutdown");
};
let addr = "0.0.0.0:8080".parse().unwrap();
info!("Listening on {addr}");
axum::Server::bind(&addr)
.serve(app.into_make_service())
// new! 👇
.with_graceful_shutdown(quit_sig)
.await
.unwrap();
}
That's all it takes!
Let's not forget to rebuild the app:
$ DOCKER_BUILDKIT=1 docker build --tag catscii .
(cut)
And then we can run it, publishing port 8080
so we can actually reach it:
$ docker run -it --env SENTRY_DSN --env HONEYCOMB_API_KEY -p 8080:8080/tcp --rm catscii
{"timestamp":"2022-12-23T17:08:26.759656Z","level":"INFO","fields":{"message":"Creating honey client","log.target":"libhoney::client","log.module_path":"libhoney::client","log.file":"/root/.cargo/git/checkouts/libhoney-rust-3b5a30a6076b74c0/9871051/src/client.rs","log.line":78},"target":"libhoney::client"}
{"timestamp":"2022-12-23T17:08:26.759679Z","level":"INFO","fields":{"message":"transmission starting","log.target":"libhoney::transmission","log.module_path":"libhoney::transmission","log.file":"/root/.cargo/git/checkouts/libhoney-rust-3b5a30a6076b74c0/9871051/src/transmission.rs","log.line":124},"target":"libhoney::transmission"}
{"timestamp":"2022-12-23T17:08:26.763730Z","level":"INFO","fields":{"message":"Listening on 0.0.0.0:8080"},"target":"catscii"}
(the app keeps running)
And then, from another terminal (CmdOrCtrl+Shift+Backtick
in VS Code, or a
plus button somewhere to the top-right of the Terminal pane), we can reach it:
$ curl -I localhost:8080
HTTP/1.1 200 OK
content-type: text/html; charset=utf-8
content-length: 134810
date: Fri, 23 Dec 2022 17:09:23 GMT
And, thanks to VS Code's automated port forwarding functionality, we can even access it in a local browser!
Cool bear's hot tip
Sometimes VS Code's automated port forwarding will get in an inconsistent state (especially if your host computer's gone to sleep a couple times and it had to reconnect to the VM over SSH).
Using the "Reload Window" command from the command palette ("F1" to open) usually sorts this out.
So, mission accomplished?
Not really. We want to run this in production!
Here's another article just for you:
Aiming for correctness with types
The Nature weekly journal of science was first published in 1869. And after one and a half century, it has finally completed one cycle of carcinization, by publishing an article about the Rust programming language.
It's a really good article.
What I liked about this article is that it didn't just talk about performance, or even just memory safety - it also talked about correctness.