前言
在Docker简单上手01中我们熟悉了常用的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
提示
我直接用的上一篇文章中下载的项目但是在后面容器化的时候一直无法成功,后来发现是上一篇下载的项目和这一篇的不一样,建议直接重新克隆下载项目。和之前容器化前端静态页面服务器相比,多了一个难点:服务器和数据库分别是两个独立的容器,但是服务器需要连接和访问数据库,怎么实现跨容器之间的通信?
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镜像体验下。
网桥网络分为两类:
- 默认网络(Docker运行时自带,不推荐用于生产环境)。
- 自定义网络(推荐使用)。
默认网络
我们会在默认的 bridge 网络上连接两个容器 alpine1 和 alpine2。 运行以下命令,查看当前已有的网络:
docker network ls
应该会看到以下输出(注意你机器上的 ID 很有可能不一样):
这三个默认网络分别对应上面的 bridge、host 和 none 网络类型。接下来我们将创建两个容器,分别名为 alpine1 和 alpine2,命令如下:
docker run -dit --name alpine1 alpine
docker run -dit --name alpine2 alpine
-dit 是 -d(后台模式)、-i(交互模式)和 -t(虚拟终端)三个选项的合并。通过这个组合,我们可以让容器保持在后台运行而不会退出(没错,相当于是在 “空转”)。
用 docker ps
命令确定以上两个容器均在后台运行:
通过以下命令查看默认的 bridge 网络的详情:
docker network inspect bridge
应该会输出 JSON 格式的网络详细数据:
[
{
"Name": "bridge",
"Id": "4741cab870f168d59a3b5eb1d67251d177dd3af14b8cccc39c130a3f0ec5e3a0",
"Created": "2020-10-07T11:18:54.983568882+08:00",
"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": {
"1af67ce67ec14f57199a6b4e9183330e017014ad33cad0f783b4a3ab9e763bd0": {
"Name": "client",
"EndpointID": "1b800afa81ba8e7d15c806c349166c5f24619d71710825860f4d4eef706badf8",
"MacAddress": "02:42:ac:11:00:02",
"IPv4Address": "172.17.0.2/16",
"IPv6Address": ""
},
"27783577c5b621ff8d2dfdc2982bad4635ac093ed261aa4f4189788f8efe0807": {
"Name": "alpine2",
"EndpointID": "8539c75c6cc44dceb143aac1936e215e2f80f6b6a412c6f10b33722c4f9cf74b",
"MacAddress": "02:42:ac:11:00:04",
"IPv4Address": "172.17.0.4/16",
"IPv6Address": ""
},
"e7a69f15fa39cdd2434dac9ec9e84f7d7b133f062dfe110cfcde297546830043": {
"Name": "alpine1",
"EndpointID": "0c0310de6ec103386a9485166b54826efa574aa6b3da7996c16385f1d0bb16c5",
"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.3 和 172.17.0.4(后面的 /16 是子网掩码,暂时不用考虑)。
进入 alpine1 容器中:
docker attach alpine1
{% blockquote %}
attach 命令只能进入设置了交互式运行的容器(也就是在启动时加了 -i 参数)。
{% endblockquote %}
如果你看到前面的命令提示符变成 / #,说明我们已经身处容器之中了。我们通过 ping 命令测试一下网络连接情况,首先 ping 一下百度 baidu.com(-c 参数代表发送数据包的数量,这里我们设为 5):
看上图,全部都连上了没有丢包(这个取决于你的网络环境)。由此可见,容器内可以访问主机所连接的全部网络(包括 localhost)。
接下来测试能否连接到 alpine2,在刚才 docker network inspect 命令的输出中找到 alpine2 的 IP 为 172.17.0.3,尝试能否 ping 通:
完美!我们能够从 alpine1 中访问 alpine2 容器。作为练习,你可以自己尝试一下能否从 alpine2 容器中 ping 通 alpine1 哦。
{% blockquote %}
如果你不想让 alpine1 停下来,记得通过 Ctrl + P + Ctrl + Q(按住 Ctrl,然后依次按 P 和 Q 键)“脱离”(detach,也就是刚才 attach 命令的反义词)容器,而不是按 Ctrl + D 哦。
{% endblockquote %}
自定义网络
默认的 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
4741cab870f1 bridge bridge local
a5ccbe18d2ab host host local
5d8856725e7f my-net bridge local
a18954d078b5 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)。
{% blockquote %}
如果在一开始创建并运行容器时忘记指定网络,那么下次再想指定网络时,可以通过 docker network connect 命令再次连上(第一个参数是网络名称 my-net,第二个是需要连接的容器 alpine3):
docker network connect my-net alpine3
{% endblockquote %}
进入到 alpine3 中,测试能否 ping 通 alpine4:
可以看到 alpine4 被自动解析成了 172.18.0.3。我们可以通过 docker network inspect 来验证一下:
docker network inspect --format '{{range .Containers}}{{.Name}}: {{.IPv4Address}} {{end}}' my-net
收尾工作
通过上面的例子我们明白了默认和自定义网络的一个大致流程,现在可以结束之前创建的容器的使命了:
docker rm -f alpine1 alpine2 alpine3 alpine4
把创建的 my-net 也删除:
docker network rm my-net
动手实践
下面我们来正式的进行今天的主题内容:
容器化服务器
我们首先对后端服务器也进行容器化。创建 server/Dockerfile,代码如下:
注意是在docker-dream目录下哦
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.
容器化前端页面
在项目根目录下,通过以下命令进行容器化:
docker build -t dream-client client
然后运行容器:
docker run -p 8080:80 --name client -d dream-client
可以通过 docker ps
命令检验三个容器是否全部正确开启:
最后,访问 localhost:8080(服务器域名:8080)就可以看到,我们在最后刷新了几次页面,数据记录也都还在,说明我们带有数据库的全栈应用跑起来了!