-
Notifications
You must be signed in to change notification settings - Fork 438
feat(orch): premade NixOS base-image support #3412
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
bca4706
8a07099
41f17c1
17365a2
8648379
dd58d19
78ed863
9fac6d0
9e5439f
7d6c806
fcc43e0
f873b3f
4596940
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| # E2B premade NixOS base image | ||
|
|
||
| NixOS templates work the inverse of every other family: instead of the | ||
| orchestrator provisioning the image imperatively, the image is **premade** from | ||
| `configuration.nix`, which declares everything `provision.sh` installs | ||
| elsewhere — the envd systemd unit, chrony, sshd, the default `user` (with a | ||
| matching `user` group and the exact sudoers line the build steps check for), | ||
| `/bin/bash` (build steps invoke it explicitly), and the journald watchdog | ||
| override. The orchestrator's `nixos` profile then only verifies and boots | ||
| (see `../distro.go` and the `InitNixOS` block in `../init.go`). | ||
|
|
||
| ## Building and publishing | ||
|
|
||
| `./build.sh <tag> [registry]` (run on a Linux host with docker; the registry | ||
| defaults to `127.0.0.1:5000`): | ||
|
|
||
| 1. evaluates the NixOS system closure with `nix` inside a `nixos/nix` | ||
| container (`nixpkgs` channel pinned in the script), from the | ||
| `configuration.nix` committed next to the script, | ||
| 2. packs the closure into a single-layer OCI rootfs tar, adding the three | ||
| pieces of glue the boot path needs: | ||
| - `/sbin/init -> /nix/var/nix/profiles/system/init` (the stage-2 init the | ||
| `nixos` profile points the kernel at), | ||
| - `/nix/var/nix/profiles/system -> <toplevel store path>`, | ||
| - a static `/etc/os-release` with `ID=nixos` so the distro selector can | ||
| identify the image *before* the first activation generates the real one, | ||
| 3. `docker import`s and pushes the tar. | ||
|
|
||
| **Push every rebuild under a NEW TAG** (hence the required `<tag>` argument). | ||
| The base-layer cache key includes the image reference as written in the | ||
| Dockerfile — republishing under the same tag silently reuses the previously | ||
| cached base layer (observed; same "default tag" ambiguity called out in | ||
| `phases/base/hash.go`). | ||
|
|
||
| ## Boot-path notes (all observed on real KVM) | ||
|
|
||
| - Before the first activation the image has **no FHS userland** — no | ||
| `/bin/sh`, no `mkdir`. The provisioning boot runs entirely through the baked | ||
| busybox (see `core/rootfs/files/rcS.sh.tpl`, `inittab.tpl`, | ||
| `provision-runner.sh.tpl`), and the `nixos` profile's `Bootstrap` puts | ||
| busybox applets on `PATH` for the shared provisioning body. | ||
| - NixOS activation manages `/etc/systemd/system` as a symlink into the store; | ||
| the baked systemd drop-ins must be removed at provisioning (the `InitNixOS` | ||
| setup does this) or `setup-etc` refuses the symlink and systemd boots with | ||
| no units at all ("Unit default.target not found"). | ||
| - The sandbox gets the nix toolchain natively (`nix-env` on PATH for `user`). |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| #!/bin/bash | ||
| # Build and publish the E2B premade NixOS base image. | ||
| # | ||
| # ./build.sh <tag> [registry] | ||
| # | ||
| # Runs from its own directory so the configuration.nix committed next to it is | ||
| # the one that gets built. The tag is required: the base-layer cache key | ||
| # includes the image reference as written in the Dockerfile, so republishing | ||
| # under a tag that was already built silently reuses the cached base layer. | ||
| set -euo pipefail | ||
|
|
||
| here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) | ||
| tag=${1:-} | ||
| registry=${2:-127.0.0.1:5000} | ||
| if [ -z "$tag" ]; then | ||
| echo "usage: ${BASH_SOURCE[0]} <tag> [registry] # push every rebuild under a NEW tag" >&2 | ||
| exit 1 | ||
| fi | ||
| image="$registry/e2b-nixos:$tag" | ||
|
|
||
| # Staged outside the repo: the closure tar is ~700 MB, and the repo checkout can | ||
| # be a slow network mount on a dev box. | ||
| work=${E2B_NIXOS_WORKDIR:-/var/tmp/e2b-nixos-base} | ||
| mkdir -p "$work" | ||
| cp "$here/configuration.nix" "$work/configuration.nix" | ||
| rm -f "$work/result" | ||
|
|
||
| # Build the toplevel closure with nix inside the nixos/nix container. | ||
| docker run --rm -v "$work:/build" nixos/nix:2.35.1 sh -c " | ||
| set -e | ||
| nix-build -I nixpkgs=channel:nixos-24.05 -I nixos-config=/build/configuration.nix \ | ||
| '<nixpkgs/nixos>' -A config.system.build.toplevel -o /build/result | ||
| top=\$(readlink /build/result) | ||
| echo \"TOPLEVEL=\$top\" | ||
| # Pack the full closure + the boot/identity glue into one rootfs tar. | ||
| nix-store -qR /build/result > /build/closure.txt | ||
| tar -cf /build/nixos-rootfs.tar \$(cat /build/closure.txt) | ||
| staging=/tmp/extra | ||
| rm -rf \$staging | ||
| mkdir -p \$staging/sbin \$staging/etc \$staging/nix/var/nix/profiles | ||
| ln -s \$top \$staging/nix/var/nix/profiles/system | ||
| # Tarring store paths does not make them valid to nix: the DB lives in | ||
| # /nix/var/nix/db, which the closure does not carry. Ship the registration so | ||
| # first boot can load it, otherwise nix-env and friends reject every path. | ||
| nix-store --dump-db \$(cat /build/closure.txt) > \$staging/nix/var/nix/db-registration | ||
| ln -s /nix/var/nix/profiles/system/init \$staging/sbin/init | ||
| cat > \$staging/etc/os-release <<OSR | ||
| NAME=NixOS | ||
| ID=nixos | ||
| VERSION_ID=\"24.05\" | ||
| PRETTY_NAME=\"NixOS 24.05 (E2B sandbox base)\" | ||
| OSR | ||
| tar -rf /build/nixos-rootfs.tar -C \$staging sbin etc nix | ||
| echo PACKED | ||
| " | ||
| ls -lh "$work/nixos-rootfs.tar" | ||
| docker import "$work/nixos-rootfs.tar" "$image" | ||
| docker push "$image" | ||
| echo "IMAGE_PUSHED $image" | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,183 @@ | ||
| # E2B premade NixOS sandbox base. | ||
| # Everything provision.sh does imperatively on other distros is declared here; | ||
| # the orchestrator's nixos profile only verifies and boots. | ||
| { config, pkgs, lib, ... }: | ||
| { | ||
| # Boot: the E2B microVM supplies its own kernel and mounts the rootfs rw | ||
| # (root=/dev/vda rw), then runs this system's stage-2 init directly — no | ||
| # initrd, no bootloader. | ||
| # The initrd/kernel in the closure are unused dead weight (the microVM | ||
| # boots E2B's kernel with init= pointing at this system's stage-2 init), | ||
| # but NixOS's module system requires them to evaluate; only grub is off. | ||
| boot.loader.grub.enable = false; | ||
| fileSystems."/" = { device = "/dev/vda"; fsType = "ext4"; }; | ||
|
|
||
| # eth0 is configured by the kernel command line (ip=...); nothing to manage. | ||
| networking.useDHCP = false; | ||
| networking.resolvconf.enable = false; | ||
| # NixOS is the only family that enables a firewall by default — provision.sh | ||
| # installs iptables/nftables for user workloads but never filters. Leaving it on | ||
| # would drop the orchestrator's connection to envd (TCP 49983) and every | ||
| # customer-exposed sandbox port; isolation is enforced by the E2B network layer. | ||
| networking.firewall.enable = false; | ||
| # The E2B rootfs layer bakes an immutable /etc/resolv.conf; NixOS must not | ||
| # try to regenerate it at activation. | ||
| environment.etc."resolv.conf".enable = false; | ||
|
tomassrnka marked this conversation as resolved.
tomassrnka marked this conversation as resolved.
|
||
| # Same for the baked /etc/hostname and /etc/hosts (both carry e2b.local, which | ||
| # every other family keeps). An empty hostName also stops activation calling | ||
| # `hostname` and overriding the running name. | ||
| networking.hostName = ""; | ||
| environment.etc."hostname".enable = false; | ||
| environment.etc."hosts".enable = false; | ||
|
|
||
| # The env daemon: E2B bakes the static binary at /usr/bin/envd as an OCI | ||
| # layer on top of this image. Mirrors envd.service.tpl (systemd family). | ||
| systemd.services.envd = { | ||
| description = "E2B env daemon"; | ||
| wantedBy = [ "multi-user.target" ]; | ||
| unitConfig.StartLimitIntervalSec = 0; | ||
| # e2b-seed-certs (baked at /usr/local/bin by the E2B layer) bind-mounts a | ||
| # tmpfs over /etc/ssl/certs seeded with DEREFERENCED copies of the trust | ||
| # bundle: envd APPENDS the egress-proxy CA to ca-certificates.crt at | ||
| # sandbox /init, which must not hit a symlink into the read-only store. | ||
| # socat and iptables are executed by name: envd spawns socat to forward | ||
| # exposed ports and shells out to iptables to pin the MMDS route. There is no | ||
| # FHS bin dir to find them in, so they must be on the unit's PATH. | ||
| # | ||
| # envd hands its OWN environment's PATH to every process it spawns, so this | ||
| # list is also the PATH of anything started through the exec API that does | ||
| # not go through a login shell (`sh -c` — what the orchestrator's pre-pause | ||
| # reclaim/sync use — or a bare argv[0]). Without the system profile that PATH | ||
| # has no `sh`, `curl`, `git`, `jq`: on the FHS families the same unit inherits | ||
| # systemd's default /usr/bin:/bin, i.e. the whole userland, so append NixOS's | ||
| # equivalent. /run/wrappers first, like the login PATH, so setuid wrappers win | ||
| # over the plain store copies; the explicit packages stay ahead of the profile | ||
| # so envd's own helpers always resolve inside this closure. | ||
| path = [ | ||
| "/run/wrappers" | ||
| pkgs.coreutils pkgs.util-linux pkgs.gnutar pkgs.socat pkgs.iptables | ||
| "/run/current-system/sw" | ||
| ]; | ||
| serviceConfig = { | ||
| Type = "simple"; | ||
| Restart = "always"; | ||
| ExecStartPre = "/usr/local/bin/e2b-seed-certs"; | ||
| ExecStart = "/usr/bin/envd"; | ||
| LimitCORE = "infinity"; | ||
| # envd.service.tpl templates GOMEMLIMIT per sandbox as min(MemoryMB/2, 512)MiB; | ||
| # a premade image can't know the sandbox size, so pin the 512 MiB ceiling — | ||
| # envd must still GC under a cap, not grow unbounded. | ||
| Environment = [ "GOTRACEBACK=all" "GOMEMLIMIT=512MiB" ]; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For supported templates below 1 GiB, this hard-coded value gives envd a much larger soft heap limit than the existing Useful? React with 👍 / 👎. |
||
| # Priority/scheduling parity with envd.service.tpl (ionice 1:4 = realtime,4). | ||
| Nice = -20; | ||
| IOSchedulingClass = "realtime"; | ||
| IOSchedulingPriority = 4; | ||
| OOMPolicy = "continue"; | ||
| OOMScoreAdjust = -1000; | ||
| # Resource-control parity: reserve envd's memory and win CPU/IO contention. | ||
| Delegate = true; | ||
| MemoryMin = "50M"; | ||
| MemoryLow = "100M"; | ||
| CPUAccounting = true; | ||
| CPUWeight = 1000; | ||
| IOAccounting = true; | ||
| IOWeight = 10000; | ||
| }; | ||
| }; | ||
|
tomassrnka marked this conversation as resolved.
|
||
|
|
||
| # Default sandbox user (matches configure.sh on the other families). | ||
| # Match useradd semantics on the other families: a per-user group named | ||
| # after the user (configure.sh chowns /home/user to user:user). | ||
| users.groups.user = {}; | ||
| users.users.user = { | ||
| isNormalUser = true; | ||
| group = "user"; | ||
| extraGroups = [ "wheel" ]; | ||
| initialHashedPassword = ""; | ||
| }; | ||
| users.users.root.initialHashedPassword = ""; | ||
| security.sudo.wheelNeedsPassword = false; | ||
| # The DEFAULT USER build step greps for this exact line before appending to | ||
| # /etc/sudoers (which is a read-only store symlink on NixOS) — declaring it | ||
| # makes that step a clean no-op. | ||
| security.sudo.extraConfig = "user ALL=(ALL:ALL) NOPASSWD: ALL"; | ||
|
cursor[bot] marked this conversation as resolved.
|
||
|
|
||
| services.openssh = { | ||
| enable = true; | ||
| settings.PermitRootLogin = "yes"; | ||
| settings.PermitEmptyPasswords = true; | ||
| }; | ||
| security.pam.services.sshd.allowNullPassword = true; | ||
|
|
||
| # Time-sync parity with provision.sh: prefer the hypervisor PHC refclock | ||
| # (kvm-ptp — no network, tracks the host) when /dev/ptp0 is present, else the | ||
| # NTP pool. A refclock line for a missing PHC is FATAL to chronyd, and a premade | ||
| # image can't probe the device at build time, so the source line is written at | ||
| # boot by e2b-chrony-source.service and pulled in via this include. | ||
| services.chrony = { | ||
| enable = true; | ||
| servers = [ ]; | ||
| extraConfig = '' | ||
| include /run/chrony-e2b/source.conf | ||
| makestep 1.0 3 | ||
| ''; | ||
| }; | ||
|
|
||
| systemd.services.e2b-chrony-source = { | ||
| description = "E2B: select chrony time source (PHC refclock if /dev/ptp0, else NTP)"; | ||
| before = [ "chronyd.service" ]; | ||
| requiredBy = [ "chronyd.service" ]; | ||
| path = [ pkgs.coreutils ]; | ||
| serviceConfig = { | ||
| Type = "oneshot"; | ||
| RemainAfterExit = true; | ||
| }; | ||
| script = '' | ||
| mkdir -p /run/chrony-e2b | ||
| if [ -e /dev/ptp0 ]; then | ||
| echo "refclock PHC /dev/ptp0 poll 2 dpoll 2" > /run/chrony-e2b/source.conf | ||
| else | ||
| echo "pool pool.ntp.org iburst maxsources 3" > /run/chrony-e2b/source.conf | ||
| fi | ||
| ''; | ||
| }; | ||
|
tomassrnka marked this conversation as resolved.
|
||
|
|
||
| # Journald must not watchdog-reboot when the microVM is paused for | ||
| # snapshots (mirrors the systemd-family override baked into other images). | ||
| systemd.services.systemd-journald.serviceConfig.WatchdogSec = 0; | ||
|
|
||
| # No serial getty fighting the console; keep the closure lean. | ||
| systemd.services."serial-getty@ttyS0".enable = false; | ||
| documentation.enable = false; | ||
|
|
||
| # Kernel tunables provision.sh writes to /etc/sysctl.conf on other | ||
| # families (NixOS reads sysctl.d from its own config instead). | ||
| boot.kernel.sysctl."fs.inotify.max_user_watches" = 65536; | ||
| boot.kernel.sysctl."vm.compaction_proactiveness" = 0; | ||
|
|
||
| # E2B build steps and customer commands are executed via /bin/bash (the | ||
| # orchestrator invokes it explicitly, like on every FHS distro) — provide it. | ||
| system.activationScripts.e2bBinBash = "mkdir -m 0755 -p /bin && ln -sfn ${pkgs.bash}/bin/bash /bin/bash"; | ||
|
|
||
| # Load the store registration build.sh packed, once, so the nix tooling sees | ||
| # the closure as valid. Never fail activation over it: without the DB the nix | ||
| # commands are broken, but the sandbox itself is fine — which is the status quo | ||
| # this repairs, not a regression it could introduce. | ||
| system.activationScripts.e2bNixDb = '' | ||
| if [ -f /nix/var/nix/db-registration ] && [ ! -e /nix/var/nix/db/db.sqlite ]; then | ||
| ${pkgs.nix}/bin/nix-store --load-db < /nix/var/nix/db-registration || true | ||
| fi | ||
| ''; | ||
|
|
||
| # Parity with the package set provision.sh installs on the other families, so | ||
| # a sandbox exposes the same userland whichever base image it was built from. | ||
| # (openssh, sudo, chrony and bash are declared as services/programs above.) | ||
| # shadow is not optional: finalize's configure.sh runs useradd, usermod and | ||
| # passwd by name, the same way it does on the families that install | ||
| # shadow/shadow-utils via Packages. | ||
| environment.systemPackages = with pkgs; [ | ||
| shadow socat curl git jq less fuse3 iptables nftables iputils nfs-utils | ||
| ]; | ||
|
tomassrnka marked this conversation as resolved.
|
||
|
|
||
| system.stateVersion = "24.05"; | ||
| } | ||
|
cursor[bot] marked this conversation as resolved.
|
||
Uh oh!
There was an error while loading. Please reload this page.