docker容器内服务程序的优雅退出

近期在试验如何将我们的产品部署到docker容器中去,这其中涉及到一个技术环节,那就是如何让docker容器退出时其内部运行的服务程序也 可以优雅的退出。所谓优雅退出,指的就是程序在退出前有清理资源(比如关闭文件描述符、关闭socket),保存必要中间状态,持久化内存数据 (比如将内存中的数据flush到文件中)的机会。docker作为目前最火的轻量级虚拟化技术,其在后台服务领域的应用是极其广泛的,其设计者 在程序优雅退出方面是有考虑的。下面我们由简单到复杂逐一考量一下。

一、优雅退出的原理

对于服务程序而言,一般都是以daemon形式运行在后台的。通知这些服务程序退出需要使用到系统的signal机制。一般服务程序都会监听某个 特定的退出signal,比如SIGINT、SIGTERM等(通过kill -l命令你可以查看到几十种signal)。当我们使用kill + 进程号时,系统会默认发送一个SIGTERM给相应的进程。该进程通过signal handler响应这一信号,并在这个handler中完成相应的“优雅退出”操作。

与“优雅退出”对立的是“暴力退出”,也就是我们常说的使用kill -9,也就是kill -s SIGKILL + 进程号,这个行为不会给目标进程任何时间空隙,而是直接将进程杀死,无论进程当前在做何种操作。这种操作常常导致“不一致”状态的出现。SIGKILL这 个信号比较特殊,进程无法有效监听该信号,无法有效针对该信号设置handler,无法改变其信号的默认处理行为。

二、测试用“服务程序”

为了测试docker容器对优雅退出的支持,我们编写如下“服务程序”用于放在docker容器中运行:

//dockerapp1.go

package main

import "fmt"
import "time"
import "os"
import "os/signal"
import "syscall"

type signalHandler func(s os.Signal, arg interface{})

type signalSet struct {
        m map[os.Signal]signalHandler
}

func signalSetNew() *signalSet {
        ss := new(signalSet)
        ss.m = make(map[os.Signal]signalHandler)
        return ss
}

func (set *signalSet) register(s os.Signal, handler signalHandler) {
        if _, found := set.m[s]; !found {
                set.m[s] = handler
        }
}

func (set *signalSet) handle(sig os.Signal, arg interface{}) (err error) {
        if _, found := set.m[sig]; found {
                set.m[sig](sig, arg)
                return nil
        } else {
                return fmt.Errorf("No handler available for signal %v", sig)
        }

        panic("won't reach here")
}

func main() {
        go sysSignalHandleDemo()
        time.Sleep(time.Hour) // make the main goroutine wait!
}

func sysSignalHandleDemo() {
        ss := signalSetNew()
        handler := func(s os.Signal, arg interface{}) {
                fmt.Printf("handle signal: %v\n", s)
                if s == syscall.SIGTERM {
                        fmt.Printf("signal termiate received, app exit normally\n")
                        os.Exit(0)
                }
        }

        ss.register(syscall.SIGINT, handler)
        ss.register(syscall.SIGUSR1, handler)
        ss.register(syscall.SIGUSR2, handler)
        ss.register(syscall.SIGTERM, handler)

        for {
                c := make(chan os.Signal)
                var sigs []os.Signal
                for sig := range ss.m {
                        sigs = append(sigs, sig)
                }
                signal.Notify(c)
                sig := <-c

                err := ss.handle(sig, nil)
                if err != nil {
                        fmt.Printf("unknown signal received: %v, app exit unexpectedly\n", sig)
                        os.Exit(1)
                }
        }
}

关于Go语言对系统Signal的处理,可以参考《Go中的系统Signal处理》一文。

三、制作测试用docker image

在《 Ubuntu Server 14.04安装docker》一文中,我们完成了在ubuntu 14.04上安装docker的步骤。要制作测试用docker image,我们首先需要pull一个base image。我们以CentOS6.5为例:

在Ubuntu 14.04上执行:
    sudo  docker pull centos:centos6

docker会自动从官方仓库下载一个制作好的docker image。下载成功后,我们可以run一下试试,像这样:

$> sudo docker run -t -i centos:centos6 /bin/bash

我们查看一下CentOS6的小版本:
$> cat /etc/centos-release
CentOS release 6.5 (Final)

这是一个极其精简的CentOS,各种工具均未安装:
bash-4.1# telnet
bash: telnet: command not found
bash-4.1# ssh
bash: ssh: command not found
bash-4.1# ftp
bash: ftp: command not found
bash-4.1# echo $PATH
/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

如果你要安装一些必要的工具,可以直接使用yum install,默认的base image已经将yum配置好了,可以直接使用。如果通过公司代理访问外部网络,别忘了先export http_proxy。另外docker直接使用宿主机的/etc/resolv.conf作为容器的DNS,我们也无需额外设置DNS。

接下来,我们就制作我们的第一个测试用image。安装官方推荐的Best Practice,我们使用Dockerfile来bulid一个测试用image。步骤如下:

- 建立~/ImagesFactory目录
- 将构建好的dockerapp1拷贝到~/ImagesFactory目录下
- 进入~/ImagesFactory目录,创建Dockerfile文件,Dockerfile内容如下:

FROM centos:centos6
MAINTAINER Tony Bai <bigwhite.cn@gmail.com>
COPY ./dockerapp1 /bin
CMD /bin/dockerapp1

- 执行docker build,结果如下:

$ sudo docker build -t="test:v1" ./
Sending build context to Docker daemon 7.496 MB
Sending build context to Docker daemon
Step 0 : FROM centos:centos6
 —> 68edf809afe7
Step 1 : MAINTAINER Tony Bai <bigwhite.cn@gmail.com>
 —> Using cache
 —> c617b456934a
Step 2 : COPY ./dockerapp1 /bin
2014/10/09 16:05:25 lchown /var/lib/docker/aufs/mnt/fb0e864d3f07ca17ef8b6b69f034728e1f1158fd3f9c83fa48243054b2f26958/bin/dockerapp1: not a directory

居然build失败,提示什么not a directory。于是各种Search,终于发现问题所在,原来是“COPY ./dockerapp1 /bin”这条命令错了,少了个“/”,将" /bin"改为“/bin/”就OK了,Docker真是奇怪啊,这块明显应该做得更兼容些。新的Dockerfile如下:

FROM centos:centos6
MAINTAINER Tony Bai <bigwhite.cn@gmail.com>
COPY ./dockerapp1 /bin/
CMD /bin/dockerapp1

构建结果如下:

$ sudo docker build -t="test:v1" ./
Sending build context to Docker daemon 7.496 MB
Sending build context to Docker daemon
Step 0 : FROM centos:centos6
 —> 68edf809afe7
Step 1 : MAINTAINER Tony Bai <bigwhite.cn@gmail.com>
 —> Using cache
 —> c617b456934a
Step 2 : COPY ./dockerapp1 /bin/
 —> 20c3783c42ab
Removing intermediate container cab639ab4321
Step 3 : CMD /bin/dockerapp1
 —> Running in 31875d3c37f9
 —> 21a720a808a7
Removing intermediate container 31875d3c37f9
Successfully built 21a720a808a7

$ sudo docker images
REPOSITORY          TAG                 IMAGE ID            CREATED             VIRTUAL SIZE
test                v1                  21a720a808a7        59 seconds ago      214.6 MB

四、第一个测试容器

我们基于image "test:v1"启动一个测试容器:

$ sudo docker run -d "test:v1"
daf3ae88fec23a31cde9f6b9a3f40057953c87b56cca982143616f738a84dcba

$ sudo docker ps
CONTAINER ID        IMAGE               COMMAND                CREATED             STATUS              PORTS               NAMES
daf3ae88fec2        test:v1             "/bin/sh -c /bin/doc   17 seconds ago      Up 16 seconds                           condescending_sammet  

通过docker run命令,我们基于image"test:v1"启动了一个容器。通过docker ps命令可以看到容器成功启动,容器id:daf3ae88fec2,别名为:condescending_sammet。

根据Dockerfile我们知道,容器启动后将执行"/bin/dockerapp1"这个程序,dockerapp1退出,容器即退出。 run命令的"-d"选项表示容器将以daemon的形式运行,我们在前台无法看到容器的输出。那么我们怎么查看容器的输出呢?我们可以通过 docker logs + 容器id的方式查看容器内应用的标准输出或标准错误。我们也可以进入容器来查看。

进入容器有多种方法,比如用sudo docker attach daf3ae88fec2。attach后,就好比将daemon方式运行的容器 拿到了前台,你可以Ctrl + C一下,可以看到如下dockerapp1的输出:

^Chandle signal: interrupt

另外一种方式是利用nsenter工具进入我们容器的namespace空间。ubuntu 14.04下可以通过如下方式安装该工具:

$ wget https://www.kernel.org/pub/linux/utils/util-linux/v2.24/util-linux-2.24.tar.gz; tar xzvf util-linux-2.24.tar.gz
$ cd util-linux-2.24
$ ./configure –without-ncurses && make nsenter
$ sudo cp nsenter /usr/local/bin

安装后,我们通过如下方式即可进入上面的容器:

$ echo $(sudo docker inspect –format "{{ .State.Pid }}" daf3ae88fec2)
5494
$ sudo nsenter –target 5494 –mount –uts –ipc –net –pid
-bash-4.1# ps -ef
UID        PID  PPID  C STIME TTY          TIME CMD
root         1     0  0 09:20 ?        00:00:00 /bin/dockerapp1
root        16     0  0 09:32 ?        00:00:00 -bash
root        27    16  0 09:32 ?        00:00:00 ps -ef
-bash-4.1#

进入容器后通过ps命令可以看到正在运行的dockerapp1程序。在容器内,我们可以通过kill来测试dockerapp1的运行情况:

-bash-4.1# kill -s SIGINT 1

通过前面的attach窗口,我们可以看到dockerapp1输出:

handle signal: interrupt

如果你发送SIGTERM信号,那么dockerapp1将终止运行,容器也就停止了。

-bash-4.1# kill 1

attach窗口显示:

signal termiate received, app exit normally

我们可以看到容器启动后默认执行的时Dockerfile中的CMD命令,如果Dockerfile中有多行CMD命令,Docker在启动容器 时只会执行最后一条CMD命令。如果在docker run中指定了命令,docker则会执行命令行中的命令而不会执行dockerapp1,比如:

$ sudo docker run -t -i "test:v1" /bin/bash
bash-4.1#

这里我们看到直接执行的时bash,dockerapp1并未执行。

五、docker stop的行为

我们先来看看docker stop的manual:

$ sudo docker stop –help
Usage: docker stop [OPTIONS] CONTAINER [CONTAINER...]
Stop a running container by sending SIGTERM and then SIGKILL after a grace period
  -t, –time=10      Number of seconds to wait for the container to stop before killing it. Default is 10 seconds.

可以看出当我们执行docker stop时,docker会首先向容器内的当前主程序发送一个SIGTERM信号,用于容器内程序的退出。如果容器在收到SIGTERM后没有马上退出, 那么stop命令会在等待一段时间(默认是10s)后,再向容器发送SIGKILL信号,将容器杀死,变为退出状态。

我们来验证一下docker stop的行为。启动刚才那个容器:

$ sudo docker start daf3ae88fec2
daf3ae88fec2

attach到容器daf3ae88fec2
$ sudo docker attach daf3ae88fec2

新打开一个窗口,执行docker stop命令:
$ sudo docker stop daf3ae88fec2
daf3ae88fec2

可以看到attach窗口输出:
handle signal: terminated
signal termiate received, app exit normally

通过docker ps查看,发现容器已经退出。

也许通过上面的例子还不能直观的展示stop命令的两阶段行为,因为dockerapp1收到SIGTERM后直接就退出 了,stop命令无需等待容器慢慢退出,也无需发送SIGKILL。我们改造一下dockerapp1这个程序。

我们复制一下dockerapp1.go为dockerapp2.go,编辑dockerapp2.go,将handler中对SIGTERM的 处理注释掉,其他不变:

handler := func(s os.Signal, arg interface{}) {
                fmt.Printf("handle signal: %v\n", s)
                /*
                if s == syscall.SIGTERM {
                        fmt.Printf("signal termiate received, app exit normally\n")
                        os.Exit(0)
                }
                */
        }

我们使用dockerapp2来构建一个新image:test:v2,将Dockerfile中得dockerapp1换成 dockerapp2即可。

$ sudo docker build -t="test:v2" ./
Sending build context to Docker daemon 9.369 MB
Sending build context to Docker daemon
Step 0 : FROM centos:centos6
 —> 68edf809afe7
Step 1 : MAINTAINER Tony Bai <bigwhite.cn@gmail.com>
 —> Using cache
 —> c617b456934a
Step 2 : COPY ./dockerapp2 /bin/
 —> 27cd613a9bd7
Removing intermediate container 07c760b6223b
Step 3 : CMD /bin/dockerapp2
 —> Running in 1aac086452a7
 —> 82eb876fefd2
Removing intermediate container 1aac086452a7
Successfully built 82eb876fefd2

利用image "test:v2"创建一个容器来测试stop。

$ sudo docker run -d "test:v2"
29f3ec1af3c355458cbbd802a5e8a53da28e9f51a56ce822c7bba2a772edceac

$ sudo docker ps
CONTAINER ID        IMAGE               COMMAND                CREATED             STATUS              PORTS               NAMES
29f3ec1af3c3        test:v2             "/bin/sh -c /bin/doc   7 seconds ago       Up 6 seconds                            romantic_feynman 
  

Attach到这个容器并观察,在另外一个窗口stop该container。我们在attach窗口只看到如下输出:

handle signal: terminated

stop命令的执行没有立即返回,而是等待容器退出。等待10s后,容器退出,stop命令执行结束。从这个例子我们可以明显看出stop的两阶 段行为。

如果我们以sudo docker run -i -t "test:v1" /bin/bash形式启动容器,那stop命令会将SIGTERM发送给bash这个程序,即使你通过nsenter进入容 器,启动了dockerapp1,dockerapp1也不会收到SIGTERM,dockerapp1会随着容器的退出而被强行终止,就像被 kill -9了一样。

六、多进程容器服务程序

上面无论是dockerapp1还是dockerapp2,都是一个单进程服务程序。如果我们在容器内执行一个多进程程序,我们该如何优雅退出 呢?我们先来编写一个多进程的服务程序dockerapp3:

在dockerapp1.go的基础上对main和sysSignalHandleDemo进行修改形成dockerapp3.go,修改后这两 个函数的代码如下:

//dockerapp3.go
… …

func main() {
        go sysSignalHandleDemo()

        pid, _, err := syscall.RawSyscall(syscall.SYS_FORK, 0, 0, 0)
        if err != 0 {
                fmt.Printf("err fork process, err: %v\n", err)
                return
        }

        if pid == 0 {
                fmt.Printf("i am in child process, pid = %v\n", syscall.Getpid())
                time.Sleep(time.Hour) // make the child process wait
        }
        fmt.Printf("i am parent process, pid = %v\n", syscall.Getpid())
        fmt.Printf("fork ok, childpid = %v\n", pid)
        time.Sleep(time.Hour) // make the main goroutine wait!
}

func sysSignalHandleDemo() {
        ss := signalSetNew()
        handler := func(s os.Signal, arg interface{}) {
                fmt.Printf("%v: handle signal: %v\n", syscall.Getpid(), s)
                if s == syscall.SIGTERM {
                        fmt.Printf("%v: signal termiate received, app exit normally\n", syscall.Getpid())
                        os.Exit(0)
                }
        }

        ss.register(syscall.SIGINT, handler)
        ss.register(syscall.SIGUSR1, handler)
        ss.register(syscall.SIGUSR2, handler)
        ss.register(syscall.SIGTERM, handler)

        for {
                c := make(chan os.Signal)
                var sigs []os.Signal
                for sig := range ss.m {
                        sigs = append(sigs, sig)
                }
                signal.Notify(c)
                sig := <-c

                err := ss.handle(sig, nil)
                if err != nil {
                        fmt.Printf("%v: unknown signal received: %v, app exit unexpectedly\n", syscall.Getpid(), sig)
                        os.Exit(1)
                }
        }
}

dockerapp3利用fork创建了一个子进程,这样dockerapp3实际上是两个进程在运行,各自有自己的signal监听 goroutine,goroutine的处理逻辑是相同的。注意:由于Windows和Mac OS X不具备fork语义,因此在这两个平台上运行dockerapp3不会得到预期结果。

利用dockerapp3,我们创建image "test:v3":

$ sudo docker build -t="test:v3" ./
[sudo] password for tonybai:
Sending build context to Docker daemon 11.24 MB
Sending build context to Docker daemon
Step 0 : FROM centos:centos6
 —> 68edf809afe7
Step 1 : MAINTAINER Tony Bai <bigwhite.cn@gmail.com>
 —> Using cache
 —> c617b456934a
Step 2 : COPY ./dockerapp3 /bin/
 —> 6ccf97065853
Removing intermediate container 6d85fe241939
Step 3 : CMD /bin/dockerapp3
 —> Running in 75d76380992a
 —> c9e7bf361ed7
Removing intermediate container 75d76380992a
Successfully built c9e7bf361ed7

启动基于test:v3 image的容器:

$ sudo docker run -d "test:v3"
781cecb4b3628cb33e1b104ea57e506ad5cb4a44243256ebd1192af86834bae6
$ sudo docker ps
CONTAINER ID        IMAGE               COMMAND                CREATED             STATUS              PORTS               NAMES
781cecb4b362        test:v3             "/bin/sh -c /bin/doc   5 seconds ago       Up 4 seconds                            insane_bohr   
   

通过docker logs查看dockerapp3的输出:

$ sudo docker logs 781cecb4b362
i am parent process, pid = 1
fork ok, childpid = 13
i am in child process, pid = 13

可以看出主进程pid为1,子进程pid为13。我们通过stop停止该容器:

$ sudo docker stop 781cecb4b362
781cecb4b362

再次通过docker logs查看:

$ sudo docker logs 781cecb4b362
i am parent process, pid = 1
fork ok, childpid = 13
i am in child process, pid = 13
1: handle signal: terminated
1: signal termiate received, app exit normally

我们可以看到主进程收到了stop发来的SIGTERM并退出,主进程的退出导致容器退出,导致子进程13也无法生存,并且没有优雅退出。而在非 容器状态下,子进程是可以被init进程接管的。

因此对于docker容器内运行的多进程程序,stop命令只会将SIGTERM发送给容器主进程,要想让其他进程也能优雅退出,需要在主进程与 其他进程间建立一种通信机制。在主进程退出前,等待其他子进程退出。待所有其他进程退出后,主进程再退出,容器停止。这样才能保证服务程序的优雅 退出。

七、容器内启动多个服务程序

虽说docker best practice建议一个container内只放置一个服务程序,但对已有的一些遗留系统,在架构没有做出重构之前,很可能会有在一个 container中部署两个以上服务程序的情况和需求。而docker Dockerfile只允许执行一个CMD,这种情况下,我们就需要借助类似supervisor这样的进程监控管理程序来启动和管理container 内的多个程序了。

下面我们来自制作一个基于centos:centos6的安装了supervisord以及两个服务程序的image。我们将dockerapp1拷贝一份,并将拷贝命名为dockerapp1-brother。下面是我们的Dockerfile:

FROM centos:centos6
MAINTAINER Tony Bai <bigwhite.cn@gmail.com>
RUN yum install python-setuptools -y
RUN easy_install supervisor
RUN mkdir -p /var/log/supervisor
COPY ./supervisord.conf /etc/supervisord.conf
COPY ./dockerapp1 /bin/
COPY ./dockerapp1-brother /bin/
CMD ["/usr/bin/supervisord"]

supervisord的配置文件supervisord.conf内容如下:

; supervisor config file

[unix_http_server]
file=/var/run/supervisor.sock   ; (the path to the socket file)
chmod=0700                       ; sockef file mode (default 0700)

[supervisord]
logfile=/var/log/supervisor/supervisord.log ; (main log file;default $CWD/supervisord.log)
pidfile=/var/run/supervisord.pid ; (supervisord pidfile;default supervisord.pid)
childlogdir=/var/log/supervisor            ; ('AUTO' child log dir, default $TEMP)

[rpcinterface:supervisor]
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface

[supervisorctl]
serverurl=unix:///var/run/supervisor.sock ; use a unix:// URL  for a unix socket

[supervisord]
nodaemon=false

[program:dockerapp1]
command=/bin/dockerapp1
stdout_logfile=/tmp/dockerapp1.log
stopsignal=TERM
stopwaitsecs=10

[program:dockerapp1-brother]
command=/bin/dockerapp1-brother
stdout_logfile=/tmp/dockerapp1-brother.log
stopsignal=QUIT
stopwaitsecs=10

开始build镜像:
    $> sudo docker build -t="test:supervisor-v1" ./
    … …
    Successfully built d006b9ad10eb

基于该镜像,启动一个容器:
$> sudo docker run -d "test:supervisor-v1"
05ded2b898c90059d4c9b5c6ccc8603b6848ae767360c42bd9b36ff87fb4b9df

执行ps命令查看镜像id:
$ sudo docker ps
CONTAINER ID        IMAGE               COMMAND             CREATED             STATUS              PORTS               NAMES

怎么回事?Container没有启动起来?

$ sudo docker ps -a
CONTAINER ID        IMAGE                 COMMAND                CREATED             STATUS                      PORTS               NAMES
05ded2b898c9        test:supervisor-v1    "/usr/bin/supervisor   22 seconds ago      Exited (0) 21 seconds ago                       hungry_engelbart

通过ps -a查看,container启动是成功了,但是成功退出了。于是尝试查看一下log:

sudo docker logs 05ded2b898c9
/usr/lib/python2.6/site-packages/supervisor-3.1.2-py2.6.egg/supervisor/options.py:296: UserWarning: Supervisord is running as root and it is searching for its configuration file in default locations (including its current working directory); you probably want to specify a "-c" argument specifying an absolute path to a configuration file for improved security.
  'Supervisord is running as root and it is searching '

似乎是supervisord转为daemon程序,容器主进程退出了,容器随之终止了。

看来容器内的supervisord不能以daemon形式运行,应该以前台形式run。修改一下supervisord.conf中得配置:


[supervisord]
nodaemon=false

改为

[supervisord]
nodaemon=true

重新制作镜像:

$ sudo docker build -t="test:supervisor-v2" ./
Sending build context to Docker daemon 13.12 MB
Sending build context to Docker daemon
Step 0 : FROM centos:centos6
 —> 68edf809afe7
Step 1 : MAINTAINER Tony Bai <bigwhite.cn@gmail.com>
 —> Using cache
 —> c617b456934a
Step 2 : RUN yum install python-setuptools -y
 —> Using cache
 —> e09c66a1ea8c
Step 3 : RUN easy_install supervisor
 —> Using cache
 —> 9c8797e8c27e
Step 4 : RUN mkdir -p /var/log/supervisor
 —> Using cache
 —> 9bfc67f8517d
Step 5 : COPY ./supervisord.conf /etc/supervisord.conf
 —> 8c514f998363
Removing intermediate container 4a185856e6ed
Step 6 : COPY ./dockerapp1 /bin/
 —> 0317bd4914d3
Removing intermediate container ac5738380854
Step 7 : COPY ./dockerapp1-brother /bin/
 —> d89711888bdf
Removing intermediate container eadc9444e716
Step 8 : CMD ["/usr/bin/supervisord"]
 —> Running in aaa042ac3914
 —> 9655256bbfed
Removing intermediate container aaa042ac3914
Successfully built 9655256bbfed

有了前面的铺垫,这次build image瞬间完成。启动容器,查看容器启动状态,查看容器内supervisord的运行日志如下:

$ sudo docker run -d "test:supervisor-v2"
61916f1c82338b28ced101b6bde119e4afb7c7fa349b4332ed51a43a4586b1b9

$ sudo docker ps
CONTAINER ID        IMAGE                COMMAND                CREATED             STATUS              PORTS               NAMES
61916f1c8233        test:supervisor-v2   "/usr/bin/supervisor   16 seconds ago      Up 16 seconds                           prickly_einstein

$ sudo docker logs 8eb3e9892e66

/usr/lib/python2.6/site-packages/supervisor-3.1.2-py2.6.egg/supervisor/options.py:296: UserWarning: Supervisord is running as root and it is searching for its configuration file in default locations (including its current working directory); you probably want to specify a "-c" argument specifying an absolute path to a configuration file for improved security.
  'Supervisord is running as root and it is searching '
2014-10-09 14:36:02,334 CRIT Supervisor running as root (no user in config file)
2014-10-09 14:36:02,349 INFO RPC interface 'supervisor' initialized
2014-10-09 14:36:02,349 CRIT Server 'unix_http_server' running without any HTTP authentication checking
2014-10-09 14:36:02,349 INFO supervisord started with pid 1
2014-10-09 14:36:03,354 INFO spawned: 'dockerapp1' with pid 14
2014-10-09 14:36:03,363 INFO spawned: 'dockerapp1-brother' with pid 15
2014-10-09 14:36:04,368 INFO success: dockerapp1 entered RUNNING state, process has stayed up for > than 1 seconds (startsecs)
2014-10-09 14:36:04,369 INFO success: dockerapp1-brother entered RUNNING state, process has stayed up for > than 1 seconds (startsecs)

可以看到supervisord已经将dockerapp1和dockerapp1-brother启动起来了。

现在我们尝试停止容器,我们预期是supervisord在退出前通知dockerapp1和dockerapp1-brother先退出,我们可以通过 查看容器内的/tmp/dockerapp1.log和/tmp/dockerapp1-brother.log来确认supervisord是否做了通 知。

$ sudo docker stop 61916f1c8233
61916f1c8233

$ sudo docker logs 61916f1c8233
… …
2014-10-09 14:37:52,253 WARN received SIGTERM indicating exit request
2014-10-09 14:37:52,254 INFO waiting for dockerapp1, dockerapp1-brother to die
2014-10-09 14:37:52,254 INFO stopped: dockerapp1-brother (exit status 0)
2014-10-09 14:37:52,256 INFO stopped: dockerapp1 (exit status 0)

通过容器的log,我们看出supervisord是等待两个程序退出后才退出的,不过我们还是要看看两个程序的输出日志以最终确认。重新启动容器,通过nsenter进入到容器中。

-bash-4.1# vi /tmp/dockerapp1.log

handle signal: terminated
signal termiate received, app exit normally

-bash-4.1# vi /tmp/dockerapp1-brother.log

handle signal: terminated
signal termiate received, app exit normally

两个程序的标准输出日志证实了我们的预期。

BTW,在物理机上测试supervisord以daemon形式运行,当kill掉supervisord时,supervisord是不会通知其监控 和管理的程序退出的。只有在以non-daemon形式运行时,supervisord才会在退出前先通知下面的程序退出。如果在一段时间内下面程序没有 退出,supervisord在退出前会kill -9强制杀死这些程序的进程。

最后要说的时,在验证一些想法时,没有必要build image,我们可以直接将本地文件copy到容器中,下面是一个例子,我们将dockerapp1和dockerapp1-brother拷贝到镜像中:
$ sudo docker ps
CONTAINER ID        IMAGE               COMMAND             CREATED             STATUS              PORTS               NAMES
4d8982bfccc7        centos:centos6      "/bin/bash"         26 minutes ago      Up 26 minutes                           sharp_thompson     
$ sudo docker inspect -f '{{.Id}}' 4d8982bfccc7
4d8982bfccc79dea762b41f8a6f669bda1ec73c8881b6ca76e7a7917c62972c4
$ sudo cp dockerapp1  /var/lib/docker/aufs/mnt/4d8982bfccc79dea762b41f8a6f669bda1ec73c8881b6ca76e7a7917c62972c4/bin/dockerapp1
$ sudo cp dockerapp1-brother  /var/lib/docker/aufs/mnt/4d8982bfccc79dea762b41f8a6f669bda1ec73c8881b6ca76e7a7917c62972c4/bin/dockerapp1-brother

Golang Channel用法简编

在进入正式内容前,我这里先顺便转发一则消息,那就是Golang 1.3.2已经正式发布了。国内的golangtc已经镜像了golang.org的安装包下载页面,国内go程序员与爱好者们可以到"Golang中 国",即golangtc.com去下载go 1.3.2版本。

Go这门语言也许你还不甚了解,甚至是完全不知道,这也有情可原,毕竟Go在TIOBE编程语言排行榜上位列30开外。但近期使用Golang 实现的一杀手级应用 Docker你却不该不知道。docker目前火得是一塌糊涂啊。你去国内外各大技术站点用眼轻瞥一下,如 果没有涉及到“docker”字样新闻的站点建 议你以后就不要再去访问了^_^。Docker是啥、怎么用以及基础实践可以参加国内一位仁兄的经验之作:《 Docker – 从入门到实践》。

据我了解,目前国内试水Go语言开发后台系统的大公司与初创公司日益增多,比如七牛、京东、小米,盛大,金山,东软,搜狗等,在这里我们可以看到一些公司的Go语言应用列表,并且目前这个列表似乎依旧在丰富中。国内Go语言的推广与布道也再稳步推进中,不过目前来看多以Go入 门与基础为主题,Go idioms、tips或Best Practice的Share并不多见,想必国内的先行者、布道师们还在韬光养晦,积攒经验,等到时机来临再厚积薄发。另外国内似乎还没有一个针对Go的 布道平台,比如Golang技术大会之类的的平台。

在国外,虽然Go也刚刚起步,但在Golang share的广度和深度方面显然更进一步。Go的国际会议目前还不多,除了Golang老东家Google在自己的各种大会上留给Golang展示自己的 机会外,由 Gopher Academy 发起的GopherCon 会议也于今年第一次举行,并放出诸多高质量资料,在这里可以下载。欧洲的Go语言大会.dotgo也即将开幕,估计后续这两个大会将撑起Golang技术分享 的旗帜。

言归正传,这里要写的东西并非原创,自己的Go仅仅算是入门级别,工程经验、Best Practice等还谈不上有多少,因此这里主要是针对GopherCon2014上的“舶来品”的学习心得。来自CloudFlare的工程师John Graham-Cumming谈了关于 Channel的实践经验,这里针对其分享的内容,记录一些学习体会和理解,并结合一些外延知识,也可以算是一种学习笔记吧,仅供参考。

一、Golang并发基础理论

Golang在并发设计方面参考了C.A.R Hoare的CSP,即Communicating Sequential Processes并发模型理论。但就像John Graham-Cumming所说的那样,多数Golang程序员或爱好者仅仅停留在“知道”这一层次,理解CSP理论的并不多,毕竟多数程序员是搞工程 的。不过要想系统学习CSP的人可以从这里下载到CSP论文的最新版本。

维基百科中概要罗列了CSP模型与另外一种并发模型Actor模型的区别:

Actor模型广义上讲与CSP模型很相似。但两种模型就提供的原语而言,又有一些根本上的不同之处:
    – CSP模型处理过程是匿名的,而Actor模型中的Actor则具有身份标识。
    – CSP模型的消息传递在收发消息进程间包含了一个交会点,即发送方只能在接收方准备好接收消息时才能发送消息。相反,actor模型中的消息传递是异步 的,即消息的发送和接收无需在同一时间进行,发送方可以在接收方准备好接收消息前将消息发送出去。这两种方案可以认为是彼此对偶的。在某种意义下,基于交 会点的系统可以通过构造带缓冲的通信的方式来模拟异步消息系统。而异步系统可以通过构造带消息/应答协议的方式来同步发送方和接收方来模拟交会点似的通信 方式。
    – CSP使用显式的Channel用于消息传递,而Actor模型则将消息发送给命名的目的Actor。这两种方法可以被认为是对偶的。某种意义下,进程可 以从一个实际上拥有身份标识的channel接收消息,而通过将actors构造成类Channel的行为模式也可以打破actors之间的名字耦合。

二、Go Channel基本操作语法

Go Channel的基本操作语法如下:

c := make(chan bool) //创建一个无缓冲的bool型Channel

c <- x        //向一个Channel发送一个值
<- c          //从一个Channel中接收一个值
x = <- c      //从Channel c接收一个值并将其存储到x中
x, ok = <- c  //从Channel接收一个值,如果channel关闭了或没有数据,那么ok将被置为false

不带缓冲的Channel兼具通信和同步两种特性,颇受青睐。

三、Channel用作信号(Signal)的场景

1、等待一个事件(Event)

等待一个事件,有时候通过close一个Channel就足够了。例如:

//testwaitevent1.go
package main

import "fmt"

func main() {
        fmt.Println("Begin doing something!")
        c := make(chan bool)
        go func() {
                fmt.Println("Doing something…")
                close(c)
        }()
        <-c
        fmt.Println("Done!")
}

这里main goroutine通过"<-c"来等待sub goroutine中的“完成事件”,sub goroutine通过close channel促发这一事件。当然也可以通过向Channel写入一个bool值的方式来作为事件通知。main goroutine在channel c上没有任何数据可读的情况下会阻塞等待。

关于输出结果:

根据《Go memory model》中关于close channel与recv from channel的order的定义:The closing of a channel happens before a receive that returns a zero value because the channel is closed.

我们可以很容易判断出上面程序的输出结果:

Begin doing something!
Doing something…
Done!

如果将close(c)换成c<-true,则根据《Go memory model》中的定义:A receive from an unbuffered channel happens before the send on that channel completes.
"<-c"要先于"c<-true"完成,但也不影响日志的输出顺序,输出结果仍为上面三行。

2、协同多个Goroutines

同上,close channel还可以用于协同多个Goroutines,比如下面这个例子,我们创建了100个Worker Goroutine,这些Goroutine在被创建出来后都阻塞在"<-start"上,直到我们在main goroutine中给出开工的信号:"close(start)",这些goroutines才开始真正的并发运行起来。

//testwaitevent2.go
package main

import "fmt"

func worker(start chan bool, index int) {
        <-start
        fmt.Println("This is Worker:", index)
}

func main() {
        start := make(chan bool)
        for i := 1; i <= 100; i++ {
                go worker(start, i)
        }
        close(start)
        select {} //deadlock we expected
}

3、Select

【select的基本操作】
select是Go语言特有的操作,使用select我们可以同时在多个channel上进行发送/接收操作。下面是select的基本操作。

select {
case x := <- somechan:
    // … 使用x进行一些操作

case y, ok := <- someOtherchan:
    // … 使用y进行一些操作,
    //
检查ok值判断someOtherchan是否已经关闭

case outputChan <- z:
    // … z值被成功发送到Channel上时

default:
    // … 上面case均无法通信时,执行此分支
}

【惯用法:for/select】

我们在使用select时很少只是对其进行一次evaluation,我们常常将其与for {}结合在一起使用,并选择适当时机从for{}中退出。

for {
        select {
        case x := <- somechan:
            // … 使用x进行一些操作

        case y, ok := <- someOtherchan:
            // … 使用y进行一些操作,
            // 检查ok值判断someOtherchan是否已经关闭

        case outputChan <- z:
            // … z值被成功发送到Channel上时

        default:
            // … 上面case均无法通信时,执行此分支
        }
}

【终结workers】

下面是一个常见的终结sub worker goroutines的方法,每个worker goroutine通过select监视一个die channel来及时获取main goroutine的退出通知。

//testterminateworker1.go
package main

import (
    "fmt"
    "time"
)

func worker(die chan bool, index int) {
    fmt.Println("Begin: This is Worker:", index)
    for {
        select {
        //case xx:
            //做事的分支
        case <-die:
            fmt.Println("Done: This is Worker:", index)
            return
        }
    }
}

func main() {
    die := make(chan bool)

    for i := 1; i <= 100; i++ {
        go worker(die, i)
    }

    time.Sleep(time.Second * 5)
    close(die)
    select {}
//deadlock we expected
}

【终结验证】

有时候终结一个worker后,main goroutine想确认worker routine是否真正退出了,可采用下面这种方法:

//testterminateworker2.go
package main

import (
    "fmt"
    //"time"
)

func worker(die chan bool) {
    fmt.Println("Begin: This is Worker")
    for {
        select {
        //case xx:
        //做事的分支
        case <-die:
            fmt.Println("Done: This is Worker")
            die <- true
            return
        }
    }
}

func main() {
    die := make(chan bool)

    go worker(die)

    die <- true
    <-die
    fmt.Println("Worker goroutine has been terminated")
}

【关闭的Channel永远不会阻塞】

下面演示在一个已经关闭了的channel上读写的结果:

//testoperateonclosedchannel.go
package main

import "fmt"

func main() {
        cb := make(chan bool)
        close(cb)
        x := <-cb
        fmt.Printf("%#v\n", x)

        x, ok := <-cb
        fmt.Printf("%#v %#v\n", x, ok)

        ci := make(chan int)
        close(ci)
        y := <-ci
        fmt.Printf("%#v\n", y)

        cb <- true
}

$go run testoperateonclosedchannel.go
false
false false
0
panic: runtime error: send on closed channel

可以看到在一个已经close的unbuffered channel上执行读操作,回返回channel对应类型的零值,比如bool型channel返回false,int型channel返回0。但向close的channel写则会触发panic。不过无论读写都不会导致阻塞。

【关闭带缓存的channel】

将unbuffered channel换成buffered channel会怎样?我们看下面例子:

//testclosedbufferedchannel.go
package main

import "fmt"

func main() {
        c := make(chan int, 3)
        c <- 15
        c <- 34
        c <- 65
        close(c)
        fmt.Printf("%d\n", <-c)
        fmt.Printf("%d\n", <-c)
        fmt.Printf("%d\n", <-c)
        fmt.Printf("%d\n", <-c)

        c <- 1
}

$go run testclosedbufferedchannel.go
15
34
65
0
panic: runtime error: send on closed channel

可以看出带缓冲的channel略有不同。尽管已经close了,但我们依旧可以从中读出关闭前写入的3个值。第四次读取时,则会返回该channel类型的零值。向这类channel写入操作也会触发panic。

【range】

Golang中的range常常和channel并肩作战,它被用来从channel中读取所有值。下面是一个简单的实例:

//testrange.go
package main

import "fmt"

func generator(strings chan string) {
        strings <- "Five hour's New York jet lag"
        strings <- "and Cayce Pollard wakes in Camden Town"
        strings <- "to the dire and ever-decreasing circles"
        strings <- "of disrupted circadian rhythm."
        close(strings)
}

func main() {
        strings := make(chan string)
        go generator(strings)
        for s := range strings {
                fmt.Printf("%s\n", s)
        }
        fmt.Printf("\n")
}

四、隐藏状态

下面通过一个例子来演示一下channel如何用来隐藏状态:

1、例子:唯一的ID服务

//testuniqueid.go
package main

import "fmt"

func newUniqueIDService() <-chan string {
        id := make(chan string)
        go func() {
                var counter int64 = 0
                for {
                        id <- fmt.Sprintf("%x", counter)
                        counter += 1
                }
        }()
        return id
}
func main() {
        id := newUniqueIDService()
        for i := 0; i < 10; i++ {
                fmt.Println(<-id)
        }
}

$ go run testuniqueid.go
0
1
2
3
4
5
6
7
8
9

newUniqueIDService通过一个channel与main goroutine关联,main goroutine无需知道uniqueid实现的细节以及当前状态,只需通过channel获得最新id即可。

五、默认情况

我想这里John Graham-Cumming主要是想告诉我们select的default分支的实践用法。

1、select  for non-blocking receive

idle:= make(chan []byte, 5) //用一个带缓冲的channel构造一个简单的队列

select {
case b = <-idle:
 //尝试从idle队列中读取
    …
default:  //队列空,分配一个新的buffer
        makes += 1
        b = make([]byte, size)
}

2、select for non-blocking send

idle:= make(chan []byte, 5) //用一个带缓冲的channel构造一个简单的队列

select {
case idle <- b: //尝试向队列中插入一个buffer
        //…
default: //队列满?

}

六、Nil Channels

1、nil channels阻塞

对一个没有初始化的channel进行读写操作都将发生阻塞,例子如下:

package main

func main() {
        var c chan int
        <-c
}

$go run testnilchannel.go
fatal error: all goroutines are asleep – deadlock!

package main

func main() {
        var c chan int
        c <- 1
}

$go run testnilchannel.go
fatal error: all goroutines are asleep – deadlock!

2、nil channel在select中很有用

看下面这个例子:

//testnilchannel_bad.go
package main

import "fmt"
import "time"

func main() {
        var c1, c2 chan int = make(chan int), make(chan int)
        go func() {
                time.Sleep(time.Second * 5)
                c1 <- 5
                close(c1)
        }()

        go func() {
                time.Sleep(time.Second * 7)
                c2 <- 7
                close(c2)
        }()

        for {
                select {
                case x := <-c1:
                        fmt.Println(x)
                case x := <-c2:
                        fmt.Println(x)
                }
        }
        fmt.Println("over")
}

我们原本期望程序交替输出5和7两个数字,但实际的输出结果却是:

5
0
0
0
… … 0死循环

再仔细分析代码,原来select每次按case顺序evaluate:
    – 前5s,select一直阻塞;
    – 第5s,c1返回一个5后被close了,“case x := <-c1”这个分支返回,select输出5,并重新select
    – 下一轮select又从“case x := <-c1”这个分支开始evaluate,由于c1被close,按照前面的知识,close的channel不会阻塞,我们会读出这个 channel对应类型的零值,这里就是0;select再次输出0;这时即便c2有值返回,程序也不会走到c2这个分支
    – 依次类推,程序无限循环的输出0

我们利用nil channel来改进这个程序,以实现我们的意图,代码如下:

//testnilchannel.go
package main

import "fmt"
import "time"

func main() {
        var c1, c2 chan int = make(chan int), make(chan int)
        go func() {
                time.Sleep(time.Second * 5)
                c1 <- 5
                close(c1)
        }()

        go func() {
                time.Sleep(time.Second * 7)
                c2 <- 7
                close(c2)
        }()

        for {
                select {
                case x, ok := <-c1:
                        if !ok {
                                c1 = nil
                        } else {
                                fmt.Println(x)
                        }
                case x, ok := <-c2:
                        if !ok {
                                c2 = nil
                        } else {
                                fmt.Println(x)
                        }
                }
                if c1 == nil && c2 == nil {
                        break
                }
        }
        fmt.Println("over")
}

$go run testnilchannel.go
5
7
over

可以看出:通过将已经关闭的channel置为nil,下次select将会阻塞在该channel上,使得select继续下面的分支evaluation。

七、Timers

1、超时机制Timeout

带超时机制的select是常规的tip,下面是示例代码,实现30s的超时select:

func worker(start chan bool) {
        timeout := time.After(30 * time.Second)
        for {
                select {
                     // … do some stuff
                case <- timeout:
                    return
                }
        }
}

2、心跳HeartBeart

与timeout实现类似,下面是一个简单的心跳select实现:

func worker(start chan bool) {
        heartbeat := time.Tick(30 * time.Second)
        for {
                select {
                     // … do some stuff
                case <- heartbeat:
                    //… do heartbeat stuff
                }
        }
}

如发现本站页面被黑,比如:挂载广告、挖矿等恶意代码,请朋友们及时联系我。十分感谢! Go语言第一课 Go语言精进之路1 Go语言精进之路2 Go语言编程指南
商务合作请联系bigwhite.cn AT aliyun.com

欢迎使用邮件订阅我的博客

输入邮箱订阅本站,只要有新文章发布,就会第一时间发送邮件通知你哦!

这里是 Tony Bai的个人Blog,欢迎访问、订阅和留言! 订阅Feed请点击上面图片

如果您觉得这里的文章对您有帮助,请扫描上方二维码进行捐赠 ,加油后的Tony Bai将会为您呈现更多精彩的文章,谢谢!

如果您希望通过微信捐赠,请用微信客户端扫描下方赞赏码:

如果您希望通过比特币或以太币捐赠,可以扫描下方二维码:

比特币:

以太币:

如果您喜欢通过微信浏览本站内容,可以扫描下方二维码,订阅本站官方微信订阅号“iamtonybai”;点击二维码,可直达本人官方微博主页^_^:
本站Powered by Digital Ocean VPS。
选择Digital Ocean VPS主机,即可获得10美元现金充值,可 免费使用两个月哟! 著名主机提供商Linode 10$优惠码:linode10,在 这里注册即可免费获 得。阿里云推荐码: 1WFZ0V立享9折!


View Tony Bai's profile on LinkedIn
DigitalOcean Referral Badge

文章

评论

  • 正在加载...

分类

标签

归档



View My Stats