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 . .
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
$ 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,就像我们在上一节中所做的那样。
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会通知已经订阅这些消息的各方。