Introduction
The FP Complete team is pleased to announce the release of the pid1
crate, a Rust library for proper signal and zombie reaping of the PID
1 process.
When deploying an application in a containerized environment, you
typically need to deploy a small init system to forward signals to
your application and reap zombies. The pid1 crate provides a simple
and easy-to-use solution to this problem.
Why
FP Complete already had a Haskell solution for this problem, which we
used when deploying to containerized environments like Kubernetes or
Amazon ECS.
Recently, we had a number of different Rust services in production for
a client. At some point, we realized that some of these services did not have
a proper init process to forward signals and reap zombie processes. In our
experience, this is a common oversight when setting up Dockerized services.
We developed this crate because:
- It ensures that proper signal handling and reaping of zombie
processes occur when it is run as PID 1 without the presence of any
other binary handling that.
- It saves time for the DevOps team.
Once we had the crate, it was trivial to create the pid1
binary to
use as a standalone mini init system if needed. If you are interested
in the technical details of how software like pid1 works, you may find
the following posts by Michael Snoyman interesting:
Using the crate
You must ensure that the launch
method is the first statement in
your main
function:
use std::time::Duration;
use pid1::Pid1Settings;
fn main() {
Pid1Settings::new()
.enable_log(true)
.launch()
.expect("Launch failed");
println!("Hello world");
// Rest of the logic...
}
By default, logging is disabled, but enabling it can be helpful for
the operations team. All logging is done to stderr.
Usage of binary
To use the binary, package it as part of your container and set it as
the entrypoint. Example:
FROM alpine:3.14.2
ADD https://github.com/fpco/pid1-rs/releases/download/v0.1.0/pid1-x86_64-unknown-linux-musl /usr/bin/pid1
RUN chmod +x /usr/bin/pid1
ENTRYPOINT [ "pid1" ]
CMD ["/myapp"]
This executable can be used regardless of which language your application is
written in, and so is a good option if your application is not written in Rust.
Note that you can also play with it locally. But unless executed with
process id 1, it won't function itself as an init system. Example:
$ pid1 --env HELLO=WORLD printenv HELLO
WORLD
Note that we are support binaries for the following architectures
which can be downloaded from Github releases:
- x86_64 Linux: Both Statically linked via musl and Dynamically linked via glibc
- ARM64 Linux: Both Statically linked via musl and Dynamically linked via glibc
- ARMv6 Linux with MUSL
- ARMv6 Linux with MUSL, hardfloat
- ARMv5TE Linux with MUSL
- ARMv7-A Linux with MUSL
- armv7-unknown-linux-musleabihf
- 32-bit Linux w/o SSE, MUSL
- 32-bit Linux with MUSL
- MIPS64 Linux, n64 ABI, MUSL
- MIPS64 (LE) Linux, n64 ABI, MUSL
For our internal and client usage, we're unlikely to use any of these
apart from x86_64 and ARM64. We looked at the architectures
supported by the tini tool and tried to support the same
ones. Please let us know in the issues if we are missing a specific
architecture.
It was very easy to cross-compile binaries with Rust using the
cross tool.
Comparing to Haskell's pid1
Almost all of the production services at FP Complete have been using
the Haskell pid1 binary, and now we have added one more choice to the
equation.
Here are the major differences between the Rust and Haskell version:
- Binary size: The statically linked musl binary for x86_64 is
around 650 KB, while the Haskell binary is around 1.5 MB. The major
difference is that the Haskell binary includes a runtime
system, which increases its size.
- Functionality: The Haskell version currently allows you to execute
as a specific user or group ID. This is not yet implemented in the
Rust version, mainly because we haven't needed it yet.
- Architectures: The Rust version supports more architectures, and
creating a static binary is much easier.
Overall, we recommend using the Rust version if possible because of
its smaller binary size and because Rust as a systems language is
better suited for a tiny init system.
Binary size
This section compares the binary size of pid1
with various other
implementation. Note that this comparison is not particularly meaningful because
different systems have different features. It is simply intended to
give a rough indication of the binary size of similar solutions:
Binary name | Language | Binary size | Architecture | Linking |
pid1 | Rust | 658 KB | x86 | Musl, Static |
pid1 | Rust | 562 KB | x86 | glibc, Dynamic |
pid1 | Rust | 554 KB | ARM64 | Musl, Static |
pid1 | Rust | 494 KB | ARM64 | glibc, Dynamic |
pid1 | Haskell | 1.5 MB | x86 | Musl, Static |
tini | C | 43 KB | x86 | Musl, Static |
tini | C | 850 KB | x86 | NA, Static |
tini | C | 24 KB | x86 | glibc, Dynamic |
tini | C | 23 KB | ARM64 | glibc, Dynamic |
tini | C | 534 KB | ARM64 | NA, Static |
At a quick glance, the Haskell binary has the largest size, while the
C binary tini has the smallest.
The initial Rust implementation used clap_lex to parse arguments
to avoid dependencies. However, we later switched to clap to
simplify the codebase. We noticed that using clap increased the binary
size by around 160 KB, which we believe is justified by the additional
features that clap offers.
Future
Please feel free to file
issues if you think any
feature is missing. One of the things we want to do is automate most
of the integration testing using a crate like
testcontainers. We
have
documented
our manual integration tests, but it would nice to have them
integrated as part of the CI process.