diff --git a/.gitignore b/.gitignore index daf913b..33835c7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +reap + # Compiled Object files, Static and Dynamic libs (Shared Objects) *.o *.a diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0e5a077 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM alpine:latest +RUN echo "http://dl-4.alpinelinux.org/alpine/edge/testing" >>/etc/apk/repositories && apk add --update runit && rm -rf /var/cache/apk/* + +ADD reap / + +ADD foo / +RUN mkdir -p /etc/service/foo +ADD run-foo /etc/service/foo/run + +ADD bar / +RUN mkdir -p /etc/service/bar +ADD run-bar /etc/service/bar/run + +ENTRYPOINT ["/reap"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..266f03c --- /dev/null +++ b/README.md @@ -0,0 +1,58 @@ +# reap + +If you have a Docker container that's a collection of runit-supervised daemons, +this process is suitable for use as the ENTRYPOINT. + +```Docker +FROM alpine:latest +RUN echo "http://dl-4.alpinelinux.org/alpine/edge/testing" >>/etc/apk/repositories && apk add --update runit && rm -rf /var/cache/apk/* + +ADD foo / +RUN mkdir -p /etc/service/foo +ADD run-foo /etc/service/foo/run + +ADD bar / +RUN mkdir -p /etc/service/bar +ADD run-bar /etc/service/bar/run + +ADD reap / +ENTRYPOINT ["/reap"] +``` + +**Why not just exec runsvdir?** + +`docker stop` issues SIGTERM (or, in a future version of Docker, perhaps another custom signal) +but if runsvdir receives a signal, +it doesn't wait for its supervised processes to exit before returning. +If you don't care about graceful shutdown of your daemons, no problem, you don't need this tool. + +**Why not wrap runsvdir in a simple shell script?** + +This works great: + +```sh +#!/bin/sh + +sv_stop() { + for s in $(ls -d /etc/service/*) + do + /sbin/sv stop $s + done +} + +trap "sv_stop; exit" SIGTERM +/sbin/runsvdir /etc/service & +wait +``` + +...except it doesn't [reap orphaned child processes](https://blog.phusion.nl/2015/01/20/docker-and-the-pid-1-zombie-reaping-problem/) +and is therefore unsuitable for being PID 1. + +**Why not use my_init from phusion/baseimage-docker?** + +That works great — if you're willing to add python3 to your Docker images :) + +**So this is just a stripped-down my_init in Go?** + +Basically, yes. + diff --git a/bar b/bar new file mode 100755 index 0000000..4747197 --- /dev/null +++ b/bar @@ -0,0 +1,15 @@ +#!/bin/sh + +handle() { + echo "got signal" + exit +} + +trap handle SIGINT + +while true +do + echo bar: `date` + sleep 1 +done + diff --git a/foo b/foo new file mode 100755 index 0000000..dcc3e0f --- /dev/null +++ b/foo @@ -0,0 +1,17 @@ +#!/bin/sh + +handle() { + echo "got signal" + exit +} + +trap "handle" SIGINT + +for i in `seq 1 5` +do + echo foo: $i + sleep 1 +done + +echo foo: terminating! + diff --git a/main.go b/main.go new file mode 100644 index 0000000..038b24b --- /dev/null +++ b/main.go @@ -0,0 +1,135 @@ +package main + +import ( + "log" + "os" + "os/exec" + "os/signal" + "path/filepath" + "strings" + "syscall" +) + +const etcService = "/etc/service" + +func main() { + log.SetFlags(0) + + runsvdir, err := exec.LookPath("runsvdir") + if err != nil { + log.Fatal(err) + } + + sv, err := exec.LookPath("sv") + if err != nil { + log.Fatal(err) + } + + if fi, err := os.Stat(etcService); err != nil { + log.Fatal(err) + } else if !fi.IsDir() { + log.Fatalf("%s is not a directory", etcService) + } + + if pid := os.Getpid(); pid != 1 { + log.Printf("warning: I'm not PID 1, I'm PID %d", pid) + } + + go reapAll() + + supervisor := cmd(runsvdir, etcService) + if err := supervisor.Start(); err != nil { + log.Fatal(err) + } + + log.Printf("%s started", runsvdir) + + go gracefulShutdown(sv, supervisor.Process) + + if err := supervisor.Wait(); err != nil { + log.Printf("%s exited with error: %v", runsvdir, err) + } else { + log.Printf("%s exited cleanly", runsvdir) + } +} + +func reapAll() { + c := make(chan os.Signal) + signal.Notify(c, syscall.SIGCHLD) + for range c { + go reapOne() + } +} + +// From https://github.com/ramr/go-reaper/blob/master/reaper.go +func reapOne() { + var ( + ws syscall.WaitStatus + pid int + err error + ) + for { + pid, err = syscall.Wait4(-1, &ws, 0, nil) + if err != syscall.EINTR { + break + } + } + if err == syscall.ECHILD { + return + } + log.Printf("reaped child process %d (%+v)", pid, ws) +} + +type signaler interface { + Signal(os.Signal) error +} + +func gracefulShutdown(sv string, s signaler) { + c := make(chan os.Signal) + signal.Notify(c, syscall.SIGTERM, syscall.SIGINT) + log.Printf("received %s", <-c) + + matches, err := filepath.Glob(filepath.Join(etcService, "*")) + if err != nil { + log.Printf("when shutting down services: %v", err) + return + } + + var stopped []string + for _, match := range matches { + fi, err := os.Stat(match) + if err != nil { + log.Printf("%s: %v", match, err) + continue + } + if !fi.IsDir() { + log.Printf("%s: not a directory", match) + continue + } + service := filepath.Base(match) + stop := cmd(sv, "stop", service) + if err := stop.Run(); err != nil { + log.Printf("%s: %v", strings.Join(stop.Args, " "), err) + continue + } + stopped = append(stopped, service) + } + + log.Printf("stopped %d: %s", len(stopped), strings.Join(stopped, ", ")) + log.Printf("stopping supervisor...") + if err := s.Signal(syscall.SIGTERM); err != nil { + log.Print(err) + } + log.Printf("graceful SIGTERM handler exiting") +} + +func cmd(path string, args ...string) *exec.Cmd { + return &exec.Cmd{ + Path: path, + Args: append([]string{path}, args...), + Env: os.Environ(), + Stdin: os.Stdin, + Stdout: os.Stdout, + Stderr: os.Stderr, + } +} diff --git a/run-bar b/run-bar new file mode 100755 index 0000000..187559b --- /dev/null +++ b/run-bar @@ -0,0 +1,3 @@ +#!/bin/sh + +exec /bar diff --git a/run-foo b/run-foo new file mode 100755 index 0000000..3fb436c --- /dev/null +++ b/run-foo @@ -0,0 +1,3 @@ +#!/bin/sh + +exec /foo