From b6dac86e0468fc2ec1aa23d9a6699606cccdd5f8 Mon Sep 17 00:00:00 2001 From: "Hongli Lai (Phusion)" Date: Sat, 15 Feb 2014 10:17:25 +0100 Subject: [PATCH] Improve the init system: support skipping startup files and running a custom main command --- Changelog.md | 3 + README.md | 45 ++++++++ image/my_init | 279 +++++++++++++++++++++++++++++++++++--------------- 3 files changed, 247 insertions(+), 80 deletions(-) diff --git a/Changelog.md b/Changelog.md index 2a30988..8cc0b8c 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,6 +1,9 @@ ## 0.9.6 * Fixed a bug in `my_init`: child processes that have been adopted during execution of init scripts are now properly reaped. + * Much improved `my_init`: + * It is now possible to run and watch a custom command, possibly in addition to running runit. See "Running a one-shot command in the container" in the README. + * It is now possible to skip running startup files such as /etc/rc.local. ## 0.9.5 (release date: 2014-02-06) diff --git a/README.md b/README.md index 013ab1e..b65537d 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ You can configure the stock `ubuntu` image yourself from your Dockerfile, so why * [Getting started](#getting_started) * [Adding additional daemons](#adding_additional_daemons) * [Running scripts during container startup](#running_startup_scripts) + * [Running a one-shot command in the container](#oneshot) * [Login to the container via SSH](#login) * [Building the image yourself](#building) * [Conclusion](#conclusion) @@ -154,6 +155,50 @@ The following example shows how you can add a startup script. This script simply RUN mkdir -p /etc/my_init.d ADD logtime.sh /etc/my_init.d/logtime.sh + +### Running a one-shot command in the container + +Normally, when you want to run a single command in a container, and exit immediately after the command, you invoke Docker like this: + + docker run YOUR_IMAGE COMMAND ARGUMENTS... + +However the downside of this approach is that the init system is not started. That is, while invoking `COMMAND`, important daemons such as cron and syslog are not running. Also, orphaned child processes are not properly reaped, because `COMMAND` is PID 1. + +Baseimage-docker provides a facility to run a single one-shot command, while solving all of the aforementioned problems. Run a single command in the following manner: + + docker run YOUR_IMAGE /sbin/my_init -- COMMAND ARGUMENTS ... + +This will perform the following: + + * Runs all system startup files, such as /etc/my_init.d/* and /etc/rc.local. + * Starts all runit services. + * Runs the specified command. + * When the specified command exits, stops all runit services. + +For example: + + $ docker run phusion/baseimage: /sbin/my_init -- ls + *** Running /etc/my_init.d/00_regen_ssh_host_keys.sh... + No SSH host key available. Generating one... + Creating SSH2 RSA key; this may take some time ... + Creating SSH2 DSA key; this may take some time ... + Creating SSH2 ECDSA key; this may take some time ... + *** Running /etc/rc.local... + *** Booting runit daemon... + *** Runit started as PID 80 + *** Running ls... + bin boot dev etc home image lib lib64 media mnt opt proc root run sbin selinux srv sys tmp usr var + *** ls exited with exit code 0. + *** Shutting down runit daemon (PID 80)... + *** Killing all processes... + +You may find that the default invocation is too noisy. Or perhaps you don't want to run the startup files. You can customize all this by passing arguments to `my_init`. Invoke `docker run YOUR_IMAGE /sbin/my_init --help` for more information. + +The following example runs `ls` without running the startup files and with less messages, while running all runit services: + + $ docker run phusion/baseimage: /sbin/my_init --skip-startup-files --quiet -- ls + bin boot dev etc home image lib lib64 media mnt opt proc root run sbin selinux srv sys tmp usr var + ### Login to the container via SSH diff --git a/image/my_init b/image/my_init index faf2381..8dc911b 100755 --- a/image/my_init +++ b/image/my_init @@ -1,8 +1,42 @@ #!/usr/bin/python2 -import os, sys, stat, signal, errno +import os, sys, stat, signal, errno, argparse, time -pid = None -status = None +KILL_PROCESS_TIMEOUT = 5 +KILL_ALL_PROCESSES_TIMEOUT = 5 + +LOG_LEVEL_ERROR = 1 +LOG_LEVEL_WARN = 1 +LOG_LEVEL_INFO = 2 +LOG_LEVEL_DEBUG = 3 + +log_level = None + +class AlarmException(Exception): + pass + +def error(message): + if log_level >= LOG_LEVEL_ERROR: + sys.stderr.write("*** %s\n" % message) + +def warn(message): + if log_level >= LOG_LEVEL_WARN: + print("*** %s" % message) + +def info(message): + if log_level >= LOG_LEVEL_INFO: + print("*** %s" % message) + +def debug(message): + if log_level >= LOG_LEVEL_DEBUG: + print("*** %s" % message) + +def ignore_signals_and_raise_keyboard_interrupt(signame): + signal.signal(signal.SIGTERM, signal.SIG_IGN) + signal.signal(signal.SIGINT, signal.SIG_IGN) + raise KeyboardInterrupt(signame) + +def raise_alarm_exception(): + raise AlarmException('Alarm') def listdir(path): try: @@ -20,100 +54,185 @@ def is_exe(path): except OSError: return False -def reap_child(signum, frame): - global pid, status, waiting_for_runit - try: - result = os.wait3(os.WNOHANG) - if result is not None and pid == result[0]: - status = result[1] - except OSError: - pass +def waitpid_reap_other_children(pid): + done = False + status = None + while not done: + this_pid, status = os.waitpid(-1, 0) + done = this_pid == pid + return status -def stop_child_process(name, pid): - print("*** Shutting down %s (PID %d)..." % (name, pid)) +def stop_child_process(name, pid, signo = signal.SIGTERM, time_limit = KILL_PROCESS_TIMEOUT): + info("Shutting down %s (PID %d)..." % (name, pid)) try: - os.kill(pid, signal.SIGHUP) + os.kill(pid, signo) except OSError: pass + signal.alarm(time_limit) + try: + try: + waitpid_reap_other_children(pid) + except OSError: + pass + except AlarmException: + warn("%s (PID %d) did not shut down in time. Forcing it to exit.") + try: + os.kill(pid, signal.SIGKILL) + except OSError: + pass + try: + waitpid_reap_other_children(pid) + except OSError: + pass + finally: + signal.alarm(0) def run_command_killable(*argv): - global pid filename = argv[0] + status = None pid = os.spawnvp(os.P_NOWAIT, filename, argv) - signal.signal(signal.SIGINT, lambda signum, frame: stop_child_process(filename, pid)) - signal.signal(signal.SIGTERM, lambda signum, frame: stop_child_process(filename, pid)) try: + status = waitpid_reap_other_children(pid) + except BaseException as s: + warn("An error occurred. Aborting.") + stop_child_process(filename, pid) + raise + if status != 0: + error("%s failed with exit code %d\n" % (filename, status)) + sys.exit(1) + +def kill_all_processes(time_limit): + info("Killing all processes...") + try: + os.kill(-1, signal.SIGTERM) + except OSError: + pass + signal.alarm(time_limit) + try: + # Wait until no more child processes exist. done = False while not done: try: - this_pid, status = os.waitpid(-1, 0) - done = this_pid == pid + os.waitpid(-1, 0) except OSError as e: - if e.errno == errno.EINTR: - sys.exit(2) + if e.errno == errno.ECHILD: + done = True else: raise - finally: - signal.signal(signal.SIGINT, signal.SIG_DFL) - signal.signal(signal.SIGTERM, signal.SIG_DFL) - if status != 0: - sys.stderr.write("*** %s failed with exit code %d\n" % (filename, status)) - sys.exit(1) - -# Run /etc/my_init.d/* -for name in listdir("/etc/my_init.d"): - filename = "/etc/my_init.d/" + name - if is_exe(filename): - print("*** Running %s..." % filename) - run_command_killable(filename) - -# Run /etc/rc.local. -if is_exe("/etc/rc.local"): - print("*** Running /etc/rc.local...") - run_command_killable("/etc/rc.local") - -# Start runit. -signal.signal(signal.SIGCHLD, reap_child) -print("*** Booting runit...") -pid = os.spawnl(os.P_NOWAIT, "/usr/bin/runsvdir", "/usr/bin/runsvdir", "-P", "/etc/service", "log: %s" % ('.' * 395)) -print("*** Runit started as PID %d" % pid) -signal.signal(signal.SIGTERM, lambda signum, frame: stop_child_process("runit", pid)) - -# Wait for runit, and while waiting, reap any adopted orphans. -done = False -while not done: - try: - this_pid, status = os.waitpid(pid, 0) - done = True - except OSError as e: - if e.errno == errno.EINTR: - # Try again + except AlarmException: + warn("Not all processes have exited in time. Forcing them to exit.") + try: + os.kill(-1, signal.SIGKILL) + except OSError: pass + finally: + signal.alarm(0) + +def run_startup_files(): + # Run /etc/my_init.d/* + for name in listdir("/etc/my_init.d"): + filename = "/etc/my_init.d/" + name + if is_exe(filename): + info("Running %s..." % filename) + run_command_killable(filename) + + # Run /etc/rc.local. + if is_exe("/etc/rc.local"): + info("Running /etc/rc.local...") + run_command_killable("/etc/rc.local") + +def start_runit(): + info("Booting runit daemon...") + pid = os.spawnl(os.P_NOWAIT, "/usr/bin/runsvdir", "/usr/bin/runsvdir", + "-P", "/etc/service", "log: %s" % ('.' * 395)) + info("Runit started as PID %d" % pid) + return pid + +def wait_for_runit_or_interrupt(pid): + try: + status = waitpid_reap_other_children(pid) + return (True, status) + except KeyboardInterrupt: + return (False, None) + +def shutdown_runit_services(): + debug("Begin shutting down runit services...") + os.system("/usr/bin/sv down /etc/service/*") + +def wait_for_runit_services(): + debug("Waiting for runit services to exit...") + done = False + while not done: + done = os.system("/usr/bin/sv status /etc/service/* | grep -q '^run:'") != 0 + if not done: + time.sleep(0.1) + +def main(args): + if not args.skip_startup_files: + run_startup_files() + + runit_exited = False + exit_code = None + + if not args.skip_runit: + runit_pid = start_runit() + try: + if len(args.main_command) == 0: + runit_exited, exit_code = wait_for_runit_or_interrupt(runit_pid) + if runit_exited: + info("Runit exited with code %d" % exit_code) else: - # The SIGCHLD handler probably caught it. - done = True + info("Running %s..." % " ".join(args.main_command)) + pid = os.spawnvp(os.P_NOWAIT, args.main_command[0], args.main_command) + try: + exit_code = waitpid_reap_other_children(pid) + info("%s exited with exit code %d." % (args.main_command[0], exit_code)) + except KeyboardInterrupt: + stop_child_process(args.main_command[0], pid) + except BaseException as s: + warn("An error occurred. Aborting.") + stop_child_process(args.main_command[0], pid) + raise + sys.exit(exit_code) + finally: + if not args.skip_runit: + shutdown_runit_services() + if not runit_exited: + stop_child_process("runit daemon", runit_pid) + wait_for_runit_services() -# Runit has exited. Reset signal handlers. -print("*** Runit exited with code %s. Waiting for all services to shut down..." % status) -signal.signal(signal.SIGCHLD, signal.SIG_DFL) -signal.signal(signal.SIGTERM, signal.SIG_DFL) -signal.siginterrupt(signal.SIGCHLD, False) -signal.siginterrupt(signal.SIGTERM, False) +# Parse options. +parser = argparse.ArgumentParser(description = 'Initialize the system.') +parser.add_argument('main_command', metavar = 'MAIN_COMMAND', type = str, nargs = '*', + help = 'The main command to run. (default: runit)') +parser.add_argument('--skip-startup-files', dest = 'skip_startup_files', + action = 'store_const', const = True, default = False, + help = 'Skip running /etc/my_init.d/* and /etc/rc.local') +parser.add_argument('--skip-runit', dest = 'skip_runit', + action = 'store_const', const = True, default = False, + help = 'Do not run runit services') +parser.add_argument('--no-kill-all-on-exit', dest = 'kill_all_on_exit', + action = 'store_const', const = False, default = True, + help = 'Don\'t kill all processes on the system upon exiting') +parser.add_argument('--quiet', dest = 'log_level', + action = 'store_const', const = LOG_LEVEL_WARN, default = LOG_LEVEL_INFO, + help = 'Only print warnings and errors') +args = parser.parse_args() +log_level = args.log_level -# Wait at most 5 seconds for services to shut down. -import time - -def shutdown(signum = None, frame = None): - global status - if status is not None: - sys.exit(status) - -signal.signal(signal.SIGALRM, shutdown) -signal.alarm(5) -done = False -while not done: - done = os.system("/usr/bin/sv status /etc/service/* | grep -q '^run:'") != 0 - if not done: - time.sleep(0.5) -shutdown() +if args.skip_runit and len(args.main_command) == 0: + error("When --skip-runit is given, you must also pass a main command.") + sys.exit(1) +# Run main function. +signal.signal(signal.SIGTERM, lambda signum, frame: ignore_signals_and_raise_keyboard_interrupt('SIGTERM')) +signal.signal(signal.SIGINT, lambda signum, frame: ignore_signals_and_raise_keyboard_interrupt('SIGINT')) +signal.signal(signal.SIGALRM, lambda signum, frame: raise_alarm_exception()) +try: + main(args) +except KeyboardInterrupt: + warn("Init system aborted.") + exit(2) +finally: + if args.kill_all_on_exit: + kill_all_processes(KILL_ALL_PROCESSES_TIMEOUT)