理解Docker容器的進程管理

hujie00 8年前發布 | 40K 次閱讀 Docker

來自: http://yq.aliyun.com/articles/5545

Docker在進程管理上有一些特殊之處,如果不注意這些細節中的魔鬼就會帶來一些隱患。另外Docker鼓勵“一個容器一個進程(one process per container)”的方式。這種方式非常適合以單進程為主的微服務架構的應用。然而由于一些傳統的應用是由若干緊耦合的多個進程構成的,這些進程難以拆分到不同的容器中,所以在單個容器內運行多個進程便成了一種折衷方案;此外在一些場景中,用戶期望利用Docker容器來作為輕量級的虛擬化方案,動態的安裝配置應用,這也需要在容器中運行多個進程。而在Docker容器中的正確運行多進程應用將給開發者帶來更多的挑戰。

今天我們會分析Docker中進程管理的一些細節,并介紹一些常見問題的解決方法和注意事項。

容器的PID namespace(名空間)

在Docker中,進程管理的基礎就是Linux內核中的PID名空間技術。在不同PID名空間中,進程ID是獨立的;即在兩個不同名空間下的進程可以有相同的PID。

Linux內核為所有的PID名空間維護了一個樹狀結構:最頂層的是系統初始化時創建的root namespace(根名空間),再創建的新PID namespace就稱之為child namespace(子名空間),而原先的PID名空間就是新創建的PID名空間的parent namespace(父名空間)。通過這種方式,系統中的PID名空間會形成一個層級體系。父節點可以看到子節點中的進程,并可以通過信號等方式對子節點中的進程產生影響。反過來,子節點不能看到父節點名空間中的任何內容,也不可能通過kill或ptrace影響父節點或其他名空間中的進程。

在Docker中,每個Container都是Docker Daemon的子進程,每個Container進程缺省都具有不同的PID名空間。通過名空間技術,Docker實現容器間的進程隔離。另外Docker Daemon也會利用PID名空間的樹狀結構,實現了對容器中的進程交互、監控和回收。注:Docker還利用了其他名空間(UTS,IPC,USER)等實現了各種系統資源的隔離,由于這些內容和進程管理關聯不多,本文不會涉及。

當創建一個Docker容器的時候,就會新建一個PID名空間。容器啟動進程在該名空間內PID為1。當PID1進程結束之后,Docker會銷毀對應的PID名空間,并向容器內所有其它的子進程發送SIGKILL。

下面我們來做一些試驗,下面我們會利用官方的Redis鏡像創建兩個容器,并觀察里面的進程。

如果你在Windows或Mac上利用"docker-machine",請利用 docker-machine ssh default 進入Boot2docker虛擬機

創建名為"redis"的容器,并在容器內部和宿主機中查看容器中的進程信息

docker@default:~$ docker run -d --name redis redis
f6bc57cc1b464b05b07b567211cb693ee2a682546ed86c611b5d866f6acc531c
docker@default:~$ docker exec redis ps -ef
UID        PID  PPID  C STIME TTY          TIME CMD
redis        1     0  0 01:49 ?        00:00:00 redis-server *:6379
root        11     0  0 01:49 ?        00:00:00 ps -ef
docker@default:~$ docker top redis
UID                 PID                 PPID                C                   STIME               TTY                 TIME                CMD
999                 9302                1264                0                   01:49               ?                   00:00:00            redis-server *:6379

創建名為"redis2"的容器,并在容器內部和宿主機中查看容器中的進程信息

docker@default:~$ docker run -d --name redis2 redis
356eca186321ab6ef4c4337aa0c7de2af1e01430587d6b0e1add2e028ed05f60
docker@default:~$ docker exec redis2 ps -ef
UID        PID  PPID  C STIME TTY          TIME CMD
redis        1     0  0 01:50 ?        00:00:00 redis-server *:6379
root        10     0  4 01:50 ?        00:00:00 ps -ef
docker@default:~$ docker top redis2
UID                 PID                 PPID                C                   STIME               TTY                 TIME                CMD
999                 9342                1264                0                   01:50               ?                   00:00:00            redis-server *:6379

我們可以使用 docker exec 命令進入容器PID名空間,并執行應用。通過 ps -ef 命令,可以看到每個Redis容器都包含一個PID為1的進程,"redis-server",它是容器的啟動進程,具有特殊意義。

利用 docker top 命令,可以讓我們從宿主機操作系統中看到容器的進程信息。在兩個容器中的"redis-server"是兩個獨立的進程,但是他們擁有相同的父進程 Docker Daemon。所以Docker可以父子進程的方式在Docker Daemon和Redis容器之間進行交互。

另一個值得注意的方面是, docker exec 命令可以進入指定的容器內部執行命令。由它啟動的進程屬于容器的namespace和相應的cgroup。但是這些進程的父進程是Docker Daemon而非容器的PID1進程。

我們下面會在Redis容器中,利用 docker exec 命令啟動一個"sleep"進程

docker@default:~$ docker exec -d redis sleep 2000
docker@default:~$ docker exec redis ps -ef
UID        PID  PPID  C STIME TTY          TIME CMD
redis        1     0  0 02:26 ?        00:00:00 redis-server *:6379
root        11     0  0 02:26 ?        00:00:00 sleep 2000
root        21     0  0 02:29 ?        00:00:00 ps -ef
docker@default:~$ docker top redis
UID                 PID                 PPID                C                   STIME               TTY                 TIME                CMD
999                 9955                1264                0                   02:12               ?                   00:00:00            redis-server *:6379
root                9984                1264                0                   02:13               ?                   00:00:00            sleep 2000

我們可以清楚的看到exec命令創建的sleep進程屬Redis容器的名空間,但是它的父進程是Docker Daemon。

如果我們在宿主機操作系統中手動殺掉容器的啟動進程(在上文示例中是redis-server),容器會自動結束,而容器名空間中所有進程也會退出。

docker@default:~$ PID=$(docker inspect --format="{{.State.Pid}}" redis)
docker@default:~$ sudo kill $PID
docker@default:~$ docker ps -a
CONTAINER ID        IMAGE               COMMAND                  CREATED             STATUS                     PORTS               NAMES
356eca186321        redis               "/entrypoint.sh redis"   23 minutes ago      Up 4 minutes               6379/tcp            redis2
f6bc57cc1b46        redis               "/entrypoint.sh redis"   23 minutes ago      Exited (0) 4 seconds ago                       redis

通過以上示例:

  • 每個容器有獨立的PID名空間,
  • 容器的生命周期和其PID1進程一致
  • 利用 docker exec 可以進入到容器的名空間中啟動進程

此外,自從Docker 1.5之后, docker run 命令引入了 --pid=host 參數來支持使用宿主機PID名空間來啟動容器進程,這樣可以方便的實現容器內應用和宿主機應用之間的交互:比如利用容器中的工具監控和調試宿主機進程。

如何指明容器PID1進程

在Docker容器中的初始化進程(PID1進程)在容器進程管理上具有特殊意義。它可以被Dockerfile中的 ENTRYPOINT 或 CMD 指令所指明;也可以被 docker run 命令的啟動參數所覆蓋。了解這些細節可以幫助我們更好地了解PID1的進程的行為。

關于ENTRYPOINT和CMD指令的不同,我們可以參見官方的Dockerfile說明和最佳實踐

值得注意的一點是:在ENTRYPOINT和CMD指令中,提供兩種不同的進程執行方式 shellexec

shell 方式中,CMD/ENTRYPOINT指令以如下方式定義

CMD executable param1 param2

這種方式中的PID1進程是以 /bin/sh -c ”executable param1 param2” 方式啟動的

而在 exec 方式中,CMD/ENTRYPOINT指令以如下方式定義

CMD ["executable","param1","param2"]

注意這里的可執行命令和參數是利用JSON字符串數組的格式定義的,這樣PID1進程會以 executable param1 param2 方式啟動的。另外,在 docker run 命令中指明的命令行參數也是以 exec 方式啟動的。

為了解釋兩種不同運行方式的區別,我們利用不同的Dockerfile分別創建兩個Redis鏡像

"Dockerfile_shell"文件內容如下,會利用shell方式啟動redis服務

FROM ubuntu:14.04
RUN apt-get update && apt-get -y install redis-server && rm -rf /var/lib/apt/lists/*
EXPOSE 6379
CMD "/usr/bin/redis-server"

"Dockerfile_exec"文件內容如下,會利用exec方式啟動redis服務

FROM ubuntu:14.04
RUN apt-get update && apt-get -y install redis-server && rm -rf /var/lib/apt/lists/*
EXPOSE 6379
CMD ["/usr/bin/redis-server"]

然后基于它們構建兩個鏡像"myredis:shell"和"myredis:exec"

docker build -t myredis:shell -f Dockerfile_shell .
docker build -t myredis:exec -f Dockerfile_exec .

運行"myredis:shell"鏡像,我們可以發現它的啟動進程(PID1)是 /bin/sh -c "/usr/bin/redis-server" ,并且它創建了一個子進程 /usr/bin/redis-server *:6379 。

docker@default:~$ docker run -d --name myredis myredis:shell
49f7fc37f4b7cf1ed7f5296537a93b2ad23b1b6686a05e5c7e40e9a2b2d3665e
docker@default:~$ docker exec myredis ps -ef
UID        PID  PPID  C STIME TTY          TIME CMD
root         1     0  0 08:12 ?        00:00:00 /bin/sh -c "/usr/bin/redis-server"
root         5     1  0 08:12 ?        00:00:00 /usr/bin/redis-server *:6379
root         8     0  0 08:12 ?        00:00:00 ps -ef

下面運行"myredis:exec"鏡像,我們可以發現它的啟動進程是 /usr/bin/redis-server *:6379 ,并沒有其他子進程存在。

docker@default:~$ docker run -d --name myredis2 myredis:exec
d1df0e4f4e3bbe36fca94f08df9ad3306fa1dee86415c853ddc5593fb9fa5673
docker@default:~$ docker exec myredis2 ps -ef
UID        PID  PPID  C STIME TTY          TIME CMD
root         1     0  0 08:13 ?        00:00:00 /usr/bin/redis-server *:6379
root         8     0  0 08:13 ?        00:00:00 ps -ef

由此我們可以清楚的看到,以exec和shell方式執行命令可能會導致容器的PID1進程不同。然而這又有什么問題呢?

原因在于:PID1進程對于操作系統而言具有特殊意義。操作系統的PID1進程是init進程,以守護進程方式運行,是所有其他進程的祖先,具有完整的進程生命周期管理能力。在Docker容器中,PID1進程是啟動進程,它也會負責容器內部進程管理的工作。而這也將導致進程管理在Docker容器內部和完整操作系統上的不同。

進程信號處理

信號是Unix/Linux中進程間異步通信機制。Docker提供了兩個命令 docker stop 和 docker kill 來向容器中的PID1進程發送信號。

當執行 docker stop 命令時,docker會首先向容器的PID1進程發送一個SIGTERM信號,用于容器內程序的退出。如果容器在收到SIGTERM后沒有結束, 那么Docker Daemon會在等待一段時間(默認是10s)后,再向容器發送SIGKILL信號,將容器殺死變為退出狀態。這種方式給Docker應用提供了一個優雅的退出(graceful stop)機制,允許應用在收到stop命令時清理和釋放使用中的資源。而 docker kill 可以向容器內PID1進程發送任何信號,缺省是發送SIGKILL信號來強制退出應用。

我們來看看不同的PID1進程,對進程信號處理的不同之處。首先,我們使用 docker stop 命令停止由**exec**模式啟動的“myredis2”容器,并檢查其日志

docker@default:~$ docker stop myredis2
myredis2
docker@default:~$ docker logs myredis2
[1] 11 Feb 08:13:01.631 # Warning: no config file specified, using the default config. In order to specify a config file use /usr/bin/redis-server /path/to/redis.conf
                .
.-``__ ''-.
.-`. `. ''-. Redis 2.8.4 (00000000/0) 64 bit .-`` .-.\/ ., ''-.
( ' , .-|, ) Running in stand alone mode |-._-...-__...-.``-._|' .-'| Port: 6379 | `-. ._ / _.-' | PID: 1-. `-. -./ _.-' _.-' |-.`-. -.__.-' _.-'_.-'| |-.`-. .-'.-' | http://redis.io
-._-.`-.__.-'.-' .-'
|`-.
-._-..-' .-'.-'|
| -._-. .-'.-' |
`-.
-._-.
.-'.-' .-'
-._-..-' .-'
`-.
_.-'
`-.
.-'

[1] 11 Feb 08:13:01.632 # Server started, Redis version 2.8.4 [1] 11 Feb 08:13:01.633 # WARNING overcommit_memory is set to 0! Background save may fail under low memory condition. To fix this issue add 'vm.overcommit_memory = 1' to /etc/sysctl.conf and then reboot or run the command 'sysctl vm.overcommit_memory=1' for this to take effect. [1] 11 Feb 08:13:01.633 The server is now ready to accept connections on port 6379 [1 | signal handler] (1455179074) Received SIGTERM, scheduling shutdown... [1] 11 Feb 08:24:34.259 # User requested shutdown... [1] 11 Feb 08:24:34.259 Saving the final RDB snapshot before exiting. [1] 11 Feb 08:24:34.262 * DB saved on disk [1] 11 Feb 08:24:34.262 # Redis is now ready to exit, bye bye... docker@default:~$ </code></pre>

我們發現對“myredis2”容器的stop命令幾乎立刻生效;而且在容器日志中,我們看到了“Received SIGTERM, scheduling shutdown...”的內容,說明“redis-server”進程接收到了SIGTERM消息,并優雅地退出。

我們再對利用**shell**模式啟動的“myredis”容器發出停止操作,并檢查其日志

docker@default:~$ docker stop myredis
myredis
docker@default:~$ docker logs myredis
[5] 11 Feb 08:12:40.108 # Warning: no config file specified, using the default config. In order to specify a config file use /usr/bin/redis-server /path/to/redis.conf
                .
.-``__ ''-.
.-`. `. ''-. Redis 2.8.4 (00000000/0) 64 bit .-`` .-.\/ ., ''-.
( ' , .-|, ) Running in stand alone mode |-._-...-__...-.``-._|' .-'| Port: 6379 | `-. ._ / _.-' | PID: 5-. `-. -./ _.-' _.-' |-.`-. -.__.-' _.-'_.-'| |-.`-. .-'.-' | http://redis.io
-._-.`-.__.-'.-' .-'
|`-.
-._-..-' .-'.-'|
| -._-. .-'.-' |
`-.
-._-.
.-'.-' .-'
-._-..-' .-'
`-.
_.-'
`-.
.-'

[5] 11 Feb 08:12:40.109 # Server started, Redis version 2.8.4 [5] 11 Feb 08:12:40.109 # WARNING overcommit_memory is set to 0! Background save may fail under low memory condition. To fix this issue add 'vm.overcommit_memory = 1' to /etc/sysctl.conf and then reboot or run the command 'sysctl vm.overcommit_memory=1' for this to take effect. [5] 11 Feb 08:12:40.109 * The server is now ready to accept connections on port 6379 docker@default:~$</code></pre>

我們發現對”myredis”容器的stop命令暫停了一會兒才結束,而且在日志中我們沒有看到任何收到SIGTERM信號的內容。原因其PID1進程sh沒有對SIGTERM信號的處理邏輯,所以它忽略了所接收到的SIGTERM信號。當Docker等待stop命令執行10秒鐘超時之后,Docker Daemon發送SIGKILL強制殺死sh進程,并銷毀了它的PID名空間,其子進程redis-server也在收到SIGKILL信號后被強制終止。如果此時應用還有正在執行的事務或未持久化的數據,強制進程退出可能導致數據丟失或狀態不一致。

通過這個示例我們可以清楚的理解PID1進程在信號管理的重要作用。所以,

  • 容器的PID1進程需要能夠正確的處理SIGTERM信號來支持優雅退出。
  • 如果容器中包含多個進程,需要PID1進程能夠正確的傳播SIGTERM信號來結束所有的子進程之后再退出。
  • 確保PID1進程是期望的進程。缺省sh/bash進程沒有提供SIGTERM的處理,需要通過shell腳本來設置正確的PID1進程,或捕獲SIGTERM信號。

另外需要注意的是:由于PID1進程的特殊性,Linux內核為他做了特殊處理。如果它沒有提供某個信號的處理邏輯,那么與其在同一個PID名空間下的進程發送給它的該信號都會被屏蔽。這個功能的主要作用是防止init進程被誤殺。我們可以驗證在容器內部發出的SIGKILL信號無法殺死PID1進程

docker@default:~$ docker start myredis
myredis
docker@default:~$ docker exec myredis kill -9 1
docker@default:~$ docker top myredis
UID                 PID                 PPID                C                   STIME               TTY                 TIME                CMD
root                3586                1290                0                   08:45               ?                   00:00:00            /bin/sh -c "/usr/bin/redis-server"
root                3591                3586                0                   08:45               ?                   00:00:00            /usr/bin/redis-server *:6379

孤兒進程與僵尸進程管理

熟悉Unix/Linux進程管理的同學對多進程應用并不陌生。

當一個子進程終止后,它首先會變成一個“失效(defunct)”的進程,也稱為“僵尸(zombie)”進程,等待父進程或系統收回(reap)。在Linux內核中維護了關于“僵尸”進程的一組信息(PID,終止狀態,資源使用信息),從而允許父進程能夠獲取有關子進程的信息。如果不能正確回收“僵尸”進程,那么他們的進程描述符仍然保存在系統中,系統資源會緩慢泄露。

大多數設計良好的多進程應用可以正確的收回僵尸子進程,比如NGINX master進程可以收回已終止的worker子進程。如果需要自己實現,則可利用如下方法:

1. 利用操作系統的waitpid()函數等待子進程結束并請除它的僵死進程,

2. 由于當子進程成為“defunct”進程時,父進程會收到一個SIGCHLD信號,所以我們可以在父進程中指定信號處理的函數來忽略SIGCHLD信號,或者自定義收回處理邏輯。

下面這些文章詳細介紹了對僵尸進程的處理方法

如果父進程已經結束了,那些依然在運行中的子進程會成為“孤兒(orphaned)”進程。在Linux中Init進程(PID1)作為所有進程的父進程,會維護進程樹的狀態,一旦有某個子進程成為了“孤兒”進程后,init就會負責接管這個子進程。當一個子進程成為“僵尸”進程之后,如果其父進程已經結束,init會收割這些“僵尸”,釋放PID資源。

然而由于Docker容器的PID1進程是容器啟動進程,它們會如何處理那些“孤兒”進程和“僵尸”進程?

下面我們做幾個試驗來驗證不同的PID1進程對僵尸進程不同的處理能力

首先在myredis2容器中啟動一個bash進程,并創建子進程“sleep 1000”

docker@default:~$ docker restart myredis2
myredis2
docker@default:~$ docker exec -ti myredis2 bash
root@d1df0e4f4e3b:/# sleep 1000

在另一個終端窗口,查看當前進程,我們可以發現一個sleep進程是bash進程的子進程。

docker@default:~$ docker exec myredis2 ps -ef
UID        PID  PPID  C STIME TTY          TIME CMD
root         1     0  0 12:21 ?        00:00:00 /usr/bin/redis-server *:6379
root         8     0  0 12:21 ?        00:00:00 bash
root        21     8  0 12:21 ?        00:00:00 sleep 1000
root        22     0  3 12:21 ?        00:00:00 ps -ef

我們殺死bash進程之后查看進程列表,這時候bash進程已經被殺死。這時候sleep進程(PID為21),雖然已經結束,而且被PID1進程(redis-server)接管,但是其沒有被父進程回收,成為僵尸狀態。

docker@default:~$ docker exec myredis2 kill -9 8
docker@default:~$ docker exec myredis2 ps -ef
UID        PID  PPID  C STIME TTY          TIME CMD
root         1     0  0 12:09 ?        00:00:00 /usr/bin/redis-server *:6379
root        21     1  0 12:10 ?        00:00:00 [sleep] <defunct>
root        32     0  0 12:10 ?        00:00:00 ps -ef
docker@default:~$ 

這是因為PID1進程“redis-server”沒有考慮過作為init對僵尸子進程的回收的場景。

我們來做另一個試驗,在用/bin/sh作為PID1進程的myredis容器中,再啟動一個bash進程,并創建子進程“sleep 1000”

docker@default:~$ docker start myredis
myredis
docker@default:~$ docker exec -ti myredis bash
root@49f7fc37f4b7:/# sleep 1000

查看容器中進程情況,

docker@default:~$ docker exec myredis ps -ef
UID        PID  PPID  C STIME TTY          TIME CMD
root         1     0  0 01:29 ?        00:00:00 /bin/sh -c "/usr/bin/redis-server"
root         5     1  0 01:29 ?        00:00:00 /usr/bin/redis-server *:6379
root         8     0  0 01:30 ?        00:00:00 bash
root        22     8  0 01:30 ?        00:00:00 sleep 1000
root        36     0  0 01:30 ?        00:00:00 ps -ef

我們殺死bash進程之后查看進程列表,發現“bash”和“sleep 1000”進程都已經被殺死和回收

docker@default:~$ docker exec myredis kill -9 8
docker@default:~$ docker exec myredis ps -ef
UID        PID  PPID  C STIME TTY          TIME CMD
root         1     0  0 01:29 ?        00:00:00 /bin/sh -c "/usr/bin/redis-server"
root         5     1  0 01:29 ?        00:00:00 /usr/bin/redis-server *:6379
root        45     0  0 01:31 ?        00:00:00 ps -ef
docker@default:~$ 

這是因為sh/bash等應用可以自動清理僵尸進程。

關于僵尸進程在Docker中init處理所需注意細節的詳細描述,可以在如下文章得到

簡單而言,如果在容器中運行多個進程,PID1進程需要有能力接管“孤兒”進程并回收“僵尸”進程。我們可以

1. 利用自定義的init進程來進行進程管理,比如 S6phusion myinitdumb-init

2. Bash/sh等缺省提供了進程管理能力,如果需要可以作為PID1進程來實現正確的進程回收。

進程監控

在Docker中,如果 docker run 命令中指明了 restart policy ,Docker Daemon會監控PID1進程,并根據策略自動重啟已結束的容器。

restart 策略 結果
no 不自動重啟,缺省值
on-failure[:max-retries] 當PID1進程退出值非0時,自動重啟容器;可以指定最大重試次數
always 永遠自動重啟容器;當Docker Daemon啟動時,會自動啟動容器
unless-stopped 永遠自動重啟容器;當Docker Daemon啟動時,如果之前容器不為stoped狀態就自動啟動容器

注意:為防止頻繁重啟故障應用導致系統過載,Docker會在每次重啟過程中會延遲一段時間。Docker重啟進程的延遲時間從100ms開始并每次加倍,如100ms,200ms,400ms等等。

利用Docker內置的restart策略可以大大簡化應用進程監控的負擔。但是Docker Daemon只是監控PID1進程,如果容器在內包含多個進程,仍然需要開發人員來處理進程監控。

大家一定非常熟悉 SupervisorMonit 等進程監控工具,他們可以方便的在容器內部中實現進程監控。Docker提供了相應的 文檔 來介紹,互聯網上也有很多資料,我們今天就不再贅述了。

另外利用Supervisor等工具作為PID1進程是在容器中支持多進程管理的主要實現方式;和簡單利用shell腳本fork子進程相比,采用Supervisor等工具有很多好處:

  • 一些傳統的服務不能以PID1進程的方式執行,利用Supervisor可以方便的適配
  • Supervisor這些監控工具大多提供了對SIGTERM的信號傳播支持,可以支持子進程優雅的退出

然而值得注意的是:Supervisor這些監控工具大多沒有完全提供Init支持的進程管理能力,如果需要支持子進程回收的場景需要配合正確的PID1進程來完成

總結

進程管理在Docker容器中和在完整的操作系統有一些不同之處。在每個容器的PID1進程,需要能夠正確的處理SIGTERM信號來支持容器應用的優雅退出,同時要能正確的處理孤兒進程和僵尸進程。

在Dockerfile中要注意shell模式和exec模式的不同。通常而言我們鼓勵使用exec模式,這樣可以避免由無意中選擇錯誤PID1進程所引入的問題。

在Docker中“一個容器一個進程的方式”并非絕對化的要求,然而在一個容器中實現對于多個進程的管理必須考慮更多的細節,比如子進程管理,進程監控等等。所以對于常見的需求,比如日志收集,性能監控,調試程序,我們依然建議采用多個容器組裝的方式來實現。

</code></code></code></code></code></code></code></code></code></code></code></code></code></code></code></code></code></code></code></code></div>

 本文由用戶 hujie00 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!