From b0b2dc97cd8a1ea988577080de9841d238f1a006 Mon Sep 17 00:00:00 2001 From: Adar Dembo Date: Thu, 7 Feb 2013 17:18:45 -0800 Subject: [PATCH] Add very basic Linux control groups support Control groups (cgroups) are a Linux kernel feature that can be used for resource management and isolation between processes. In my case, I have an out-of-band application that manages the cgroups themselves, but as I use supervisor to manage process lifecycle, it's well positioned to attach processes to cgroups, and to reattach them if they're autorestarted. Here we do that, with two main changes: 1. After forking a process, supervisor will attach it to every desired cgroup by writing its pid to each 'tasks' pseudofile. Note that cgroup membership is per-thread (not per-process), but at this stage in the process' life, it should be single threaded. 2. When queried for process information, supervisor will also mention if the process has cgroups or not. Ideally we'd take into account whether attaching actually succeeded, but effecting that change in the parent is tough from the forked child. --- docs/api.rst | 7 ++++++- supervisor/options.py | 6 ++++-- supervisor/process.py | 31 ++++++++++++++++++++++++++++ supervisor/rpcinterface.py | 1 + supervisor/tests/base.py | 3 ++- supervisor/tests/test_options.py | 12 +++++++++-- supervisor/tests/test_process.py | 23 +++++++++++++++++++++ supervisor/tests/test_supervisord.py | 3 ++- 8 files changed, 79 insertions(+), 7 deletions(-) diff --git a/docs/api.rst b/docs/api.rst index 3481cca78..d3aa11ccf 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -231,7 +231,8 @@ Process Control 'exitstatus': 0, 'stdout_logfile': '/path/to/stdout-log', 'stderr_logfile': '/path/to/stderr-log', - 'pid': 1} + 'pid': 1, + 'has_cgroups' 0} .. describe:: name @@ -286,6 +287,10 @@ Process Control UNIX process ID (PID) of the process, or 0 if the process is not running. + .. describe:: has_cgroups + + 1 if the process was configured to attach to at least one control + group, or 0 if not. .. automethod:: getAllProcessInfo diff --git a/supervisor/options.py b/supervisor/options.py index fc3f426e9..e46c72891 100644 --- a/supervisor/options.py +++ b/supervisor/options.py @@ -794,6 +794,7 @@ def processes_from_section(self, parser, section, group_name, serverurl = get(section, 'serverurl', None) if serverurl and serverurl.strip().upper() == 'AUTO': serverurl = None + cgroups = list_of_strings(get(section, 'cgroups', None)) umask = get(section, 'umask', None) if umask is not None: @@ -883,7 +884,8 @@ def processes_from_section(self, parser, section, group_name, exitcodes=exitcodes, redirect_stderr=redirect_stderr, environment=environment, - serverurl=serverurl) + serverurl=serverurl, + cgroups=cgroups) programs.append(pconfig) @@ -1600,7 +1602,7 @@ class ProcessConfig(Config): 'stderr_logfile_backups', 'stderr_logfile_maxbytes', 'stderr_events_enabled', 'stopsignal', 'stopwaitsecs', 'stopasgroup', 'killasgroup', - 'exitcodes', 'redirect_stderr' ] + 'exitcodes', 'redirect_stderr', 'cgroups' ] optional_param_names = [ 'environment', 'serverurl' ] def __init__(self, options, **params): diff --git a/supervisor/process.py b/supervisor/process.py index c33a68154..98061bb84 100644 --- a/supervisor/process.py +++ b/supervisor/process.py @@ -289,6 +289,19 @@ def _spawn_as_child(self, filename, argv): options.setpgrp() self._prepare_child_fds() # sending to fd 2 will put this output in the stderr log + + # Attach the child to cgroups before dropping privileges + # (may need to be root to do this). + msg = self.attach_cgroups() + if msg: + cgroups = self.config.cgroups + s = 'supervisor: error attaching process to cgroups %s ' % cgroups + options.write(2, s) + options.write(2, "(%s)\n" % msg) + # It would be great to actually affect parent state here + # (i.e. the parent should know that we haven't actually + # attached), but this only logs. + msg = self.set_uid() if msg: uid = self.config.uid @@ -491,6 +504,24 @@ def set_uid(self): msg = self.config.options.dropPrivileges(self.config.uid) return msg + def attach_cgroups(self): + # Doesn't undo in the event of partial failure (e.g. attach succeeds + # for one but not the other). + for cgroup in self.config.cgroups: + tasks_path = os.path.join(cgroup, "tasks") + if not os.path.isfile(tasks_path): + return "Can't find cgroup path %s" % tasks_path + try: + tasks = open(tasks_path, "w") + try: + # We assume that we're only called during process creation, + # so we're single-threaded and can write just our PID. + tasks.write(str(self.config.options.get_pid())) + finally: + tasks.close() + except IOError: + return "Couldn't attach process to cgroup %s" % tasks_path + def __cmp__(self, other): # sort by priority return cmp(self.config.priority, other.config.priority) diff --git a/supervisor/rpcinterface.py b/supervisor/rpcinterface.py index 92797a87f..80b675f60 100644 --- a/supervisor/rpcinterface.py +++ b/supervisor/rpcinterface.py @@ -527,6 +527,7 @@ def getProcessInfo(self, name): 'stdout_logfile':stdout_logfile, 'stderr_logfile':stderr_logfile, 'pid':process.pid, + 'has_cgroups':int(len(process.config.cgroups) > 0), } description = self._interpretProcessInfo(info) diff --git a/supervisor/tests/base.py b/supervisor/tests/base.py index 1a5c6de06..80953118b 100644 --- a/supervisor/tests/base.py +++ b/supervisor/tests/base.py @@ -484,7 +484,7 @@ def __init__(self, options, name, command, directory=None, umask=None, stderr_logfile_backups=0, stderr_logfile_maxbytes=0, redirect_stderr=False, stopsignal=None, stopwaitsecs=10, stopasgroup=False, killasgroup=False, - exitcodes=(0,2), environment=None, serverurl=None): + exitcodes=(0,2), environment=None, serverurl=None, cgroups=[]): self.options = options self.name = name self.command = command @@ -518,6 +518,7 @@ def __init__(self, options, name, command, directory=None, umask=None, self.umask = umask self.autochildlogs_created = False self.serverurl = serverurl + self.cgroups = cgroups def create_autochildlogs(self): self.autochildlogs_created = True diff --git a/supervisor/tests/test_options.py b/supervisor/tests/test_options.py index c3cf03a29..d1f579900 100644 --- a/supervisor/tests/test_options.py +++ b/supervisor/tests/test_options.py @@ -263,6 +263,7 @@ def test_options(self): numprocs = 2 command = /bin/cat autorestart=unexpected + cgroups=/path/one,/path/two """ % {'tempdir':tempfile.gettempdir()}) from supervisor import datatypes @@ -322,6 +323,7 @@ def test_options(self): self.assertEqual(proc1.directory, '/tmp') self.assertEqual(proc1.umask, 002) self.assertEqual(proc1.environment, dict(FAKE_ENV_VAR='/some/path')) + self.assertEqual(proc1.cgroups, []) cat2 = options.process_group_configs[1] self.assertEqual(cat2.name, 'cat2') @@ -343,6 +345,7 @@ def test_options(self): self.assertEqual(proc2.stdout_logfile_backups, 2) self.assertEqual(proc2.exitcodes, [0,2]) self.assertEqual(proc2.directory, None) + self.assertEqual(proc2.cgroups, []) cat3 = options.process_group_configs[2] self.assertEqual(cat3.name, 'cat3') @@ -364,6 +367,7 @@ def test_options(self): self.assertEqual(proc3.stopsignal, signal.SIGTERM) self.assertEqual(proc3.stopasgroup, True) self.assertEqual(proc3.killasgroup, True) + self.assertEqual(proc3.cgroups, []) cat4 = options.process_group_configs[3] self.assertEqual(cat4.name, 'cat4') @@ -386,6 +390,7 @@ def test_options(self): self.assertEqual(proc4_a.stopsignal, signal.SIGTERM) self.assertEqual(proc4_a.stopasgroup, False) self.assertEqual(proc4_a.killasgroup, False) + self.assertEqual(proc4_a.cgroups, ["/path/one", "/path/two"]) proc4_b = cat4.process_configs[1] self.assertEqual(proc4_b.name, 'fleeb_1') @@ -403,6 +408,7 @@ def test_options(self): self.assertEqual(proc4_b.stopsignal, signal.SIGTERM) self.assertEqual(proc4_b.stopasgroup, False) self.assertEqual(proc4_b.killasgroup, False) + self.assertEqual(proc4_b.cgroups, ["/path/one", "/path/two"]) here = os.path.abspath(os.getcwd()) self.assertEqual(instance.uid, 0) @@ -704,6 +710,7 @@ def test_processes_from_section(self): environment = KEY1=val1,KEY2=val2,KEY3=%(process_num)s numprocs = 2 process_name = %(group_name)s_%(program_name)s_%(process_num)02d + cgroups = foo,bar """) from supervisor.options import UnhosedConfigParser config = UnhosedConfigParser() @@ -730,6 +737,7 @@ def test_processes_from_section(self): self.assertEqual(pconfig.redirect_stderr, False) self.assertEqual(pconfig.environment, {'KEY1':'val1', 'KEY2':'val2', 'KEY3':'0'}) + self.assertEqual(pconfig.cgroups, ["foo", "bar"]) def test_processes_from_section_host_node_name_expansion(self): instance = self._makeOne() @@ -1445,7 +1453,7 @@ def _makeOne(self, *arg, **kw): 'stderr_events_enabled', 'stderr_logfile_backups', 'stderr_logfile_maxbytes', 'stopsignal', 'stopwaitsecs', 'stopasgroup', 'killasgroup', 'exitcodes', - 'redirect_stderr', 'environment'): + 'redirect_stderr', 'environment', 'cgroups'): defaults[name] = name defaults.update(kw) return self._getTargetClass()(*arg, **defaults) @@ -1519,7 +1527,7 @@ def _makeOne(self, *arg, **kw): 'stderr_events_enabled', 'stderr_logfile_backups', 'stderr_logfile_maxbytes', 'stopsignal', 'stopwaitsecs', 'stopasgroup', 'killasgroup', 'exitcodes', - 'redirect_stderr', 'environment'): + 'redirect_stderr', 'environment', 'cgroups'): defaults[name] = name defaults.update(kw) return self._getTargetClass()(*arg, **defaults) diff --git a/supervisor/tests/test_process.py b/supervisor/tests/test_process.py index 57a6d504d..9e398f33c 100644 --- a/supervisor/tests/test_process.py +++ b/supervisor/tests/test_process.py @@ -904,6 +904,29 @@ def test_set_uid(self): self.assertEqual(options.privsdropped, 1) self.assertEqual(msg, None) + def test_attach_cgroups(self): + cgroups = ["/tmp/test_cg1", "/tmp/test_cg2"] + for cg in cgroups: + if os.path.exists(cg): + os.rmdir(cg) + os.makedirs(cg) + open(os.path.join(cg, "tasks"), "w").close() + + options = DummyOptions() + config = DummyPConfig(options, 'test', '/test', cgroups=cgroups) + instance = self._makeOne(config) + msg = instance.attach_cgroups() + for cg in cgroups: + tasks_path = os.path.join(cg, "tasks") + tasks = open(tasks_path) + try: + self.assertEqual(tasks.read().strip(), str(os.getpid())) + finally: + tasks.close() + os.remove(tasks_path) + os.rmdir(cg) + self.assertEqual(msg, None) + def test_cmp_bypriority(self): options = DummyOptions() config = DummyPConfig(options, 'notthere', '/notthere', diff --git a/supervisor/tests/test_supervisord.py b/supervisor/tests/test_supervisord.py index 9bc5322f4..14837daf2 100644 --- a/supervisor/tests/test_supervisord.py +++ b/supervisor/tests/test_supervisord.py @@ -261,7 +261,8 @@ def make_pconfig(name, command, **params): 'stopsignal': None, 'stopwaitsecs': 10, 'stopasgroup': False, 'killasgroup': False, - 'exitcodes': (0,2), 'environment': None, 'serverurl': None } + 'exitcodes': (0,2), 'environment': None, 'serverurl': None, + 'cgroups': None } result.update(params) return ProcessConfig(options, **result)