libstore/build: cgroup delegation to sandbox

We offer full cgroup delegation to our sandbox now, required for running
containers inside the sandbox.

To run systemd-nspawn or containers managers inside the sandbox, there
is a need for one extra ingredient now: control over your own cgroup
subtree inside the sandbox.

If, in addition, you need multiple UIDs, for e.g. rootless usecases, you
need to run with the `uid-range` system feature.

Therefore, when the daemon or Nix runs under the right condition, e.g.
systemd-style delegation of the cgroup subtree while placing the
nix-daemon in a supervisor sub-cgroup, we create a new sub-cgroup for
each build based on the build UID and delegate that sub-cgroup to the
builder's process.

Additionally, `uid-range` always request the `cgroups` feature now, as
`uid-range` builds would probably always benefit from having cgroups
delegated, but the converse is not true.

Inspired from https://github.com/NixOS/nix/pull/11412 with a different
design that does not use function-local statics to derive the root
cgroup.

Co-authored-by: Linus Heckemann <git@sphalerite.org>
Co-authored-by: Parker Hoyes <contact@parkerhoyes.com>
Change-Id: Ic8947c5adaf4b5bbd153386e05fad65a935274fa
Signed-off-by: Raito Bezarius <raito@lix.systems>
This commit is contained in:
Raito Bezarius
2025-06-10 20:50:00 +02:00
co-authored by Linus Heckemann Parker Hoyes
parent 1783d5b348
commit a527bb251a
21 changed files with 965 additions and 127 deletions
+69
View File
@@ -0,0 +1,69 @@
---
synopsis: New cgroup delegation model
issues: [fj#537, fj#77]
cls: [3230]
category: "Breaking Changes"
credits: [raito, horrors, lheckemann]
---
Builds using cgroups (i.e. `use-cgroups = true` and the experimental feature
`cgroups`) now always delegate a cgroup tree to the sandbox.
Compared to the original C++ Nix project, our delegation includes the
`subtree_control` file as well, which means that the sandbox can disable
certain controllers in its own cgroup tree.
This is a breaking change because this requires the Nix daemon to run with an
already delegated cgroup tree by the service manager.
## How to setup the cgroup tree with systemd?
systemd offers knobs to perform the required setup using:
```
[Unit]
Delegate=yes
DelegateSubtree=supervisor
```
These directives are now included in our systemd packaging.
## What about using Nix as root without connecting to the daemon?
Builds run as `root` without connecting to the daemon relying on the cgroup
feature are now broken, i.e.
```console
# nix-build --use-cgroups --sandbox ... # will not work
```
Consider doing instead:
```console
# systemd-run --same-dir --wait -p Delegate=yes -p DelegateSubgroup=supervisor nix-build --use-cgroups ...
```
If you need to disable cgroups temporarily, remember that you can do
`NIX_CONF='include /etc/nix/nix.conf\nuse-cgroups = false' nix-build ...` or
`nix-build --no-use-cgroups ...`.
## What about other service managers than systemd?
systemd has a [documentation](https://systemd.io/CGROUP_DELEGATION/) on how to
handle cgroup delegation from service management perspective.
If your service manager adheres to systemd semantics, e.g. writing an extended
attribute `user.delegate=1` on the delegated cgroup tree directory and moving
the `nix-daemon` process inside a cgroup tree to respect the inner process
rule, then, the feature will work as well.
## Why is the cgroup feature still experimental?
While the cgroup feature unlocks many use cases, its behavior and integration (e.g. user experience), especially at scale on build farms or in multi-tenant environments, are not yet fully matured. Theres also potential for deeper systemd integration (e.g. using slices and scopes) that has not been fully explored.
To avoid locking in an unstable interface, were keeping the experimental flag until we have validated the feature across a broader range of scenarios, including but not limited to:
* Nix as root
* Hydra-style build farms
* Forgejo CI runners
* Shared remote builders
@@ -0,0 +1,15 @@
---
synopsis: uid-range depends on cgroups
issues: []
cls: [3230]
category: "Breaking Changes"
credits: [raito, horrors]
---
`uid-range` builds now depends on `cgroups`, an experimental feature.
`uid-range` builds already depended upon `auto-allocate-uids`, another experimental feature.
The rationale for doing so is that `uid-range` provides a sandbox with many
UIDs, this is useful for re-mapping them into a nested namespace, e.g. a
container.
@@ -402,9 +402,11 @@ void LocalDerivationGoal::cleanupPostOutputsRegisteredModeNonCheck()
kj::Promise<Result<void>> LocalDerivationGoal::startBuilder()
try {
#if !(__linux__)
if (buildUser && buildUser->getUIDCount() != 1) {
throw Error("cgroups are not supported on this platform");
}
#endif
/* Make sure that no other processes are executing under the
sandbox uids. This must be done before any chownToBuilder()
@@ -4,9 +4,20 @@
#include "lix/libstore/build/derivation-goal.hh"
#include "lix/libstore/local-store.hh"
#include "lix/libutil/processes.hh"
#include "lix/libutil/cgroup.hh"
namespace nix {
struct BuildContext
{
#ifdef __linux__
/**
* Control group for this derivation goal.
*/
std::optional<AutoDestroyCgroup> cgroup;
#endif
};
struct LocalDerivationGoal : public DerivationGoal
{
LocalStore & getLocalStore();
@@ -16,6 +27,11 @@ struct LocalDerivationGoal : public DerivationGoal
*/
std::unique_ptr<UserLock> buildUser;
/**
* Build context for this goal.
*/
BuildContext context;
/**
* The process ID of the builder.
*/
+79
View File
@@ -37,6 +37,85 @@ Worker::Worker(Store & store, Store & evalStore)
, children(errorHandler)
{
/* Debugging: prevent recursive workers. */
#ifdef __linux__
/* When cgroups are used, we need to verify if our context allows
* us to fully use cgroups delegation or ability to kill certain cgroups.
*
* Note that `uid-range` builds implies cgroups, the converse is false.
* A `uid-range` build is defined as `settings.autoAllocateUids && settings.uidCount >= 1`
*/
if (settings.autoAllocateUids && settings.uidCount > 1 && !settings.useCgroups) {
throw Error(
"Running builds with UID ranges (setting `%s` enabled and `%d` UIDs) requires the "
"setting '%s' to be enabled.",
settings.autoAllocateUids.name,
settings.uidCount,
settings.useCgroups.name
);
}
/* Cgroup build absolutely need build user separation. */
if (!useBuildUsers() && settings.useCgroups) {
throw Error(
"Running all builds with cgroups requires privilege separation for build users but Lix "
"is not configured to use build users."
);
}
/*
* At this point, we know that if `settings.useCgroups = true`, then `useBuildUsers() = true`.
* UID ranges may or may not be available.
*/
if (settings.useCgroups) {
if (!hasCgroupFeature(
platformFeatures.availableCgroupFeatures, CgroupAvailableFeatureSet::CGROUPV2
))
{
throw Error("Running a build with cgroups requires cgroups v2 support on the system.");
}
if (!hasCgroupFeature(
platformFeatures.availableCgroupFeatures, CgroupAvailableFeatureSet::CGROUPV2_KILL
))
{
throw Error(
"Running a build with cgroups requires cgroups v2 kill feature which requires "
"a Linux kernel newer than 5.14."
);
}
if (!hasCgroupFeature(
platformFeatures.availableCgroupFeatures,
CgroupAvailableFeatureSet::CGROUPV2_PARENT_DELEGATED
))
{
if (hasCgroupFeature(
platformFeatures.availableCgroupFeatures,
CgroupAvailableFeatureSet::CGROUPV2_SELF_DELEGATED
))
{
throw Error(
"Running a build with cgroups requires the parent cgroup tree to be "
"delegated, but only this process' cgroup is delegated.\n"
"If you used systemd with `Delegate=yes`, consider moving the process in a "
"sub-cgroup or use `DelegateSubtree=` to move it automatically.\n"
"See <https://systemd.io/CGROUP_DELEGATION/> for more information."
);
} else {
throw Error(
"Running a build with cgroups requires the parent cgroup tree to be "
"delgated.\n"
"If you use systemd, adding `Delegate=yes` and `DelegateSubtree=supervisor` to "
"the [Unit] section will delegate the parent cgroup tree.\n"
"See <https://systemd.io/CGROUP_DELEGATION/> for more information."
);
}
}
}
#endif
}
+10
View File
@@ -6,6 +6,7 @@
#include "lix/libutil/concepts.hh"
#include "lix/libutil/notifying-counter.hh"
#include "lix/libutil/result.hh"
#include "lix/libutil/cgroup.hh"
#include "lix/libutil/types.hh"
#include "lix/libstore/lock.hh"
#include "lix/libstore/store-api.hh"
@@ -187,6 +188,15 @@ public:
Store & evalStore;
AsyncSemaphore substitutions, localBuilds;
struct PlatformFeatures
{
#ifdef __linux__
CgroupAvailableFeatureSet availableCgroupFeatures = detectAvailableCgroupFeatures();
#endif
};
PlatformFeatures platformFeatures;
private:
kj::TaskSet children;
+1
View File
@@ -108,6 +108,7 @@ libstore_setting_definitions = files(
'settings/timeout.md',
'settings/trusted-public-keys.md',
'settings/trusted-substituters.md',
'settings/use-cgroups.md',
'settings/use-sqlite-wal.md',
'settings/use-xdg-base-directories.md',
# keep-sorted end
+58 -3
View File
@@ -1,4 +1,5 @@
#include "lix/libstore/build/worker.hh"
#include "lix/libutil/cgroup.hh"
#include "lix/libutil/finally.hh"
#include "lix/libstore/gc-store.hh"
#include "lix/libutil/signals.hh"
@@ -780,12 +781,14 @@ void LinuxLocalDerivationGoal::prepareSandbox()
nobody account. The latter is kind of a hack to support
Samba-in-QEMU. */
createDirs(chrootRootDir + "/etc");
if (parsedDrv->useUidRange())
chownToBuilder(chrootRootDir + "/etc");
if (parsedDrv->useUidRange() && (!buildUser || buildUser->getUIDCount() < 65536))
throw Error("feature 'uid-range' requires the setting '%s' to be enabled", settings.autoAllocateUids.name);
if (parsedDrv->useUidRange()) {
chownToBuilder(chrootRootDir + "/etc");
}
/* Create /etc/hosts with localhost entry. */
if (derivationType->isSandboxed())
writeFile(chrootRootDir + "/etc/hosts", "127.0.0.1 localhost\n::1 localhost\n");
@@ -818,6 +821,42 @@ void LinuxLocalDerivationGoal::prepareSandbox()
for (auto & i : drv->outputsAndPaths(worker.store)) {
pathsInChroot.erase(worker.store.printStorePath(i.second.second));
}
if (buildUser && (buildUser->getUIDCount() != 1 || settings.useCgroups)) {
context.cgroup.emplace(
settings.nixStateDir + "/cgroups",
fmt("nix-build-uid-%d", buildUser->getUID()),
buildUser->getUID(),
buildUser->getGID()
);
debug("using cgroup '%s' for build", context.cgroup->name());
/* TODO(raito): it would be very nice if we could propagate system features
* based on which cgroup controllers are available in `context.cgroup`
* so that we would re-schedule any derivation that actually has
* anti-affinity or pro-affinity with certain cgroup controllers, e.g.
* a derivation that is very sensitive to the memory cgroup controller
* for performance reason.
*
* Unfortunately, the current design of system features prevent mutation
* and worse, we are too late for rescheduling this derivation.
*
* Therefore, we decide to always copy all the available controllers
* to the delegated cgroup.
*/
debug(
"available cgroup controllers for cgroup '%s': '%s'",
context.cgroup->name(),
concatStringsSep(",", context.cgroup->controllers())
);
}
if (parsedDrv->useUidRange() && !context.cgroup) {
throw Error(
"feature 'uid-range' requires the setting '%s' to be enabled", settings.useCgroups.name
);
}
}
Pid LinuxLocalDerivationGoal::startChild(std::function<void()> openSlave)
@@ -957,6 +996,11 @@ Pid LinuxLocalDerivationGoal::startChild(std::function<void()> openSlave)
"nixbld:!:%1%:\n"
"nogroup:x:65534:\n", sandboxGid()));
/* Migrate the child inside the available control group. */
if (context.cgroup) {
context.cgroup->adoptProcess(pid.get());
}
/* Signal the builder that we've updated its user namespace. */
writeFull(userNamespaceSync.writeSide.get(), "1");
@@ -965,7 +1009,18 @@ Pid LinuxLocalDerivationGoal::startChild(std::function<void()> openSlave)
void LinuxLocalDerivationGoal::killSandbox(bool getStats)
{
if (!useChroot) {
if (context.cgroup) {
context.cgroup->kill();
if (getStats) {
auto stats = context.cgroup->getStatistics();
buildResult.cpuUser = stats.cpuUser;
buildResult.cpuSystem = stats.cpuSystem;
}
/* It may be desireable to destroy the cgroup here
* but we may be calling this at the start of the build
* to ensure that no leftover process are running under sandbox UIDs.
* With control groups, that's already impossible. */
} else if (!useChroot) {
/* Linux sandboxes use PID namespaces, which ensure that processes cannot escape from a build.
Therefore, we don't need to kill all processes belonging to the build user.
This avoids processes unrelated to the build being killed, thus avoiding: https://git.lix.systems/lix-project/lix/issues/667 */
+17
View File
@@ -0,0 +1,17 @@
---
name: use-cgroups
internalName: useCgroups
platforms: [linux]
type: bool
default: false
experimentalFeature: cgroups
---
Whether to execute builds inside cgroups.
Cgroups are required and enabled automatically for derivations
that require the `uid-range` system feature.
Cgroups requires a cgroup delegation according to <https://systemd.io/CGROUP_DELEGATION/>, i.e.
the Nix process (`nix-daemon` or any single user command) should run in a cgroup tree of a parent cgroup which possess the `user.delegate=1` extended attribute.
In this scenario, Nix will run builds in a sibling cgroup named `nix-build-uid-<build user uid>`.
+335 -96
View File
@@ -1,7 +1,8 @@
#include "lix/libutil/logging.hh"
#include "regex.hh"
#include "logging.hh"
#if __linux__
#include "regex.hh"
#include "lix/libutil/cgroup.hh"
#include "lix/libutil/file-system.hh"
#include "lix/libutil/finally.hh"
@@ -10,32 +11,29 @@
#include <chrono>
#include <cmath>
#include <regex>
#include <unordered_set>
#include <thread>
#include <signal.h>
#include <dirent.h>
#include <mntent.h>
#include <sys/xattr.h>
namespace nix {
std::optional<Path> getCgroupFS()
static bool isCgroupDelegated(const Path & path)
{
static auto res = [&]() -> std::optional<Path> {
auto fp = fopen("/proc/mounts", "r");
if (!fp) return std::nullopt;
Finally delFP = [&]() { fclose(fp); };
while (auto ent = getmntent(fp))
if (std::string_view(ent->mnt_type) == "cgroup2")
return ent->mnt_dir;
char delegate_xattr;
return std::nullopt;
}();
return res;
if (getxattr(path.c_str(), "user.delegate", &delegate_xattr, sizeof(delegate_xattr)) >= 1) {
if (delegate_xattr != '1') {
throw Error("Unexpected `user.delegate` xattr: '%c'", delegate_xattr);
}
return true;
}
return false;
}
// FIXME: obsolete, check for cgroup2
std::map<std::string, std::string> getCgroups(const Path & cgroupFile)
static std::map<std::string, std::string> getCgroups(const Path & cgroupFile)
{
std::map<std::string, std::string> cgroups;
@@ -52,101 +50,342 @@ std::map<std::string, std::string> getCgroups(const Path & cgroupFile)
return cgroups;
}
static CgroupStats destroyCgroup(const Path & cgroup, bool returnStats)
static CgroupStats readStatistics(const std::filesystem::path & cgroup)
{
if (!pathExists(cgroup)) return {};
auto procsFile = cgroup + "/cgroup.procs";
if (!pathExists(procsFile))
throw Error("'%s' is not a cgroup", cgroup);
/* Use the fast way to kill every process in a cgroup, if
available. */
auto killFile = cgroup + "/cgroup.kill";
if (pathExists(killFile))
writeFile(killFile, "1");
/* Otherwise, manually kill every process in the subcgroups and
this cgroup. */
for (auto & entry : readDirectory(cgroup)) {
if (entry.type != DT_DIR) continue;
destroyCgroup(cgroup + "/" + entry.name, false);
}
int round = 1;
std::unordered_set<pid_t> pidsShown;
while (true) {
auto pids = tokenizeString<std::vector<std::string>>(readFile(procsFile));
if (pids.empty()) break;
if (round > 20)
throw Error("cannot kill cgroup '%s'", cgroup);
for (auto & pid_s : pids) {
pid_t pid;
if (auto o = string2Int<pid_t>(pid_s))
pid = *o;
else
throw Error("invalid pid '%s'", pid);
if (pidsShown.insert(pid).second) {
try {
auto cmdline = readFile(fmt("/proc/%d/cmdline", pid));
using namespace std::string_literals;
warn("killing stray builder process %d (%s)...",
pid, trim(replaceStrings(cmdline, "\0"s, " ")));
} catch (SysError &) {
}
}
// FIXME: pid wraparound
if (kill(pid, SIGKILL) == -1 && errno != ESRCH)
throw SysError("killing member %d of cgroup '%s'", pid, cgroup);
}
auto sleep = std::chrono::milliseconds((int) std::pow(2.0, std::min(round, 10)));
if (sleep.count() > 100)
printError("waiting for %d ms for cgroup '%s' to become empty", sleep.count(), cgroup);
std::this_thread::sleep_for(sleep);
round++;
}
CgroupStats stats;
if (returnStats) {
auto cpustatPath = cgroup + "/cpu.stat";
auto cpustatPath = cgroup / "cpu.stat";
if (pathExists(cpustatPath)) {
for (auto & line : tokenizeString<std::vector<std::string>>(readFile(cpustatPath), "\n")) {
std::string_view userPrefix = "user_usec ";
if (line.starts_with(userPrefix)) {
auto n = string2Int<uint64_t>(line.substr(userPrefix.size()));
if (n) stats.cpuUser = std::chrono::microseconds(*n);
if (pathExists(cpustatPath)) {
for (auto & line : tokenizeString<std::vector<std::string>>(readFile(cpustatPath), "\n")) {
std::string_view userPrefix = "user_usec ";
if (line.starts_with(userPrefix)) {
auto n = string2Int<uint64_t>(line.substr(userPrefix.size()));
if (n) {
stats.cpuUser = std::chrono::microseconds(*n);
}
}
std::string_view systemPrefix = "system_usec ";
if (line.starts_with(systemPrefix)) {
auto n = string2Int<uint64_t>(line.substr(systemPrefix.size()));
if (n) stats.cpuSystem = std::chrono::microseconds(*n);
std::string_view systemPrefix = "system_usec ";
if (line.starts_with(systemPrefix)) {
auto n = string2Int<uint64_t>(line.substr(systemPrefix.size()));
if (n) {
stats.cpuSystem = std::chrono::microseconds(*n);
}
}
}
}
if (rmdir(cgroup.c_str()) == -1)
throw SysError("deleting cgroup '%s'", cgroup);
return stats;
}
CgroupStats destroyCgroup(const Path & cgroup)
static void killCgroup(const std::string & name, const std::filesystem::path & cgroup)
{
return destroyCgroup(cgroup, true);
auto killFile = cgroup / "cgroup.kill";
if (pathExists(killFile))
writeFile(killFile, "1");
else {
throw SysError(
"cgroup '%s' at '%s' does not possess `cgroup.kill` ; are you running Lix on a kernel "
"older than 5.14 with cgroups?",
name,
cgroup
);
}
}
static std::optional<CgroupStats>
destroyCgroup(const std::string & name, const std::filesystem::path & aliveCgroup)
{
debug("destroying cgroup '%s' at '%s'", name, aliveCgroup);
if (!pathExists(aliveCgroup)) {
debug("destroying cgroup '%s' already destroyed", name);
return {};
}
auto procsFile = aliveCgroup / "cgroup.procs";
if (!pathExists(procsFile)) {
throw SysError(
"cgroup '%s' at '%s' has an invalid cgroup hierarchy (missing `cgroup.procs`)",
name,
aliveCgroup
);
}
killCgroup(name, aliveCgroup);
CgroupStats stats = readStatistics(aliveCgroup);
if (rmdir(aliveCgroup.c_str()) == -1) {
throw SysError("deleting cgroup '%s' at '%s'", name, aliveCgroup);
}
debug("cgroup '%s' destroyed", name);
return stats;
}
CgroupHierarchy getLocalHierarchy(const std::filesystem::path & cgroupFilesystem)
{
CgroupHierarchy hierarchy;
auto ourCgroups = getCgroups("/proc/self/cgroup");
auto ourCgroup = ourCgroups[""];
if (ourCgroup == "") {
throw Error("cannot determine cgroup name from '/proc/self/cgroup'");
}
if (ourCgroup[0] == '/') {
ourCgroup.erase(0, 1);
}
auto ourCgroupPath = (cgroupFilesystem / ourCgroup).lexically_normal();
if (!pathExists(ourCgroupPath)) {
throw Error("expected cgroup directory '%s'", ourCgroupPath);
}
hierarchy.ourCgroupPath = ourCgroupPath;
return hierarchy;
}
CgroupAvailableFeatureSet operator|(CgroupAvailableFeatureSet lhs, CgroupAvailableFeatureSet rhs)
{
return static_cast<CgroupAvailableFeatureSet>(
static_cast<std::underlying_type<CgroupAvailableFeatureSet>::type>(lhs)
| static_cast<std::underlying_type<CgroupAvailableFeatureSet>::type>(rhs)
);
}
CgroupAvailableFeatureSet &
operator|=(CgroupAvailableFeatureSet & lhs, CgroupAvailableFeatureSet rhs)
{
return lhs = lhs | rhs;
}
CgroupAvailableFeatureSet operator&(CgroupAvailableFeatureSet lhs, CgroupAvailableFeatureSet rhs)
{
return static_cast<CgroupAvailableFeatureSet>(
static_cast<std::underlying_type<CgroupAvailableFeatureSet>::type>(lhs)
& static_cast<std::underlying_type<CgroupAvailableFeatureSet>::type>(rhs)
);
}
bool hasCgroupFeature(CgroupAvailableFeatureSet featureSet, CgroupAvailableFeatureSet testedFeature)
{
return static_cast<std::underlying_type<CgroupAvailableFeatureSet>::type>(
featureSet & testedFeature
)
!= 0;
}
CgroupAvailableFeatureSet detectAvailableCgroupFeatures()
{
CgroupAvailableFeatureSet features = {};
auto fs = getCgroupFS();
if (fs && !fs->empty()) {
features |= CgroupAvailableFeatureSet::CGROUPV2;
auto localHierarchy = getLocalHierarchy(*fs);
if (pathExists(localHierarchy.ourCgroupPath / "cgroup.kill")) {
features |= CgroupAvailableFeatureSet::CGROUPV2_KILL;
}
if (isCgroupDelegated(localHierarchy.ourCgroupPath)) {
features |= CgroupAvailableFeatureSet::CGROUPV2_SELF_DELEGATED;
}
auto parentCgroupPath = localHierarchy.parentCgroupPath();
if (parentCgroupPath && isCgroupDelegated(*parentCgroupPath)) {
features |= CgroupAvailableFeatureSet::CGROUPV2_PARENT_DELEGATED;
}
}
return features;
}
static std::vector<std::string> readControllers(const std::filesystem::path & cgroupPath)
{
return tokenizeString<std::vector<std::string>>(
readFile(cgroupPath / "cgroup.controllers"), " "
);
}
AutoDestroyCgroup::AutoDestroyCgroup(
const std::filesystem::path & cgroupRecordsDir, std::string const & name
)
: name_(name)
{
auto cgroupFilesystem = getCgroupFS();
if (!cgroupFilesystem || cgroupFilesystem->empty()) {
throw Error("cannot determine the path to the cgroupv2 filesystem");
}
auto hierarchy = getLocalHierarchy(*cgroupFilesystem);
auto parentCgroupPath = hierarchy.parentCgroupPath();
assert(parentCgroupPath && "AutoDestroyCgroup cannot be used on the root cgroup");
/* We assert that the parent cgroup is delegated at this point.
* This is a responsibility of the caller. */
assert(isCgroupDelegated(*parentCgroupPath) && "parent cgroup was supposed to be delegated");
/* All available controllers on the parent cgroup path will be delegated.
* TODO(raito): implementing a filtering mechanism is for the future. */
controllers_ = readControllers(*parentCgroupPath);
/* Enable all the controllers */
writeFile(
*parentCgroupPath / "cgroup.subtree_control",
concatMapStringsSep(
" ",
controllers_,
[](const std::string & controller) -> std::string { return fmt("+%s", controller); }
)
);
cgroup_ = *parentCgroupPath / name;
/*
* In case we get interrupted without cleaning up the cgroup we just created,
* we look at Nix's state directory where we record all cgroups being used
* and destroy it before reusing it. */
cleansePreviousInstancesAndRecordOurself(cgroupRecordsDir);
}
AutoDestroyCgroup::AutoDestroyCgroup(
const std::filesystem::path & cgroupRecordsDir, std::string const & name, uid_t uid, gid_t gid
)
: AutoDestroyCgroup(cgroupRecordsDir, name)
{
auto path = std::get<std::filesystem::path>(cgroup_);
if (mkdir(path.c_str(), 0755) == -1) {
throw SysError(
"cannot create the top-level directory at '%s' for cgroup '%s'", path, name_
);
}
if (chown(path.c_str(), uid, gid) == -1) {
throw SysError(
"cannot delegate the top-level directory '%s' from cgroup '%s' to user uid=%d,gid=%d",
path,
name_,
uid,
gid
);
}
AutoCloseFD cgroupFd{open(path.c_str(), O_PATH | O_NOFOLLOW)};
for (auto node : {"procs", "threads", "subtree_control"}) {
if (fchownat(cgroupFd.get(), fmt("cgroup.%s", node).c_str(), uid, gid, 0) == -1) {
throw SysError(
"cannot delegate '%s' from cgroup '%s' to user uid=%d,gid=%d", node, name_, uid, gid
);
}
}
delegation_ = {.uid = uid, .gid = gid};
}
AutoDestroyCgroup::~AutoDestroyCgroup()
{
try {
std::visit(
overloaded{
[&, this](const Path & aliveCgroup) {
auto maybeStats = destroyCgroup(name_, aliveCgroup);
if (!maybeStats) {
warn(
"cgroup '%s' was destroyed unexpectedly (something else removed the "
"cgroup).",
aliveCgroup
);
}
},
[&](const CgroupStats & stats) {}
},
cgroup_
);
} catch (...) {
ignoreExceptionInDestructor();
}
}
void AutoDestroyCgroup::cleansePreviousInstancesAndRecordOurself(
const std::filesystem::path & cgroupRecordsDir
)
{
createDirs(cgroupRecordsDir);
auto cgroupFile = cgroupRecordsDir / name_;
if (pathExists(cgroupFile)) {
auto prevCgroup = readFile(cgroupFile);
warn("destroying past cgroup '%s' found in the state directory", name_);
destroyCgroup(fmt("past %s", name_), prevCgroup);
}
writeFile(cgroupFile, std::get<std::filesystem::path>(cgroup_).string());
stateRecord = AutoDelete(cgroupFile, false);
}
void AutoDestroyCgroup::adoptProcess(int pid)
{
auto path = std::get_if<std::filesystem::path>(&cgroup_);
if (!path) {
throw SysError("cgroup '%s' went away while adopting process '%d'", name_, pid);
}
writeFile(*path / "cgroup.procs", fmt("%d", pid));
}
void AutoDestroyCgroup::kill()
{
auto path = std::get_if<std::filesystem::path>(&cgroup_);
if (!path) {
throw SysError("killing cgroup '%s' but it went away", name_);
}
killCgroup(name_, *path);
}
CgroupStats AutoDestroyCgroup::getStatistics() const
{
/* Either:
* - we need to pull statistics from an alive cgroup.
* - we need to get historical statistics from a dead cgroup.
*/
return std::visit(
nix::overloaded{
[&](const Path & aliveCgroup) { return readStatistics(aliveCgroup); },
[&](const CgroupStats & stats) { return stats; }
},
cgroup_
);
}
std::optional<std::filesystem::path> getCgroupFS()
{
static auto res = [&]() -> std::optional<std::filesystem::path> {
auto fp = fopen("/proc/mounts", "r");
if (!fp) {
return {};
}
Finally delFP = [&]() { fclose(fp); };
while (auto ent = getmntent(fp)) {
if (std::string_view(ent->mnt_type) == "cgroup2") {
return {ent->mnt_dir};
}
}
return {};
}();
return res;
}
}
#endif
+182 -7
View File
@@ -1,18 +1,99 @@
#pragma once
///@file
#include "file-system.hh"
#if __linux__
#include <chrono>
#include <optional>
#include <filesystem>
#include <kj/common.h>
#include "lix/libutil/types.hh"
namespace nix {
std::optional<Path> getCgroupFS();
/* This represent a cgroup hierarchy.
* When doing cgroup delegation, it is necessary to ensure that all processes
* lives in the leaves of the cgroup tree.
*
* When Nix runs a build, the cgroup tree should be arranged so that:
*
* Nix parent process cgroup (e.g. `system.slice` on systemd)
* |
* |
* nix-daemon.service cgroup
* / \
* / \
* / \
* / \
* / \
* / \
* nix-build-uid-418238 cgroup supervisor cgroup
* | |
* derivation build processes |
* nix-daemon subdaemons
*
* Notice that if you put the nix-daemon subdaemons directly inside the `nix-daemon.service`
* cgroup, then there would be processes in _inner cgroups_, which is a violation of cgroup best
* practices (it opens up the path for children to compete for cgroup control).
*
* If you run systemd with `Delegate` and `DelegateSubgroup` options, the hierarchy is setup
* like the above automatically.
*
* If you are not in these conditions, you will need to write code to move the various daemon-like
* processes inside a sibling cgroup.
*
*/
struct CgroupHierarchy
{
std::filesystem::path ourCgroupPath;
std::optional<std::filesystem::path> parentCgroupPath() const
{
if (ourCgroupPath.has_parent_path()) {
return ourCgroupPath.parent_path();
} else {
return {};
}
}
};
std::map<std::string, std::string> getCgroups(const Path & cgroupFile);
/* Return the current process's view of the cgroup hierarchy, i.e.
* the parent process's cgroup and this current process's cgroup.
* */
CgroupHierarchy getLocalHierarchy(std::filesystem::path const & cgroupFilesystem);
/* Return a path to the cgroupv2 filesystem path, if it exist */
std::optional<std::filesystem::path> getCgroupFS();
/* Help detect the list of feature sets available
* for the running kernel. */
enum class CgroupAvailableFeatureSet : uint8_t {
/* cgroupvs2 were detected. */
CGROUPV2 = (1 << 0),
/* cgroupsv2 kill capability detected. The absence of this capability is unsupported.
* It appeared in kernel 5.14. */
CGROUPV2_KILL = (1 << 1),
/* Current process is delegated à la systemd-style, e.g.
* `user.delegate=1` was written as an xattr of the cgroup directory. */
CGROUPV2_SELF_DELEGATED = (1 << 2),
/* Current process' parent cgroup was delegated à la systemd-style. */
CGROUPV2_PARENT_DELEGATED = (1 << 3),
};
CgroupAvailableFeatureSet operator|(CgroupAvailableFeatureSet lhs, CgroupAvailableFeatureSet rhs);
CgroupAvailableFeatureSet &
operator|=(CgroupAvailableFeatureSet & lhs, CgroupAvailableFeatureSet rhs);
CgroupAvailableFeatureSet operator&(CgroupAvailableFeatureSet lhs, CgroupAvailableFeatureSet rhs);
/* Whether the `featureSet` given do possess the `testedFeature` in the set? */
bool hasCgroupFeature(
CgroupAvailableFeatureSet featureSet, CgroupAvailableFeatureSet testedFeature
);
/* Lix cgroup support relies on certain modern features to be available to avoid implementing many
* legacy code paths. */
CgroupAvailableFeatureSet detectAvailableCgroupFeatures();
struct CgroupStats
{
@@ -20,13 +101,107 @@ struct CgroupStats
};
/**
* Destroy the cgroup denoted by 'path'. The postcondition is that
* 'path' does not exist, and thus any processes in the cgroup have
* been killed. Also return statistics from the cgroup just before
* destruction.
* RAII class to hold an owned cgroup
* which will kill all processes under its hierarchy at destruction time.
*/
CgroupStats destroyCgroup(const Path & cgroup);
class AutoDestroyCgroup
{
private:
struct Delegation
{
/*
* Delegatee UID
*/
uid_t uid;
/*
* Delegatee GID
*/
gid_t gid;
};
/* Friendly name of this cgroup */
std::string name_;
/* Enabled controllers of this cgroup */
std::vector<std::string> controllers_;
/* A cgroup can be delegated to a pair of UID/GID */
std::optional<Delegation> delegation_;
/*
* Either the cgroup exist and is alive,
* either the cgroup was destroyed and we collected its statistics.
*/
std::variant<std::filesystem::path, CgroupStats> cgroup_;
/*
* Path to the state record of this cgroup's existence.
* This is used when the deletion process is interrupted
* for the next run.
*/
AutoDelete stateRecord;
/* Kill all processes under its hierarchy and tear down the cgroup */
void destroy();
/*
* Cleanse all previous instances of this cgroup where the deletion process
* might have been interrupted and record ourself in stead.
* If this cgroup deletion process does not run, on the next run, this will be
* reaped when the same name will be reused.
*/
void cleansePreviousInstancesAndRecordOurself(const std::filesystem::path & cgroupRecordsDir);
public:
KJ_DISALLOW_COPY(AutoDestroyCgroup);
explicit AutoDestroyCgroup(
const std::filesystem::path & cgroupRecordsDir, std::string const & name
);
/* Delegate this cgroup to the given UID and GID. */
explicit AutoDestroyCgroup(
const std::filesystem::path & cgroupRecordsDir,
std::string const & name,
uid_t uid,
gid_t gid
);
~AutoDestroyCgroup();
std::optional<Path> path() const
{
return std::visit(
nix::overloaded{
[&](const Path & path) -> std::optional<Path> { return {path}; },
[&](const CgroupStats &) -> std::optional<Path> { return {}; }
},
cgroup_
);
}
const std::string & name() const
{
return name_;
}
const std::vector<std::string> & controllers() const
{
return controllers_;
}
std::optional<Delegation> delegation() const
{
return delegation_;
}
/* Adopt a process in this cgroup. */
void adoptProcess(int pid);
/* Kill all processes under the control group. */
void kill();
/* Return all statistics inside of this cgroup. */
CgroupStats getStatistics() const;
};
}
#endif
+2 -5
View File
@@ -32,11 +32,8 @@ unsigned int getMaxCPU()
auto cgroupFS = getCgroupFS();
if (!cgroupFS) return 0;
auto cgroups = getCgroups("/proc/self/cgroup");
auto cgroup = cgroups[""];
if (cgroup == "") return 0;
auto cpuFile = *cgroupFS + "/" + cgroup + "/cpu.max";
auto localHierarchy = getLocalHierarchy(*cgroupFS);
auto cpuFile = localHierarchy.ourCgroupPath / "cpu.max";
auto cpuMax = readFile(cpuFile);
auto cpuMaxParts = tokenizeString<std::vector<std::string>>(cpuMax, " \n");
@@ -0,0 +1,6 @@
---
name: cgroups
internalName: Cgroups
---
Allows Nix to execute builds inside cgroups. See
the [`use-cgroups`](../command-ref/conf-file.md#conf-use-cgroups) setting for details.
+1
View File
@@ -142,6 +142,7 @@ libutil_headers = files(
experimental_feature_definitions = files(
# keep-sorted start
'experimental-features/auto-allocate-uids.md',
'experimental-features/cgroups.md',
'experimental-features/coerce-integers.md',
'experimental-features/daemon-trust-override.md',
'experimental-features/fetch-closure.md',
+2
View File
@@ -11,6 +11,8 @@ ExecStart=@@bindir@/nix-daemon nix-daemon --daemon
KillMode=process
LimitNOFILE=1048576
TasksMax=1048576
Delegate=yes
DelegateSubgroup=supervisor
[Install]
WantedBy=multi-user.target
+40
View File
@@ -0,0 +1,40 @@
{ nixpkgs, ... }:
{
name = "cgroups";
nodes =
{
host =
{ config, pkgs, ... }:
{ virtualisation.additionalPaths = [ pkgs.stdenvNoCC ];
nix.extraOptions =
''
extra-experimental-features = nix-command auto-allocate-uids cgroups
extra-system-features = uid-range
'';
nix.settings.use-cgroups = true;
nix.nixPath = [ "nixpkgs=${nixpkgs}" ];
};
};
testScript = { nodes }: ''
start_all()
host.wait_for_unit("multi-user.target")
# Start build in background
host.execute("NIX_REMOTE=daemon nix build --use-cgroups --auto-allocate-uids --file ${./hang.nix} >&2 &")
service = "/sys/fs/cgroup/system.slice/nix-daemon.service"
# Wait for cgroups to be created
host.succeed(f"until [ -e {service}/supervisor ]; do sleep 1; done", timeout=30)
host.succeed(f"until [ -e {service}/nix-build-uid-* ]; do sleep 1; done", timeout=30)
# Check that there aren't processes where there shouldn't be, and that there are where there should be
host.succeed(f'[ -z "$(cat {service}/cgroup.procs)" ]')
host.succeed(f'[ -n "$(cat {service}/supervisor/cgroup.procs)" ]')
host.succeed(f'[ -n "$(cat {service}/nix-build-uid-*/cgroup.procs)" ]')
'';
}
+10
View File
@@ -0,0 +1,10 @@
{ }:
with import <nixpkgs> {};
runCommand "hang"
{ requiredSystemFeatures = "uid-range";
}
''
sleep infinity
''
+28 -16
View File
@@ -15,10 +15,13 @@
(import ./systemd-nspawn.nix { inherit nixpkgs; }).toplevel
];
virtualisation.memorySize = 4096;
nix.settings.substituters = lib.mkForce [ ];
nix.settings = {
substituters = lib.mkForce [ ];
use-cgroups = true;
};
nix.extraOptions =
''
extra-experimental-features = nix-command auto-allocate-uids
extra-experimental-features = nix-command auto-allocate-uids cgroups
extra-system-features = uid-range
'';
nix.nixPath = [ "nixpkgs=${nixpkgs}" ];
@@ -33,30 +36,39 @@
# Test that 'id' gives the expected result in various configurations.
# Existing UIDs, sandbox.
host.succeed("nix build -v --no-auto-allocate-uids --sandbox -L --offline --impure --file ${./id-test.nix} --argstr name id-test-1")
host.succeed("nix build -v --no-auto-allocate-uids --no-use-cgroups --sandbox -L --offline --impure --file ${./id-test.nix} --argstr name id-test-1")
host.succeed("[[ $(cat ./result) = 'uid=1000(nixbld) gid=100(nixbld) groups=100(nixbld)' ]]")
# Existing UIDs, no sandbox.
host.succeed("nix build -v --no-auto-allocate-uids --no-sandbox -L --offline --impure --file ${./id-test.nix} --argstr name id-test-2")
host.succeed("nix build -v --no-auto-allocate-uids --no-use-cgroups --no-sandbox -L --offline --impure --file ${./id-test.nix} --argstr name id-test-2")
host.succeed("[[ $(cat ./result) = 'uid=30001(nixbld1) gid=30000(nixbld) groups=30000(nixbld)' ]]")
# Auto-allocated UIDs, sandbox.
host.succeed("nix build -v --auto-allocate-uids --sandbox -L --offline --impure --file ${./id-test.nix} --argstr name id-test-3")
host.succeed("[[ $(cat ./result) = 'uid=1000(nixbld) gid=100(nixbld) groups=100(nixbld)' ]]")
# Auto-allocated UIDs, sandbox but no cgroups.
host.fail("nix build -v --auto-allocate-uids --no-use-cgroups --sandbox -L --offline --impure --file ${./id-test.nix} --argstr name id-test-3")
# Auto-allocated UIDs, no sandbox.
host.succeed("nix build -v --auto-allocate-uids --no-sandbox -L --offline --impure --file ${./id-test.nix} --argstr name id-test-4")
host.succeed("[[ $(cat ./result) = 'uid=872415232 gid=30000(nixbld) groups=30000(nixbld)' ]]")
# Auto-allocated UIDs, no sandbox but no cgroups.
host.fail("nix build -v --auto-allocate-uids --no-use-cgroups --no-sandbox -L --offline --impure --file ${./id-test.nix} --argstr name id-test-4")
# Auto-allocated UIDs, UID range, sandbox.
host.succeed("nix build -v --auto-allocate-uids --sandbox -L --offline --impure --file ${./id-test.nix} --argstr name id-test-5 --arg uidRange true")
# Auto-allocated UIDs, UID range, sandbox, via daemon.
host.succeed("NIX_REMOTE=daemon nix build -v --auto-allocate-uids --sandbox -L --offline --impure --file ${./id-test.nix} --argstr name id-test-5 --arg uidRange true")
host.succeed("[[ $(cat ./result) = 'uid=0(root) gid=0(root) groups=0(root)' ]]")
# Auto-allocated UIDs, UID range, no sandbox.
host.fail("nix build -v --auto-allocate-uids --no-sandbox -L --offline --impure --file ${./id-test.nix} --argstr name id-test-6 --arg uidRange true")
# Auto-allocated UIDs, UID range, no sandbox, with and without daemon.
host.fail("NIX_REMOTE=daemon nix build -v --auto-allocate-uids --no-sandbox -L --offline --impure --file ${./id-test.nix} --argstr name id-test-6 --arg uidRange true")
host.fail("nix build -v --auto-allocate-uids --no-use-cgroups --no-sandbox -L --offline --impure --file ${./id-test.nix} --argstr name id-test-7 --arg uidRange true")
# Run systemd-nspawn in a Nix build.
host.succeed("nix build -v --auto-allocate-uids --sandbox -L --offline --impure --file ${./systemd-nspawn.nix} --argstr nixpkgs ${nixpkgs}")
# Run systemd-nspawn in a Nix build, via daemon.
host.succeed("NIX_REMOTE=daemon nix build -vv --auto-allocate-uids --sandbox -L --offline --impure --file ${./systemd-nspawn.nix} --argstr nixpkgs ${nixpkgs}")
host.succeed("[[ $(cat ./result/msg) = 'Hello World' ]]")
# Auto-allocated UIDs, UID range, sandbox, WITHOUT daemon (so-called: Nix as root), IN `systemd-run` transient scope.
# `systemd-run` is CRITICAL to run this successfully.
host.succeed("systemd-run --same-dir --wait -p Delegate=yes -p DelegateSubgroup=supervisor nix build -v --auto-allocate-uids --sandbox -L --offline --impure --file ${./id-test.nix} --argstr name id-test-8 --arg uidRange true -I nixpkgs=${nixpkgs}")
host.succeed("[[ $(cat ./result) = 'uid=0(root) gid=0(root) groups=0(root)' ]]")
# Run systemd-nspawn in a Nix build, WITHOUT daemon (so-called: Nix as root) IN `systemd-run`.
# `systemd-run` is CRITICAL to run this successfully.
host.succeed("systemd-run --same-dir --wait -p Delegate=yes -p DelegateSubgroup=supervisor nix build -v --auto-allocate-uids --sandbox -L --offline --impure --file ${./systemd-nspawn.nix} --argstr nixpkgs ${nixpkgs} -I nixpkgs=${nixpkgs}")
host.succeed("[[ $(cat ./result/msg) = 'Hello World' ]]")
'';
+8
View File
@@ -0,0 +1,8 @@
{ name, uidRange ? false }:
with import <nixpkgs> {};
runCommand name
{ requiredSystemFeatures = if uidRange then ["uid-range"] else [];
}
"id; id > $out"
+80
View File
@@ -0,0 +1,80 @@
{ nixpkgs }:
let
machine = { config, pkgs, ... }:
{
system.stateVersion = "22.05";
boot.isContainer = true;
systemd.services.console-getty.enable = false;
networking.dhcpcd.enable = false;
services.httpd = {
enable = true;
adminAddr = "nixos@example.org";
};
systemd.services.test = {
wantedBy = [ "multi-user.target" ];
after = [ "httpd.service" ];
script = ''
source /.env
echo "Hello World" > $out/msg
ls -lR /dev > $out/dev
${pkgs.curl}/bin/curl -sS --fail http://localhost/ > $out/page.html
'';
unitConfig = {
FailureAction = "exit-force";
FailureActionExitStatus = 42;
SuccessAction = "exit-force";
};
};
};
cfg = (import (nixpkgs + "/nixos/lib/eval-config.nix") {
modules = [ machine ];
system = "x86_64-linux";
});
config = cfg.config;
in
with cfg._module.args.pkgs;
runCommand "test"
{ buildInputs = [ config.system.path ];
requiredSystemFeatures = [ "uid-range" ];
toplevel = config.system.build.toplevel;
}
''
root=$(pwd)/root
mkdir -p $root $root/etc
export > $root/.env
# Make /run a tmpfs to shut up a systemd warning.
mkdir /run
mount -t tmpfs none /run
mount -t cgroup2 none /sys/fs/cgroup
mkdir -p $out
chmod +w /etc
touch /etc/os-release
echo a5ea3f98dedc0278b6f3cc8c37eeaeac > /etc/machine-id
SYSTEMD_NSPAWN_UNIFIED_HIERARCHY=1 \
${config.systemd.package}/bin/systemd-nspawn \
--keep-unit \
-M ${config.networking.hostName} -D "$root" \
--register=no \
--resolv-conf=off \
--bind-ro=/nix/store \
--bind=$out \
--bind=/proc:/run/host/proc \
--bind=/sys:/run/host/sys \
--private-network \
$toplevel/init
''
+4
View File
@@ -150,6 +150,10 @@ in
tarballFlakes = runNixOSTestFor "x86_64-linux" ./tarball-flakes.nix;
containers = runNixOSTestFor "x86_64-linux" ./containers/containers.nix;
cgroups = runNixOSTestFor "x86_64-linux" ./cgroups;
setuid = lib.genAttrs
["i686-linux" "x86_64-linux"]
(system: runNixOSTestFor system ./setuid/setuid.nix);