在实际应用中,不同的服务之间是需要通信的,例如后端 API 和数据库;幸运的是,Docker 为我们提供了网络(Network)机制,能够轻松实现容器互联。这篇文章将带你轻松上手 Docker 网络,学会使用默认网络和自定义网络,成为一名能够连接多个“梦境”的筑梦师!
在 上一篇教程 中,我们带你了解了镜像和容器这两大关键的概念,熟悉了常用的 docker 命令,并成功地容器化了第一个应用。但是,那只是我们“筑梦之旅”的序章。接下来,我们将实现后端 API 服务器 + 数据库的容器化。
我们为你准备好了应用程序代码,请运行以下命令:
# 如果你看了上一篇教程,仓库已经克隆下来了
cd docker-dream
git fetch origin network-start
git checkout network-start
# 如果你打算直接从这篇教程开始
git clone -b network-start https://github.com/tuture-dev/docker-dream.git
cd docker-dream
复制代码
和之前容器化前端静态页面服务器相比,多了一个难点:服务器和数据库分别是两个独立的容器,但是服务器需要连接和访问数据库,怎么实现跨容器之间的通信?
在《盗梦空间》中,不同的梦境之间是无法连接的,然而幸运的是在 Docker 中是可以的——借助 Docker Network。
提示
在早期,Docker 容器可以通过 docker run 命令的
--link
选项来连接容器,但是 Docker 官方宣布这种方式已经过时,并有可能被移除 ( 参考文档 )。而本文将讲解 Docker 官方推荐的方式连接容器: 自定义网络 (User-defined Networks)。
Network 类型
Network,顾名思义就是“网络”,能够让不同的容器之间相互通信。首先有必要要列举一下 Docker Network 的五种驱动模式(driver):
-
bridge
:默认的驱动模式,即“网桥”,通常用于 单机 (更准确地说,是单个 Docker 守护进程) -
overlay
:Overlay 网络能够连接多个 Docker 守护进程,通常用于 集群 ,后续讲 Docker Swarm 的文章会重点讲解 -
host
:直接使用主机(也就是运行 Docker 的机器)网络,仅适用于 Docker 17.06+ 的集群服务 -
macvlan
:Macvlan 网络通过为每个容器分配一个 MAC 地址,使其能够被显示为一台物理设备,适用于希望直连到物理网络的应用程序(例如嵌入式系统、物联网等等) -
none
:禁用此容器的所有网络
这篇文章将围绕默认的 Bridge 网络驱动展开 。没错,就是连接不同梦境的那座“桥”。
小试牛刀
我们还是通过一些小实验来理解和感受 Bridge Network。与上一节不同的是,我们将使用 Alpine Linux 镜像作为实验原材料,因为:
- 非常轻量小巧(整个镜像仅 5MB 左右)
- 功能丰富,比“瑞士军刀” Busybox 还要完善
网桥网络可分为两类:
- 默认网络(Docker 运行时自带,不推荐用于生产环境)
- 自定义网络(推荐使用)
让我们分别实践一下吧。
默认网络
这个小实验的内容如下图所示:
我们会在默认的
bridge
网络上连接两个容器
alpine1
和
alpine2
。 运行以下命令,查看当前已有的网络:
docker network ls
复制代码
应该会看到以下输出(注意你机器上的 ID 很有可能不一样):
NETWORK ID NAME DRIVER SCOPE
cb33efa4d163 bridge bridge local
010deedec029 host host local
772a7a450223 none null local
复制代码
这三个默认网络分别对应上面的
bridge
、
host
和
none
网络类型。接下来我们将创建两个容器,分别名为
alpine1
和
alpine2
,命令如下:
docker run -dit --name alpine1 alpine
docker run -dit --name alpine2 alpine
复制代码
-dit
是
-d
(后台模式)、
-i
(交互模式)和
-t
(虚拟终端)三个选项的合并。通过这个组合,我们可以让容器保持在后台运行而不会退出(没错,相当于是在“空转”)。
用
docker ps
命令确定以上两个容器均在后台运行:
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
501559d2fab7 alpine "/bin/sh" 2 seconds ago Up 1 second alpine2
18bed3178732 alpine "/bin/sh" 3 seconds ago Up 2 seconds alpine1
复制代码
通过以下命令查看默认的
bridge
网络的详情:
docker network inspect bridge
复制代码
应该会输出 JSON 格式的网络详细数据:
[
{
"Name": "bridge",
"Id": "cb33efa4d163adaa61d6b80c9425979650d27a0974e6d6b5cd89fd743d64a44c",
"Created": "2020-01-08T07:29:11.102566065Z",
"Scope": "local",
"Driver": "bridge",
"EnableIPv6": false,
"IPAM": {
"Driver": "default",
"Options": null,
"Config": [
{
"Subnet": "172.17.0.0/16",
"Gateway": "172.17.0.1"
}
]
},
"Internal": false,
"Attachable": false,
"Ingress": false,
"ConfigFrom": {
"Network": ""
},
"ConfigOnly": false,
"Containers": {
"18bed3178732b5c7a37d7ad820c111fac72a6b0f47844401d60a18690bd37ee5": {
"Name": "alpine1",
"EndpointID": "9c7d8ee9cbd017c6bbdfc023397b23a4ce112e4957a0cfa445fd7f19105cc5a6",
"MacAddress": "02:42:ac:11:00:02",
"IPv4Address": "172.17.0.2/16",
"IPv6Address": ""
},
"501559d2fab736812c0cf181ed6a0b2ee43ce8116df9efbb747c8443bc665b03": {
"Name": "alpine2",
"EndpointID": "da192d61e4b2df039023446830bf477cc5a9a026d32938cb4a350a82fea5b163",
"MacAddress": "02:42:ac:11:00:03",
"IPv4Address": "172.17.0.3/16",
"IPv6Address": ""
}
},
"Options": {
"com.docker.network.bridge.default_bridge": "true",
"com.docker.network.bridge.enable_icc": "true",
"com.docker.network.bridge.enable_ip_masquerade": "true",
"com.docker.network.bridge.host_binding_ipv4": "0.0.0.0",
"com.docker.network.bridge.name": "docker0",
"com.docker.network.driver.mtu": "1500"
},
"Labels": {}
}
]
复制代码
我们重点要关注的是两个字段:
-
IPAM
:IP 地址管理信息(IP Address Management),可以看到网关地址为172.17.0.1
(由于篇幅有限,想要了解 网关 的同学可自行查阅计算机网络以及 TCP/IP 协议方面的资料) -
Containers
:包括此网络上连接的所有容器,可以看到我们刚刚创建的alpine1
和alpine2
,它们的 IP 地址分别为172.17.0.2
和172.17.0.3
(后面的/16
是子网掩码,暂时不用考虑)
提示
如果你熟悉 Go 模板语法,可以通过
-f
(format
)参数过滤掉不需要的信息。例如我们只想查看bridge
的网关地址:
$ docker network inspect --format '{{json .IPAM.Config }}' bridge [{"Subnet":"172.17.0.0/16","Gateway":"172.17.0.1"}] 复制代码
让我们进入
alpine1
容器中:
docker attach alpine1
复制代码
注意
attach
命令只能进入设置了交互式运行的容器(也就是在启动时加了-i
参数)。
如果你看到前面的命令提示符变成
/ #
,说明我们已经身处容器之中了。我们通过
ping
命令测试一下网络连接情况,首先 ping 一波图雀社区的主站 tuture.co(
-c
参数代表发送数据包的数量,这里我们设为 5):
/ # ping -c 5 tuture.co
PING tuture.co (150.109.19.98): 56 data bytes
64 bytes from 150.109.19.98: seq=2 ttl=37 time=65.294 ms
64 bytes from 150.109.19.98: seq=3 ttl=37 time=65.425 ms
64 bytes from 150.109.19.98: seq=4 ttl=37 time=65.332 ms
--- tuture.co ping statistics ---
5 packets transmitted, 3 packets received, 40% packet loss
round-trip min/avg/max = 65.294/65.350/65.425 ms
复制代码
OK,虽然丢了几个包,但是可以连上(取决于你的网络环境,全丢包也是正常的)。由此可见, 容器内可以访问主机所连接的全部网络 (包括 localhost)。
接下来测试能否连接到
alpine2
,在刚才
docker network inspect
命令的输出中找到
alpine2
的 IP 为
172.17.0.3
,尝试能否 ping 通:
/ # ping -c 5 172.17.0.3
PING 172.17.0.3 (172.17.0.3): 56 data bytes
64 bytes from 172.17.0.3: seq=0 ttl=64 time=0.147 ms
64 bytes from 172.17.0.3: seq=1 ttl=64 time=0.103 ms
64 bytes from 172.17.0.3: seq=2 ttl=64 time=0.102 ms
64 bytes from 172.17.0.3: seq=3 ttl=64 time=0.125 ms
64 bytes from 172.17.0.3: seq=4 ttl=64 time=0.125 ms
--- 172.17.0.3 ping statistics ---
5 packets transmitted, 5 packets received, 0% packet loss
round-trip min/avg/max = 0.102/0.120/0.147 ms
复制代码
完美!我们能够从
alpine1
中访问
alpine2
容器。作为练习,你可以自己尝试一下能否从
alpine2
容器中 ping 通
alpine1
哦。
注意
如果你不想让
alpine1
停下来,记得通过 Ctrl + P + Ctrl + Q(按住 Ctrl,然后依次按 P 和 Q 键)“脱离”(detach,也就是刚才attach
命令的反义词)容器,而不是按 Ctrl + D 哦。
自定义网络
如果你跟着上面一路试下来,会发现默认的 bridge 网络存在一个很大的问题: 只能通过 IP 地址相互访问 。这毫无疑问是非常麻烦的,当容器数量很多的时候难以管理,而且每次的 IP 都可能发生变化。
而自定义网络则很好地解决了这一问题。
在同一个自定义网络中,每个容器能够通过彼此的名称相互通信
,因为 Docker 为我们搞定了 DNS 解析工作,这种机制被称为
服务发现
(Service Discovery)。具体而言,我们将创建一个自定义网络
my-net
,并创建
alpine3
和
alpine4
两个容器,连上
my-net
,如下图所示。
让我们开始动手吧。首先创建自定义网络
my-net
:
docker network create my-net
# 由于默认网络驱动为 bridge,因此相当于以下命令
# docker network create --driver bridge my-net
复制代码
查看当前所有的网络:
docker network ls
复制代码
可以看到刚刚创建的
my-net
:
NETWORK ID NAME DRIVER SCOPE
cb33efa4d163 bridge bridge local
010deedec029 host host local
feb13b480be6 my-net bridge local
772a7a450223 none null local
复制代码
创建两个新的容器
alpine3
和
alpine4
:
docker run -dit --name alpine3 --network my-net alpine
docker run -dit --name alpine4 --network my-net alpine
复制代码
可以看到,我们通过
--network
参数指定容器想要连接的网络(也就是刚才创建的
my-net
)。
提示
如果在一开始创建并运行容器时忘记指定网络,那么下次再想指定网络时,可以通过
docker network connect
命令再次连上(第一个参数是网络名称my-net
,第二个是需要连接的容器alpine3
):docker network connect my-net alpine3 复制代码
进入到
alpine3
中,测试能否 ping 通
alpine4
:
$ docker attach alpine3
/ # ping -c 5 alpine4
PING alpine4 (172.19.0.3): 56 data bytes
64 bytes from 172.19.0.3: seq=0 ttl=64 time=0.247 ms
64 bytes from 172.19.0.3: seq=1 ttl=64 time=0.176 ms
64 bytes from 172.19.0.3: seq=2 ttl=64 time=0.180 ms
64 bytes from 172.19.0.3: seq=3 ttl=64 time=0.176 ms
64 bytes from 172.19.0.3: seq=4 ttl=64 time=0.161 ms
--- alpine4 ping statistics ---
5 packets transmitted, 5 packets received, 0% packet loss
round-trip min/avg/max = 0.161/0.188/0.247 ms
复制代码
可以看到
alpine4
被自动解析成了
172.19.0.3
。我们可以通过
docker network inspect
来验证一下:
$ docker network inspect --format '{{range .Containers}}{{.Name}}: {{.IPv4Address}} {{end}}' my-net
alpine4: 172.19.0.3/16 alpine3: 172.19.0.2/16
复制代码
可以看到
alpine4
的 IP 的确为
172.19.0.3
,解析是正确的!
一些收尾工作
实验做完了,让我们把之前所有的容器全部销毁:
docker rm -f alpine1 alpine2 alpine3 alpine4
复制代码
把创建的
my-net
也删除:
docker network rm my-net
复制代码
动手实践
容器化服务器
我们首先对后端服务器也进行容器化。创建
server/Dockerfile
,代码如下:
FROM node:10
# 指定工作目录为 /usr/src/app,接下来的命令全部在这个目录下操作
WORKDIR /usr/src/app
# 将 package.json 拷贝到工作目录
COPY package.json .
# 安装 npm 依赖
RUN npm config set registry https://registry.npm.taobao.org && npm install
# 拷贝源代码
COPY . .
# 设置环境变量(服务器的主机 IP 和端口)
ENV MONGO_URI=mongodb://dream-db:27017/todos
ENV HOST=0.0.0.0
ENV PORT=4000
# 开放 4000 端口
EXPOSE 4000
# 设置镜像运行命令
CMD [ "node", "index.js" ]
复制代码
可以看到这个 Dockerfile 比 上一篇教程 中的要复杂不少。每一行的含义已经注释在代码中了,我们来看一看多了哪些新东西:
-
RUN
指令用于在容器中运行任何命令,这里我们通过npm install
安装所有项目依赖(当然之前配置了一下 npm 镜像,可以安装得快一点) -
ENV
指令用于向容器中注入环境变量,这里我们设置了 数据库的连接字符串MONGO_URI
( 注意这里给数据库取名为dream-db
,后面就会创建这个容器 ),还配置了服务器的HOST
和PORT
-
EXPOSE
指令用于开放端口 4000。之前在用 Nginx 容器化前端项目时没有指定,是因为 Nginx 基础镜像已经开放了 8080 端口,无需我们设置;而这里用的 Node 基础镜像则没有开放,需要我们自己去配置 -
CMD
指令用于指定此容器的启动命令(也就是docker ps
查看时的 COMMAND 一列),对于服务器来说当然就是保持运行状态。在后面“回忆与升华”部分会详细展开。
注意
初次尝试容器的朋友很容易犯的一个错误就是忘记将服务器的
host
从localhost
(127.0.0.1
)改成0.0.0.0
,导致服务器无法在容器之外被访问到( 我自己学习的时候也浪费了很多时间 )。
与之前前端容器化类似,创建
server/.dockerignore
文件,忽略服务器日志
access.log
和
node_modules
,代码如下:
node_modules
access.log
复制代码
在项目根目录下运行以下命令,构建服务器镜像,指定名称为
dream-server
:
docker build -t dream-server server
复制代码
连接服务器与数据库
根据之前的知识,我们为现在的“梦想清单”应用创建一个自定义网络
dream-net
:
docker network create dream-net
复制代码
我们使用官方的
mongo
镜像创建并运行 MongoDB 容器,命令如下:
docker run --name dream-db --network dream-net -d mongo
复制代码
我们指定容器名称为
dream-db
(还记得这个名字吗),所连接的网络为
dream-net
,并且在后台模式下运行(
-d
)。
提示
你也许会问,为什么这里开启容器的时候没有指定端口映射呢?因为 在同一自定义网络中的所有容器会互相暴露所有端口 ,不同的应用之间可以更轻松地相互通信;同时,除非通过
-p
(--publish
)手动开放端口, 网络之外无法访问网络中容器的其他端口 ,实现了良好的隔离性。 网络之内的互操作性 和 网络内外的隔离性 也是 Docker Network 的一大优势所在。
危险!
这里我们在开启 MongoDB 数据库容器时没有设置任何鉴权措施(例如设置用户名和密码),所有连接数据库的请求都可以任意修改数据,在生产环境是极其危险的。后续文章中我们会讲解如何在容器中管理机密信息(例如密码)。
然后运行服务器容器:
docker run -p 4000:4000 --name dream-api --network dream-net -d dream-server
复制代码
查看服务器容器的日志输出,确定 MongoDB 连接成功:
$ docker logs dream-api
Server is running on http://0.0.0.0:4000
Mongoose connected.
复制代码
接着你可以通过 Postman 或者 curl 来测试一波服务器 API (
localhost:4000
),这里为了节约篇幅就省略了。当然你也可以直接跳过,因为马上我们就可以通过前端来操作数据了!
容器化前端页面
正如 上一篇文章 所实现的那样,在项目根目录下,通过以下命令进行容器化:
docker build -t dream-client client
复制代码
然后运行容器:
docker run -p 8080:80 --name client -d dream-client
复制代码
可以通过
docker ps
命令检验三个容器是否全部正确开启:
最后,访问
localhost:8080
: