8

你不可不知的容器编排进阶技巧

 3 years ago
source link: https://blog.yuanpei.me/posts/172025911/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

你不可不知的容器编排进阶技巧

2021-08-1414 34 min.

在团队内推广Docker Compose有段时间啦,值得庆幸的是,最终落地效果还不错,因为说到底,大家都不大喜欢,那一长串复杂而枯燥的命令行参数。对我而言,最为重要的一点,团队内使用的技术变得更加透明化、标准化,因为每个微服务的配置信息都写在docker-compose.yml文件中,任何人都可以快速地构建出一套可用的服务,而不是每次都要去找具体的某一个人。我想说,这其实是一个信息流如何在团队内流动的问题。也许,我们有文档或者Wiki,可新人能不能快速融入其中,这才是检验信息流是否流动的唯一标准。就这样,团队从刀耕火种的Docker时代,进入到使用服务编排的Docker Compose时代。接下来,能否进入K8S甚至是云原生的时代,我终究不得而知。今天我想聊聊,在使用Docker Compose的过程中,我们遇到的诸如容器的启动顺序网络模式健康检查这类问题,我有一点Docker Compose的进阶使用技巧想和大家分享。

容器的启动顺序

使用服务编排以后,大家最关心的问题是,如果服务间存在依赖关系,那么如何保证容器的启动顺序?我承认,这是一个真实存在的问题,譬如,你的应用依赖某个数据库,理论上数据库要先启动,抑或者是像RedisKafkaEnvoy这样的基础设施,总是要优先于应用服务本身启动。

假如章鱼的这些脚互相影响会怎么样?
假如章鱼的这些脚互相影响会怎么样?

熟悉Docker Compose的同学,也许会想到depends_on这个选项,可如果大家亲自去尝试过就会知道,这终究只是我们的一厢情愿。为什么呢?因为这个depends_on主要是看目标容器是不是处于running的状态,所以,在大多数情况下,我们会注意到Docker Compose并不是按我们期望的顺序去启动的,因为目标容器在某一瞬间的确已经是running的状态了,那这样简直太尴尬了有木有啊!我们从一个简单的例子开始:

version: "3.8"
services:
redis_server:
image: redis:latest
command: >
/bin/bash -c '
sleep 5;
echo "sleep over";'
networks:
- backend
city_service:
build: CityService/
container_name: city_service
ports:
- "8081:80"
networks:
- backend
depends_on:
- redis_server

networks:
backend:

可以注意到,为了证明city_service服务不会等待redis_server服务,我故意让子弹飞了一会儿,结果如何呢?我们一起来看看:

Docker Compose 启动顺序:一厢情愿
Docker Compose 启动顺序:一厢情愿

果然,我没有骗各位,city_service服务不会等待redis_server服务。我们知道,Redis提供的命令行接口中,有一个PING命令,当Redis可以正常连接的时候,它会返回一个PONG,也许,这就是乒乓球的魅力所在。基于这个想法,我们继续修改docker-compose.yml文件:

version: "3.8"
services:
redis_server:
image: redis:latest
networks:
- backend
city_service:
build: CityService/
container_name: city_service
ports:
- "8081:80"
networks:
- backend
depends_on:
- redis_server
command: >
/bin/bash -c '
while ! nc -z redis_server 6379;
do
echo "wait for redis_server";
sleep 1;
done;

echo "redis_server is ready!";
echo "start city_service here";
'
networks:
backend:

这里,我们用了一种取巧的方法,Ubuntu中的nc命令可以对指定主机、指定端口进行检测,换言之,我们简单粗暴的认为,只要6379这个端口可以访问,就认为Redis准备就绪啦,因为我们没有办法在city_service这个容器中调用redis-cli,这个做法本身并不严谨,我们这里更多的是验证想法:

Docker Compose 启动顺序:检测 Redis
Docker Compose 启动顺序:检测 Redis

可以注意到,此时,city_service服务会等待redis_server服务,直到redis_server服务就绪。所以,要解决服务编排时,容器的启动顺序的问题,本质上就是把需要等待的服务、端口以及当前服务的启动命令,统一到容器的入口中。为此,官方提供了 wait-for-it 这个方案,官方关于容器启动顺序的文档,可以参考:Startup Order。对于上面的例子,我们可以这样改写docker-compose.yml文件:

version: "3.8"
services:
redis_server:
image: redis:latest
networks:
- backend
city_service:
build: CityService/
container_name: city_service
ports:
- "8081:80"
networks:
- backend
depends_on:
- redis_server
command: ["/wait-for-it.sh", "redis_server:6379", "--", "dotnet", "CityService.dll"]
networks:
backend:

此时,启动容器时的效果如下,因为这个方案依赖 Netcat 这样一个工具,所以,我们的容器中还需要加入这个工具,此时,可以使用下面的脚本片段:

FROM debian:buster-slim as wait-for-it
RUN apt-get update && apt-get install -y "wait-for-it"
COPY --from=wait-for-it /usr/bin/wait-for-it .

不过,不太明白为什么这里一直提示路径不对:

Docker Compose 启动顺序:wait-for-it.sh
Docker Compose 启动顺序:wait-for-it.sh

个人建议,最好将这个语句写在Dockerfile,或者试提供一个类似于entrypoint.sh的脚本文件。关于这个方案的更多细节,大家可以参考官方文档,写这篇文章的时候,我不由得感慨:Shell脚本真的是太难学了(逃……。所以,点到为止。刚刚提到过,我个人觉得这种主机 + 端口号的检测方式不够严谨,因为一个端口可以PING通,并不代表服务一定是可用的,所以,在接下来的内容里,我会介绍基于健康检查的思路。

容器的健康检查

不知道大家有没有这样的经历,就是你明明看到一个容器的状态变成Up ,可对应的微服务就是死活调不通。面对来自前端同事的戏谑与嘲讽,你不禁仰天长叹一声,开始在容器里翻箱倒柜,一通操作如虎。过了许久,你终于发现是容器内部出现了始料不及的错误。看来,容器状态显示为Up,并不代表容器内的服务就是可用的啊!果然,还是需要一种机制来判断容器内的服务是否可用啊!等等,这不就是传说中的健康检查?恭喜你,答对了!

Docker 经典集装箱形象
Docker 经典集装箱形象

DockerDocker Compse中,均原生支持 健康检查 机制,一旦一个容器指定了HEALTHCHECK选项,Docker会定时检查容器内的服务是否可用。我们都知道,一个普通的 Docker 容器,无非是开始、运行中、停止这样三种状态,而提供了HEALTHCHECK选项的Docker容器,会在这个基础上增加健康(healthy)和非健康(unhealthy)两种状态,所以,我们应该用这两个状态来判断容器内的服务是否可用。下面是一个指定了HEALTHCHECK选项的容器示例:

FROM FROM mcr.microsoft.com/dotnet/core/aspnet:3.1-buster-slim
EXPOSE 80
EXPOSE 443
WORKDIR /app
COPY /app/publish .
ENTRYPOINT ["dotnet", "CityService.dll"]
HEALTHCHECK --interval=5s --timeout=3s \
CMD curl -fs http://localhost:80/city || exit 1

可以注意到,Docker原生的健康机制,需要通过CMD的方式来执行一个命令行,如果该命令行返回 0 ,则表示成功;返回 1,则表示失败。

此处,我们还可以配置以下三个参数,--interval=<间隔>表示健康检查的间隔,默认为30秒;--timeout=<时长>表示健康检查命令超时时间,超过该时间即表示unhealthy,默认为30秒;--retries=<次数>表示连续失败的次数,超过该次数即表示unhealthy。对于我们这里的ASP.NET Core应用而言,如果程序正常启动,显然这个地址是可以调通的,我们可以用这个来作为一个“探针”。

Docker 健康检查:healthy
Docker 健康检查:healthy

我们可以注意到,在容器启动的第14秒,其状态为:health:starting。而等到容器启动的第16秒,其状态则为:healthy,这表明我们的服务是健康的。此时此刻,如果我们耍点小心思,让curl去访问一个不存在的地址会怎么样呢?可以注意到,此时状态变成了:unhealthy:

Docker 健康检查:unhealthy
Docker 健康检查:unhealthy

HEALTHCHECK指令除了可以直接写在Dockerfile中以外,还可以直接附加到docker run命令上,还是以上面的项目作为示例:

docker run  --name city_service -d -p 8081:80  city_service \
--health-cmd="curl -fs http://localhost:80/city || exit 1" \
--health-interval=3s \
--health-timeout=5s \
--health-retries=3

甚至,我们还可以使用下面的命令来查询容器的健康状态:docker inspect --format='' <ContainerID>

{
"Status": "unhealthy",
"FailingStreak": 5,
"Log": [{
"Start": "2021-08-14T15:27:50.3325424Z",
"End": "2021-08-14T15:27:50.3813102Z",
"ExitCode": 1,
"Output": ""
}]
}

不过,我个人感觉这个curl的写法非常别扭,尤其是当我试图在docker-compose中写类似命令的时候,我觉得稍微复杂一点的健康检查,还是交给脚本语言来实现吧!例如,下面是官方提供的针对MongoDB的健康检查的脚本docker-healthcheck.sh

#!/bin/bash
set -eo pipefail
host="$(hostname --ip-address || echo '127.0.0.1')"
if mongo --quiet "$host/test" --eval 'quit(db.runCommand({ ping: 1 }).ok ? 0 : 2)'; then
exit 0
fi
exit 1

此时,HEALTHCHECK可以简化为:

HEALTHCHECK --interval=5s --timeout=3s \
CMD bin/bash docker-healthcheck.sh

更多的示例,请参考:docker-library/healthcheck/ 以及 rodrigobdz/docker-compose-healthchecks

其实,对于容器的启动顺序问题,我们还可以借助检查检查的思路来解决,因为depends_on并不会等待目标容器进入ready状态,而是等目标容器进入running状态。这样,就回到了我们一开始描述的现象:一个容器明明都变为Up状态了,可为什么接口就是死活调不通呢?因为我们无法界定这样一个ready状态。考虑到depends_on可以指定condition,此时,我们可以这样编写docker-compose.yml文件:

version: "3.8"
services:
redis_server:
image: redis:latest
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 1s
timeout: 3s
retries: 30
networks:
- backend
city_service:
build: CityService/
container_name: city_service
ports:
- "8081:80"
networks:
- backend
depends_on:
redis_server:
condition: service_healthy
networks:
backend:

简单来说,我们使用了Redis内置的命令对redis_server服务进行健康检查,而city_service服务则依赖于redis_server服务的健康状态,只有当Redis准备就绪了以后,city_service才会开始启动。下面是实际启动过程的截图,看看是不是和我们想的一样:

Docker 健康检查:容器启动顺序
Docker 健康检查:容器启动顺序

果然,奇怪的知识有增加了呢,我们唯一需要解决的问题,就是怎么给某一个服务做健康检查,以上!

容器的网络模式

接下来,我们来说说Docker里的网络模式,特别是当我们使用docker-compose来编排一组服务的时候,假设我们有一个目录app,在这个牡蛎里我们放置了服务编排文件docker-compose.yml,默认情况下,Docker-Compose会创建一个一个名为app_default的网络,并且这个网络是bridge,即网桥模式的一个网络。什么是网桥模式呢?你可能会感到困惑,而这要从Docker中的网络模式开始说起,这里简单下常用的几种:

  • host模式,或叫做主机模式,可以认为容器和主机使用相同的端口进行访问,因为容器和主机在同一个网络下,此模式下,意味着通过-p绑定的端口失效,因为所有容器都使用主机的网络,所以容器间可以相互通信,此模式通过--network=host指定。
  • bridge模式,或叫做网桥模式,这是Docker中默认的网络设置,此模式下,容器和主机有各自的IP/端口号,两者之间通过一个虚拟网桥进行通信,虚拟网桥的作用类似于物理交换机。因此,不同容器间的网络是相互隔离的,此模式通过--network=bridge指定。
  • none模式,通俗讲就是无网络模式,意味着容器是一个封闭的环境,无法通过主机访问外部的网络,这种模式在那种讲究保密性质、封闭式开发的场合应该会有一点用,可这都2021年了,难道你还能把互联网上的软件全部下载下来吗?此模式通过--network=none指定。
  • container模式,或叫做共享模式,通俗来讲,就是指一个容器共享某个已经存在的容器的Network Namespace,此时,该容器将不会拥有属于自己的IP/端口号等资源,因为这种模式可以节约一定的网络资源,此模式通过--network=<Container_ID>/<Container_Name>指定。

为了帮助大家理解和区分这四种模式,博主绘制了下面的图示来补充说明:

容器的网络模式(主机、容器、网桥)示意图
容器的网络模式(主机、容器、网桥)示意图

通过以上的图文信息反复加深印象,相信大家可以找出点规律:

  • 如果你的容器网络与主机网络不需要隔离,那么选择主机模式(host)
  • 如果你的应用运行在不同的容器里,并且这些容器间需要相互通信,那么选择网桥模式(bridge)
  • 如果你的应用需要运行在一个隔绝外界网络的环境中,那么选择无网络模式(none)
  • 如果你希望在节省网络资源的同时,实现不同容器间的通信,那么选择容器模式(container)

以上四种网络模式,除了可以在docker run的时候指定以外,我们还可以在docker-compose.yml文件中指定。例如,下面表示的是一个主机模式的容器:

version: '3.8'
services:
cache_server:
build: .
container_name: cache_server
restart: always
network_mode: host

大多数情况下,我们只需要连接到docker0这个虚拟网卡即可,而如果你想为某个容器或者一组容器单独建立这样一张网卡,此时,就不得不提到Docker中的自定义网络功能,我们一起来看下面的示例:

// 创建一个网络:test-network
docker network create test-network
// 创建一个Nginx的容器:nginx_8087,使用网络:test-network
docker run -d --name nginx_8087 --network test-network -p 8087:80 nginx:latest
// 创建一个Nginx的容器:nginx_8088
docker run -d --name nginx_8088 -p 8088:80 nginx:latest
// 连接容器:nginx_8088 至网络:test-network
docker network connect test-network nginx_8088

接下来,通过下面的命令,我们可以拿到两个容器的ID,在此基础上我们看一下两个容器各自分配的IP是多少:

docker ps -a
docker inspect --format='{{.NetworkSettings.IPAddress}}' <ContainerID>

此时,我们会发现一个有趣的现象,nginx_8087这个容器,可以获得IP地址172.17.0.2,而nginx_8088则无法获得IP地址,这是为什么呢?这其实就是我们前面提到过的容器模式(container),此时,nginx_8088这个容器实际上是和nginx_8087共享一个Network Namespace,即使它们有各自的文件系统。同样地,我们可以使用下面的命令来让容器从某个网络中断开:

// 断开容器:nginx_8088 至网络:test-network
docker network disconnect test-network nginx_8088
// 删除网络
docker network rm test-network

是否觉得手动维护容器的网络非常痛苦?幸好,我们还有Docker-Compose可以用,上面两个Nginx的容器我们可以这样维护:

version: "3.8"
services:
nginx_8087:
image: nginx:latest
container_name: nginx_8087
ports:
- 8087:80
networks:
- test-network
nginx_8088:
image: nginx:latest
container_name: nginx_8088
ports:
- 8088:80
networks:
- test-network

networks:
test-network:
driver: bridge

此时,我们可以注意到,Docker Compose会创建两个网络,即network_mode_defaultnetwork_mode_test-network

Docker Compose 中使用自定义网络
Docker Compose 中使用自定义网络

这说明默认网络依然存在,如果我们希望完全地使用自定义网络,此时,我们可以这样修改服务编排文件:

networks:
default:
driver: host

这表示默认网络会采用主机模式,相应地,你需要修改nginx_8087nginx_8088两个容器的network选项,使其指向default

除此之外,你还可以使用external指向一个已经存在的网络:

networks:
default:
external: true
name: a-existing-network

Docker中,每个容器都会分配IP,因为这个IP总是不固定的,所以,如果我们希望像虚拟机那样使用一个静态IP的话,可以考虑下面的做法:

version: "3.8"
services:
nginx_8087:
image: nginx:latest
container_name: nginx_8087
ports:
- 8087:80
networks:
- test-network
ipv4_address: 172.2.0.10
nginx_8088:
image: nginx:latest
container_name: nginx_8088
ports:
- 8088:80
networks:
- test-network
ipv4_address: 172.2.0.11

networks:
test-network:
driver: bridge
config:
- subnet: 172.2.0.0/24

关于DockerDocker Compose中的网络驱动,如 macvlanoverlay 等等,这些显然是更加深入的话题,考虑到篇幅,不在这里做进一步的展开,对此感兴趣的朋友可以参考官方文档:Networking Overview 以及 Networking in Compose。博主写这篇文章的想法,主要是源于团队内落地Docker-Compose时的一次经历,当时有台虚拟机偶尔会出现IP被篡改的情况,而罪魁祸首居然是Docker-Compose,虽然最终用主机模式勉强解决了这个问题,可终究留下了难以言说的疑问,此刻,大概能稍微对Docker的网络有点了解。果然,越靠近底层,就是越是抽象、越是难以理解。

本文分享了DockerDocker-Compose中的进阶使用技巧,主要探索了服务编排场景下容器的启动顺序、健康检查、网络模式三类问题。默认情况下,Docker-Composedepends_on选项,取决于容器是否处于running状态,因此,当我们有多个服务需要启动时,实际上启动顺序并不会受到depends_on选项的影响,因为此时容器都是running的状态。为了解决这个问题,官方提供了 wait-for-it 的方案,这是一种利用 NetcatTCPUDP进行检测的机制,当检测条件被满足的时候,它会执行由用户指定的启动脚本。从这里看,其实已经有了一点健康检查的影子,而官方的健康检查,则允许用户使用更加自由的命令或者脚本去实现检测逻辑,所以,从这个角度上来讲,HEALTHCHECK结合depends_on,这才是实现容器启动顺序控制的终极方案。Docker的网络是一个相对复杂的概念,所以,这里就是简单的介绍了下常见的四种网络模式,更深入的话题比如网络驱动等,还需要花时间去做进一步的探索。本文示例以上传至Github,供大家参考。好了,以上就是这篇博客的全部内容啦,谢谢大家!


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK