跳到内容

c

编排基础

React in container

接下来让我们创建一个React应用并进行容器化。让我们选择npm作为软件包管理器,尽管create-react-app默认为yarn。

$ npx create-react-app hello-front --use-npm
  ...

  Happy hacking!

create-react-app已经为我们安装了所有的依赖项,所以我们不需要在这里运行npm install。

下一步是将JavaScript代码和CSS,变成可生产的静态文件。create-react-app已经有build作为一个npm脚本,所以让我们使用它。

$ npm run build
  ...

  Creating an optimized production build...
  ...
  The build folder is ready to be deployed.
  ...

很好!最后一步是想出一个办法,使用服务器来提供静态文件。正如你所知,我们可以使用express.static和Express服务器来提供静态文件。我将把这个问题留给你在家里做练习。相反,我们将继续写我们的Docker文件。

FROM node:16

WORKDIR /usr/src/app

COPY . .

RUN npm ci

RUN npm run build

这看起来是对的。让我们来构建它,看看我们是否在正确的轨道上。我们的目标是让构建成功而不出错。然后我们将使用bash检查容器内部,看看文件是否在那里。

$ docker build . -t hello-front
  [+] Building 172.4s (10/10) FINISHED

$ docker run -it hello-front bash

root@98fa9483ee85:/usr/src/app# ls
  Dockerfile  README.md  build  node_modules  package-lock.json  package.json  public  src

root@98fa9483ee85:/usr/src/app# ls build/
  asset-manifest.json  favicon.ico  index.html  logo192.png  logo512.png  manifest.json  robots.txt  static

既然我们在容器中已经有了Node,那么为静态文件提供服务的一个有效选项是service。让我们试着安装serve,并在容器内提供静态文件。

root@98fa9483ee85:/usr/src/app# npm install -g serve

  added 88 packages, and audited 89 packages in 6s

root@98fa9483ee85:/usr/src/app# serve build

   ┌───────────────────────────────────┐
   │                                   │
   │   Serving!                        │
   │                                   │
   │   Local:  http://localhost:5000   │
   │                                   │
   └───────────────────────────────────┘

太好了!让我们用ctrl+c退出,然后把这些添加到我们的Docker文件中。

服务的安装在Docker文件中变成了一个RUN。这样,在构建过程中就可以安装这个依赖关系。到serve构建目录的命令将成为启动容器的命令。

FROM node:16

WORKDIR /usr/src/app

COPY . .

RUN npm ci

RUN npm run build

RUN npm install -g serve
CMD ["serve", "build"]

我们的CMD现在包括方括号,因此我们现在使用了所谓的CMD的exec形式。实际上,CMD有三种不同的形式,其中exec形式是首选。阅读文档获取更多信息。

当我们现在用docker build构建镜像。-t hello-front并使用docker run -p 5000:3000 hello-front运行它,应用将在http://localhost:5000

Using multiple stages

虽然服务是一个有效的选项,我们可以做得更好。一个好的目标是创建Docker镜像,使其不包含任何无关的东西。有了最小数量的依赖,镜像就不太可能随着时间的推移而损坏或变得脆弱。

多阶段构建是为将构建过程分成许多独立的阶段而设计的,在这些阶段中可以限制镜像文件的哪些部分被移动。这为限制图像的大小提供了可能,因为并非所有的构建副产品都是所产生的图像所必需的。较小的图像在上传和下载时更快,它们有助于减少你的软件可能存在的漏洞数量。

对于多阶段构建,像Nginx这样久经考验的解决方案可以用来提供静态文件,而不会有很多麻烦。Docker Hub Nginx的页面告诉我们打开端口和 "托管一些简单的静态内容 "所需的信息。

让我们使用之前的Docker文件,但改变FROM以包括舞台的名称。

# The first FROM is now a stage called build-stage
FROM node:16 AS build-stage
WORKDIR /usr/src/app

COPY . .

RUN npm ci

RUN npm run build

# This is a new stage, everything before this is gone, except the files we want to COPY
FROM nginx:1.20-alpine
# COPY the directory build from build-stage to /usr/share/nginx/html
# The target location here was found from the docker hub page
COPY --from=build-stage /usr/src/app/build /usr/share/nginx/html

我们还声明了另一个阶段,其中只移动了第一阶段的相关文件(build目录,包含静态内容)。

在我们再次构建之后,图像就可以为静态内容提供服务了。Nginx的默认端口将是80,所以像-p 8000:80这样的端口也可以工作,所以运行命令的参数需要改变一下。

多阶段构建还包括一些内部优化,可能会影响你的构建。举个例子,多阶段构建会跳过那些不使用的阶段。如果我们想用一个阶段来代替构建管道的一部分,比如测试或通知,我们必须把一些数据传递给下面的阶段。在某些情况下,这是合理的:把测试阶段的代码复制到构建阶段。这可以确保你正在构建经过测试的代码。

Development in containers

让我们把整个todo应用的开发转移到一个容器中。有几个原因可以说明你为什么要这样做。

  • 保持开发和生产环境的相似性,以避免只出现在生产环境中的bug

  • 避免开发人员和他们的个人环境之间的差异导致应用开发的困难

  • 通过让新的团队成员安装容器运行时间来帮助他们跳入,而不要求其他。

这些都是很好的理由。权衡之下,我们可能会遇到一些非常规的行为,当我们没有像我们习惯的那样运行应用。我们至少需要做两件事来把应用移到一个容器中。

  • 以开发模式启动应用

  • 用VSCode访问文件

让我们从前端开始。由于Dockerfile将与生产的Dockerfile有很大的不同,让我们创建一个新的,叫做dev.Dockerfile

在开发模式下启动create-react-app应该很容易。让我们从下面开始。

FROM node:16

WORKDIR /usr/src/app

COPY . .

# Change npm ci to npm install since we are going to be in development mode
RUN npm install

# npm start is the command to start the application in development mode
CMD ["npm", "start"]

在构建过程中,标志-f将被用来告诉使用哪个文件,否则它将默认为Dockerfile,所以docker build -f ./dev.Dockerfile -t hello-front-dev . 将构建镜像。create-react-app将在3000端口提供服务,所以你可以通过运行一个发布了该端口的容器来测试它是否工作。

第二个任务,用VSCode访问文件,还没有完成。至少有两种方法可以做到这一点。

让我们来看看后者,因为它也可以在其他编辑器中使用。让我们用标志-v做一次试运行,如果成功了,那么我们就把配置移到docker-compose文件中。为了使用-v,我们将需要告诉它当前的目录。命令pwd应该为你输出当前目录的路径。在你的命令行中用echo $(pwd)试试。我们可以用它作为-v的左侧,将当前目录映射到容器的内部,或者你可以使用完整的目录路径。

$ docker run -p 3000:3000 -v "$(pwd):/usr/src/app/" hello-front-dev

  Compiled successfully!

  You can now view hello-front in the browser.

现在我们可以编辑文件src/App.js,并且这些变化应该被热加载到浏览器上!

接下来,让我们把配置移到docker-compose.yml。这个文件也应该在项目的根目录下。

services:
  app:
    image: hello-front-dev
    build:
      context: . # The context will pick this directory as the "build context"
      dockerfile: dev.Dockerfile # This will simply tell which dockerfile to read
    volumes:
      - ./:/usr/src/app # The path can be relative, so ./ is enough to say "the same location as the docker-compose.yml"
    ports:
      - 3000:3000
    container_name: hello-front-dev # This will name the container hello-front-dev

有了这个配置,docker-compose up可以在开发模式下运行应用。你甚至不需要安装Node来开发它!

对于这样的开发设置来说,安装新的依赖项是一个令人头痛的问题。其中一个更好的选择是将新的依赖关系安装在容器内。因此,你必须在运行中的容器中进行安装,例如:docker exec hello-front-dev npm install axios,或者将其添加到package.json中并再次运行docker build,而不是做例如_npm install axios。

Communication between containers in a Docker network

docker-compose工具在容器之间建立了一个网络,并包括一个DNS来轻松连接两个容器。让我们在docker-compose中添加一个新的服务,我们将看到网络和DNS是如何工作的。

Busybox是一个小的可执行文件,包含了你可能需要的多种工具。它被称为 "嵌入式Linux的瑞士军刀",而我们绝对可以利用它来发挥我们的优势。

Busybox可以帮助我们调试我们的配置。因此,如果你在本节后面的练习中迷失了方向,你应该用Busybox来找出哪些工作和哪些不工作。让我们用它来探索刚才所说的内容。容器是在一个网络内,你可以很容易地在它们之间进行连接。通过改变docker-compose.yml,可以将Busybox加入到这个组合中。

services:
  app:
    image: hello-front-dev
    build:
      context: .
      dockerfile: dev.Dockerfile
    volumes:
      - ./:/usr/src/app
    ports:
      - 3000:3000
    container_name: hello-front-dev
  debug-helper:    image: busybox

Busybox容器不会有任何进程在里面运行,这样我们就可以在里面exec。正因为如此,docker-compose up的输出也会像这样。

$ docker-compose up
  Pulling debug-helper (busybox:)...
  latest: Pulling from library/busybox
  8ec32b265e94: Pull complete
  Digest: sha256:b37dd066f59a4961024cf4bed74cae5e68ac26b48807292bd12198afa3ecb778
  Status: Downloaded newer image for busybox:latest
  Starting hello-front-dev          ... done
  Creating react-app_debug-helper_1 ... done
  Attaching to react-app_debug-helper_1, hello-front-dev
  react-app_debug-helper_1 exited with code 0

  hello-front-dev |
  hello-front-dev | > react-app@0.1.0 start
  hello-front-dev | > react-scripts start

这是预期的,因为它只是一个工具箱。让我们用它来向hello-front-dev发送一个请求,看看DNS是如何工作的。当hello-front-dev运行时,我们可以用wget进行请求,因为它是Busybox中的一个工具,可以从debug-helper向hello-front-dev发送请求。

使用Docker Compose,我们可以使用docker-compose run SERVICE COMMAND来运行一个具有特定命令的服务。命令wget需要标志-O-来输出响应到stdout。

$ docker-compose run debug-helper wget -O - http://app:3000

  Creating react-app_debug-helper_run ... done
  Connecting to hello-front-dev:3000 (172.26.0.2:3000)
  writing to stdout
  <!DOCTYPE html>
  <html lang="en">
    <head>
      <meta charset="utf-8" />
      ...

这里的URL是有趣的部分。我们只是说要连接到服务hello-front-dev和该端口3000。hello-front-dev是容器的名字,这是我们在docker-compose文件中用container/name给出的。而使用的端口是应用在该容器中可用的端口。该端口不需要发布,因为同一网络中的其他服务也能连接到它。docker-compose文件中的 "端口 "只用于外部访问。

让我们改变docker-compose.yml中的端口配置来强调这一点。

services:
  app:
    image: hello-front-dev
    build:
      context: .
      dockerfile: dev.Dockerfile
    volumes:
      - ./:/usr/src/app
    ports:
      - 3210:3000    container_name: hello-front-dev
  debug-helper:
    image: busybox

随着docker-compose up,应用在主机http://localhost:3210中可用,但仍然docker-compose run debug-helper wget -O - http://app:3000 工作,因为端口在docker网络内仍然是3000。

fullstack content

如上图所示,docker-compose run要求debug-helper在网络内发送请求。而主机中的浏览器则从网络外发送请求。

现在你知道在docker-compose.yml中找到其他服务是多么容易,而且我们没有什么要调试的,我们可以删除debug-helper,并在我们的docker-compose.yml中把端口恢复到3000:3000。

Communications between containers in a more ambitious environment

接下来,我们将在我们的docker-compose.yml中添加一个反向代理。根据维基百科的说法

反向代理是一种代理服务器,它代表客户从一个或多个服务器中检索资源。这些资源然后被返回给客户,看起来就像它们来自反向代理服务器本身。

所以在我们的案例中,反向代理将是我们应用的单一入口点,而最终的目标是将React前端和Express后端都设置在反向代理后面。

反向代理的实现有多种不同的选择,如Traefik、Caddy、Nginx和Apache(按初始版本从新到旧排序)。

我们的选择是Nginx

现在让我们把hello-frontend放在反向代理后面。

在项目根目录下创建一个文件nginx.conf,以下列模板为起点。我们将需要做一些小的编辑以使我们的应用运行。

# events is required, but defaults are ok
events { }

# A http server, listening at port 80
http {
  server {
    listen 80;

    # Requests starting with root (/) are handled
    location / {
      # The following 3 lines are required for the hot loading to work (websocket).
      proxy_http_version 1.1;
      proxy_set_header Upgrade $http_upgrade;
      proxy_set_header Connection 'upgrade';

      # Requests are directed to http://localhost:3000
      proxy_pass http://localhost:3000;
    }
  }
}

接下来,在docker-compose.yml文件中创建一个Nginx服务。按照Docker Hub页面的指示添加一个卷,右边是:/etc/nginx/nginx.conf:ro,最后的ro声明该卷将是只读的

services:
  app:
    # ...
  nginx:
    image: nginx:1.20.1
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
    ports:
      - 8080:80
    container_name: reverse-proxy
    depends_on:
      - app # wait for the frontend container to be started

添加了这个,我们可以运行docker-compose up,看看会发生什么。

$ docker container ls
CONTAINER ID   IMAGE             COMMAND                  CREATED         STATUS         PORTS                                       NAMES
a02ae58f3e8d   nginx:1.20.1      "/docker-entrypoint.…"   4 minutes ago   Up 4 minutes   0.0.0.0:8080->80/tcp, :::8080->80/tcp       reverse-proxy
5ee0284566b4   hello-front-dev   "docker-entrypoint.s…"   4 minutes ago   Up 4 minutes   0.0.0.0:3000->3000/tcp, :::3000->3000/tcp   hello-front-dev

连接到http://localhost:8080 将导致一个看起来很熟悉的页面,状态为502。

这是因为将请求指向 http://localhost:3000 没有任何结果,因为Nginx容器没有在3000端口运行的应用。根据定义,localhost指的是当前用于访问的计算机。对于容器来说,localhost对每个容器都是唯一的,导致容器本身。

让我们通过进入Nginx容器内部,用curl向应用本身发送一个请求来测试一下。在我们的用法中,curl类似于wget,但不需要任何标志。

$ docker exec -it reverse-proxy bash

root@374f9e62bfa8:/# curl http://localhost:80
  <html>
  <head><title>502 Bad Gateway</title></head>
  ...

为了帮助我们,当我们运行docker-compose up时,docker-compose设置了一个网络。它还将docker-compose.yml中的所有容器添加到网络中。一个DNS确保我们可以找到另一个容器。容器被赋予两个名字:服务名和容器名。

由于我们在容器内,我们也可以测试DNS!让我们把服务名(app)在3000端口上卷起来

root@374f9e62bfa8:/# curl http://app:3000
  <!DOCTYPE html>
  <html lang="en">
    <head>
    ...
    <meta
      name="description"
      content="Web site created using create-react-app"
    />
    ...

就是这样!让我们把nginx.conf中的proxy_pass地址换成这个。

如果你仍然遇到502,请确保create-react-app已经被构建。你可以从docker-compose up读取日志输出。

还有一件事:我们在配置中添加了一个选项depend_on,确保nginx容器在前端容器app被盯上之前不会被启动。

services:
  app:
    # ...
  nginx:
    image: nginx:1.20.1
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
    ports:
      - 8080:80
    container_name: reverse-proxy
    depends_on:      - app

如果我们不使用depends_on强制执行启动顺序,Nginx有可能在启动时失败,因为它试图重新爱护配置文件中提到的所有DNS名称。

http {
  server {
    listen 80;

    location / {
      proxy_http_version 1.1;
      proxy_set_header Upgrade $http_upgrade;
      proxy_set_header Connection 'upgrade';

      proxy_pass http://app:3000;    }
  }
}

注意,depends_on并不保证被依赖的容器中的服务已经准备好了,它只是确保该容器已经被启动(并且相应的条目被添加到DNS中)。如果一个服务需要等待另一个服务在启动前做好准备,应该使用其他解决方案

Tools for Production

容器是在开发中使用的有趣工具,但它们的最佳使用情况是在生产环境中。有很多比docker-compose更强大的工具可以在生产中运行容器。

Kubernetes这样的重量级容器编排工具使我们能够在一个全新的水平上管理容器。这些工具隐藏了物理机器,让我们这些开发者少担心基础设施。

如果你有兴趣更深入地了解容器,请参加DevOps with Docker课程,你可以在高级的5学分DevOps with Kubernetes课程中找到更多关于Kubernetes的信息。你现在应该具备完成这两门课程的技能了!