使用 docker 来编排 Web 应用

175 天前
 BaymaxK

前言

使用 docker 可以轻松构建一个项目并运行,然而在真实的使用场景中,我们的项目并非是单一的,而是多个项目相互依赖组成一个 web 应用。

考虑这样一个场景,一个正在运行的 web 应用,它用到了 vue 、java8 、java15 、tomcat 、nginx 、php 、mysql 和 redis 。如果要将这个应用迁移到一台新的服务器上运行,那么就需要在这台机器上重新安装所需的软件以及环境变量,这是很痛苦的。

那么,有没有什么办法来解决这个问题呢?当然有,那就是本文的主角docker-compose( docker 编排)。本文就跟大家分享下我是如何使用它解决我的痛苦的,欢迎各位感兴趣的开发者阅读本文。

前置知识

阅读本文前,你需要先提前安装好Dockerdocker-compose。如果你已经安装,我们先来了解几个基础概念。如果你仅仅只是想学习我是如何编排整个 web 应用的,请直接从编排容器章节开始阅读。

镜像

操作系统分为内核用户空间,对于 Linux 而言,内核启动后,会挂载root文件系统,为其提供用户空间支持。而Docker镜像就相当于一个root文件系统。比如官方的镜像ubuntu:20.04就包含了完整的一套 ubuntu 最小系统的rooot文件系统。

Docker 的镜像是一个比较特殊的文件系统,它除了提供容器运行时所需的东西外,还包含了一些为运行时准备的一些配置参数(挂载卷、环境变量、用户等)。镜像不会包含任何的动态数据,因此在构建之后,它的内容不会被改变。

如果你还是一头雾水的话,可以将它比作“备份”,在需要的时候把它拿出来即可原封不动的获得备份时刻的内容。

可能有些开发者会有疑问,即使是最小的 root 文件系统,它的体积也是庞大的。Docker 在设计时,就充分利用 Union FS 的技术,将其设计为分层存储的架构。这种架构使得镜像的复用、定制变的更为容易,可以用之前构建好的镜像作为基础层,然后进一步添加新的层,以定制自己所需的内容,构建新的镜像。

⚠️注意:镜像构建时,会一层层构建,前一层是后一层的基础。每一层构建完就不会再发生改变,后一层上的任何改变只发生在自己这一层。因此,在编写镜像配置文件的时候,每一层尽量只包含该层需要添加的东西,任何额外的东西应该在该层构建结束前清理掉(比如你在某一层需要删除前一层的文件,它只是将文件标记为了已删除,最终容器运行的时候,虽然不会看到这个文件,但是实际上该文件会一直跟随镜像)。

容器

镜像与容器之间的关系,类似于 Java 中的实例。镜像是类,容器是实例化出来的实例。容器可以被创建、启动、停止、删除、暂停等。

容器的实质是进程,但与直接在宿主执行的进程不同,容器进程运行于属于自己的独立的命名空间。因此容器可以拥有自己的 root 文件系统、网络配置、进程空间。容器内的进程是运行在一个隔离的环境里,使用起来,就好像是在一个独立于宿主的系统下操作一样。

前面我们讲过镜像使用的是分层存储,容器也是如此。每一个容器运行时,以镜像为基础层,在其上创建一个当前容器的存储层,这个为容器运行时读写而准备的存储层称之为容器存储层

⚠️注意:容器存储层的生命周期和容器一样,容器销毁时,存储层也随之销毁。因此,任何保存于存储层的信息都会随着容器的销毁而丢失。

因此,当我们的容器需要进行数据写入时,需要 使用volume数据卷 或者 挂载宿主机的目录 来实现数据的持久化。

仓库

当我们构建好镜像之后,很容易在当前的宿主机上运行,但是,如果需要在其他服务器上使用这个镜像,我们就需要一个仓库来存储和分发它们。Docker Registry 就是这样的服务。

一个 Docker Registry 中可以包含多个 仓库Repository);每个仓库可以包含多个 标签Tag);每个标签对应一个镜像。

一个仓库包含同一个软件不同版本的镜像,标签就对应着软件的各个版本。我们通过<仓库名>:<标签>的格式来指定具体是那个版本的镜像。

我们以 ubuntu 镜像为例,ubuntu 是仓库的名字,其内包含有不同的版本标签,如:18.0420.04。我们在编写配置文件的时候,就可以通过ubuntu:18.04ubuntu:20.04来指定具体所需版本的镜像。如果忽略了标签,将视为ubuntu:latest

公开服务

Docker Registry 公开服务是开放给用户使用,允许用户管理镜像的 Registry 服务。一般这类公开服务允许用户免费上传、下载公开的镜像。

最常使用的公开服务是官方的 Docker Hub,这个也是默认的 Registry 。拥有大量的官方镜像。除此之外,还有:

私有部署

当用户制作好一个镜像后,不方便在公开服务上发布时,就需要在内部自己搭建 Docker Registry 。官方提供了 Docker Registry 镜像,可以直接使用做为私有 Registry 服务。

开源的 Docker Registry 镜像只提供了 Docker Registry API 的服务端实现,足以支持 docker 命令,不影响使用。但不包含图形界面,以及镜像维护、用户管理、访问控制等高级功能。

除了官方的 Docker Registry 外,还有第三方软件实现了 Docker Registry API ,提供了用户界面以及一些高级功能。比如:Harbor

创建镜像

Docker 镜像的构建是通过读取Dockerfile文件来完成的,它本质是一个文本文件,其内包含了一条条的指令,每一条指令构建一层,因此每一条指令的内容,就是描述该层应当如何构建。

编写配置文件

此处以我的开源项目 chat-system 的后端服务为例,在项目的根目录创建名为chat-system-server-DockerFile的文件,在文件内添加下述代码:

FROM tomcat:9.0.41-jdk8-openjdk

COPY ./chat-system-server.war /usr/local/tomcat/webapps/
COPY ./tomcat/conf/server.xml /usr/local/tomcat/conf/server.xml


EXPOSE 8080

上面的指令:我们用了 9.0.41 版本的 tomcat 且 jdk 版本为 1.8 的镜像作为基础环境,随后我们拷贝了项目的 war 包到 tomact 的 webapps 目录下,拷贝了 tomcat 的配置文件到了镜像内。声明了8080为本服务的访问端口号。

常用的指令

在 DockerFile 中,除了前面我们所讲的,还有很多内置的指令可以用,此处我们挑几个常用的来做下讲解。

如果你想了解完整的的指令列表,请移步:Dockerfile 指令详解

⚠️注意:在编写配置文件的时候,如果需要执行多个类似于RUN的指令时,请用&&来拼接。因为前面我们讲过 docker 是分层存储机制,每一个指令都会建立一层,如果运行了多个 RUN 指令,它就会创建多层镜像,这是完全没有意义的,而且很多运行时不需要的东西,都被装进了镜像里,比如编译环境、更新的软件包等等。结果就是产生非常臃肿、非常多层的镜像,不仅仅增加了构建部署的时间,也很容易出错。Dockerfile 支持 Shell 类的行尾添加 \ 的命令换行方式,以及行首 # 进行注释的格式。

RUN set -x; buildDeps='gcc libc6-dev make wget' \
    && apt-get update \
    && apt-get install -y $buildDeps \
    && wget -O redis.tar.gz "http://download.redis.io/releases/redis-5.0.3.tar.gz" 

构建镜像

打开终端,进入 Dockerfile 文件所在在的目录,执行 docker build -t chat-system-server:1.0.0 -f chat-system-server-DockerFile .命令,即可开始构建流程。

⚠️注意:如果你没有指定配置文件,它默认会使用目录下名为Dockerfile的文件。如果你的配置文件中包含了 COPY 操作,请务必指定上下文路径。

启动容器

启动容器有两种方式,一种是基于镜像新建一个容器并启动,另一种是启动一个处于终止状态的容器。

新建并启动

使用docker run 镜像名即可创建一个容器并启动它,我们以上个章节创建的镜像为例。

docker run chat-system-server:1.0.0

命令执行后,你将在控制台看到相关的输出。

容器启动之后,通过镜像中声明的 8080 端口访问,你会发现访问不了。这是因为容器启动后没有做端口映射,我们需要在启动命令中添加-p参数来指定端口。

docker run -p 127.0.0.1:8080:8080 chat-system-server:1.0.0

启动已终止容器

使用docker container start 容器名即可启动一个处于终止状态的容器。因为我们是新建的容器,没有给它命名,docker 会默认一个容器名,我们可以通过docker ps -a命令来拿到容器名,或者通过 Webstorm 的 Docker 插件来获取。

docker container start crazy_wu

常用的参数与命令

容器在启动时,有丰富的参数可以配置,此处我列举几个最常用的参数。

在后台运行容器

我们新建并启动一个容器时,默认会在当前终端中运行,终端窗口关闭后,容器也会跟着终止。通过-d参数即可让它在后台运行。

docker run -d -p 127.0.0.1:8080:8080 chat-system-server:1.0.0

为容器命名

我们在新建容器时,可以通过--name参数来给它命名。

docker run --name local_chat_system_server -d -p 127.0.0.1:8080:8080 chat-system-server:1.0.0

终止与删除容器

我们可以通过stoprm指令来终止和删除容器。

# 终止容器
docker container stop 容器名
# 删除容器
docker container rm 容器名

进入容器

如果容器处于后台运行时,我们需要进入容器,做一些操作。就需要使用docker exec -it 容器名 bash来实现。

docker exec -it local_chat_system_server bash

执行命令后,就能看到熟悉的终端了。

数据挂载

前面我们说过,容器内存储的数据会随着容器的终止而丢失,需要挂载数据卷来实现数据的持久化存储。通常有两种做法:

通过数据卷来做存储需要先使用docker volume create 卷名 命令来创建,新建并启动一个容器的时候通过--mount指令或者-v 数据卷:容器目录地址来挂载。

# 创建一个数据卷
docker volume create chat-system-data
# 启动容器并挂载数据卷
docker run -d  \
    --name local_chat_system_server \
    --mount source=chat-system-data,target=/usr/local/data \
   chat-system-server:1.0.0

容器启动后,会将日志写到我挂载的数据卷中。当我想在主机上查看写入的日志数据时,发现怎么找也找不到。查了很多资料,最后发现他的数据卷存储很复杂,并没有集中存储,而是分布式的。

这种方式显然不是我想要的,而目录映射的方式正好满足我的需求。我们只需要将数据卷改为主机的路径即可,此处我们以-v 参数为例。

docker run -d  \
    --name local_chat_system_server \
    -v /Users/likai/Documents/chat-system-server-data:/usr/local/data \
   chat-system-server:1.0.0

⚠️注意:目录映射的形式会把指定的主机路径与容器内的目标路径做关联,本地主机做的操作会响应到容器内,反之亦然。

编排容器

现在,我们回到文章开头所说的那个场景,一开始我打算使用ubuntu:20.04作为基础镜像,写DockerFile安装我需要的依赖包,设置环境变量,打包成镜像来使用。我在做事情之前,喜欢先请教下这方面玩的比较好的朋友。

他们告诉我说,如果全部打包到一个镜像里 ,后期维护与扩展将成为恶梦,后面你要添加新项目,想访问一些之前项目里提供的服务,你就得做很大的改动。一般这种场景我们都会使用Docker Compose来实现的。

简而言之,Docker Compose 的作用就是将多个独立的容器组合起来,让容器之间可以轻易的互相访问,最终实现我们的需求。

编写配置文件

容器的编排是通过编写docker-compose.yml配置文件来实现的,一般我们会将这个文件创建在项目的根目录。它的配置文件中包含有很多配置项,此处我们只列举本文需要用到的。

定义网络

在物理机上部署服务时,多个服务之间相互访问,需要物理机必须处于同一个网关下(防火墙也要正确的配置),这样就能实现多个容器之间的互联互通。

在 docker-compose 中流程也是一样的,因此我们就需要先定义一个网络,设置好网关。如下所示,我们定义了一个名为kaisir-docker-network的自定义网络。

networks:
    kaisir-docker-network:
        external: true
        name: kaisir-docker-network
        driver: bridge
        ipam:
            driver: default
            config:
                - subnet: 192.168.30.0/24
                  gateway: 192.168.30.1

通过上述配置,我们就可以在服务中连接这个网络了。有些开发者可能对192.168.30.0/24比较疑惑,这里就稍作下解释 。这是一个 CIDR ( Classless Inter-Domain Routing )表示法,用于表示 IP 地址范围。CIDR 表示法包括两部分:IP 地址的网络部分和主机部分,通过斜杠后面的数字表示网络的长度。

192.168.30.0/24 中:

  • 192.168.30.0 是网络的基础 IP 地址。
  • /24 表示网络的前缀长度,即网络中有多少个连续的 IP 地址。在这里,它表示有 24 位用于网络,剩余的 32 - 24 = 8 位用于主机。

具体地,192.168.30.0/24 表示从 192.168.30.0192.168.30.255 的 IP 地址范围,其中包括了 192.168.30.1192.168.30.254 的所有 IP 地址。192.168.30.0 是网络地址,192.168.30.255 是广播地址。

定义服务

我可以在services指令下定义我们需要的服务,为他们连接网络、挂载数据卷、设置时区、定义访问端口等,我们以 mysql 为例来做讲解,如下所示:

services:
    mysql:
        image: mysql:5.7.42
        container_name: local_mysql
        volumes:
             - /Users/likai/Documents/mysql_data:/var/lib/mysql
             - /Users/likai/Documents/mysql_conf/my.cnf:/etc/my.cnf
        ports:
            - 3306:3306
        networks:
            kaisir-docker-network:
                ipv4_address: 192.168.30.11
        environment:
            - MYSQL_ROOT_PASSWORD=xxxx
            - TZ=Asia/Shanghai

通过这几行配置,我们就拥有了一个 mysql 服务。并且其他服务可以通过192.168.30.11:3306访问到这个服务。

我们再来定义 Java 服务和 nginx 服务,以我的chat-system-server为例,在打包 Java 服务时,将数据库的地址指向我们刚才的 mysql 服务即可。

    chat-system-server:
        image: tomcat:9.0.41-jdk8-openjdk
        container_name: chat_system_server
        ports:
            - 8080:8080
        volumes:
            - /Users/likai/Documents/docker_tomcat/webapps:/usr/local/tomcat/webapps
            - /Users/likai/Documents/chat-system-server-data:/usr/local/data
            - /Users/likai/Documents/docker_tomcat/conf/:/usr/local/tomcat/conf
        environment:
            - TZ=Asia/Shanghai
        networks:
            kaisir-docker-network:
                ipv4_address: 192.168.30.12
    nginx-server:
        image: nginx:1.18.0
        container_name: local_nginx
        ports:
            - 80:80
            - 443:443
        volumes:
            - /Users/likai/Documents/nginx_config:/etc/nginx
            - /Users/likai/Documents/nginx_data:/usr/share/nginx/data
        environment:
            - TZ=Asia/Shanghai
        networks:
            - kaisir-docker-network
        depends_on:
            - redis
            - mysql
            - chat-system-server
            - kodbox
            - halo
                

同样的,其他的服务也通过这种格式进行定义即可。上面的配置中,我还定义了 redis 、kodbox 、halo 这三个服务。

因为有多个服务都需要用到数据库,我需要确保数据库先启动,因此上面的配置中,我还用depends_on指令定义了服务的启动顺序。

⚠️注意:上面的配置文件中,本地路径是写死在配置文件中的,实际在使用的时候一般会通过变量的形式注入一个路径前缀进来,如下所示:

  • 如果 MY_VOLUME_PATH 环境变量存在并且非空,则使用它的值
  • 否则,使用默认值 /default/path
        volumes:
            - ${MY_VOLUME_PATH:-/default/path}/docker_tomcat/webapps:/usr/local/tomcat/webapps

启动服务

最后,我们在终端通过docker-compose up命令即可启动我们定义好的所有服务。因为我们映射了本地的 8080 端口为 chat_system_server 的服务,我们在 postman 内通过127.0.0.1:8080即可访问到这个容器所提供的服务了。

docker-compose up

在 nginx 的配置文件中我也暴露了 80 端口出来,指向了 halo 服务,在浏览器中直接访问127.0.0.1就能看到服务所提供的内容了。

⚠️注意:如果你的配置文件中定义了路径变量则需要在启动时传入这个变量,如下所示:

MY_VOLUME_PATH=/path/to/your/volume docker-compose up

踩坑记录

我在配置 mysql 服务时,走了很多弯路,不知道哪步搞错了,启动服务后会报错find: '/var/lib/mysql/mysql.sock': No such file or directory,导致启动失败,我尝试过删除容器、删除镜像,清空主机映射的目录文件,都解决不了。

最后,发现是权限原因,在 docker-compose.yml文件中,给 mysql 服务添加下述配置即可解决。问题解决后,再把这些去掉即可。

command:
      - /bin/bash
      - -c
      - |
        chmod +rw /var/lib/mysql
        mysqld    

写在最后

至此,文章就分享完毕了。

我是神奇的程序员,一位前端开发工程师。

如果你对我感兴趣,请移步我的个人网站,进一步了解。

1656 次点击
所在节点    Docker
6 条回复
monsterx2a
174 天前
写的很好,排版也很棒。

docker-compose.yml 现在是我对某个服务操作的单元了,因为我的程序规模比较小,所以 mysql, nignx, asp.net/backend , next.js/frontend 都在一个 compose 里面。

甚至我在 compose 增加了 mysql_database_bakup 和 nettools 等服务,方便对整个服务的维护和调试。
这里很多点你都提到了,但是每个点都能再扩展开来,如果扩展开来那就是它的文档了。
4ark
174 天前
写的不错
meiguozhiguang
174 天前
本地或者开发测试环境用着一套部署非常方便
pengdahan4
174 天前
这个结合 chatgpt ,做一个服务编排的 demo ,比以前花的时间少多了
PRStarDust
174 天前
写得真不错
TabGre
174 天前
单独一个 mysql 容器会不会更好,对于数据库是否有更好的实践

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/992298

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX