小驿搭建(三)——用Docker建立服务(原理概述)

6/23/2021 DevOpsDocker服务部署

如果你对容器技术已经十分熟悉,请跳过此章。

# 从虚拟化说起

容器化(Containerization)是虚拟化技术的一种。要说容器,我们应当首先说说虚拟化。

虚拟化有两个重要特性:

  1. 仿真(Emulation):模仿已有的物理机器的资源用于向上层提供。
  2. 透明(Transparency):在虚拟化层上的操作系统无法(或很难)感知到虚拟化层的存在。

从这些特性可以看出,我们能够通过虚拟化在同一物理机上创建出大量独立的实例,以运行具有各异性(Heterogeneity)的程序。这些程序如果单独配置,则需要大量时间、精力;同时如果这些程序的资源利用不高,则服务器的资源也会遭到浪费。因此,将这些程序配置在一个主机的不同虚拟环境下就成为了一种利用好服务器资源的有利模式。这就是我们利用虚拟化的动机。

# 虚拟化的类型

虚拟化有两个典型方式,一种是虚拟机,一种是容器。虚拟机是一套运行在虚拟化硬件或虚拟化层上的OS,容器则一般运行在OS中。虚拟机技术较为多样,实现方式各不相同,例如Hyper-V,Xen,KVM等,在此先不做展开讨论。

# 容器化

容器技术则是基于进程隔离方式实现虚拟化,通过控制组(control group,cGroup)和命名空间(namespace)机制,容器运行时(runtime)能够提供独立的进程空间用于模拟一个OS。在这个OS中,CPU、内存、网络、存储卷等资源都由运行时通过cGroup管理,为了使运行的进程区别于主OS的进程,命名空间就起到了隔离的作用,它将容器内的用户和进程与外部OS的进程进行了隔离,从而使得每个容器认为自己是一个独立的OS。

这里我们用一个简单的例子来说明namespace,下面的bash记录了一个gitea容器内外的两个视角下gitea进程的信息:

ubuntu@VM-0-11-ubuntu:~$ sudo docker exec gitea-web bash -c " ps -ef | grep gitea"
   14 git       3h13 /app/gitea/gitea web
ubuntu@VM-0-11-ubuntu:~$ ps -ef | grep gitea
1996      2516  2514  0 May03 ?        03:13:01 /app/gitea/gitea web

正如你看到的,对于内部和外部,同一个进程的PID、UID是不同的。在容器内(通过docker exec获得信息),PID是14;但在外部,此进程的PID就变成了2516。同时,内部视角下进程的创建者是git,而外部就变成了1996。正是通过namespace的方式,容器实现了提供不同视角、从而隔离运行的效果。

那么,这里应当采用哪种虚拟化的方式呢?答案是显而易见的,我们应当使用容器作为这些简单服务的虚拟化方法。首先,我们不需要一个VM来模拟一整套OS。对于每个服务而言,其主要功能是提供一个进程用于监听然后服务,很多时候它并不需要太多的资源,因而它是一个轻量的应用,使用容器即可解决。其次,我们需要的是一系列的服务。如果每个服务都运行在一个VM上,它们之间的交互就难以组织了,需要明确每个虚拟机的资源,然后要解决网络的通信问题。这对于我们的网站这样资源占用低、更新需求快的小站,是不必要的。

# 容器与编排

Docker是一个容器化环境,它提供了丰富的容器功能,海量的镜像为我们提供了非常方便的配置过程, 只需要简单地获取镜像并通过一行代码运行(例如 docker run -p 80:80 nginx:latest),我们就可以在服务器上建立一个运行中的容器来提供服务了。关于docker的安装,应当参考官方文档 (opens new window),此处不做描述,以防止方法过期带来的误导。

# Docker概念简述

假如你已经安装好了Docker,就可以开始使用docker配置web服务了。但在这之前,我们需要首先了解一下Docker的基本使用方法。 首先我们看一看Docker的命令,这里先挑出一些常用的典型的命令用于说明一些Docker的概念和基本使用方法:

$ docker --help

Usage:  docker [OPTIONS] COMMAND

A self-sufficient runtime for containers

......

Commands:
  build       Build an image from a Dockerfile
  commit      Create a new image from a container's changes
  cp          Copy files/folders between a container and the local filesystem
  create      Create a new container
  exec        Run a command in a running container
  pull        Pull an image or a repository from a registry
  run         Run a command in a new container

......

Run 'docker COMMAND --help' for more information on a command.

To get more help with docker, check out our guides at https://docs.docker.com/go/guides/

首先,我们能够看到的是docker images,这里提到了镜像(image)这个概念,结合docker run,我们可以看到另一个概念,容器(container)。它们之间的关系可以理解为:镜像是存储于硬盘上的文件,它是由很多层文件构成的,每层对应着构建这个镜像的一个操作。当我们需要使用时,我们通过设置docker run,就把它加载到内存中配置后运行,启动一个容器。当容器运行完后,又可以使用docker commit构建一个镜像,这个镜像将包含在容器运行期间产生的所有文件(包括不需要的文件),因而这种做法一般不被推荐。一般的做法是通过docker build建立新的镜像,在这里help中提到了Dockerfile,这是一种用于构建镜像的文本文件,Docker会解析这个文件并根据每行的命令构建镜像,我们使用一个具体文件来说明:

FROM    python:3.7-slim
EXPOSE  80

COPY    /project/flask-api-server /home/flask-api-server
RUN     python3 -m pip install -r requirement.txt

CMD     gunicorn -w4 app.py

这是一个简单的Dockerfile示例,通过复制flask项目代码文件(COPY复制到镜像内的文件系统)到基础镜像(FROM对应的镜像)中,在镜像中安装依赖包(RUN模拟命令行执行命令),并提供运行的命令(CMD提供服务运行的命令)来封装构建一个可以直接运行并提供给服务(EXPOSE标示开放的端口)的应用镜像。需要注意的是,每个命令都会为构建的Docker镜像添加一层文件,如果需要执行特别多的RUN命令,可以考虑使用&&连接命令或是使用shell脚本等。

# 容器编排与Docker-Compose

然而,尽管方便至此,Docker仍需要通过命令行来配置运行的选项(如-p, -v, --name, --rm, -d等等),以达到期望的运行效果,同时容器的网络组织也需要使用Docker Network来处理。假如一个容器的镜像可能需要被频繁更新,而我们的服务又需要很多容器进行交互,容器的网络就需要使用Docker Network来处理,手动处理当然很麻烦,那么有什么替代方法吗?

这时我们就需要用到容器编排,通过配置文件的方式避免手动操作容器的配置过程,把多个容器联系在一起,并配置每个容器需要的资源,这样我们就减少了容器运维中的人工引入错误并提高效率。目前的主流包括了Kubernetes和Docker-compose,前者拥有更加完整的资源调配、容器组织的功能,后者则提供了简单的单机运行配置方法。 由于规模小得多,小型网站的运维远比商业网站简单。因而这个网站只需要简单地使用Docker-compose即可实现。

# Docker-Compose

Docker-compose的安装非常简单,因为它是基于Python3编写的一系列脚本。对于Windows,当你在安装好Docker Desktop的时候,你就已经安装好了Compose。对于Linux,你可能还需要安装python和pip(Ubuntu20.04已经默认携带此包)再安装Compose:

# Install Python if not
$ apt install python3 python3-pip
# Install Docker-compose
$ python3 -m pip install --user docker-compose

注意这里使用了--user安装compose,这是为了在使用非root的情况下使用Docker,因为root模式使用Docker在安全实践上并不好。

以下是一个Docker-compose的案例,它展示了Docker-compose的多种编排功能:

version: "3"

services: 
  server:
    image: drone/drone
    container_name: ci_server.kelmory.xyz
    volumes: 
      - ./data/drone:/data
      - /etc/timezone:/etc/timezone:ro
      - /etc/localtime:/etc/localtime:ro
    environment: 
      - DRONE_GITEA_SERVER=*****
      - DRONE_GITEA_CLIENT_ID=*****
      - DRONE_GITEA_CLIENT_SECRET=*****
      - DRONE_RPC_SECRET=*****
      - DRONE_SERVER_PROTO=http
      - DRONE_SERVER_HOST=*****
    restart: always
    ports:
      - "3001:80"
    networks: 
      - backend
  runner:
    image: drone/drone-runner-docker
    container_name: ci_blog.kelmory.xyz
    environment:
      - DRONE_RPC_SECRET=*****
      - DRONE_RUNNER_CAPACITY=2
      - DRONE_RUNNER_NAME=*****
      - DRONE_RPC_HOST=server
      - DRONE_RPC_PROTO=http
    volumes: 
      - "/var/run/docker.sock:/var/run/docker.sock"
    restart: always
    depends_on:
      - server
    ports: 
      - "3002:3000"
    networks: 
      - backend

networks: 
  backend:

以上展示了一组Drone CI的编排,他启动了两个服务,其中一个是Drone的服务器,另一个是Drone的一个流水线管道的Runner。关于Drone CI,我们在后续的文章进行讨论,这里主要讨论Docker-Compose的编排设置文件docker-compose.yml。首先需要指出,yaml是一种类json的语言,相对于json通过空格行分行减少了对大括号的需求,增强了可读性,但相应的也。在yaml中只要前序的空格数量相等,就可以

首先,在最开始的地方我们使用version指明解析后续选项的支持版本。这里使用的是3,这是一个基础版本,而后续的3.43.7等数字会支持更多的特性。在这之后,主要支持三个一级关键字servicesvolumesnetworks,它们分别对应使用的服务容器、docker的挂载卷和docker网络。

services下是我们定义服务的区域,一个服务可以被看作一组产生相同行为、通过Compose的负载均衡提供访问的容器。每个service首先由一个命名表示,例如在上面的示例中,我们使用了一个被docker-compose视为server的服务,在这个服务中我们声明了一些对应到docker命令的选项,这些选项几乎涵盖了docker-compose中常见的选项,下面对其做略为详细的说明:

  1. image:指定加载的镜像,与docker cli相似,使用的是[org/]image_name[:tag]格式;
  2. container_name:指定容器名称,这个名称与docker cli中看到的相同;
  3. volume:指定挂载卷,可以包含多个,通过yml数组形式记录,对于每一条挂载记录,其使用方式之一与docker cli相似,即host_path:container_path[:privilege],其中主机的路径可以是相对路径,权限privilege:ro等,用于控制容器内文件访问行为;另一方面,也可以使用docker-compose文件中定义的的命名挂载卷;
  4. network:指定docker容器接入到的compose定义的网络,docker容器通常可以接入到多个虚拟网络中,在每个虚拟网络中都对应分配一个域内的私有地址,例如有两个网络backend、frontend,假如一个服务加入了这两个网络,那么它可能会被分配到172.16.1.0/24和172.16.2.0/24,即分别对应到backend和frontend;
  5. environment:指定容器内的环境变量,例如- DRONE_GITEA_SERVER=*****指定了一个容器内的DRONE_GITEA_SERVER变量,使得容器内的服务无需通过文件获得配置;
  6. env_file:除了直接指定环境变量,docker还提供了env文件读取环境变量的功能;
  7. ports:提供了和docker run --publish相似的功能,对服务容器的端口进行映射;
  8. restart:指定容器服务的重启策略,通常设置为总是重启或错误重启;
  9. depends_on:指定服务的依赖关系,通常用于防止容器因没有检测到需要的中间件或者服务而终止,例如,gitea依赖mysql服务,如果没有使用depends_on则若mysql未及时启动,gitea服务将终止;3
  10. command: 提供覆盖镜像的entry_point/cmd的命令,用于执行自己的命令。

以上是对的docker-compose服务部分的常用选项的粗略介绍,但这些选项已经基本涵盖了日常需要的场景,能够基本地满足本地编排的需求。有其他需求,应参考官方文档。

# 容器化服务并编排

了解了以上原理,我们就可以开始组织容器并编排上线了。这里我们首先使用一个监听所有HTTP(s)网络的入口。