From: Michael Tremer Date: Mon, 11 Jan 2021 15:15:47 +0000 (+0000) Subject: python: Refactor cgroups X-Git-Tag: 0.9.28~1285^2~900 X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=f42e623d1ed64f8bf345b672652c262295b320ef;p=pakfire.git python: Refactor cgroups This is now using the newer cgroup v2 API and we require cgroups on all systems to avoid shipping any compatibility code. Resource limits have to be implemented, yet. Signed-off-by: Michael Tremer --- diff --git a/src/pakfire/builder.py b/src/pakfire/builder.py index 5b25d5d8c..ca45d0e56 100644 --- a/src/pakfire/builder.py +++ b/src/pakfire/builder.py @@ -32,7 +32,7 @@ import uuid from . import _pakfire from . import base -from . import cgroup +from . import cgroup as cgroups from . import config from . import downloaders from . import logger @@ -105,8 +105,8 @@ class Builder(object): if not _pakfire.arch_supported_by_host(self.arch): raise BuildError(_("Cannot build for %s on this host") % self.arch) - # Initialize a cgroup (if supported) - self.cgroup = self.make_cgroup() + # Initialize cgroups + self.cgroup = self._make_cgroup() # Unshare namepsace. # If this fails because the kernel has no support for CLONE_NEWIPC or CLONE_NEWUTS, @@ -148,26 +148,11 @@ class Builder(object): self.log.debug("Leaving %s" % self.path) # Kill all remaining processes in the build environment - if self.cgroup: - # Move the builder process out of the cgroup. - self.cgroup.migrate_task(self.cgroup.parent, os.getpid()) + self.cgroup.killall() - # Kill all still running processes in the cgroup. - self.cgroup.kill_and_wait() - - # Remove cgroup and all parent cgroups if they are empty. - self.cgroup.destroy() - - parent = self.cgroup.parent - while parent: - if not parent.is_empty(recursive=True): - break - - parent.destroy() - parent = parent.parent - - else: - util.orphans_kill(self.path) + # Destroy the cgroup + self.cgroup.destroy() + self.cgroup = None # Umount the build environment self._umountall() @@ -197,25 +182,21 @@ class Builder(object): # If no logile was given, we use the root logger. self.log = logging.getLogger("pakfire") - def make_cgroup(self): + def _make_cgroup(self): """ - Initialize cgroup (if the system supports it). + Initialises a cgroup so that we can enforce resource limits + and can identify processes belonging to this build environment. """ - if not cgroup.supported(): - return - - # Search for the cgroup this process is currently running in. - parent_cgroup = cgroup.find_by_pid(os.getpid()) - if not parent_cgroup: - return + # Find our current group + parent = cgroups.get_own_group() - # Create our own cgroup inside the parent cgroup. - c = parent_cgroup.create_child_cgroup("pakfire/builder/%s" % self.build_id) + # Create a sub-group + cgroup = parent.create_subgroup("pakfire-%s" % self.build_id) - # Attach the pakfire-builder process to the group. - c.attach() + # Make this process join the new group + cgroup.attach_self() - return c + return cgroup def lock(self): filename = os.path.join(self.path, ".lock") diff --git a/src/pakfire/cgroup.py b/src/pakfire/cgroup.py index a2f932840..e0ad2e7a6 100644 --- a/src/pakfire/cgroup.py +++ b/src/pakfire/cgroup.py @@ -1,107 +1,119 @@ #!/usr/bin/python3 +############################################################################### +# # +# Pakfire - The IPFire package management system # +# Copyright (C) 2021 Pakfire development team # +# # +# This program is free software: you can redistribute it and/or modify # +# it under the terms of the GNU General Public License as published by # +# the Free Software Foundation, either version 3 of the License, or # +# (at your option) any later version. # +# # +# This program is distributed in the hope that it will be useful, # +# but WITHOUT ANY WARRANTY; without even the implied warranty of # +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # +# GNU General Public License for more details. # +# # +# You should have received a copy of the GNU General Public License # +# along with this program. If not, see . # +# # +############################################################################### +import logging import os -import shutil import signal import time -import logging log = logging.getLogger("pakfire.cgroups") -CGROUP_MOUNTPOINT = "/sys/fs/cgroup/systemd" - -class CGroup(object): - def __init__(self, name): - assert supported(), "cgroups are not supported by this kernel" +def find_group_by_pid(pid): + """ + Returns the cgroup of the process currently running with pid + """ + with open("/proc/%s/cgroup" % pid) as f: + for line in f: + if not line.startswith("0::"): + continue - self.name = name - self.path = os.path.join(CGROUP_MOUNTPOINT, name) - self.path = os.path.abspath(self.path) + # Clean up path + path = line[3:].rstrip() - # The parent cgroup. - self._parent = None + return CGroup(path) - # Initialize the cgroup. - self.create() +def get_own_group(): + """ + Returns the cgroup of the process we are currently in + """ + pid = os.getpid() - log.debug("cgroup '%s' has been successfully initialized." % self.name) + return find_group_by_pid(pid) - def __repr__(self): - return "<%s %s>" % (self.__class__.__name__, self.name) +class CGroup(object): + """ + cgroup controller + """ + root = "/sys/fs/cgroup/unified" - def __cmp__(self, other): - return cmp(self.path, other.path) + def __init__(self, path): + if not path.startswith("/"): + raise ValueError("Invalid cgroup path") - @classmethod - def find_by_pid(cls, pid): - """ - Returns the cgroup of the process with the given PID. + # Store path + self.path = path - If no cgroup can be found, None is returned. - """ - if not cls.supported: - return + # Make absolute path + self.abspath = "%s%s" % (self.root, self.path) - for d, subdirs, files in os.walk(CGROUP_MOUNTPOINT): - if not "tasks" in files: - continue + if not os.path.isdir(self.abspath): + raise ValueError("Non-existant cgroup") - cgroup = cls(d) - if pid in cgroup.tasks: - return cgroup + def __repr__(self): + return "<%s path=%s>" % (self.__class__.__name__, self.path) - @staticmethod - def supported(): + def _open(self, path, mode="r"): """ - Returns true, if this hosts supports cgroups. + Opens a file in this cgroup for reading of writing """ - return os.path.ismount(CGROUP_MOUNTPOINT) + # Make full path + path = os.path.join(self.abspath, path) - def create(self): + return open(path, mode) + + @property + def parent(self): """ - Creates the filesystem structure for - the cgroup. + Returns the parent group """ - if os.path.exists(self.path): - return + return self.__class__(os.path.dirname(self.path)) - log.debug("cgroup '%s' has been created." % self.name) - os.makedirs(self.path) + def create_subgroup(self, name): + path = os.path.join(self.path, name) - def create_child_cgroup(self, name): - """ - Create a child cgroup with name relative to the - parent cgroup. - """ - return self.__class__(os.path.join(self.name, name)) + # Create directory + try: + os.mkdir("%s%s" % (self.root, path)) - def attach(self): - """ - Attaches this task to the cgroup. - """ - pid = os.getpid() - self.attach_task(pid) + log.debug("New cgroup '%s' created" % path) + + # Silently continue if groups already exists + except FileExistsError: + pass + + # Return new instance + return self.__class__(path) def destroy(self): """ - Deletes the cgroup. - - All running tasks will be migrated to the parent cgroup. + Destroys this cgroup """ - # Don't delete the root cgroup. - if self == self.root: - return + log.debug("Destroying cgroup %s" % self.path) - # Move all tasks to the parent. + # Move whatever is left to the parent group self.migrate(self.parent) - # Just make sure the statement above worked. - assert self.is_empty(recursive=True), "cgroup must be empty to be destroyed" - assert not self.processes - - # Remove the file tree. + # Remove the file tree try: - os.rmdir(self.path) + os.rmdir(self.abspath) except OSError as e: # Ignore "Device or resource busy". if e.errno == 16: @@ -109,224 +121,87 @@ class CGroup(object): raise - def _read(self, file): - """ - Reads the contect of file in the cgroup directory. - """ - file = os.path.join(self.path, file) - - with open(file) as f: - return f.read() - - def _read_pids(self, file): + @property + def pids(self): """ - Reads file and interprets the lines as a sorted list. + Returns the PIDs of all currently in this group running processes """ - _pids = self._read(file) - pids = [] - for pid in _pids.splitlines(): - try: - pid = int(pid) - except ValueError: - continue - - if pid in pids: - continue + with self._open("cgroup.procs") as f: + for line in f: + try: + pid = int(line) + except (TypeError, ValueError): + pass - pids.append(pid) + pids.append(pid) - return sorted(pids) + return pids - def _write(self, file, what): + def attach_process(self, pid): """ - Writes what to file in the cgroup directory. + Attaches the process PID to this group """ - file = os.path.join(self.path, file) - - f = open(file, "w") - f.write("%s" % what) - f.close() + log.debug("Attaching process %s to group %s" % (pid, self.path)) - @property - def root(self): - if self.parent: - return self.parent.root - - return self - - @property - def parent(self): - # Cannot go above CGROUP_MOUNTPOINT. - if self.path == CGROUP_MOUNTPOINT: - return + with self._open("cgroup.procs", "w") as f: + f.write("%s\n" % pid) - if self._parent is None: - parent_name = os.path.dirname(self.name) - self._parent = CGroup(parent_name) - - return self._parent - - @property - def subgroups(self): - subgroups = [] - - for name in os.listdir(self.path): - path = os.path.join(self.path, name) - if not os.path.isdir(path): - continue - - name = os.path.join(self.name, name) - group = CGroup(name) - - subgroups.append(group) - - return subgroups - - def is_empty(self, recursive=False): + def attach_self(self): """ - Returns True if the cgroup is empty. - - Otherwise returns False. + Attaches this process to the group """ - if self.tasks: - return False + return self.attach_process(os.getpid()) - if recursive: - for subgroup in self.subgroups: - if subgroup.is_empty(recursive=recursive): - continue - - return False + def detach_self(self): + pid = os.getpid() - return True + # Move process to parent + if pid in self.pids: + self.parent.attach_process(pid) - @property - def tasks(self): + def migrate(self, group): """ - Returns a list of pids of all tasks - in this process group. + Migrates all processes to the given group """ - return self._read_pids("tasks") + for pid in self.pids: + group.attach_process(pid) - @property - def processes(self): + def _kill(self, signal=signal.SIGTERM): """ - Returns a list of pids of all processes - that are currently running within the cgroup. + Sends signal to all processes in this cgroup """ - return self._read_pids("cgroup.procs") + for pid in self.pids: + log.debug("Sending signal %s to process %s" % (signal, pid)) - def attach_task(self, pid): - """ - Attaches the task with the given PID to - the cgroup. - """ - self._write("tasks", pid) + try: + os.kill(pid, signal) + except OSError as e: + # Skip "No such process" error + if e.errno == 3: + pass + else: + raise + + # Return True if there are any processes left + return not self.pids - def migrate_task(self, other, pid): + def killall(self, timeout=10): """ - Migrates a single task to another cgroup. + Kills all processes """ - other.attach_task(pid) - - def migrate(self, other): - if self.is_empty(recursive=True): - return - - log.info("Migrating all tasks from '%s' to '%s'." \ - % (self.name, other.name)) + self.detach_self() - while True: - # Migrate all tasks to the new cgroup. - for task in self.tasks: - self.migrate_task(other, task) - - # Also do that for all subgroups. - for subgroup in self.subgroups: - subgroup.migrate(other) - - if self.is_empty(): - break - - def kill(self, sig=signal.SIGTERM, recursive=True): - killed_processes = [] - - mypid = os.getpid() - - while True: - for proc in self.processes: - # Don't kill myself. - if proc == mypid: - continue - - # Skip all processes that have already been killed. - if proc in killed_processes: - continue - - # If we haven't killed the process yet, we kill it. - log.debug("Sending signal %s to process %s..." % (sig, proc)) - - try: - os.kill(proc, sig) - except OSError as e: - # Skip "No such process" error - if e.errno == 3: - pass - else: - raise - - # Save all killed processes to a list. - killed_processes.append(proc) - - else: - # If no processes are left to be killed, we end the loop. - break - - # Nothing more to do if not in recursive mode. - if not recursive: - return - - # Kill all processes in subgroups as well. - for subgroup in self.subgroups: - subgroup.kill(sig=sig, recursive=recursive) - - def kill_and_wait(self): - # Safely kill all processes in the cgroup. - # This first sends SIGTERM and then checks 8 times - # after 200ms whether the group is empty. If not, - # everything what's still in there gets SIGKILL - # and it is five more times checked if everything - # went away. - - sig = None - for i in range(15): - if i == 0: - sig = signal.SIGTERM - elif i == 9: - sig = signal.SIGKILL + for i in range(timeout * 10): + if i >= 10: + s = signal.SIGKILL else: - sig = None + s = signal.SIGTERM - # If no signal is given and there are no processes - # left, our job is done and we can exit. - if not self.processes: + # Send signal and end loop when no processes are left + if self._kill(signal=s): break - if sig: - # Send sig to all processes in the cgroup. - log.debug("Sending signal %s to all processes in '%s'." % (sig, self.name)) - self.kill(sig=sig, recursive=True) - - # Sleep for 200ms. - time.sleep(0.2) - - return self.is_empty() - - -# Alias for simple access to check if this host supports cgroups. -supported = CGroup.supported - -# Alias for simple access to find the cgroup of a certain process. -find_by_pid = CGroup.find_by_pid + # Sleep for 100ms + time.sleep(0.1) diff --git a/src/pakfire/util.py b/src/pakfire/util.py index 66b4e73cd..a414e3642 100644 --- a/src/pakfire/util.py +++ b/src/pakfire/util.py @@ -186,35 +186,6 @@ def text_wrap(s, length=65): #return "\n".join(lines) return lines -def orphans_kill(root, killsig=signal.SIGTERM): - """ - kill off anything that is still chrooted. - """ - log.debug(_("Killing orphans...")) - - killed = False - for fn in [d for d in os.listdir("/proc") if d.isdigit()]: - try: - r = os.readlink("/proc/%s/root" % fn) - if os.path.realpath(root) == os.path.realpath(r): - log.warning(_("Process ID %s is still running in chroot. Killing...") % fn) - killed = True - - pid = int(fn, 10) - os.kill(pid, killsig) - os.waitpid(pid, 0) - except OSError as e: - pass - - # If something was killed, wait a couple of seconds to make sure all file descriptors - # are closed and we can proceed with umounting the filesystems. - if killed: - log.warning(_("Waiting for processes to terminate...")) - time.sleep(3) - - # Calling ourself again to make sure all processes were killed. - orphans_kill(root, killsig=killsig) - def scriptlet_interpreter(scriptlet): """ This function returns the interpreter of a scriptlet.