]> git.ipfire.org Git - pakfire.git/commitdiff
python: Refactor cgroups
authorMichael Tremer <michael.tremer@ipfire.org>
Mon, 11 Jan 2021 15:15:47 +0000 (15:15 +0000)
committerMichael Tremer <michael.tremer@ipfire.org>
Mon, 11 Jan 2021 15:15:47 +0000 (15:15 +0000)
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 <michael.tremer@ipfire.org>
src/pakfire/builder.py
src/pakfire/cgroup.py
src/pakfire/util.py

index 5b25d5d8cf6c5a60137db4e44ed7ea69b4c26479..ca45d0e56e40af96794d7ee48713fdbb3cf8350b 100644 (file)
@@ -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")
index a2f932840e9ddab8c33c7e1b1e08ef174a54abe9..e0ad2e7a641a99950b91782fd7cc34f536581e44 100644 (file)
 #!/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 <http://www.gnu.org/licenses/>.       #
+#                                                                             #
+###############################################################################
 
+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)
index 66b4e73cd57bfae72a2d3acd4dc8f636643a3eb0..a414e3642bf8ebeaf6f94cd695be9e3b9a06251c 100644 (file)
@@ -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.