Tutorial: Profiling Rust applications in Docker with perf
With Moore’s law coming to an end, optimizing code to avoid performance pitfalls is becoming more and more useful.
To this end, programming languages like Rust are designed to produce fast and memory-efficient programs out-of-the-box.
When that is not sufficient, profilers like perf
are useful to measure where the code is slow and therefore which algorithms and data structures should be optimized.
Now, you may wonder why profiling Rust code within Docker, an engine to run containerized applications. The main advantage of containers is that when configured properly, they provide isolation from the rest of your system, allowing to restrict access to resources (files, networking, memory, etc.).
Isolation is useful when developing code, because you soon end up running untrusted code, especially with modern languages that facilitate adding dependencies, that themselves collect more and more dependencies.
You probably don’t want to run with malicious code that sneaked into a dependency (as happened last year in the event-stream
npm package) with root
privileges, nor even as your user.
Even if you trust the code, a container allows to very easily clean up any artifacts (e.g. configuration files) added by your code (or dependencies), intentionally or not, and to restart from a clean state for reproducibility. It also allows to test if your code depends on any resources and if your code reports useful errors when these resources are unavailable.
The drawback of isolation is that you need quite a bit of configuration to restrict privileges while giving access to the needed resources. In this context, I want to share my journey in profiling Rust code within Docker containers, and to answer the following questions.
- What is the minimal setup to install a Rust toolchain in a Docker container?
- What privileges are necessary to compile and profile Rust code?
- How to tell the Rust compiler to create a profiling-friendly binary?
The goal of this blog post is also to explain why I’ve chosen each parameter, instead of just dumping a configuration that happens to work for me.
- Installation: Docker image
- Running the Docker image with limited privileges
- Profiling Rust code
- Conclusion
Installation: Docker image
The first step is to create a Docker image which contains a Rust compiler and the perf
tool.
We’ll then run this image to build our Rust application and profile it.
In this section we’ll walk through the Dockerfile (Docker’s instructions to build an image).
Docker base image
First of all, I suggest to start with a Debian testing base image. This is a reasonably trusted base as it is maintained by the Docker project and used by many other images. Debian is a well-maintained distribution, and the testing version will give us access to recent updates of all packages, while being more stable than – well – Debian unstable (a.k.a. sid). We’ll also pick the slim version, which strips away a bunch of unneeded files, such as man pages.
FROM debian:bullseye-slim
Within the image, the next step I suggest is to create an unprivileged (non-root) user; let’s name is dev
.
We’ll associate it with a unique user ID such as 5000
so that it doesn’t clash with any other user on your (host) system, which provides an additional layer of isolation.
Let’s also create a home directory, as the Rust compiler will expect it to store packages downloaded by Cargo.
RUN useradd --uid 5000 --create-home --shell /bin/bash dev
Perf
Perf is a Linux profiling system that can collect performance events from the CPU, but also many kinds of resources in the Linux kernel, including filesystems, networking, memory access, the scheduler, etc.
Brendan Gregg’s website contains many examples of how to use perf
.
Chandler Carruth also gave a talk with a live demo walking through using perf
to profile programs.
perf
is tightly integrated in the Linux kernel, which exposes these performance counters via a custom syscall called perf_event_open
.
However, using this syscall directly would be quite cumbersome, so there is a program named perf
that exposes a user-friendly interface to run and profile other programs.
To install it, you need the linux-perf
and linux-base
Debian packages.
RUN apt-get install -y linux-perf linux-base
Rust toolchain
In order to compile Rust programs, you need the Rust toolchain, which consists of rustc
– the Rust compiler – and cargo
– Rust’s package manager.
There are multiple ways to install it.
- If you want to use stable Rust, and have a Debian testing image (like we did), you can simply install the
cargo
package (which depends on therustc
package). At the time of writing, the version ofrustc
available on testing is very close to the latest stable Rust.RUN apt-get install -y cargo
- If you need Rust nightly, there is unfortunately no Debian package for it, as it changes too often and has no stability guarantees.
In that case, you can use
rustup
(Rust’s own toolchain management tool). I suggest to use the following steps.- Download the
rustup
script with something likewget
, and compare it with a known checksum to verify integrity. This requires theca-certificates
package, so that an HTTPS connection can be established withrustup
’s website. You can do the comparison withsha256sum -c
. You’ll have to update this checksum every now and then whenrustup
changes. - Install the nightly toolchain and remove the
rustup
script. - Move the
$HOME/.cargo
folder to somewhere else (e.g.$HOME/cargo
), so that we can later overlay a tmpfs on$HOME/.cargo
(see the next section). - Add
$HOME/cargo/bin
to the$PATH
, so that the Rust toolchain binaries are found by the shell. - Additionally, you will likely need the
build-essential
package, as some Rust crates need a C++ compiler to build.
RUN apt-get install -y ca-certificates wget build-essential RUN wget https://sh.rustup.rs -O rustup.sh \ && sha256sum rustup.sh \ && echo "<checksum> rustup.sh" | sha256sum -c - \ && sh rustup.sh --default-toolchain none -y \ && rm rustup.sh \ && /home/dev/.cargo/bin/rustup default nightly \ && mv /home/dev/.cargo /home/dev/cargo ENV PATH=${PATH}:/home/dev/cargo/bin
- Download the
Misc packages
Additionally, it can be useful to install the following Debian packages: git
to download code, and ca-certificates
to connect with HTTPS (e.g. for a git repository with an https://
URL).
RUN apt-get install -y git ca-certificates
Recap
Here are Dockerfiles summarizing the configuration.
- Rust stable: Dockerfile.
FROM debian:bullseye-slim
RUN useradd --uid 5000 --create-home --shell /bin/bash dev
RUN apt-get update \
&& apt-get install -y \
linux-perf \
linux-base \
cargo \
ca-certificates \
git \
--no-install-recommends \
&& rm -rf /var/lib/apt/lists/*
USER dev
WORKDIR "/home/dev"
ENTRYPOINT [ "/bin/bash" ]
- Rust nightly: Dockerfile.
FROM debian:bullseye-slim
RUN useradd --uid 5000 --create-home --shell /bin/bash dev
RUN apt-get update \
&& apt-get install -y \
linux-perf \
linux-base \
ca-certificates \
wget \
build-essential \
git \
--no-install-recommends \
&& rm -rf /var/lib/apt/lists/*
USER dev
WORKDIR "/home/dev"
RUN wget https://sh.rustup.rs -O rustup.sh \
&& sha256sum rustup.sh \
&& echo "9a165af7c7446f07a978c6ab618dc5080bfe918b2bc88e9ad0f6df985b4e4dcb rustup.sh" | sha256sum -c - \
&& sh rustup.sh --default-toolchain none -y \
&& rm rustup.sh \
&& /home/dev/.cargo/bin/rustup default nightly \
&& mv /home/dev/.cargo /home/dev/cargo
ENV PATH=${PATH}:/home/dev/cargo/bin
ENTRYPOINT [ "/bin/bash" ]
Running the Docker image with limited privileges
One advantage of Docker is that besides packaging your code and dependencies in a hermetic/reproducible environment, running code in a container is more isolated than a process running directly as your user. However, you need to be careful about what privileges you give to the container: running code as root (or even worse, in a privileged container) can be less secure than not using a container at all.
Unfortunately, the defaults of docker run
still give quite a few unnecessary privileges, so I’ll quickly go through flags to tighten your container privileges.
I’d also recommend to check the guidelines for Docker security published by OWASP.
We’ll also need to add one permission so that perf
can do its profiling job.
Before we start, I strongly recommend NOT to add your user to the
docker
group. This may avoid you typing a fewsudo
, but as explained here, this is equivalent to grantingroot
privileges to your user, due to the attack surface exposed by the Docker daemon. I don’t think that the reducedsudo
-typing friction is worth the loss in security.
Running a temporary container in the foreground
Before looking at the security configuration, I just want to mention two useful flags when spawning a new container with docker run
.
These allow to start a temporary container with a shell in the foreground.
--rm
, so that the container is automatically removed upon exiting.-it
, to run the container in foreground mode. This is actually a combination of two flags,-t
(a.k.a.--tty
) to allocate a pseudo-TTY, and-i
(a.k.a.--interactive
) to keep stdin open. This allows to run an interactive shell in the container (in our case, the Dockerfile’sENTRYPOINT
is/bin/bash
).
Locking down the container
To reduce the attack surface when running Docker containers, I suggest to use the following flags when running docker run
.
--cap-drop=all
, to remove all privileged capabilities. You can then manually whitelist some capabilities, but we won’t need any.--security-opt no-new-privileges
, to prevent privilege escalation viasetuid
/setgid
binaries.--read-only
, to mount the image’s file system as read-only. We’ll then add tmpfs mounts to have writable folders to build the code.-u dev
, to use our unprivilegeddev
user instead ofroot
. Note that we’ve also specified it in the Dockerfile, but better be safe than sorry.- If you don’t need any networking, use
--network=none
. Unfortunately, Cargo needs networking to download package dependencies, so we’ll just leave the default, a bridge network distinct from the host network. See also Docker documentation on networking.
I also suggest the following flags to limit the resources available within the container.
--cpus 4
, to only allow the container to use 4 CPUs.--memory=1024m
, to limit the RAM used by the container to 1 GB. The Rust compiler can be quite memory-hungry, so it’s hard to go below this limit for non-trivial cases.--memory-swap=1024m
, to limit the RAM + swap to 1 GB. This effectively prevents the container from using the swap (which could slow down your program and/or wear out your non-volatile storage).--memory-swappiness=0
, to turn off anonymous page swapping. This also prevents the container from using swap.
Last, here are a few security recommendations beyond docker run
.
- Keep your kernel up-to-date! Given that the kernel is shared between the host and the container, a kernel with public vulnerabilities undermines the isolation provided by Docker.
- Given that we don’t need this feature, you should disable inter-container communication at the Docker daemon level.
On Linux, this is done by adding an
"icc": false
entry in/etc/docker/daemon.json
(you can create this JSON file if it doesn’t exist). After that, runsudo service docker restart
to apply the changes (if using systemd to manage the Docker daemon). You can check that ICC is disabled by runningsudo iptables -L -n -v
and looking for the following entry in theFORWARD
rule.Chain FORWARD pkts bytes target prot opt in out source destination ... 0 0 DROP all -- docker0 docker0 anywhere anywhere
Custom tmpfs mounts to build Rust code
In order to build code, the Rust toolchain needs to write some files.
Because we passed the --read-only
flag to run our container, we’ll need to configure some folders as writable.
One way to do it is with a tmpfs, a temporary file system backed by volatile memory (RAM).
This has several advantages:
- Memory is fast, because it’s backed by RAM.
- It doesn’t wear your non-volatile storage (hard drive, SSD), because you don’t write on it.
- It doesn’t “pollute” the host’s file system, as everything is temporary.
There are a few drawbacks as well, but the advantages usually outweigh them:
- RAM capacity is more limited, but 1 GB is usually enough to compile an average Rust crate.
- It’s not persistent, so the Rust compiler will have to download all dependencies every time you start a container.
Setting up a tmpfs with Docker is quite simple.
You need to pass the --tmpfs
flag along with a directory where to mount the tmpfs.
You can also set a size limit, and configure options such as whether executables are allowed to run in the tmpfs.
In our case, we’ll need three folders:
$HOME/rust
, where we’ll build and run our code,$HOME/.cargo
, where the Cargo package manager will download dependencies,/tmp
, so that the Rust compiler can write some temporary files.
Given that we’ll run our compiled code (to profile it), we need to set the exec
bit in the first folder.
The following size parameters are usually enough for common cases, but don’t hesitate to increase them if you get No space left on device
(a.k.a. ENOSPC
) errors.
sudo docker run \
--tmpfs /home/dev/rust:exec,size=256m \
--tmpfs /home/dev/.cargo:size=256m \
--tmpfs /tmp:size=64m \
...
Custom seccomp-bpf profile for perf
By default, Docker uses seccomp-pbf (a secure computing mode feature of the Linux kernel) to ban some system calls from ever being run inside the container. The Docker documentation on seccomp explains why a bunch of system calls are not allowed. You can also learn about it in jessfraz’s blog post.
The main thing to know is that perf
needs to use the perf_event_open
system call to collect profiling information about your program, and that this system call is not allowed by Docker’s default profile (the rationale is that is is a « tracing/profiling syscall, which could leak a lot of information on the host. »).
To generate a custom profile, you can download the current default profile on Docker’s GitHub repository.
Then make a copy of this JSON file (let’s call it seccomp-perf.json
) and add perf_event_open
to the long list of system calls around the beginning of the file.
You can then use this custom profile with the following flag.
sudo docker run \
--security-opt seccomp=seccomp-perf.json \
...
Don’t forget to re-download the default profile regularly to update it, as it changes quite frequently! Unfortunately, I don’t know of a way to tell Docker to use the default profile with a custom diff on top of it.
Recap
Here is a summary to build the Docker image and start a Docker container with suitable flags for profiling Rust code.
# Create a folder containing your Dockerfile.
mkdir rust-profiling
cp Dockerfile rust-profiling-folder/Dockerfile
# Build the Docker image.
sudo docker build -t rust-profiling rust-profiling-folder
# Launch a Docker container based on this image.
sudo docker run \
--rm \
-it \
--cap-drop=all \
--security-opt no-new-privileges \
--read-only \
-u dev \
--cpus 4 \
--memory=1024m \
--memory-swap=1024m \
--memory-swappiness=0 \
--tmpfs /home/dev/rust:exec,size=256m \
--tmpfs /home/dev/.cargo:size=256m \
--tmpfs /tmp:size=64m \
--security-opt seccomp=seccomp-perf.json \
rust-profiling
Profiling Rust code
We now have a Docker image with a Rust toolchain and perf
installed, and we know how to launch it in a container with suitable flags.
Let’s compile and profile your Rust code!
Compiler flags to build a profiling-friendly binary
In order to take the most out of our profiling, we’ll need to adapt how we compile Rust code.
The first thing to do is to compile in release mode with cargo build --release
, to obtain an optimized binary representative of what you run in production.
Frame pointers
However, one of the optimizations applied by the compiler with the --release
flag is to omit the so-called frame pointers.
In compiled languages like Rust or C, each function has a frame pointer which indicates the starting address of the function’s stack frame and is commonly stored in a specific CPU register (ebp
on x86).
You can learn more about frame pointers on Wikipedia or in these lecture notes.
However, the fact that ebp
contains the frame pointer is mostly a convention (the CPU itself doesn’t have a concept of frames) and a compiler can omit updating the frame pointer for every function call, to save a few instructions and maybe use epb
for other computation.
Back to profiling, the important part is that the frame pointer allows perf
to retrieve the call stack when the program is interrupted by a perf event.
This allows to retrieve which functions are run the most often (the goal of a profiler).
Without these call traces, the output is not very useful.
So the second thing to do is to tell Cargo to keep the frame pointers.
This will modify a little your binary compared to production, but in practice the difference is often negligible, and the benefit is that you get actionable profiling output.
The easiest way is to use the RUSTFLAGS
environment variable when running Cargo.
You’ll probably want to clean your build first, so that Cargo recompiles all your code and dependencies with frame pointers.
cargo clean
RUSTFLAGS='-C force-frame-pointers=y' cargo build --release
Running with perf
Now that we have some binary, let’s profile it! This is actually the easy part, you simply have to run the following commands in the container.
perf record -g <binary>
perf report -g graph,0.5,caller
First perf record
will call your binary and record perf events.
The -g
argument tells perf to record the call graph.
Then, perf report
will open an interface in the terminal where you can navigate functions and assembly to figure out which code is run the most often.
Interactive demo of perf
, recorded with asciinema
(click to replay the recording).
On some systems, you may encounter an error saying that you don’t have permissions to collect stats. This is because the
dev
user running in the container doesn’t haveCAP_SYS_ADMIN
privileges (which we certainly don’t want to grant!), and perf event collection is restricted to these privileges by default (a good secure-by-default setting!).To enable the relevant profiling, run the following command on the host (with
root
privileges).echo 2 > /proc/sys/kernel/perf_event_paranoid
To disable profiling, toggle the setting back to
3
.echo 3 > /proc/sys/kernel/perf_event_paranoid
To learn more about how perf
works and how to get the most of its user interface, I recommend watching Chandler Carruth’s talk.
If however you aren’t a big fan of terminal-based interfaces or are afraid of reading assembly, there’s another way of looking at profiling data.
Flame graphs
As we have seen, perf
allows you to record events and walk through the call graph down to the assembly level to see which functions and instructions are executed most often.
While this low-level command-line interface is powerful, it’s not necessarily the most intuitive way to get an overview of your profiled code.
Flame graph profile of lzma-rs (click to open an interactive view).
Flame graphs provide a complementary visualization:
- Each function is represented by a bar proportional to the time spent in that function. This allows to quickly see which functions are in the hot path.
- Functions are stacked on top of each other according to function calls: if a function
f
callsg
andh
, then the bars forg
andh
will be stacked on top off
’s bar. This allows to understand how much of the time spent on a function is due to recursive calls to other functions.
Brendan Gregg’s published useful tools on GitHub to create flame graphs from perf
output.
Let’s use them to create a nice flame graph in SVG format in our container!
# Let's just clone the FlameGraph repository from within our container.
git clone https://github.com/brendangregg/FlameGraph
# Record with perf as usual (including call graph).
perf record -g <binary>
# Generate a flame graph.
perf script | ./FlameGraph/stackcollapse-perf.pl > out.perf-folded
./FlameGraph/flamegraph.pl out.perf-folded > rust-perf.svg
That’s it! We now have an SVG image containing our interactive flame graph.
Exfiltrating a file from our container
However, we don’t have anything to display SVG files in our Docker image…
We could add a program to visualize images to our Dockerfile, but our docker run
setup doesn’t give any permissions to interact with the windowing system, so programs within the container cannot show anything on screen (apart from writing to the console).
We could also configure that, but doing it securely would be a long blog post in and of itself1.
Instead, we can copy the SVG file out of the container, and then display it on the host.
There is a command to do that – docker cp
– but unfortunately it doesn’t work on tmpfs file systems.
A workaround is to use docker exec
to exfiltrate the file contents via the terminal.
docker exec
is a useful tool allowing to run an additional command in an existing container, e.g. to inspect the contents of the container.
To use it, you need to know the ID of the container, which you can obtain by running docker ps
and look at the hexadecimal CONTAINER ID
(the mnemonic NAME
is also a valid identifier).
Back to exfiltrating the file, Docker’s documentation gives an example with tar
, but we can simply use cat
.
sudo docker exec <container_id> cat /path/to/container/file.svg > /path/to/host/file.svg
If you also find Bash syntax confusing, this command should be understood as follows.
(sudo (docker exec <container_id> (cat /path/to/container/file.svg))) > /path/to/host/file.svg
When you run it on the host shell, Docker will run cat /path/to/container/file.svg
inside the container, sending the contents to stdout.
The piping > /path/to/host/file.svg
then happens on the host shell, taking the data passing through the container’s stdout and writing it to a file on the host.
Rust library in a C++ application
Another use case you might have is to compile a Rust library, and call it from another language like C++.
For example, you had a C++ code base and migrated part of it to Rust.
The binary that you are profiling is then a hybrid of Rust and C++.
It turns out that perf
can perfectly handle this case!
As before, you can compile your Rust library with RUSTFLAGS='-C force-frame-pointers=y' cargo build --release
.
The easiest is to create a static library (.a
on Linux).
On the C++ side, the relevant compiler flag to keep frame pointers is -fno-omit-frame-pointer
.
You can then link your Rust library with linker flags such as -L/path/to/your/rust/library -lfoo
(for a library at /path/to/your/rust/library/libfoo.a
).
With that, the usual perf
and flame graph commands should work on the resulting binary.
Conclusion
You should now be able to compile Rust programs and profile them inside Docker containers! As we have seen, although we ended up doing quite a bit of configuration, this doesn’t require any unstable or unsafe feature of Rust.
If you want to analyze the performance of Rust programs from a different angle, Cargo has a built-in feature: the cargo bench
command.
Although it requires the nightly compiler, this is a great integrated way of running micro-benchmarks.
All you need is to tag a function to benchmark with the #[bench]
attribute, and enable #![feature(test)]
.
You can find some examples in my lzma-rs
crate or my Rust implementation of Gravity-SPHINCS cryptographic signatures.
Cargo bench of gravity-rs, recorded with asciinema
(click to replay the recording).
This post was edited to clarify the risk of adding your user to the docker
group, and to recommend that the Docker daemon be invoked with sudo
.
It was also edited to add the --default-toolchain none
parameter to rustup.sh
when installing the nightly toolchain.
-
The
/tmp/.X11-unix
path could be mounted to the container, but this gives broad access to anything happening on your display, including keyboard/mouse inputs (no need to be the window on focus to collect them), clipboard contents, etc. See The Linux Security Circus: On GUI isolation by Joanna Rutkowska. ↩
Comments
To react to this blog post please check the Twitter thread.
You may also like