Docker curriculum (3): 多容器环境

多容器环境

在上一节中,我们看到了使用 Docker 运行应用程序是多么容易和有趣。我们从一个简单的静态网站开始,然后尝试了一个 Flask 应用程序。只需几个命令,我们就可以在本地或者云服务器中运行这两者。这两个应用程序的一个共同点是它们都在一个容器中运行

那些在生产中运行服务的人都知道,现在的应用程序通常并不那么简单。几乎总是涉及到数据库(或任何其他类型的持久存储)。Redis和Memcached等系统已成为大多数 Web 应用程序架构的必备品。因此,在本节中,我们将花一些时间来学习如何将依赖于不同服务运行的应用程序 Docker化

特别是,我们将了解如何运行和管理多容器docker 环境。你可能会问为什么是多容器?好吧,Docker 的关键点之一是它提供隔离的方式。将进程与其依赖项捆绑在沙箱(称为容器)中的想法使其如此强大。

就像解耦应用程序层是一个很好的策略一样,将每个服务的容器分开是明智的。每个层可能有不同的资源需求,这些需求可能以不同的速度增长。通过将层分成不同的容器,可以根据不同的资源需求使用最合适的实例类型来组合每一层。这也非常适合整个微服务,这也是 Docker(或任何其他容器技术)处于现代微服务架构前沿的主要原因之一。

项目实践

以SF FOOD Trucks为例,来实现多容器环境。构建这个应用程序的目标是有一些有用的东西(因为它类似于现实世界的应用程序),依赖于至少一个服务。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-fSDv1U97-1632740666341)(C:\Users\DELL-3020\AppData\Roaming\Typora\typora-user-images\image-20210927150421460.png)]

该应用程序的后端是用 Python (Flask) 编写的,它使用Elasticsearch进行搜索。与本教程中的其他内容一样,整个源代码都可以在Github 上找到。本文将使用它作为候选应用程序来学习如何构建、运行和部署多容器环境。

首先,让我们在本地克隆存储库

$ git clone https://github.com/prakhar1989/FoodTrucks
$ cd FoodTrucks
$ tree -L 2
.
├── Dockerfile
├── README.md
├── aws-compose.yml
├── docker-compose.yml
├── flask-app
│   ├── app.py
│   ├── package-lock.json
│   ├── package.json
│   ├── requirements.txt
│   ├── static
│   ├── templates
│   └── webpack.config.js
├── setup-aws-ecs.sh
├── setup-docker.sh
├── shot.png
└── utils
    ├── generate_geojson.py
    └── trucks.geojson

flask-app文件夹包含 Python 应用程序,而该utils文件夹具有一些用于将数据加载到 Elasticsearch 的实用程序。该目录还包含一些 YAML 文件和一个 Dockerfile,随着本教程的进展,将更详细地了解所有这些文件。

现在很兴奋(希望如此),让我们考虑如何对应用程序进行容器化。我们可以看到该应用程序由一个 Flask 后端服务器和一个 Elasticsearch 服务组成。拆分此应用程序的一种自然方式是拥有两个容器——一个运行 Flask 进程,另一个运行 Elasticsearch (ES) 进程。这样,如果我们的应用程序变得流行,我们可以根据瓶颈所在的位置添加更多容器来扩展它。

太好了,所以我们需要两个容器。那应该不难吧?我们已经在上一节中构建了我们自己的 Flask 容器。而对于 Elasticsearch,让我们看看我们是否可以在hub上找到一些东西。

$ docker search elasticsearch
NAME                              DESCRIPTION                                     STARS     OFFICIAL   AUTOMATED
elasticsearch                     Elasticsearch is a powerful open source se...   697       [OK]
itzg/elasticsearch                Provides an easily configurable Elasticsea...   17                   [OK]
tutum/elasticsearch               Elasticsearch image - listens in port 9200.     15                   [OK]
barnybug/elasticsearch            Latest Elasticsearch 1.7.2 and previous re...   15                   [OK]
digitalwonderland/elasticsearch   Latest Elasticsearch with Marvel & Kibana       12                   [OK]
monsantoco/elasticsearch          ElasticSearch Docker image                      9                    [OK]

毫不奇怪,Elasticsearch存在官方支持的图像。为了让 ES 运行,我们可以简单地使用docker run并立即在本地运行一个单节点 ES 容器。

我们先拉取镜像:

docker pull docker.elastic.co/elasticsearch/elasticsearch:6.3.2

然后通过指定端口和设置环境变量来在开发模式下运行它,该环境变量将 Elasticsearch 集群配置为作为单节点运行。

$ docker run -d --name es -p 9200:9200 -p 9300:9300 -e "discovery.type=single-node" docker.elastic.co/elasticsearch/elasticsearch:6.3.2
277451c15ec183dd939e80298ea4bcf55050328a39b04124b387d668e3ed3943

如上所示,使用--name es给容器一个名称,以便在后续命令中使用。容器启动后,可以通过docker container logs使用容器名称(或 ID)运行来检查日志来查看日志。如果 Elasticsearch 启动成功,应该会看到类似于下面的日志。

$ docker container ls
CONTAINER ID        IMAGE                                                 COMMAND                  CREATED             STATUS              PORTS                                            NAMES
277451c15ec1        docker.elastic.co/elasticsearch/elasticsearch:6.3.2   "/usr/local/bin/dock…"   2 minutes ago       Up 2 minutes        0.0.0.0:9200->9200/tcp, 0.0.0.0:9300->9300/tcp   es

$ docker container logs es
[INFO ][o.e.n.Node               ] [] initializing ...
[INFO ][o.e.e.NodeEnvironment    ] [L1VMyzt] using [1] data paths, mounts [[/ (overlay)]], net usable_space [54.1gb], net total_space [62.7gb], types [overlay]
[INFO ][o.e.e.NodeEnvironment    ] [L1VMyzt] heap size [990.7mb], compressed ordinary object pointers [true]
[INFO ][o.e.p.PluginsService     ] [L1VMyzt] loaded module [x-pack-security]
[INFO ][o.e.p.PluginsService     ] [L1VMyzt] loaded module [x-pack-sql]
[INFO ][o.e.p.PluginsService     ] [L1VMyzt] loaded module [x-pack-upgrade]
[INFO ][o.e.p.PluginsService     ] [L1VMyzt] loaded module [x-pack-watcher]
[INFO ][o.e.p.PluginsService     ] [L1VMyzt] loaded plugin [ingest-geoip]
[INFO ][o.e.p.PluginsService     ] [L1VMyzt] loaded plugin [ingest-user-agent]
[INFO ][o.e.d.DiscoveryModule    ] [L1VMyzt] using discovery type [single-node]
[INFO ][o.e.n.Node               ] [L1VMyzt] initialized
[INFO ][o.e.n.Node               ] [L1VMyzt] starting ...
[INFO ][o.e.t.TransportService   ] [L1VMyzt] publish_address {172.17.0.2:9300}, bound_addresses {0.0.0.0:9300}
[INFO ][o.e.x.s.t.n.SecurityNetty4HttpServerTransport] [L1VMyzt] publish_address {172.17.0.2:9200}, bound_addresses {0.0.0.0:9200}
[INFO ][o.e.n.Node               ] [L1VMyzt] started
[WARN ][o.e.x.s.a.s.m.NativeRoleMappingStore] [L1VMyzt] Failed to clear cache for realms [[]]
[INFO ][o.e.g.GatewayService     ] [L1VMyzt] recovered [0] indices into cluster_state

现在,尝试看看是否可以向 Elasticsearch 容器发送请求。我们使用9200端口向cURL容器发送请求。

$ curl 0.0.0.0:9200
{
  "name" : "ijJDAOm",
  "cluster_name" : "docker-cluster",
  "cluster_uuid" : "a_nSV3XmTCqpzYYzb-LhNw",
  "version" : {
    "number" : "6.3.2",
    "build_flavor" : "default",
    "build_type" : "tar",
    "build_hash" : "053779d",
    "build_date" : "2018-07-20T05:20:23.451332Z",
    "build_snapshot" : false,
    "lucene_version" : "7.3.1",
    "minimum_wire_compatibility_version" : "5.6.0",
    "minimum_index_compatibility_version" : "5.0.0"
  },
  "tagline" : "You Know, for Search"
}

看起来不错!在此期间,也让 Flask 容器运行。但在开始之前,需要一个Dockerfile. 在上一节中,我们使用python:3图像作为基础图像。然而,这一次,除了通过 安装 Python 依赖项之外pip,我们还希望应用程序还生成用于生产的缩小的 Javascript 文件。为此,将需要 Nodejs。由于需要自定义构建步骤,因此将从ubuntu基础镜像开始Dockerfile,从头开始构建镜像。

注意:如果发现现有镜像不能满足需求,请随时从另一个基本镜像开始并自行调整。对于Docker Hub上的大部分镜像,应该可以在Github上找到对应的Dockerfile。通读现有的 Dockerfile 是学习如何推出自己的 Dockerfile 的最佳方法之一。

# start from base
FROM ubuntu:18.04

MAINTAINER Prakhar Srivastav <[email protected]>

# install system-wide deps for python and node
RUN apt-get -yqq update
RUN apt-get -yqq install python3-pip python3-dev curl gnupg
RUN curl -sl https://deb.nodesource.com/setup_10.x | bash
RUN apt-get install -yq nodejs

# copy our application code
ADD flask-app /opt/flask-app
WORKDIR /opt/flask-app

# fetch app specific deps
RUN npm install
RUN npm run build
RUN pip3 install -r requirements.txt

# expose port
EXPOSE 5000

# start app
CMD [ "python3", "./app.py" ]

这个dockerfile中有很多新东西,所以快速浏览一下这个文件。首先从Ubuntu LTS基础镜像开始,并使用包管理器apt-get来安装依赖项——Python 和 Node.js。yqq标志用于抑制输出并对所有提示假定“是”。

然后使用**ADD命令将我们的应用程序复制到容器中的新卷中** - /opt/flask-app. 这是我们的代码将驻留的地方。还将其设置为我们的工作目录以便在此位置的上下文中运行以下命令。现在系统范围的依赖项已经安装,下面开始安装特定于应用程序的依赖项。首先,我们通过从 npm 安装包并运行我们的package.json 文件中定义的构建命令来处理 Node 。通过安装 Python 包、公开端口和定义CMD运行来完成文件,就像我们在上一节中所做的那样。

最后,构建镜像并运行容器(prakhar1989在下面替换为您的用户名)。

$ docker build -t prakhar1989/foodtrucks-web .

在第一次运行时,这将需要一些时间,因为 Docker 客户端将下载 ubuntu 映像,运行所有命令并准备编辑的映像。docker build在对应用程序代码进行任何后续更改后重新运行几乎是即时的。现在让我们尝试运行应用程序。

$ docker run -P --rm prakhar1989/foodtrucks-web
Unable to connect to ES. Retying in 5 secs...
Unable to connect to ES. Retying in 5 secs...
Unable to connect to ES. Retying in 5 secs...
Out of retries. Bailing out...

OoppS!应用程序无法运行,因为它无法连接到 Elasticsearch。我们如何告诉一个容器有另一个容器并让它们相互串起来。下面继续讲解

Docker Network

在讨论 Docker 提供的专门用于处理此类场景的功能之前,看看我们是否可以找到解决问题的方法。并对将要研究的特定功能有所了解。

好的,运行docker container ls(与 相同docker ps),看看有什么。

$ docker container ls
CONTAINER ID        IMAGE                                                 COMMAND                  CREATED             STATUS              PORTS                                            NAMES
277451c15ec1        docker.elastic.co/elasticsearch/elasticsearch:6.3.2   "/usr/local/bin/dock…"   17 minutes ago      Up 17 minutes       0.0.0.0:9200->9200/tcp, 0.0.0.0:9300->9300/tcp   es

从上述可以看到,在0.0.0.0:9200端口上运行了一个 ES 容器,我们可以直接访问它。如果我们可以告诉Flask 应用程序连接到这个 URL,它应该能够连接ES并与 ES 对话,对吗?下面深入研究Python 代码,看看连接细节是如何定义的。

es = Elasticsearch(host='es')

为了让这个工作,我们需要告诉 Flask 容器 ES 容器正在0.0.0.0主机上运行(默认端口是9200),这应该让它工作,对吧?不幸的是,这是不正确的,因为 IP0.0.0.0是从**主机(**即从我的 Mac)访问 ES 容器的 IP 。另一个容器将无法在同一 IP 地址上访问它。

现在是开始探索 Docker 网络的好时机,安装 docker 后,它会自动创建三个网络bridge, host和null。

$ docker network ls
NETWORK ID          NAME                DRIVER              SCOPE
c2c695315b3a        bridge              bridge              local
a875bec5d6fd        host                host                local
ead0e804a67b        none                null                local

bridge网络是其中容器默认运行网络。所以这意味着当运行 ES 容器时,它正在这个桥接网络中运行。为了验证这一点,检查网络。

$ docker network inspect bridge
[
    {
        "Name": "bridge",
        "Id": "c2c695315b3aaf8fc30530bb3c6b8f6692cedd5cc7579663f0550dfdd21c9a26",
        "Created": "2018-07-28T20:32:39.405687265Z",
        "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": {
            "277451c15ec183dd939e80298ea4bcf55050328a39b04124b387d668e3ed3943": {
                "Name": "es",
                "EndpointID": "5c417a2fc6b13d8ec97b76bbd54aaf3ee2d48f328c3f7279ee335174fbb4d6bb",
                "MacAddress": "02:42:ac:11:00:02",
                "IPv4Address": "172.17.0.2/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": {}
    }
]

可以看到容器277451c15ec1列在Containers输出的部分下。我们还看到的是该容器已分配的 IP 地址 - 172.17.0.2。这是我们要查找的 IP 地址吗?通过运行我们的容器并尝试访问这个 IP 来找出答案。

$ docker run -it --rm prakhar1989/foodtrucks-web bash
root@35180ccc206a:/opt/flask-app# curl 172.17.0.2:9200
{
  "name" : "Jane Foster",
  "cluster_name" : "elasticsearch",
  "version" : {
    "number" : "2.1.1",
    "build_hash" : "40e2c53a6b6c2972b3d13846e450e66f4375bd71",
    "build_timestamp" : "2015-12-15T13:05:55Z",
    "build_snapshot" : false,
    "lucene_version" : "5.3.1"
  },
  "tagline" : "You Know, for Search"
}
root@35180ccc206a:/opt/flask-app# exit

现在这对你来说应该是相当简单的。我们以与bash进程交互的方式启动容器。这--rm是运行一次性命令的一个方便标志,因为容器在其工作完成后会被清理。我们尝试 curl一下,就会看到我们确实可以在172.17.0.2:9200上与 ES交互。

虽然我们已经找到了一种让容器相互通信的方法,但这种方法仍然存在两个问题

  1. 由于 IP 可以更改,如何告诉 Flask 容器es主机名代表主机名172.17.0.2或其他的一些 IP?
  2. 由于默认情况下每个容器都共享桥接网络,因此这种方法并不安全。如何隔离网络?

好消息是 Docker 很好地回答了上述问题。它允许我们定义自己的网络,同时使用docker network命令将它们隔离。

首先继续创建自己的网络。

$ docker network create foodtructs-net
0815b2a3bb7a6608e850d05553cc0bda98187c4528d94621438f31d97a6fea3c

$ docker network ls
NETWORK ID          NAME                DRIVER              SCOPE
c2c695315b3a        bridge              bridge              local
0815b2a3bb7a        foodtrucks-net      bridge              local
a875bec5d6fd        host                host                local
ead0e804a67b        none                null                local

network create命令创建了一个新的桥接网络,这正是目前所需要的。就 Docker 而言,桥接网络使用软件桥接器,允许连接到同一桥接网络的容器进行通信同时提供与未连接到该桥接网络的容器的隔离。Docker 网桥驱动程序会自动在宿主机中安装规则,以便不同网桥网络上的容器无法直接相互通信。此外,还可以创建其他类型的网络,可以在官方文档 中阅读有关它们的信息。

现在有了自己的网络后,可以使用这个--net标志在这个网络中启动我们的容器。但首先,为了启动一个具有相同名称的新容器,将停止并删除在网桥(默认)网络中运行的 ES 容器。

$ docker container stop es
es

$ docker container rm es
es 

$ docker run -d --name es --net foodtrucks-net -p 9200:9200 -p 9300:9300 -e "discovery.type=single-node" docker.elastic.co/elasticsearch/elasticsearch:6.3.2
13d6415f73c8d88bddb1f236f584b63dbaf2c3051f09863a3f1ba219edba3673

$ docker network inspect foodtrucks-net
[
    {
        "Name": "foodtrucks-net",
        "Id": "0815b2a3bb7a6608e850d05553cc0bda98187c4528d94621438f31d97a6fea3c",
        "Created": "2018-07-30T00:01:29.1500984Z",
        "Scope": "local",
        "Driver": "bridge",
        "EnableIPv6": false,
        "IPAM": {
            "Driver": "default",
            "Options": {},
            "Config": [
                {
                    "Subnet": "172.18.0.0/16",
                    "Gateway": "172.18.0.1"
                }
            ]
        },
        "Internal": false,
        "Attachable": false,
        "Ingress": false,
        "ConfigFrom": {
            "Network": ""
        },
        "ConfigOnly": false,
        "Containers": {
            "13d6415f73c8d88bddb1f236f584b63dbaf2c3051f09863a3f1ba219edba3673": {
                "Name": "es",
                "EndpointID": "29ba2d33f9713e57eb6b38db41d656e4ee2c53e4a2f7cf636bdca0ec59cd3aa7",
                "MacAddress": "02:42:ac:12:00:02",
                "IPv4Address": "172.18.0.2/16",
                "IPv6Address": ""
            }
        },
        "Options": {},
        "Labels": {}
    }
]

如上所见,es容器现在正在foodtrucks-net桥接网络中运行。现在检查一下在foodtrucks-net网络中启动时会发生什么。

$ docker run -it --rm --net foodtrucks-net prakhar1989/foodtrucks-web bash
root@9d2722cf282c:/opt/flask-app# curl es:9200
{
  "name" : "wWALl9M",
  "cluster_name" : "docker-cluster",
  "cluster_uuid" : "BA36XuOiRPaghPNBLBHleQ",
  "version" : {
    "number" : "6.3.2",
    "build_flavor" : "default",
    "build_type" : "tar",
    "build_hash" : "053779d",
    "build_date" : "2018-07-20T05:20:23.451332Z",
    "build_snapshot" : false,
    "lucene_version" : "7.3.1",
    "minimum_wire_compatibility_version" : "5.6.0",
    "minimum_index_compatibility_version" : "5.0.0"
  },
  "tagline" : "You Know, for Search"
}
root@53af252b771a:/opt/flask-app# ls
app.py  node_modules  package.json  requirements.txt  static  templates  webpack.config.js
root@53af252b771a:/opt/flask-app# python3 app.py
Index not found...
Loading data in elasticsearch ...
Total trucks loaded:  733
 * Running on http://0.0.0.0:5000/ (Press CTRL+C to quit)
root@53af252b771a:/opt/flask-app# exit

可以看到,在 foodtrucks-net 等用户定义的网络上,容器不仅可以通过 IP 地址进行通信,还可以将容器名称解析为 IP 地址。此功能称为 automatic service discovery。现在真正启动 Flask 容器

$ docker run -d --net foodtrucks-net -p 5000:5000 --name foodtrucks-web prakhar1989/foodtrucks-web
852fc74de2954bb72471b858dce64d764181dca0cf7693fed201d76da33df794

$ docker container ls
CONTAINER ID        IMAGE                                                 COMMAND                  CREATED              STATUS              PORTS                                            NAMES
852fc74de295        prakhar1989/foodtrucks-web                            "python3 ./app.py"       About a minute ago   Up About a minute   0.0.0.0:5000->5000/tcp                           foodtrucks-web
13d6415f73c8        docker.elastic.co/elasticsearch/elasticsearch:6.3.2   "/usr/local/bin/dock…"   17 minutes ago       Up 17 minutes       0.0.0.0:9200->9200/tcp, 0.0.0.0:9300->9300/tcp   es


$ curl -I 0.0.0.0:5000
HTTP/1.0 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: 3697
Server: Werkzeug/0.11.2 Python/2.7.6
Date: Sun, 10 Jan 2016 23:58:53 GMT

前往http://0.0.0.0:5000并实时查看精彩应用!虽然这看起来像是很多工作,但实际上只输入了 4 个命令,从零到运行。已经整理了bash 脚本中 setup-docker.sh的命令。

#!/bin/bash

# build the flask container
docker build -t prakhar1989/foodtrucks-web .

# create the network
docker network create foodtrucks-net

# start the es container
docker run -d --name es --net foodtrucks-net -p 9200:9200 -p 9300:9300 -e "discovery.type=single-node" docker.elastic.co/elasticsearch/elasticsearch:6.3.2

# start the flask app container
docker run -d --net foodtrucks-net -p 5000:5000 --name foodtrucks-web prakhar1989/foodtrucks-web

现在假设正在将该应用程序分发给朋友,或者在安装了 docker 的服务器上运行。只需一个命令即可运行整个应用程序!

$ git clone https://github.com/prakhar1989/FoodTrucks
$ cd FoodTrucks
$ ./setup-docker.sh

Docker Compose

到目前为止,已经把所有的时间都花在了探索 Docker 客户端上。然而,在 Docker 生态系统中,还有许多其他开源工具与 Docker 配合得非常好。

  • Docker Machine: 在自己的计算机、云提供商和自己的数据中心内创建 Docker 主机
  • Docker Compose: 用于定义和运行多容器Docker应用程序的工具
  • Docker Swarm: Docker的原生集群解决方案
  • Kubernetes: Kubernetes 是一个开源系统,用于自动部署、扩展和管理容器化应用程序

在本节中,将介绍其中一种工具 Docker Compose,并了解它如何使处理多容器应用程序变得更容易。

Docker Compose 的背景故事非常有趣。大约在 2014 年 1 月左右,一家名为 OrchardUp 的公司推出了一个名为 Fig 的工具。 Fig 背后的想法是使隔离的开发环境与 Docker 一起工作。该项目在Hacker News上非常受欢迎。

论坛上的第一个评论实际上没有解释Fig是好的工作:

所以在这一点上,这就是 Docker 的意义所在:运行进程。现在 Docker 提供了一个相当丰富的 API 来运行进程:容器之间的共享卷(目录)(即运行镜像)、从主机到容器的转发端口、显示日志等。但就是这样:到目前为止,Docker 仍处于流程级别。

虽然它提供了编排多个容器以创建单个“应用程序”的选项,但它并未将此类容器组作为单个实体进行管理。这就是 Fig 之类的工具的用武之地:将一组容器作为单个实体进行讨论。想想“运行一个应用程序”(即“运行一个精心设计的容器集群”)而不是“运行一个容器”。

事实证明,很多使用 docker 的人都同意这种观点。随着 Fig 慢慢流行起来,Docker Inc. 注意到了这一点,收购了该公司并将 Fig 重新命名为 Docker Compose。

那么Compose 有什么用呢?Compose 是一种用于以简单的方式定义和运行多容器 Docker 应用程序的工具。它提供了一个名为的配置文件docker-compose.yml,可用于通过一个命令启动应用程序及其依赖的服务套件。Compose 适用于所有环境:生产、预发布、开发、测试以及 CI 工作流,尽管 Compose 是开发和测试环境的理想选择。

下面看看是否可以docker-compose.yml为 SF-Foodtrucks 应用程序创建一个文件,并评估 Docker Compose 是否兑现了它的承诺。

首先,第一步是安装 Docker Compose。如果运行的是 Windows 或 Mac,Docker Compose 已经安装在 Docker 工具箱中。Linux 用户可以按照文档中的说明轻松掌握 Docker Compose 。由于 Compose 是用 Python 编写的,因此可以简单地执行pip install docker-compose. 使用以及测试安装结果

$ docker-compose --version
docker-compose version 1.21.2, build a133471

下一步编写Docker Compose 文件docker-compose.yml。YAML 的语法非常简单,repo 已经包含将使用的 docker-compose文件

version: "3"
services:
  es:
    image: docker.elastic.co/elasticsearch/elasticsearch:6.3.2
    container_name: es
    environment:
      - discovery.type=single-node
    ports:
      - 9200:9200
    volumes:
      - esdata1:/usr/share/elasticsearch/data
  web:
    image: prakhar1989/foodtrucks-web
    command: python3 app.py
    depends_on:
      - es
    ports:
      - 5000:5000
    volumes:
      - ./flask-app:/opt/flask-app
volumes:
  esdata1:
    driver: local

详细说一下上面的文件的含义。在父级,定义服务的名称esweb. 该image参数始终是必需的,对于希望 Docker 运行的每个服务,可以添加其他参数。对于es,仅参考elasticsearchElastic Registry 上可用的镜像。对于 Flask 应用程序,指定开头构建的镜像。

其他参数,例如commandports提供有关容器的更多信息。该volumes参数指定了web容器中代码将驻留的挂载点。这是可选的,如果需要访问日志等,这很有用。稍后将看到这在开发过程中如何有用。请参阅联机参考以了解有关此文件支持的参数的更多信息。此外还为es容器添加了卷,以便加载的数据在重新启动之间保持不变。还指定了depends_on,它告诉 docker 在es之前启动容器web。可以在docker compose docs上阅读有关它的更多信息。

注意:必须在docker-compose.yml文件所在的目录中才能执行大多数 Compose 命令。

现在文件已经准备好了,在开始之前,需要确保端口和名称是空闲的。所以如果有 Flask 和 ES 容器在运行,先把它们关掉。

$ docker stop es foodtrucks-web
es
foodtrucks-web

$ docker rm es foodtrucks-web
es
foodtrucks-web

现在可以运行docker-compose. 进入 food trucks 目录并运行docker-compose up

$ docker-compose up
Creating network "foodtrucks_default" with the default driver
Creating foodtrucks_es_1
Creating foodtrucks_web_1
Attaching to foodtrucks_es_1, foodtrucks_web_1
es_1  | [INFO ][node                     ] [Comet] version[2.1.1], pid[1], build[40e2c53/2015-12-15T13:05:55Z]
es_1  | [INFO ][node                     ] [Comet] initializing ...
es_1  | [INFO ][plugins                  ] [Comet] loaded [], sites []
es_1  | [INFO ][env                      ] [Comet] using [1] data paths, mounts [[/usr/share/elasticsearch/data (/dev/sda1)]], net usable_space [16gb], net total_space [18.1gb], spins? [possibly], types [ext4]
es_1  | [INFO ][node                     ] [Comet] initialized
es_1  | [INFO ][node                     ] [Comet] starting ...
es_1  | [WARN ][common.network           ] [Comet] publish address: {0.0.0.0} is a wildcard address, falling back to first non-loopback: {172.17.0.2}
es_1  | [INFO ][transport                ] [Comet] publish_address {172.17.0.2:9300}, bound_addresses {[::]:9300}
es_1  | [INFO ][discovery                ] [Comet] elasticsearch/cEk4s7pdQ-evRc9MqS2wqw
es_1  | [INFO ][cluster.service          ] [Comet] new_master {Comet}{cEk4s7pdQ-evRc9MqS2wqw}{172.17.0.2}{172.17.0.2:9300}, reason: zen-disco-join(elected_as_master, [0] joins received)
es_1  | [WARN ][common.network           ] [Comet] publish address: {0.0.0.0} is a wildcard address, falling back to first non-loopback: {172.17.0.2}
es_1  | [INFO ][http                     ] [Comet] publish_address {172.17.0.2:9200}, bound_addresses {[::]:9200}
es_1  | [INFO ][node                     ] [Comet] started
es_1  | [INFO ][gateway                  ] [Comet] recovered [0] indices into cluster_state
es_1  | [INFO ][cluster.metadata         ] [Comet] [sfdata] creating index, cause [auto(index api)], templates [], shards [5]/[1], mappings [truck]
es_1  | [INFO ][cluster.metadata         ] [Comet] [sfdata] update_mapping [truck]
es_1  | [INFO ][cluster.metadata         ] [Comet] [sfdata] update_mapping [truck]
es_1  | [[INFO ][cluster.metadata         ] [Comet] [sfdata] update_mapping [truck]
es_1  | [INFO ][cluster.metadata         ] [Comet] [sfdata] update_mapping [truck]
es_1  | [INFO ][cluster.metadata         ] [Comet] [sfdata] update_mapping [truck]
web_1 |  * Running on http://0.0.0.0:5000/ (Press CTRL+C to quit)

前往 IP 以实时查看您的应用。只需几行配置,就可以同时成功运行两个 Docker 容器。停止服务并在分离模式下重新运行。

web_1 |  * Running on http://0.0.0.0:5000/ (Press CTRL+C to quit)
Killing foodtrucks_web_1 ... done
Killing foodtrucks_es_1 ... done

$ docker-compose up -d
Creating es               ... done
Creating foodtrucks_web_1 ... done

$ docker-compose ps
      Name                    Command               State                Ports
--------------------------------------------------------------------------------------------
es                 /usr/local/bin/docker-entr ...   Up      0.0.0.0:9200->9200/tcp, 9300/tcp
foodtrucks_web_1   python3 app.py                   Up      0.0.0.0:5000->5000/tcp

不出所料,可以看到两个容器都成功运行。名字从何而来?这些是由 Compose 自动创建的。但是Compose 是否也会自动创建网络?好问题!下面来了解一下。

首先,停止运行服务。但是可以通过一个命令将它们恢复。数据量将持续存在,因此可以使用 docker-compose up 使用相同的数据再次启动集群。要销毁集群和数据卷,只需输入docker-compose down -v

$ docker-compose down -v
Stopping foodtrucks_web_1 ... done
Stopping es               ... done
Removing foodtrucks_web_1 ... done
Removing es               ... done
Removing network foodtrucks_default
Removing volume foodtrucks_esdata1

在此过程中,还可以删除foodtrucks上次创建的网络

$ docker network rm foodtrucks-net
$ docker network ls
NETWORK ID          NAME                 DRIVER              SCOPE
c2c695315b3a        bridge               bridge              local
a875bec5d6fd        host                 host                local
ead0e804a67b        none                 null                local

现在我们有了一个干净的环境,重新运行我们的服务,看看Compose是否发挥了它的魔力

$ docker-compose up -d
Recreating foodtrucks_es_1
Recreating foodtrucks_web_1

 docker container ls
CONTAINER ID        IMAGE                        COMMAND                  CREATED             STATUS              PORTS                    NAMES
f50bb33a3242        prakhar1989/foodtrucks-web   "python3 app.py"         14 seconds ago      Up 13 seconds       0.0.0.0:5000->5000/tcp   foodtrucks_web_1
e299ceeb4caa        elasticsearch                "/docker-entrypoint.s"   14 seconds ago      Up 14 seconds       9200/tcp, 9300/tcp       foodtrucks_es_1

到现在为止还挺好, 查看是否创建了任何网络

$ docker network ls
NETWORK ID          NAME                 DRIVER
c2c695315b3a        bridge               bridge              local
f3b80f381ed3        foodtrucks_default   bridge              local
a875bec5d6fd        host                 host                local
ead0e804a67b        none                 null                local

可以看到 compose 继续创建了一个名为foodtrucks_default的新网络,并附加了两个新服务到该网络中,以便其中的每一个都可以被另一个发现。服务的每个容器都加入默认网络,并且可以被该网络上的其他容器访问,并且可以在与容器名称相同的主机名上被它们发现。

 docker ps
CONTAINER ID        IMAGE                                                 COMMAND                  CREATED              STATUS              PORTS                              NAMES
8c6bb7e818ec        docker.elastic.co/elasticsearch/elasticsearch:6.3.2   "/usr/local/bin/dock…"   About a minute ago   Up About a minute   0.0.0.0:9200->9200/tcp, 9300/tcp   es
7640cec7feb7        prakhar1989/foodtrucks-web                            "python3 app.py"         About a minute ago   Up About a minute   0.0.0.0:5000->5000/tcp             foodtrucks_web_1

$ docker network inspect foodtrucks_default
[
    {
        "Name": "foodtrucks_default",
        "Id": "f3b80f381ed3e03b3d5e605e42c4a576e32d38ba24399e963d7dad848b3b4fe7",
        "Created": "2018-07-30T03:36:06.0384826Z",
        "Scope": "local",
        "Driver": "bridge",
        "EnableIPv6": false,
        "IPAM": {
            "Driver": "default",
            "Options": null,
            "Config": [
                {
                    "Subnet": "172.19.0.0/16",
                    "Gateway": "172.19.0.1"
                }
            ]
        },
        "Internal": false,
        "Attachable": true,
        "Ingress": false,
        "ConfigFrom": {
            "Network": ""
        },
        "ConfigOnly": false,
        "Containers": {
            "7640cec7feb7f5615eaac376271a93fb8bab2ce54c7257256bf16716e05c65a5": {
                "Name": "foodtrucks_web_1",
                "EndpointID": "b1aa3e735402abafea3edfbba605eb4617f81d94f1b5f8fcc566a874660a0266",
                "MacAddress": "02:42:ac:13:00:02",
                "IPv4Address": "172.19.0.2/16",
                "IPv6Address": ""
            },
            "8c6bb7e818ec1f88c37f375c18f00beb030b31f4b10aee5a0952aad753314b57": {
                "Name": "es",
                "EndpointID": "649b3567d38e5e6f03fa6c004a4302508c14a5f2ac086ee6dcf13ddef936de7b",
                "MacAddress": "02:42:ac:13:00:03",
                "IPv4Address": "172.19.0.3/16",
                "IPv6Address": ""
            }
        },
        "Options": {},
        "Labels": {
            "com.docker.compose.network": "default",
            "com.docker.compose.project": "foodtrucks",
            "com.docker.compose.version": "1.21.2"
        }
    }
]

Development Workflow

在跳到下一部分之前,将介绍关于 docker-compose 的最后一件事。如前所述,docker-compose 非常适合开发和测试。因此,如何配置 compose 以在开发过程中使得开发更轻松?。

在本教程中,使用了现成的 docker 镜像。虽然是从头开始构建镜像,但我们还没有接触任何应用程序代码,并且主要限制在自己编辑 Dockerfiles 和 YAML 配置。但必须知道的一件事是开发过程中的工作流是什么样的?是否应该为每个更改继续创建 Docker 映像,然后发布它,然后运行它以查看更改是否按预期工作?这肯定听起来非常乏味,一定有更好的方法。在本节中,这就是要探索的内容。

让我们看看如何对刚刚运行的 Foodtrucks 应用程序进行更改。确保应用程序正在运行

$ docker container ls
CONTAINER ID        IMAGE                                                 COMMAND                  CREATED             STATUS              PORTS                              NAMES
5450ebedd03c        prakhar1989/foodtrucks-web                            "python3 app.py"         9 seconds ago       Up 6 seconds        0.0.0.0:5000->5000/tcp             foodtrucks_web_1
05d408b25dfe        docker.elastic.co/elasticsearch/elasticsearch:6.3.2   "/usr/local/bin/dock…"   10 hours ago        Up 10 hours         0.0.0.0:9200->9200/tcp, 9300/tcp   es

现在看看我们是否可以更改此应用程序以Hello world!在发出/hello路由请求时显示消息。目前,该应用程序以 404 响应。

$ curl -I 0.0.0.0:5000/hello
HTTP/1.0 404 NOT FOUND
Content-Type: text/html
Content-Length: 233
Server: Werkzeug/0.11.2 Python/2.7.15rc1
Date: Mon, 30 Jul 2018 15:34:38 GMT

什么会发生这种情况?由于后端是 Flask 应用程序,可以查看app.py(链接)以获得答案。在 Flask 中,路由是用 @app.route 语法定义的。在该文件中,你会看到,只有三条路线定义- //debug/search。该/路线呈现主要的应用程序,该debug是用来返回一些调试信息,最后search被使用的应用程序来查询elasticsearch, 没有/hello

$ curl 0.0.0.0:5000/debug
{
  "msg": "yellow open sfdata Ibkx7WYjSt-g8NZXOEtTMg 5 1 618 0 1.3mb 1.3mb\n",
  "status": "success"
}

鉴于这种情况,将修改flask-app/app.py

@app.rout('/')
def index():
    return render_template("index.html")

# add a new hello route
@app.rout('/hello')
def hello():
    return "hello, world!"

现在再次尝试发出请求

$ curl -I 0.0.0.0:5000/hello
HTTP/1.0 404 NOT FOUND
Content-Type: text/html
Content-Length: 233
Server: Werkzeug/0.11.2 Python/2.7.15rc1
Date: Mon, 30 Jul 2018 15:34:38 GMT

可以看到还是没有起作用, 我们做错了什么?虽然确实更改了app.py,但该文件驻留在的机器(或主机)中,由于 Docker 基于prakhar1989/foodtrucks-web镜像运行我们的容器,因此它不知道此更改。为了验证这一点,run一下看看

$ docker-compose run web bash
Starting es ... done
root@581e351c82b0:/opt/flask-app# ls
app.py        package-lock.json  requirements.txt  templates
node_modules  package.json       static            webpack.config.js
root@581e351c82b0:/opt/flask-app# grep hello app.py
root@581e351c82b0:/opt/flask-app# exit

尝试做的是验证我们的更改不在app.py容器中运行。通过运行docker-compose run命令来做到这一点,它类似于docker run的表亲,但服务(web例子中是)采用了额外的参数。一旦运行bash,shell就会按照我们/opt/flask-app的Dockerfile 中的指定打开。从 grep 命令中可以看到我们的更改不在文件中。

下面进行修复这个问题。首先,需要告诉 docker compose 不要使用镜像,而是使用本地文件。此外还将调试模式设置为true以便 Flask 知道在app.py更改时重新加载服务器。像这样替换文件的web一部分docker-compose.yml

version: "3"
services:
  es:
    image: docker.elastic.co/elasticsearch/elasticsearch:6.3.2
    container_name: es
    environment:
      - discovery.type=single-node
    ports:
      - 9200:9200
    volumes:
      - esdata1:/usr/share/elasticsearch/data
  web:
    build: . # replaced image with build
    command: python3 app.py
    environment:
      - DEBUG=True # set an env var for flask
    depends_on:
      - es
    ports:
      - "5000:5000"
    volumes:
      - ./flask-app:/opt/flask-app
volumes:
  esdata1:
    driver: local

有了这个变化(diff),重新停止和启动容器。

$ docker-compose down -v
Stopping foodtrucks_web_1 ... done
Stopping es               ... done
Removing foodtrucks_web_1 ... done
Removing es               ... done
Removing network foodtrucks_default
Removing volume foodtrucks_esdata1

$ docker-compose up -d
Creating network "foodtrucks_default" with the default driver
Creating volume "foodtrucks_esdata1" with local driver
Creating es ... done
Creating foodtrucks_web_1 ... done

再次运行看看

$ curl 0.0.0.0:5000/hello
hello world

可以看到得到了有效的回应,自己也可以尝试在应用程序中进行更多更改。

使用 Docker Compose还可以暂停服务、在容器上运行一次性命令,甚至扩展容器的数量。此外还建议查看Docker compose 的其他一些用例。

AWS 弹性容器服务

在上一节中,曾经使用docker-compose单个命令docker-compose up在本地运行应用程序, 现在有了一个功能强大的应用程序,想与全世界分享这个应用程序,吸引一些用户,赚很多钱。这部分内容将花时间来弄清楚如何使用 AWS 在云上部署多容器应用程序。

如果已经读到这里,就会非常确信 Docker 是一项非常酷的技术。Docker 的迅速崛起,几乎所有云供应商都开始致力于增加对在其平台上部署 Docker 应用程序的支持。截至今天,可以在Google Cloud Platform、AWS、Azure和许多其他平台上部署容器。已经了解了使用 Elastic Beanstalk 部署单容器应用程序的入门知识,在本节中,将介绍AWS 的Elastic Container Service(或 ECS)。

AWS ECS 是一种可扩展且超级灵活的容器管理服务,支持 Docker 容器。它允许您通过易于使用的 API 在 EC2 实例之上操作 Docker 集群。Beanstalk 带有合理的默认值,而 ECS 允许您根据需要完全调整您的环境。在我看来,这使得 ECS 开始使用起来非常复杂。

幸运的是,ECS 有一个友好的CLI工具,它可以理解 Docker Compose 文件并自动在 ECS 上配置集群!由于我们已经有了一个功能,docker-compose.yml因此在 AWS 上启动和运行应该不需要太多的努力。

第一步是安装 CLI。官方文档中非常清楚地解释了在 Mac 和 Linux 上安装 CLI 的说明。安装 CLI完成后,通过运行验证安装

$ ecs-cli --version
ecs-cli version 1.18.1 (7e9df84)

接下来,将着手配置 CLI,以便可以与 ECS 对话。按照AWS ECS 文档的官方指南中详述的步骤进行操作。如有任何混淆,请随时参考该指南。

第一步将涉及创建一个配置文件吗, 将在本教程的其余部分使用该配置文件。首先获得AWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEY。要获取这些信息,请按照本页上标题为“访问密钥”和“秘密访问密钥”部分中的详细步骤操作。

$ ecs-cli configure profile --profile-name ecs-foodtrucks --access-key $AWS_ACCESS_KEY_ID --secret-key $AWS_SECRET_ACCESS_KEY

接下来,需要获取一个用于登录实例的密钥对。前往EC2 控制台并创建一个新的密钥对。下载密钥对并将其存储在安全位置。在离开此屏幕之前要注意的另一件事是区域名称。

Docker curriculum (3): 多容器环境_第1张图片

下一步是配置 CLI

$ ecs-cli configure --region us-east-1 --cluster foodtrucks
INFO[0000] Saved ECS CLI configuration for cluster (foodtrucks)

configure命令提供了希望集群驻留的区域名称和集群名称。确保提供创建密钥对时使用的区域名称相同的区域名称。如果前没有在的计算机上配置AWS CLI,可以使用官方指南,该指南非常详细地解释了如何让一切顺利进行。

下一步使 CLI 能够创建CloudFormation模板

$ ecs-cli up --keypair ecs --capability-iam --size 1 --instance-type t2.medium
INFO[0000] Using recommended Amazon Linux 2 AMI with ECS Agent 1.39.0 and Docker version 18.09.9-ce
INFO[0000] Created cluster                               cluster=foodtrucks
INFO[0001] Waiting for your cluster resources to be created
INFO[0001] Cloudformation stack status                   stackStatus=CREATE_IN_PROGRESS
INFO[0062] Cloudformation stack status                   stackStatus=CREATE_IN_PROGRESS
INFO[0122] Cloudformation stack status                   stackStatus=CREATE_IN_PROGRESS
INFO[0182] Cloudformation stack status                   stackStatus=CREATE_IN_PROGRESS
INFO[0242] Cloudformation stack status                   stackStatus=CREATE_IN_PROGRESS
VPC created: vpc-0bbed8536930053a6
Security Group created: sg-0cf767fb4d01a3f99
Subnet created: subnet-05de1db2cb1a50ab8
Subnet created: subnet-01e1e8bc95d49d0fd
Cluster creation succeeded.

在这里,提供了最初下载的密钥对的名称(ecs)、使用的实例数量 ( --size) 以及容器运行的实例类型。该--capability-iam标志告诉 CLI 承认此命令可能会创建 IAM 资源。

最后一步是将使用我们的docker-compose.yml文件的地方。这里需要做一些小改动,所以不要修改原始文件,而是复制一份。此文件的内容(进行更改后)如下所示

version: '2'
services:
  es:
    image: docker.elastic.co/elasticsearch/elasticsearch:7.6.2
    cpu_shares: 100
    mem_limit: 3621440000
    environment:
      - discovery.type=single-node
      - bootstrap.memory_lock=true
      - "ES_JAVA_OPTS=-Xms512m -Xmx512m"
    logging:
      driver: awslogs
      options:
        awslogs-group: foodtrucks
        awslogs-region: us-east-1
        awslogs-stream-prefix: es
  web:
    image: prakhar1989/foodtrucks-web
    cpu_shares: 100
    mem_limit: 262144000
    ports:
      - "80:5000"
    links:
      - es
    logging:
      driver: awslogs
      options:
        awslogs-group: foodtrucks
        awslogs-region: us-east-1
        awslogs-stream-prefix: web

对原始版本docker-compose.yml所做的唯一更改是为每个容器提供mem_limit(以字节cpu_shares为单位)和值,并添加一些日志记录配置。这使我们能够在AWS CloudWatch 中查看容器生成的日志。前往 CloudWatch创建一个名为foodtrucks日志组。请注意,由于 ElasticSearch 通常最终会占用更多内存,因此这里给出了大约 3.4 GB 的内存限制。在进入下一步之前,需要做的另一件事是在 Docker Hub 上发布自己的镜像。

$ docker push prakhar1989/foodtrucks-web

现在将在 ECS 上部署自己的应用程序

$ cd aws-ecs
$ ecs-cli compose up
INFO[0000] Using ECS task definition                     TaskDefinition=ecscompose-foodtrucks:2
INFO[0000] Starting container...                         container=845e2368-170d-44a7-bf9f-84c7fcd9ae29/es
INFO[0000] Starting container...                         container=845e2368-170d-44a7-bf9f-84c7fcd9ae29/web
INFO[0000] Describe ECS container status                 container=845e2368-170d-44a7-bf9f-84c7fcd9ae29/web desiredStatus=RUNNING lastStatus=PENDING taskDefinition=ecscompose-foodtrucks:2
INFO[0000] Describe ECS container status                 container=845e2368-170d-44a7-bf9f-84c7fcd9ae29/es desiredStatus=RUNNING lastStatus=PENDING taskDefinition=ecscompose-foodtrucks:2
INFO[0036] Describe ECS container status                 container=845e2368-170d-44a7-bf9f-84c7fcd9ae29/es desiredStatus=RUNNING lastStatus=PENDING taskDefinition=ecscompose-foodtrucks:2
INFO[0048] Describe ECS container status                 container=845e2368-170d-44a7-bf9f-84c7fcd9ae29/web desiredStatus=RUNNING lastStatus=PENDING taskDefinition=ecscompose-foodtrucks:2
INFO[0048] Describe ECS container status                 container=845e2368-170d-44a7-bf9f-84c7fcd9ae29/es desiredStatus=RUNNING lastStatus=PENDING taskDefinition=ecscompose-foodtrucks:2
INFO[0060] Started container...                          container=845e2368-170d-44a7-bf9f-84c7fcd9ae29/web desiredStatus=RUNNING lastStatus=RUNNING taskDefinition=ecscompose-foodtrucks:2
INFO[0060] Started container...                          container=845e2368-170d-44a7-bf9f-84c7fcd9ae29/es desiredStatus=RUNNING lastStatus=RUNNING taskDefinition=ecscompose-foodtrucks:2

上面的调用看起来类似于Docker Compose 中使用的调用,这并非巧合。如果一切顺利,应该看到 adesiredStatus=RUNNING lastStatus=RUNNING作为最后一行。

在浏览器中打开http://54.86.14.14,应该会看到Food Trucks的所有黑黄色光彩!AWS ECS控制台的外观如下:

Docker curriculum (3): 多容器环境_第2张图片

可以在上面看到,ECS 集群名为“foodtrucks”,现在正在运行 1 个任务和 2 个容器实例。花一些时间浏览此控制台以了解此处的所有选项。

清理

使用已部署的应用程序后,请记住关闭集群

$ ecs-cli down --force
INFO[0001] Waiting for your cluster resources to be deleted...
INFO[0001] Cloudformation stack status                   stackStatus=DELETE_IN_PROGRESS
INFO[0062] Cloudformation stack status                   stackStatus=DELETE_IN_PROGRESS
INFO[0124] Cloudformation stack status                   stackStatus=DELETE_IN_PROGRESS
INFO[0155] Deleted cluster                               cluster=foodtrucks

结论

经过漫长、详尽但有趣的教程后,学习了如何设置 Docker、运行自己的容器、使用静态和动态网站,最重要的是获得了将应用程序部署到云的经验!

希望完成本教程能让您对自己处理服务器的能力更有信心。当您有了构建下一个应用程序的想法时,您可以确信您能够以最少的努力将它呈现在人们面前。

后续

进入容器世界的旅程才刚刚开始!在本教程中的目标是激发您的胃口并向您展示 Docker 的强大功能。在新技术的海洋中,单独在水域中航行是很困难的,像这样的教程可以提供帮助。这是我刚开始时希望拥有的 Docker 教程。希望它的目的是让您对容器感到兴奋,这样您就不再只能作为一个旁观者了。

以下是一些有益的额外资源。对于下一个项目,强烈建议使用 Docker。

其他资源

  • Awesome Docker
  • Why Docker
  • Docker Weekly and archives
  • Codeship Blog

Reference

  • https://docker-curriculum.com/#what-is-docker-

你可能感兴趣的:(Docker,docker,curriculum)