【Part 1】面向 Javascript 开发人员的 Docker 简介(基于 Node.js)

4,100 阅读7分钟

Docker开源的应用容器引擎,如果你是从事后端的开发者,相信对这门技术应该是了解或是熟悉,而对于很多前端开发者,也许只是停留在听过的阶段上,甚至不知道是啥?或是会认为这是后端的技术,我不需要知道,比如说我,还真的不知道是什么,但是如果想成为一名资深前端,这部分空缺是需要填补上的。咸鱼也要有梦想嘛,也许哪天可以跃龙门呢!

本文会通过构建带有web前端代码和mongoDB数据库的全栈node.js应用程序出发,来进一步了解Docker以及它的用途。

什么是Docker

Docker 是一个开源的应用容器引擎,基于 Go 语言 并遵从 Apache2.0 协议开源。Docker 可以让开发者打包他们的应用以及依赖包到一个轻量级、可移植的容器中,然后发布到任何流行的 Linux 机器上,也可以实现虚拟化。

听不懂,能否通俗解释Docker前世今生?

2010年,几个搞IT的年轻人,在美国旧金山成立了一家名叫dotCloud(搞容器技术)的公司。结果坚持不下去,于是开源,结果火了,火了得重新起个牛逼的名字呀,于是Docker就出现了。

Docker出现前,如何模拟一个相互隔离的系统环境?答案就是虚拟机,大家应该都不陌生,很多开发者电脑里面都会装VMWare,通过它,我们可以变出好几台子电脑,一个装window11、一个装CentOS,安装上我喜欢的QQ微信等软件,多个子电脑互相隔离、互不影响,美滋滋!但是动不动就几个G、几十个G,磁盘吃不消呀,而且还启动慢。

前面说到了Docker出现前,虚拟机在做环境隔离上是业界的网红,但是弊端是,而Docker容器技术,其实也是一种虚拟化技术,而且轻、快、一体化,只需要MB级甚至KB级,不像虚拟机,需要模拟一个操作系统出来,Docker只需要虚拟一个小规模的环境(类似“沙箱”)。

不行,我要看数据比对,我才信,安排....

Docker核心概念

上一小节,我们了解了Docker是一种容器虚拟化技术,更轻、更快、更容易一体化,接下来我们快速了解一下它的核心概念后,再去进入我们今天的主题,在编写代码才更好理解。

Docker的三大核心概念:

  • 镜像(Image)
  • 容器(Container)
  • 仓库(Repository)

以上关系图能反映出三者之间的关系,此处需要注意的是,我们说Docker是一种容器技术,但是Docker本身并不是容器,它是创建容器的工具,是应用容器引擎。

镜像,也就是Docker镜像,是一个特殊的文件系统。它除了提供容器运行时所需的程序、库、资源、配置等文件外,还包含了一些为运行时准备的一些配置参数(例如环境变量),同时镜像不包含任何动态数据。

我们可以有很多镜像,我们想存起来,然后可以到任何地方去使用它创建容器环境,那么就需要仓库来存储,也就是Docker仓库

有怎么一个仓库存在,那么所有人都可以往里面存镜像么?不是的,如果存放了个有问题的镜像,那创建容器时候不就挂了么?所以需要有个负责对Docker镜像进行管理的角色,就是Docker Registry服务(类似仓库管理员)了。官方也提供了公共Registry服务,就是Docker Hub(有点像我们的npm市场),里面存放着很多高质量的官方镜像。

同时我们还可以通过Dockfile文件来定制我们的镜像,后续有介绍

通过上面的介绍,相信大家对Docker应该也有了个大概的认识了,这里提供一些常用的Docker命令,

# 容器
$ docker run  // 创建并启动容器
$ docker start // 启动容器
$ docker ps // 查看容器
$ docker stop // 终止容器
$ docker restart // 查看容器
$ docker attach // 进入容器
$ docker exec // 查看容器
$ docker export // 导出容器
$ docker import // 导入容器快照
$ docker rm // 删除容器
$ docker log // 查看日志

# 镜像
$ docker search // 检索镜像
$ docker pull // 获取镜像
$ docker images // 列出镜像
$ docker image ls // 列出镜像
$ docker rmi // 删除镜像
$ docker image rm // 删除镜像
$ docker save // 导出镜像
$ docker load // 导入镜像

# Dockfile定制镜像以及常用指令

$ docker build // 构建镜像
$ docker run // 运行镜像

COPY // 复制文件
ADD // 高级复制
CMD // 容器启动指令
ENV // 环境变量
EXPOSE // 暴露接口


# 服务
$ docker -v // 查看docker的简要信息
$ docker -version // 查看docker版本的简详细信息
$ systemctl start docker // 启动docker
$ systemctl stop docker // 关闭docker
$ systemctl enable docker // 设置开机启动
$ service docker restart // 重启docker服务
$ service docker stop // 关闭docker服务

创建Hello-World容器

首先需要下载Docker下载地址,我下载的是Window Docker DesktopDocker 并非是一个通用的容器工具,它依赖于已存在并运行的 Linux 内核环境。所以需要有Hyper-V/WSL2的支持),接下来查看版本信息是否下载成功,

我下载的是20.10.11版本

然后拉取Docker Hub的官方hello-world镜像,

创建并执行容器,

这样我们第一个容器就创建出来了,可以用用上面提到的命令行docker image ls / docker image prune来查看或是删除没有用(停止运行容器时,它不会被删除,会帮助下次下载安装速度加快)的镜像,更多命令大家可以去试试!

创建Node程序

接下来我们创建个Node程序,在后面教程介绍需要使用到,具体代码详情就不去做介绍了,可以查看以下server.js和package.json

const express = require("express");
const app = express();
const port = 8080;

app.get("/", async (req, res) => {
  res.setHeader("Content-Type", "text/html");
  res.status(200);
  res.send("<h1>你好呀!前端晚间课</h1>");
});

app.listen(port, () => {
  console.log(`Example app listening at http://localhost:${port}`);
});
{
  "name": "docker-example",
  "version": "1.0.0",
  "description": "",
  "main": "server.js",
  "scripts": {
    "start": "nodemon server.js"
  },
  "author": "前端晚间课",
  "license": "ISC",
  "dependencies": {
    "express": "^4.17.2"
  },
  "devDependencies": {
    "nodemon": "^2.0.15"
  }
}


运行npm run start,成功跑起来了...

Node版本不同的困扰

针对上面的server.js文件,我们添加下面的代码:

 // ...
 const myPromise = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve("good");
  }, 300);
  reject("bad");
});

myPromise.then(() => {
  console.log("this will never run");
});

然后分别在Node < 15Node >= 15运行,结果会得到两个不同的结果,

Node < 15

(node:764) UnhandledPromiseRejectionWarning: something happened
(Use `node --trace-warnings ...` to show where the warning was created)
(node:764) UnhandledPromiseRejectionWarning: Unhandled promise rejection. This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). To terminate the node process on unhandled promise rejection, use the CLI flag `--unhandled-rejections=strict` (see https://nodejs.org/api/cli.html#cli_unhandled_rejections_mode). (rejection id: 2)
(node:764) [DEP0018] DeprecationWarning: Unhandled promise rejections are deprecated. In the future, promise rejections that are not handled will terminate the Node.js process with a non-zero exit code.

Node >= 15

node:internal/process/promises:218
          triggerUncaughtException(err, true /* fromPromise */);
          ^

[UnhandledPromiseRejection: This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). The promise rejected with the reason "recipe 
could not be generated".] {
  code: 'ERR_UNHANDLED_REJECTION'
}

会发现两个版本的执行结果会不一致,高版本(Node >= 15)会直接导致程序崩溃(ERR_UNHANDLED_REJECTION),这是未处理的被拒绝 Promise的错误。

现在假设出于某种原因,此应用程序必须在 Node v14 或更早版本上运行才能工作(忽略用try...catch)。团队中的每个开发人员都必须准备好在该环境中开发和运行,但我们公司还有一个新的应用程序,要在 Node v17 上运行!

那这时应该如何去解决这个问题?答案:Docker

创建Dockerfile

上一小节,我们介绍了两个不同版本的Node引起的未处理的被拒绝 Promise的问题,我们介绍如何使用Docker去解决这个问题,其实也很简单,就是需要一个Node < 15的运行环境来保证我们的程序不会崩溃, 我们在Docker Hub可以搜索到关于的Node的镜像,而且还有很多版本信息可选。

当然我们没必要直接使用docker pull node来拉取node镜像,前面我们有提过Dockerfile可以用来自定义定制镜像,它自动判断当前机器是否存在Node镜像,没有的话再自动去Docker Hub拉取,看一下我们的Dockerfile文件:

# 首先先选择你需要的镜像,运行在alpine的node版本是当下最流行的
FROM node:14-alpine3.12

# 工作目录
# 这是您你将在容器内的位置
WORKDIR /usr/src/app

# 通配符用于确保 package.json 和 package-lock.json 都被复制
# COPY 源目录 容器的工作目录
COPY package*.json ./

# 安装应用依赖
RUN npm install

# 如果你正在构建用于生产的代码
# RUN npm ci --only=production

# 捆绑应用程序源
COPY . .

# 配置这个端口可以从容器外部访问
# 浏览器向 Node 应用程序发送 HTTP 请求所必需的
EXPOSE 8080

# CMD 在docker run 时运行
# 就是执行shell npm run start
CMD [ "npm", "run", "start"]

ok,我们的Dockerfile文件已经创建成功,对Dockerfile指令在上面代码中注释也有做简单的介绍,更多详细指令用法可以到谷歌搜索,但你可能会好奇上面的配置文件,为什么COPY需要执行两次,最后的COPY . .不是复制了整个目录,为啥上面还需要复制package*.json?

Docker的层和缓存

COPY两次是有必要的,因为Docker拥有layers(层的特性),每执行一条指令都会基于上一次指令创建的图层基础上再创建一层图层,创建的图层会被缓存,只要发生改变时才会再次重新创建,我们再回过头来看Dockerfile文件。

COPY package*.json ./ 我们创建了一个基于该文件内容的图层,然后再运行npm install,这意味着除非我们更改 package.json,否则下次我们构建 Docker 时将使用npm install已经运行的缓存层,我们不必每次运行时都安装所有依赖项docker build。这将为我们节省大量时间。

COPY . .会查看我们项目目录中的每个文件,因此该层将在任何文件更改时重建(除了package*.json)。这正是我们想要的。

构建应用容器

我们再添加个.dockerignore文件,类似我们的.gitignore,因为我们不想复制这些文件呀。

node_modules
npm-debug.log

一切准备就绪,我们开始构建属于自己的镜像,

# 以当前项目目录为源目录,给镜像名个名叫做qianduanwanjianke
$ docker build . -t qianduanwanjianke

再查看我们创建的镜像存不存在?

创建镜像后,我们现在准备从镜像构建一个容器来运行我们的应用程序:

# --name 我们给容器名了个名,叫做qianduanwanjianke-container
# -p标志将端口从我们的主机(我们的计算机)环境3001端口映射到容器环境的8080端口,当然也可以是8080:8080。
docker run -p 3001:8080 --name qianduanwanjianke-container qianduanwanjianke

大功告成,我们访问一下http://localhost:3001/看看是否成功,

结语

到这里,我们通过一个Node 应用程序切入来介绍Docker,创建了我们的第一个自定义 Docker 镜像和容器,并在其中运行我们的应用程序!这是面向 Javascript 开发人员的 Docker 简介,由Node程序切入,文章内容对于熟悉Docker的后端开发者来说可能是很相当容易的,对于我们前端开发者来说应该是很好的入门教程。由于篇幅已经很长了,我决定把Docker Volume("连接"起容器内部的程序副本与项目目录中的副本,更新同步) 以及引入数据库,分析数据库托管服务器不同,如何创建分离、Docker Compose等内容放置到下篇内容再做介绍。

过程中踩的坑

1、拉取镜像时报错,报错信息:error during connect: This error may indicate that the docker daemon is not running...

解决办法:

# 在Powershell 提升访问权限解决此问题
cd "C:\Program Files\Docker\Docker"
./DockerCli.exe -SwitchDaemon

2、创建应用容器的时候,执行docker build . -t my-node-app, 报错信息:no matching manifest for windows/amd64 10.0.18363 in the manifest list entries

解决办法: 打开Docker Devlop软件, settiong -> Docker Engine,将experimental设置为true,重启Docker

3、构建镜像时,报错信息:Can no longer build docker images (returned a non-zero code: 239)

如果在启动的时候,如果会让你重新更新WSL, 展示如图,

必须升级,不然会遇到上面报错信息,因为针对window安装docker,需要满足(两个条件之一):

  • 1、WSL 2 后端

  • 2、Hyper-V 后端和 Windows 容器

在 Windows 上安装 Docker 桌面

下载 Linux 内核更新包