跳到内容
a 容器介绍
c 编排基础

b

构建配置环境

在上一节中,我们使用了两个不同的基础镜像:ubuntu和node,并做了一些手工工作来获得一个简单的 "Hello, World!"运行。在这个过程中,我们学到的工具和命令将是有帮助的。在本节中,我们将学习如何为我们的应用构建镜像和配置环境。我们将从一个普通的Express/Node.js后端开始,在此基础上建立其他服务,包括MongoDB数据库。

Dockerfile

我们可以创建一个包含 "Hello, World!"应用的新镜像,而不是通过复制里面的文件来修改一个容器。这方面的工具是Dockerfile。Dockerfile是一个简单的文本文件,包含创建镜像的所有指令。让我们从 "Hello, World!"应用创建一个Dockerfile的例子。

如果你还没有,在你的机器上创建一个目录,并在该目录下创建一个名为Dockerfile的文件。让我们也在Dockerfile旁边放一个index.js,包含console.log(''Hello, World!')。你的目录结构应该是这样的。

├── index.js
└── Dockerfile

在这个Docker文件中,我们将告诉镜像三件事。

  • 使用node:16作为我们镜像的基础

  • 在镜像中包含index.js,这样我们就不需要手动将其复制到容器中。

  • 当我们从镜像中运行一个容器时,使用node来执行index.js文件。

上面的愿望将转化为一个基本的Docker文件。放置这个文件的最佳位置通常是在项目的根部。

产生的Dockerfile如下所示:

FROM node:16

WORKDIR /usr/src/app

COPY ./index.js ./index.js

CMD node index.js

FROM指令将告诉Docker,镜像的基础应该是node:16。COPY指令将把主机上的文件index.js复制到镜像中的同名文件。CMD指令讲述了使用docker run时的情况。CMD是默认的指令,然后可以用镜像名称后给出的参数来覆盖。如果你忘记了,请看docker run --help

WORKDIR指令是为了确保我们不干扰镜像的内容而悄悄加入的。它将保证所有下面的命令都将/usr/src/app设置为工作目录。如果该目录不存在于基本镜像中,它将被自动创建。

如果我们不指定一个WORKDIR,我们就有可能意外地覆盖重要的文件。如果你用docker run node:16 ls检查node:16镜像的根(/),你可以注意到所有已经包含在镜像中的目录和文件。

现在我们可以使用命令docker build来构建一个基于Docker文件的镜像。让我们用一个额外的标志来完善这个命令。-t,这将帮助我们命名镜像。

$ docker build -t fs-hello-world .
[+] Building 3.9s (8/8) FINISHED
...

所以结果是 "docker please build with tag fs-hello-world the Dockerfile in this directory"。你可以指向任何Docker文件,但在我们的例子中,一个简单的点将意味着这个目录中的Docker文件。这就是为什么该命令以句号结束。构建完成后,你可以用docker run fs-hello-world运行它。

由于图像只是文件,它们可以被随意移动、下载和删除。你可以用docker image ls列出你在本地的图像,用docker image rm删除它们。用docker image --help查看你还有哪些可用的命令。

More meaningful image

把Express服务器移到一个容器里应该和把 "Hello, World!"应用移到一个容器里一样简单。唯一的区别是,有更多的文件。幸好COPY 指令可以处理所有这些。让我们删除index.js并创建一个新的Express服务器。让我们使用express-generator来创建一个基本的Express应用骨架。

$ npx express-generator
  ...

  install dependencies:
    $ npm install

  run the app:
    $ DEBUG=playground:* npm start

首先,让我们运行这个应用来了解一下我们刚刚创建的东西。注意,运行应用的命令可能与你不同,我的目录被称为playground。

$ npm install
$ DEBUG=playground:* npm start
  playground:server Listening on port 3000 +0ms

很好,所以现在我们可以导航到http://localhost:3000,应用正在那里运行。

基于之前的例子,容器化应该是比较容易的。

  • 使用节点作为基础

  • 设置工作目录,这样我们就不会干扰到基础镜像的内容了

  • 把这个目录下的所有文件都复制到镜像上

  • 用DEBUG=playground:* npm start开始。

让我们把下面的Docker文件放在项目的根目录下。

FROM node:16

WORKDIR /usr/src/app

COPY . .

CMD DEBUG=playground:* npm start

让我们用docker build -t express-server .的命令从Docker文件中构建镜像,然后用docker run -p 3123:3000 express-server运行它。-p标志将通知Docker,主机上的一个端口应该被打开并指向容器中的一个端口。其格式是-p host-port:application-port

$ docker run -p 3123:3000 express-server

> playground@0.0.0 start
> node ./bin/www

Tue, 29 Jun 2021 10:55:10 GMT playground:server Listening on port 3000

如果你的标记不起作用,跳到下一节。这里有一个解释,为什么即使你正确地遵循了这些步骤,它也可能不工作。

应用现在正在运行!让我们通过向http://localhost:3123/发送一个GET请求来测试它。

目前,关闭它是一个令人头痛的问题。使用另一个终端和docker kill命令来杀死这个应用。docker kill将向应用发送一个杀戮信号(SIGKILL),迫使它关闭。它需要容器的名称或ID作为参数。

顺便说一下,当使用id作为参数时,ID的开头足以让Docker知道我们指的是哪个容器。

$ docker container ls
  CONTAINER ID   IMAGE            COMMAND                  CREATED         STATUS         PORTS                                       NAMES
  48096ca3ffec   express-server   "docker-entrypoint.s…"   9 seconds ago   Up 6 seconds   0.0.0.0:3123->3000/tcp, :::3123->3000/tcp   infallible_booth

$ docker kill 48
  48

在未来,让我们在-p的两边使用相同的端口。这样我们就不必记住我们碰巧选择了哪一个。

Fixing potential issues we created by copy-pasting

为了创建一个更全面的Docker文件,我们需要改变几个步骤。甚至可能因为我们跳过了一个重要的步骤,上面的例子并不是在所有情况下都有效。

当我们在机器上运行npm安装时,在某些情况下,节点包管理器可能会在安装步骤中安装操作系统的特定依赖。我们可能会不小心用COPY指令将非功能性的部分转移到镜像中。如果我们把node_modules目录复制到镜像中,这很容易发生。

当我们建立镜像时,这是一个需要记住的关键问题。最好是在构建过程中做大多数事情,比如在容器内运行npm install,而不是在构建前做这些事情。简单的经验法则是,只复制你要推送到GitHub的文件。构建工件或依赖关系不应该被复制,因为它们可以在构建过程中被安装。

我们可以使用.dockerignore来解决这个问题。.dockerignore这个文件与.gitignore非常相似,你可以用它来防止不需要的文件被复制到你的镜像中。该文件应该放在Dockerfile的旁边。下面是一个可能的.dockerignore的内容

.dockerignore
.gitignore
node_modules
Dockerfile

然而,在我们的案例中,.dockerignore并不是唯一需要的东西。我们将需要在构建步骤中安装依赖项。Dockerfile改变为。

FROM node:16

WORKDIR /usr/src/app

COPY . .

RUN npm install
CMD DEBUG=playground:* npm start

npm的安装可能会有风险。与其使用npm install,npm提供了一个更好的安装依赖项的工具,即ci命令。

ci和install之间的区别。

  • install可能会更新package-lock.json

  • install 可能会安装不同版本的依赖关系,如果你在依赖关系的版本里有 ^ 或 ~。
  • 在安装任何东西之前,ci会删除node_modules文件夹

  • ci将遵循package-lock.json,不改变任何文件。

所以简而言之:ci创建可靠的构建,而install是在你想安装新的依赖时使用的。

由于我们在构建步骤中没有安装任何新的东西,而且我们不希望版本突然改变,我们将使用ci

FROM node:16

WORKDIR /usr/src/app

COPY . .

RUN npm ci
CMD DEBUG=playground:* npm start

甚至更好的是,我们可以使用npm ci --only=production来不浪费时间安装开发依赖。

正如你在对比列表中注意到的;npm ci会删除node_modules文件夹,所以创建.dockerignore并不重要。然而,当你想优化你的构建过程时,.dockerignore是一个了不起的工具。我们将在后面简要地谈一谈这些优化。

现在Docker文件应该又能工作了,用docker build -t express-server . && docker run -p 3000:3000 express-server试试。

注意,我们在这里用&&连接了两个bash命令。我们可以通过单独运行这两个命令来获得(几乎)同样的效果。当用&&串联命令时,如果一个命令失败了,串联中的下一个命令将不会被执行。

我们在CMD中为npm启动设置了一个环境变量DEBUG=playground:*。然而,通过Dockerfiles,我们也可以使用指令ENV来设置环境变量。让我们这么做吧。

FROM node:16

WORKDIR /usr/src/app

COPY . .

RUN npm ci

ENV DEBUG=playground:*
CMD npm start

如果你想知道DEBUG环境变量的作用,请阅读这里

Dockerfile best practices

在创建图像时,你应该遵循两条经验法则。

  • 尽量创建一个安全的的图像

  • 尽量创造一个**小的图像

较小的映像由于具有较少的攻击表面积而更加安全,较小的映像在部署管道中也移动得更快。

Snyk有一个伟大的清单,列出了节点/表达式容器化的10个最佳实践。阅读这些这里

我们还有一个很大的疏忽,就是以root身份运行应用,而不是使用一个权限较低的用户。让我们对Docker文件做一个最后的修正。

FROM node:16

WORKDIR /usr/src/app

COPY --chown=node:node . .
RUN npm ci

ENV DEBUG=playground:*

USER node
CMD npm start

Using docker-compose

在上一节中,我们创建了express-server,并知道它在3000端口运行,然后用docker build -t express-server . && docker run -p 3000:3000 express-server运行它。这看起来已经是你需要放到脚本中去记忆的东西了。幸运的是,Docker为我们提供了一个更好的解决方案。

Docker-compose是另一个神奇的工具,它可以帮助我们管理容器。让我们开始使用docker-compose,因为它可以帮助我们节省一些配置的时间,当我们学习更多关于容器的知识。

从这个链接安装docker-compose工具: https://docs.docker.com/compose/install/

让我们检查一下它是否工作。

$ docker-compose -v
docker-compose version 1.29.2, build 5becea4c

现在我们可以把之前的咒语变成一个yaml文件。关于yaml文件最好的部分是你可以把这些文件保存到Git仓库里

创建文件docker-compose.yml,并把它放在项目的根目录下,紧挨着Docker文件。该文件内容为

version: '3.8'            # Version 3.8 is quite new and should work

services:
  app:                    # The name of the service, can be anything
    image: express-server # Declares which image to use
    build: .              # Declares where to build if image is not found
    ports:                # Declares the ports to publish
      - 3000:3000

每一行的含义都以注释的形式解释。如果你想看到完整的规范,请看文档

现在我们可以使用docker-compose up来构建和运行该应用。如果我们想重建图像,可以使用docker-compose up --build

你也可以用docker-compose up -d (-d表示分离)在后端运行应用,用docker-compose down关闭它。

创建像这样的文件,声明你想要的东西,而不是你需要按特定顺序/特定次数运行的脚本文件,通常是一个很好的做法。

Utilizing containers in development

当你在开发软件时,容器化可以用各种方式来提高你的生活质量。最有用的情况之一是绕过了两次安装和配置工具的需要。

把你的整个开发环境移到一个容器中可能不是最好的选择,但如果这是你想要的,那是可行的。我们将在本章节的最后重新审视这个想法。但在那之前,在容器之外运行node应用本身

我们在前面的练习中遇到的应用使用MongoDB。让我们探索Docker Hub,找到一个MongoDB镜像。Docker Hub是Docker拉取镜像的默认地点,你也可以使用其他注册表,但由于我们已经深入到Docker中,所以这是一个不错的选择。通过快速搜索,我们可以找到https://hub.docker.com/_/mongo

创建一个新的yaml,名为todo-app/todo-backend/docker-compose.dev.yml,看起来如下。

version: '3.8'

services:
  mongo:
    image: mongo
    ports:
      - 3456:27017
    environment:
      MONGO_INITDB_ROOT_USERNAME: root
      MONGO_INITDB_ROOT_PASSWORD: example
      MONGO_INITDB_DATABASE: the_database

上面定义的两个第一环境变量的含义在Docker Hub页面上有解释。

这些变量结合起来使用,可以创建一个新的用户并设置该用户的密码。这个用户在管理员认证数据库中被创建,并被赋予root的角色,这是一个 "超级用户 "角色。

最后一个环境变量MONGO_INITDB_DATABASE将告诉MongoDB以该名称创建一个数据库。

你可以使用-f标志来指定一个文件来运行Docker Compose命令,例如:docker-compose -f docker-compose.dev.yml up。现在,我们可能有多个它's useful.

现在用docker-compose -f docker-compose.dev.yml up -d启动MongoDB。使用-d,它将在后端运行。你可以用docker-compose -f docker-compose.dev.yml logs -f查看输出日志。那里的-f将确保我们遵循日志。

如前所述,目前我们想在容器中运行Node应用。在应用本身处于容器内时进行开发是一个挑战。我们将在本章节的后面探讨这个选项。

首先在你的机器上运行老式的npm install来设置Node应用。然后用相关的环境变量启动应用。你可以修改代码,将它们设置为默认值,或者使用.env文件。把这些密钥放到GitHub上没有什么坏处,因为它们只在你的本地开发环境中使用。我只是把它们和npm run dev放在一起,帮助你复制粘贴。

$ MONGO_URL=mongodb://localhost:3456/the_database npm run dev

这还不够,我们需要创建一个用户,以便在容器中获得授权。url http://localhost:3000/todos 导致了一个认证错误。

[nodemon] 2.0.12
[nodemon] to restart at any time, enter `rs`
[nodemon] watching path(s): *.*
[nodemon] watching extensions: js,mjs,json
[nodemon] starting `node ./bin/www`
(node:37616) UnhandledPromiseRejectionWarning: MongoError: command find requires authentication
    at MessageStream.messageHandler (/Users/mluukkai/opetus/docker-fs/container-app/express-app/node_modules/mongodb/lib/cmap/connection.js:272:20)
    at MessageStream.emit (events.js:314:20)

Bind mount and initializing the database

MongoDB Docker Hub页面的 "初始化一个新实例 "下有关于如何执行JavaScript来初始化数据库和用户的信息。

练习项目有文件todo-app/todo-backend/mongo/mongo-init.js,内容如下。

db.createUser({
  user: 'the_username',
  pwd: 'the_password',
  roles: [
    {
      role: 'dbOwner',
      db: 'the_database',
    },
  ],
});

db.createCollection('todos');

db.todos.insert({ text: 'Write code', done: true });
db.todos.insert({ text: 'Learn about containers', done: false });

这个文件将用一个用户和一些todos来初始化数据库。接下来,我们需要在启动时将其放入容器中。

我们可以创建一个新的镜像 FROM mongo 并将文件复制到里面,或者我们可以使用 bind mount 将文件 mongo-init.js 挂到容器中。让我们来做后者。

绑定挂载是将主机上的文件与容器中的文件绑定的行为。我们可以用container run添加一个-v标志。语法是-v FILE-IN-HOST:FILE-IN-CONTAINER。因为我们已经学习了Docker Compose,所以我们跳过这个。绑定挂载在docker-compose的volumes键下声明。否则格式是一样的,先是主机,然后是容器。

  mongo:
    image: mongo
    ports:
     - 3456:27017
    environment:
      MONGO_INITDB_ROOT_USERNAME: root
      MONGO_INITDB_ROOT_PASSWORD: example
      MONGO_INITDB_DATABASE: the_database
    volumes:      - ./mongo/mongo-init.js:/docker-entrypoint-initdb.d/mongo-init.js

绑定挂载的结果是,主机的mongo文件夹中的文件mongo-init.js与容器的/docker-entrypoint-initdb.d目录中的mongo-init.js文件相同。对任何一个文件的修改都可以在另一个文件中使用。我们不需要在运行时做任何改变。但这将是在容器中开发软件的关键。

运行docker-compose -f docker-compose.dev.yml down --volumes以确保没有任何东西被留下,然后用docker-compose -f docker-compose.dev.yml up从一张白纸开始初始化数据库。

如果你看到类似这样的错误。

mongo_database | failed to load: /docker-entrypoint-initdb.d/mongo-init.js
mongo_database | exiting with code -3

你可能有一个读取权限问题。在处理卷的时候,它们并不罕见。在上述情况下,你可以使用chmod a+r mongo-init.js,这将给予每个人对该文件的读取权限。使用chmod时要小心,因为授予更多的权限可能是一个安全问题。只在你电脑上的mongo-init.js上使用chmod

现在用正确的环境变量启动Express应用应该可以了。

$ MONGO_URL=mongodb://the_username:the_password@localhost:3456/the_database npm run dev

让我们检查一下 http://localhost:3000/todos 是否返回所有的 todos。它应该返回我们初始化的两个todos。我们可以而且应该使用Postman来测试应用的基本功能,比如添加或删除一个todos。

Persisting data with volumes

默认情况下,容器是不会保存我们的数据的。当你关闭mongo容器时,你可能会也可能不会拿回数据。

这是一种罕见的情况,它确实保留了数据,因为为Mongo制作Docker镜像的开发者已经定义了一个要使用的卷。https://github.com/docker-library/mongo/blob/cb8a419053858e510fc68ed2d69415b3e50011cb/4.4/Dockerfile#L113 这一行将指示Docker保留这些目录中的数据。

有两种不同的方法来存储数据。

  • 在你的文件系统中声明一个位置(称为绑定挂载)

  • 让Docker决定存储数据的位置(卷)。

在大多数情况下,只要你真的需要避免删除数据,我更喜欢第一种选择。让我们看看这两种方式在docker-compose中的应用。

services:
  mongo:
    image: mongo
    ports:
     - 3456:27017
    environment:
      MONGO_INITDB_ROOT_USERNAME: root
      MONGO_INITDB_ROOT_PASSWORD: example
      MONGO_INITDB_DATABASE: the_database
    volumes:
      - ./mongo/mongo-init.js:/docker-entrypoint-initdb.d/mongo-init.js
      - ./mongo_data:/data/db

上面将在你的本地文件系统中创建一个名为mongo/data的目录,并将其映射到容器中的/data/db。这意味着/data/db中的数据被存储在容器之外,但仍然可以被容器访问!只要记得把这个目录添加到.gitignore中。

类似的结果也可以用一个命名的卷来实现。

services:
  mongo:
    image: mongo
    ports:
     - 3456:27017
    environment:
      MONGO_INITDB_ROOT_USERNAME: root
      MONGO_INITDB_ROOT_PASSWORD: example
      MONGO_INITDB_DATABASE: the_database
    volumes:
      - ./mongo/mongo-init.js:/docker-entrypoint-initdb.d/mongo-init.js
      - mongo_data:/data/db
volumes:
  mongo_data:

现在卷被创建,但由Docker管理。启动应用(docker-compose -f docker-compose.dev.yml up)后,你可以用docker volume ls列出卷,用docker volume inspect检查其中一个,甚至用docker volume rm删除它们。它仍然存储在你的本地文件系统中,但找出哪里可能不像以前的选项那样简单。

Debugging issues in containers

在编码时,你很可能最终陷入一切都被破坏的境地。

`- Matti Luukkainen

当使用容器开发时,我们需要学习新的调试工具,因为我们不能只是 "console.log "一切。当代码出现错误时,你可能经常处于这样一种状态,即至少有一些东西在工作,所以你可以从那里继续工作。配置最经常处于两种状态中的一种。1.工作或2.损坏。我们将介绍一些工具,当你的应用处于后一种状态时,它们可以提供帮助。

当开发软件时,你可以安全地一步一步地前进,一直验证你所编码的东西是否符合预期。通常,在做配置的时候,情况并非如此。你所写的配置可能在完成的那一刻才会被破坏。因此,当你写了很长的docker-compose.yml或Dockerfile而它没有工作时,你需要花点时间,想一想你可以通过各种方式来确认某些东西是否工作。

Question Everything is still applicable here. As said in part 3: The key is to be systematic. Since the problem can exist anywhere, you must question everything, and eliminate all possible sources of error one by one.

对我自己来说,最有价值的调试方法是停下来思考我想完成的任务,而不是一味地对着问题乱撞。通常情况下,有一个简单的、备用的解决方案或快速的谷歌搜索会让我继续前进。

exec

Docker命令exec是一个重击手。它可以用来在一个容器运行时直接跳入它。

让我们在后端启动一个Web服务器,做一点调试,让它运行并在浏览器中显示 "Hello, exec!"的信息。让我们选择Nginx,除此之外,它是一个能够提供静态HTML文件的服务器。它有一个默认的index.html,我们可以替换它。

$ docker container run -d nginx

好的,现在的问题是。

  • 我们的浏览器应该去哪里?

    -它甚至在运行吗?

我们知道如何回答后者:通过列出运行中的容器。

$ docker container ls
CONTAINER ID   IMAGE           COMMAND                  CREATED              STATUS                      PORTS     NAMES
3f831a57b7cc   nginx           "/docker-entrypoint.…"   About a minute ago   Up About a minute           80/tcp    keen_darwin

是的!我们也得到了第一个问题的答案。从上面的输出中可以看出,它似乎是在监听80端口。

让我们把它关闭,然后用-p标志重新启动,让我们的浏览器访问它。

$ docker container stop keen_darwin
$ docker container rm keen_darwin

$ docker container run -d -p 8080:80 nginx

让我们到http://localhost:8080,看看这个应用。看来这个应用显示了错误的信息!让我们直接跳到容器中去,解决这个问题。保持你的浏览器打开,我们不需要为这个修复而关闭容器。我们将在容器中执行bash,标志_-it_将确保我们能与容器进行交互。

$ docker container ls
CONTAINER ID   IMAGE     COMMAND                  CREATED              STATUS              PORTS                                   NAMES
7edcb36aff08   nginx     "/docker-entrypoint.…"   About a minute ago   Up About a minute   0.0.0.0:8080->80/tcp, :::8080->80/tcp   wonderful_ramanujan

$ docker exec -it wonderful_ramanujan bash
root@7edcb36aff08:/#

现在我们已经进入了,我们需要找到有问题的文件并替换它。快速的谷歌告诉我们这个文件本身是/usr/share/nginx/html/index.html

让我们移动到该目录并删除该文件

root@7edcb36aff08:/# cd /usr/share/nginx/html/
root@7edcb36aff08:/# rm index.html

现在,如果我们去http://localhost:8080/,我们知道我们删除了正确的文件。该页面显示404。让我们用一个包含正确内容的文件来替换它。

root@7edcb36aff08:/# echo "Hello, exec!" > index.html

刷新页面,我们的信息就显示出来了!现在我们知道exec是如何被用来与容器交互的。记住,当容器被删除时,所有的变化都会丢失。为了保留这些变化,你必须使用commit,就像我们在上一节中所做的那样。

Redis

Redis是一个key-value数据库。与MongoDB相比,存储在键值存储中的数据结构较少,例如没有集合或表,它只包含一些数据,可以根据附加在数据上的keyvalue)来获取。

默认情况下,Redis在内存中工作,这意味着它不会持久性地存储数据。

Redis的一个很好的用例是把它作为一个缓存。缓存通常被用来存储数据,否则获取和保存数据的速度会很慢,直到它不再有效。在缓存失效后,你会再次获取数据并将其存储在缓存中。

Redis与容器毫无关系。但既然我们已经能够在你的应用中添加任何第三方服务,为什么不了解一个新的服务呢。

Persisting data with Redis

在上一节中提到,默认情况下Redis并不持久化数据。然而,持久化是很容易切换的。我们只需要按照Docker hub page的指示,用一个不同的命令来启动Redis。

services:
  redis:
    # Everything else
    command: ['redis-server', '--appendonly', 'yes'] # Overwrite the CMD
    volumes: # Declare the volume
      - ./redis_data:/data

数据现在将被持久化到主机的redis_data目录。

记得将该目录添加到.gitignore!

Other functionality of Redis

除了对键和值的GET、SET和DEL操作外,Redis还可以做很多事情。例如,它可以自动过期键,当Redis被用作缓存时,这是一个非常有用的功能。

Redis也可以用来实现所谓的发布-订阅(或PubSub)模式,这是一种分布式应用的异步通信机制。在这种情况下,Redis作为两个或多个应用之间的消息代理工作。一些应用通过向Redis发送消息来发布消息,当消息到达时,Redis会通知已经订阅这些消息的各方。