nix process composition
This commit is contained in:
parent
6ecd78dc62
commit
9ef363410f
@ -0,0 +1,248 @@
|
||||
---
|
||||
title: >-
|
||||
Composing Processes Into a Static Binary With Nix
|
||||
description: >-
|
||||
Goodbye, docker-compose!
|
||||
---
|
||||
|
||||
It's pretty frequent that one wants to use a project that requires multiple
|
||||
processes running. For example, a small web api which uses some database to
|
||||
store data in, or a networking utility which has some monitoring process which
|
||||
can be run alongside it.
|
||||
|
||||
In these cases it's extremely helpful to be able to compose these disparate
|
||||
processes together into a single process. From the user's perspective it's much
|
||||
nicer to only have to manage one process (even if it has hidden child
|
||||
processes). From a dev's perspective the alternatives are: finding libraries in
|
||||
the same language which do the disparate tasks and composing them into the same
|
||||
process via import, or (if such libraries don't exist, which is likely)
|
||||
rewriting the functionality of all processes into a new, monolithic project
|
||||
which does everything; a huge waste of effort!
|
||||
|
||||
## docker-compose
|
||||
|
||||
A tool I've used before for process composition is
|
||||
[docker-compose][docker-compose]. While it works well for composition, it
|
||||
suffers from the same issues docker in general suffers from: annoying networking
|
||||
quirks, a questionable security model, and the need to run the docker daemon.
|
||||
While these issues are generally surmountable for a developer or sysadmin, they
|
||||
are not suitable for a general-purpose project which will be shipped to average
|
||||
users.
|
||||
|
||||
## nix-bundle
|
||||
|
||||
Enter [nix-bundle][nix-bundle]. This tools will take any [nix][nix] derivation
|
||||
and construct a single static binary out of it, a la [AppImage][appimage].
|
||||
Combined with a process management tool like [circus][circus], nix-bundle
|
||||
becomes a very useful tool for composing processes together!
|
||||
|
||||
To demonstrate this, we'll be looking at putting together a project I wrote
|
||||
called [markov][markov], a simple REST API for building [markov
|
||||
chains][markov-chain] which is written in [go][golang] and backed by
|
||||
[redis][redis].
|
||||
|
||||
## Step 1: Building Individual Components
|
||||
|
||||
Step one is to get [markov][markov] and its dependencies into a state where it
|
||||
can be run with [nix][nix]. Doing this is fairly simple, we merely use the
|
||||
`buildGoModule` function:
|
||||
|
||||
```
|
||||
pkgs.buildGoModule {
|
||||
pname = "markov";
|
||||
version = "618b666484566de71f2d59114d011ff4621cf375";
|
||||
src = pkgs.fetchFromGitHub {
|
||||
owner = "mediocregopher";
|
||||
repo = "markov";
|
||||
rev = "618b666484566de71f2d59114d011ff4621cf375";
|
||||
sha256 = "1sx9dr1q3vr3q8nyx3965x6259iyl85591vx815g1xacygv4i4fg";
|
||||
};
|
||||
vendorSha256 = "048wygrmv26fsnypsp6vxf89z3j0gs9f1w4i63khx7h134yxhbc6";
|
||||
}
|
||||
```
|
||||
|
||||
This expression results in a derivation which places the markov binary at
|
||||
`bin/markov`.
|
||||
|
||||
The other component we need to run markov is [redis][redis], which conveniently
|
||||
is already packaged in nixpkgs as `pkg.redis`.
|
||||
|
||||
## Step 2: Composing Using Circus
|
||||
|
||||
[Circus][circus] can be configured to run multiple processes at the same time.
|
||||
It will collect the stdout/stderr logs of these processes and combine them into
|
||||
a single stream, or write them to log files. If any processes fail circus will
|
||||
automatically restart them. It has a simple configuration and is, overall, a
|
||||
great tool for a simple project like this.
|
||||
|
||||
Circus also comes pre-packed in nixpkgs, so we don't need to do anything to
|
||||
actually build it. We only need to configure it. To do this we'll write a bash
|
||||
script which generates the configuration on-the-fly, and then runs the process
|
||||
with that configuration.
|
||||
|
||||
This script is going to act as the "frontend" for our eventual static binary;
|
||||
the user will pass in configuration parameters to this script, and this script
|
||||
will translate those into the appropriate configuration for all sub-process
|
||||
(markov, redis, circus). For this demo we won't go nuts with the configuration,
|
||||
we'll just expose the following:
|
||||
|
||||
* `MARKOV_LISTEN_ADDR`: Address REST API will listen on (defaults to
|
||||
`localhost:8000`).
|
||||
|
||||
* `MARKOV_TIMEOUT`: Expiration time of each link of the chain (defaults to 720
|
||||
hours).
|
||||
|
||||
* `MARKOV_DATA_DIR`: Directory where data will be stored (defaults to current
|
||||
working directory).
|
||||
|
||||
The bash script will take these params in as environment variables. The nix
|
||||
expression to generate the bash script, which we'll call our entrypoint script,
|
||||
will look like this (assumes that the expression to generate `bin/markov`,
|
||||
defined above, is set to the `markov` variable):
|
||||
|
||||
```
|
||||
pkgs.writeScriptBin "markov" ''
|
||||
#!${pkgs.stdenv.shell}
|
||||
|
||||
# On every run we create new, temporary, configuration files for redis and
|
||||
# circus. To do this we create a new config directory.
|
||||
markovCfgDir=$(${pkgs.coreutils}/bin/mktemp -d)
|
||||
echo "generating configuration to $markovCfgDir"
|
||||
|
||||
cat >$markovCfgDir/redis.conf <<EOF
|
||||
save ""
|
||||
dir "''${MARKOV_DATA_DIR:-$(pwd)}"
|
||||
appendonly yes
|
||||
appendfilename "markov.data"
|
||||
EOF
|
||||
|
||||
cat >$markovCfgDir/circus.ini <<EOF
|
||||
|
||||
[circus]
|
||||
|
||||
[watcher:markov]
|
||||
cmd = ${markov}/bin/markov \
|
||||
-listenAddr ''${MARKOV_LISTEN_ADDR:-localhost:8000} \
|
||||
-timeout ''${MARKOV_TIMEOUT:-720}
|
||||
numprocesses = 1
|
||||
|
||||
[watcher:redis]
|
||||
cmd = ${pkgs.redis}/bin/redis-server $markovCfgDir/redis.conf
|
||||
numprocesses = 1
|
||||
EOF
|
||||
|
||||
exec ${pkgs.circus}/bin/circusd $markovCfgDir/circus.ini
|
||||
'';
|
||||
```
|
||||
|
||||
By `nix-build`ing this expression we end up with a derivation with
|
||||
`bin/markov`, and running that should result in the following output:
|
||||
|
||||
```
|
||||
generating configuration to markov.VLMPwqY
|
||||
2021-04-22 09:27:56 circus[181906] [INFO] Starting master on pid 181906
|
||||
2021-04-22 09:27:56 circus[181906] [INFO] Arbiter now waiting for commands
|
||||
2021-04-22 09:27:56 circus[181906] [INFO] markov started
|
||||
2021-04-22 09:27:56 circus[181906] [INFO] redis started
|
||||
181923:C 22 Apr 2021 09:27:56.063 # oO0OoO0OoO0Oo Redis is starting oO0OoO0OoO0Oo
|
||||
181923:C 22 Apr 2021 09:27:56.063 # Redis version=6.0.6, bits=64, commit=00000000, modified=0, pid=181923, just started
|
||||
181923:C 22 Apr 2021 09:27:56.063 # Configuration loaded
|
||||
...
|
||||
```
|
||||
|
||||
The `markov` server process doesn't have many logs, unfortunately, but redis'
|
||||
logs at least work well, and doing a `curl localhost:8000` results in the
|
||||
response from the `markov` server.
|
||||
|
||||
At this point our processes are composed using circus, let's now bundle it all
|
||||
into a single static binary!
|
||||
|
||||
## Step 3: nix-bundle
|
||||
|
||||
The next step is to run [nix-bundle][nix-bundle] on the entrypoint expression,
|
||||
and nix-bundle will compile all dependencies (including markov, redis, and
|
||||
circus) into a single archive file, and make that file executable. When the
|
||||
archive is executed it will run our entrypoint script directly.
|
||||
|
||||
Getting nix-bundle is very easy, just use nix-shell!
|
||||
|
||||
```
|
||||
nix-shell -p nix-bundle
|
||||
```
|
||||
|
||||
This will open a shell where the `nix-bundle` binary is available on your path.
|
||||
From there just run the following to construct the binary (this assumes that the
|
||||
nix code described so far is stored in `markov.nix`, the full source of which
|
||||
will be linked to at the end of this post):
|
||||
|
||||
```
|
||||
nix-bundle '((import ./markov.nix) {}).entrypoint' '/bin/markov'
|
||||
```
|
||||
|
||||
The resulting binary is called `markov`, and is 89MB. The size is a bit jarring,
|
||||
considering the simplicity of the functionality, but it could probably be
|
||||
trimmed by using a different process manager than circus (which requires
|
||||
bundling an entire python runtime into the binary).
|
||||
|
||||
Running the binary directly as `./markov` produces the same result as when we
|
||||
ran the entrypoint script earlier. Success! We have bundled multiple existing
|
||||
processes into a single, opaque, static binary. Installation of this binary is
|
||||
now as easy as copying it to any linux machine and running it.
|
||||
|
||||
## Bonus Step: nix'ing nix-bundle
|
||||
|
||||
Installing and running [nix-bundle][nix-bundle] manually is _fine_, but it'd be even better if
|
||||
that was defined as part of our nix setup as well. That way any new person
|
||||
wouldn't have to worry about that step, and still get the same deterministic
|
||||
output from the build.
|
||||
|
||||
Unfortunately, we can't actually run `nix-bundle` from within a nix build
|
||||
derivation, as it requires access to the nix store and that can't be done (or at
|
||||
least I'm not on that level yet). So instead we'll have to settle for defining
|
||||
the `nix-bundle` binary in nix and then using a `Makefile` to call it.
|
||||
|
||||
Defining a `nix-bundle` expression is easy enough:
|
||||
|
||||
```
|
||||
nixBundleSrc = pkgs.fetchFromGitHub {
|
||||
owner = "matthewbauer";
|
||||
repo = "nix-bundle";
|
||||
rev = "8e396533ef8f3e8a769037476824d668409b4a74";
|
||||
sha256 = "1lrq0990p07av42xz203w64abv2rz9xd8jrzxyvzzwj7vjj7qwyw";
|
||||
};
|
||||
|
||||
nixBundle = (import "${nixBundleSrc}/release.nix") {
|
||||
nixpkgs' = pkgs;
|
||||
};
|
||||
```
|
||||
|
||||
Then the Makefile:
|
||||
|
||||
```make
|
||||
bundle:
|
||||
nix-build markov.nix -A nixBundle
|
||||
./result/bin/nix-bundle '((import ./markov.nix) {}).entrypoint' '/bin/markov'
|
||||
```
|
||||
|
||||
Now all a developer needs to rebuild the project is to do `make` within the
|
||||
directory, while also having nix set up. The result will be a deterministically
|
||||
built, static binary, encompassing multiple processes which will all work
|
||||
together behind the scenes. This static binary can be copied to any linux
|
||||
machine and run there without any further installation steps.
|
||||
|
||||
How neat is that!
|
||||
|
||||
The final source files used for this project can be found below:
|
||||
|
||||
* [markov.nix](/assets/markov/markov.nix.html)
|
||||
* [Makefile](/assets/markov/Makefile.html)
|
||||
|
||||
[nix]: https://nixos.org/manual/nix/stable/
|
||||
[nix-bundle]: https://github.com/matthewbauer/nix-bundle
|
||||
[docker-compose]: https://docs.docker.com/compose/
|
||||
[appimage]: https://appimage.org/
|
||||
[circus]: https://circus.readthedocs.io/en/latest/
|
||||
[markov]: https://github.com/mediocregopher/markov
|
||||
[markov-chain]: https://en.wikipedia.org/wiki/Markov_chain
|
||||
[golang]: https://golang.org/
|
||||
[redis]: https://redis.io/
|
3
src/assets/markov/Makefile
Normal file
3
src/assets/markov/Makefile
Normal file
@ -0,0 +1,3 @@
|
||||
bundle:
|
||||
nix-build markov.nix -A nixBundle
|
||||
./result/bin/nix-bundle '((import ./markov.nix) {}).entrypoint' '/bin/markov'
|
6
src/assets/markov/Makefile.md
Normal file
6
src/assets/markov/Makefile.md
Normal file
@ -0,0 +1,6 @@
|
||||
---
|
||||
layout: code
|
||||
include: Makefile
|
||||
lang: make
|
||||
---
|
||||
|
63
src/assets/markov/markov.nix
Normal file
63
src/assets/markov/markov.nix
Normal file
@ -0,0 +1,63 @@
|
||||
{
|
||||
pkgs ? import (fetchTarball "https://github.com/NixOS/nixpkgs/archive/d50923ab2d308a1ddb21594ba6ae064cab65d8ae.tar.gz") {}
|
||||
}:
|
||||
|
||||
rec {
|
||||
|
||||
markov = pkgs.buildGoModule {
|
||||
pname = "markov";
|
||||
version = "618b666484566de71f2d59114d011ff4621cf375";
|
||||
src = pkgs.fetchFromGitHub {
|
||||
owner = "mediocregopher";
|
||||
repo = "markov";
|
||||
rev = "618b666484566de71f2d59114d011ff4621cf375";
|
||||
sha256 = "1sx9dr1q3vr3q8nyx3965x6259iyl85591vx815g1xacygv4i4fg";
|
||||
};
|
||||
vendorSha256 = "048wygrmv26fsnypsp6vxf89z3j0gs9f1w4i63khx7h134yxhbc6";
|
||||
};
|
||||
|
||||
entrypoint = pkgs.writeScriptBin "markov" ''
|
||||
#!${pkgs.stdenv.shell}
|
||||
|
||||
# On every run we create new, temporary, configuration files for redis and
|
||||
# circus. To do this we create a new config directory.
|
||||
markovCfgDir=$(${pkgs.coreutils}/bin/mktemp -d)
|
||||
echo "generating configuration to $markovCfgDir"
|
||||
|
||||
${pkgs.coreutils}/bin/cat >$markovCfgDir/redis.conf <<EOF
|
||||
save ""
|
||||
dir "''${MARKOV_DATA_DIR:-$(pwd)}"
|
||||
appendonly yes
|
||||
appendfilename "markov.data"
|
||||
EOF
|
||||
|
||||
${pkgs.coreutils}/bin/cat >$markovCfgDir/circus.ini <<EOF
|
||||
|
||||
[circus]
|
||||
|
||||
[watcher:markov]
|
||||
cmd = ${markov}/bin/markov \
|
||||
-listenAddr ''${MARKOV_LISTEN_ADDR:-localhost:8000} \
|
||||
-timeout ''${MARKOV_TIMEOUT:-720}
|
||||
numprocesses = 1
|
||||
|
||||
[watcher:redis]
|
||||
cmd = ${pkgs.redis}/bin/redis-server $markovCfgDir/redis.conf
|
||||
numprocesses = 1
|
||||
EOF
|
||||
|
||||
exec ${pkgs.circus}/bin/circusd $markovCfgDir/circus.ini
|
||||
'';
|
||||
|
||||
nixBundleSrc = pkgs.fetchFromGitHub {
|
||||
owner = "matthewbauer";
|
||||
repo = "nix-bundle";
|
||||
rev = "8e396533ef8f3e8a769037476824d668409b4a74";
|
||||
sha256 = "1lrq0990p07av42xz203w64abv2rz9xd8jrzxyvzzwj7vjj7qwyw";
|
||||
};
|
||||
|
||||
nixBundle = (import "${nixBundleSrc}/release.nix") {
|
||||
nixpkgs' = pkgs;
|
||||
};
|
||||
}
|
||||
|
6
src/assets/markov/markov.nix.md
Normal file
6
src/assets/markov/markov.nix.md
Normal file
@ -0,0 +1,6 @@
|
||||
---
|
||||
layout: code
|
||||
include: markov.nix
|
||||
lang: plain
|
||||
---
|
||||
|
Loading…
Reference in New Issue
Block a user