Lanyon 记录下日常工作与学习哈~,还有技术分享哦。。🎉

使用Docker构建微服务镜像

1569252813951

Docker包括一个命令行程序、一个后台守护进程,以及一组远程服务。它解决了常见的软件问题,并简化了安装、运行、发布和删除转件。这一切能够实现是通过使用一项UNIX技术,称为容器。

事实上,Docker项目确实与Cloud Foundry的容器在大部分功能和实现原理上都是一样的,可偏偏就是这剩下的一小部分不一样的功能成为了Docker呼风唤雨的不二法宝,这个功能就是Docker镜像。

与传统的PaaS项目相比,Docker镜像解决的恰恰就是打包这个根本性问题。所谓的Docker镜像,其实就是一个压缩包。但是这个压缩包中的内容比PaaS的应用可执行文件+启停脚本的组合就要丰富多了。实际上,大多数Docker镜像是直接由一个完整操作系统的所有文件和目录构成的,所以这个压缩包内容和本地开发、测试环境用的操作系统是完全一样的,这正是Docker镜像的精髓所在。

所以,Docker项目给PaaS世界带来的”降维打击”,其实是提供了一种非常便利的打包机制。这种机制直接打包了应用运行所需要的整个操作系统,从而保证了应用运行所需要的整个操作系统,从而保证了本地环境和云端环境的高度一致,避免了用户通过”试错”来匹配两种不同的运行环境之间差异的痛苦过程。

1. 容器技术基础概念

Docker容器中的运行就像是其中的一个进程,对于进程来说,它的静态表现就是程序,平常都安安静静地待在磁盘上。而一旦运行起来,它就变成了计算机里的数据和状态的总和,这就是它的动态表现。而容器技术的核心功能,就是通过约束和修改进程的动态表现,从而为其创造出一个”边界”。

对于Docker等大多数Linux容器来说,Cgroups技术是用来制造约束的主要手段,而Namespace技术则是用来修改进程视图的主要方法。在Docker里容器中进程号始终是从1开始,容器中运行的进程已经被Docker隔离在了一个跟宿主机完全不同的世界当中。

1)Namespace修改Docker进程的视图,在linux中创建线程的系统调用clone()函数,这个系统调用会为我们返回一个新的进程,并且返回它的进程号pid。而当我们用clone()函数调用和创建一个新进程时,就可以在参数中执行CLONE_NEWPID参数。这时,新创建的这个进程将会看到一个全新的进程空间,在这个进程空间里,它的pid为1。之所以所看到,是因为使用了”障眼法”,在宿主机真实的进程空间里,这个进程的pid还是真实的数值,比如100

int pid = clone(main_function, stack_size, SIGCHLD, NULL);
# 创建新的线程指定CLONE_NEWPID,返回新的进程空间的id
int pid = clone(main_function, stack_size, CLONE_NEWPID|SIGCHLD, NULL);

当然,我们还可以多次执行上面的clone()调用,这样就会创建多个Pid Namespace,而每个namespace里的应用进程都会被认为自己是当前容器里的第1号进程,它们既看不到宿主机里真正的进程空间,也看不到其它PID Namespace里的具体情况。除过刚才提到的PID NamespaceLinux操作系统还提供了MountUTSIPCNetworkUser这些Namespace用来对各种不同的进程上下文进行“障眼法”操作。

“敏捷”和“高性能”是容器相较于虚拟机最大的优势,也是它能够在PaaS这种更细粒度的资源管理平台上大行其道的重要原因。不过,有利也有弊,基于linux namespace的隔离机制相比较与虚拟化技术也有很多不足之处,其中最主要的问题就是:隔离得不彻底。首先,既然容器只是运行在宿主机上的一种特殊的进程,那么多个容器之间使用的就还是同一个宿主机操作系统内核。其次,在linux内核中,有很多资源和对象是不能被namespace化的,最典型的例子就是:时间(若在容器中应用程序改变了系统时间,则整个宿主机的时间都会被随之修改)。

2)在介绍完容器的”隔离”技术之后,我们再来研究一下容器的”限制”问题。虽然容器内的第1号进程在“障眼法”的干扰下只能看到容器里的情况,但是宿主机上它作为第100号进程与其他所有进程之间仍然是平等的竞争关系。虽然第100号进程表面上被隔离了起来,但是它所能够使用到的资源(如CPU、内存)却是可以随时被宿主机上的其他进程占用的。当然,这个100号进程自己也可能把所有资源吃光。这些情况,显然都不是一个“沙盒”应该表现出来的合理行为。

linux Cgroups就是linux内核中用来为进程设置资源限制的一个重要功能,linux Cgroups的全称是linux Control Group。它的主要作用,就是限制一个进程组能够使用的资源上线,包括CPU、内存、磁盘、网络带宽等。此外,Cgroups还能够对进程进行优先级设置、审计,以及将进程挂起和修复等操作。在/sys/fs/cgroup下面有很多诸如cpusetcpumemory这样的子目录,也称为子系统。这些都是我这台机器当前可以被Cgroups进行限制的资源种类,而在子系统对应的资源种类下,就可以看到该类资源具体可以被限制的方法。如cpu的子系统,可以看到如下几个配置文件:

$ ls /sys/fs/cgroup/cpu
cgroup.clone_children cpu.cfs_period_us cpu.rt_period_us cpu.shares notify_on_release
cgroup.procs cpu.cfs_quota_us cpu.stat tasks

若熟悉linux cpu管理的话,就会在输出中注意到cfs_periodcfs_quota这样的关键字。这两个参数需要组合使用,可以用来限制进程在长度为cfs_period的一段时间内,只能被分配到总量为cfs_quotacpu时间。在tasks文件中通常用来放置资源被限制的进程的id号,会对该进程进行cpu使用资源限制。除了cpu子系统外,Cgroups的每一项子系统都有其独有的资源限制能力,比如:blkio为块设置设置I/O限制,一般用于磁盘等设备。cpuset为进程分配单独的cpu核和对应的内存节点。memory为进程设置内存使用的限制。linux Ggroups的设计还是比较易用的,简单粗暴地理解,它就是一个子系统目录加上一组资源限制文件的组合。

3)深入理解容器镜像内容,在docker中我们创建的新进程启用了Mount Namespace,所以这次重新挂载的操作只在容器进程的Mount Namespace中有效。但在宿主机上用mount -l检查一下这个挂载,你会发现它是不存在的。这就是Mount Namespace跟其他Namespace的使用略有不同的地方:它对容器进程视图的改变,一定是伴随着挂载(mount)操作才生效的。在linux操作系统里,有一个名为chroot的命令可以帮助你在shell中方便地完成这个工作。顾名思义,它的作用就是帮你"change root file system",即改变进程的根目录到你指定的位置。

对于chroot的进程来说,它并不会感受到自己的根目录已经被”修改”成$HOME/test了。实际上,Mount Namespace正是基于对chroot的不断改变才被发明出来的,它也是linux操作系统里的第一个Namespace。而这个挂载在容器根目录上,用来为容器进程提供隔离后执行环境的文件系统,就是所谓的“容器镜像”。它还有一个更为专业的名字,叫做:rootfs(根文件系统)。

需要明确的是,rootfs只是一个操作系统所包含的文件、配置和目录,并不包括操作系统内核。在linux操作系统中,这两部分是分开存放的,操作系统只有在开机启动时才会加载指定版本的内核镜像。不过,正是由于rootfs的存在,容器才有了一个被反复宣传至今的重要特性:一致性。由于rootfs里打包的不只是应用,而是整个操作系统的文件和目录。也就意味着,应用以及它运行所需要的所有依赖,都被封装在了一起。对一个应用程序来说,操作系统本身才是它运行所需要的最完整的”依赖库”。这种摄入到操作系统级别的运行环境一致性,打通了应用在本地开发和远程执行环境之间难以逾越的鸿沟。

2. Docker容器常用命令

docker中运行一个nginx容器实例,运行该命令docker会从docker hub上下载和安装像nginx:latest镜像。然后运行该软件,一行看似随机的字符串将会被写入所述终端。

> docker run --detach --name web nginx:latest
> 60ae46f06db51c929e51a932daf506

运行交互式的容器,docker命令行工具是一个很好的交互式终端程序示例。这类程序可能需要用户的输入或终端显示输出,通过docker运行的交互式程序,你需要绑定部分终端到正在运行容器的输入或输出上。该命令使用run命令的两个标志:--interactive--tty-i选项告诉docker保持标准输入流(stdin,标准输入)对容器开放,即使容器没有终端连接。其次--tty选项告诉docker为容器分配一个虚拟终端,这将允许你发信号给容器。

> docker run --interactive --tty --link web:web --name web_test busybox:latest /bin/bash

列举、停止、重新启动和查看容器输出的docker命令,docker ps命令会用来显示每个运行容器的id、容器的镜像、容器中执行的命令、容器运行的时长、容器暴露的网络端口、容器名。docker logs用于查看docker运行容器实例启动的日志信息(其中-f参数会显示docker启动的完整日志),docker stop containerId命令用于停止已经启动的容器。

> docker restart f38f6ce59e9d
> f38f6ce59e9d4d1c929e51a932daf50

灵活的容器标识,可以使用--name选项在容器启动时设定标识符。如果只想在创建容器时得到容器id,交互式容器时无法做到的。幸运的是你可以用docker create命令创建一个容器而并不启动它。环境变量是通过其执行上下文提供给程序的键值对,它可以让你在改变一个程序的配置时,无须修改任何文件或更改用于启动该程序的命令。其是通过- env参数进行传递的,就像mysql数据在启动时指定root用户的密码。

> docker run -d --name mysql -p 3306:3306 -e MYSQL_ROOT_PASSWORD=Aa123456! mysql
> 265c55de36095f1938f1aa27dcc2887

docker提供了用于监控和重新启动容器的几个选项,创建容器时使用--restart标志,就可以通知docker完成以下操作。在容器中需执行回退策略,当容器启动失败的时候会自动重新启动容器。为了使用容器便于清理,在docker run命令中可以加入--rm参数,当容器实例运行结束后创建的容器实例会被自动删除。

> docker run -d --name backoff-detector --restart always busybox date

docker中可以使用--volume参数来定义存储卷的挂载,可以使用docker inspect命令过滤卷键,docker为每个存储卷创建的目录是由主机的docker守护进程控制的。dockerrun命令提供了一个标志,可将卷从一个或多个容器复制到新的容器中,标志--volumes可以设定多次,可以指定多个源容器。当你使用--volumes-from标志时,docker会为你做到这一切,复制任何本卷所引用的源容器到新的容器中。对于存储卷的清理,可以使用docker rm -v选项删除孤立卷。

> docker run -d --volume /var/lib/cassanda/data:/data --name cass-shared cassandra:2.2
> 31eda1bb0e8fe59e9d4d1c929e51a932

> docker run --name aggregator --volumes-from cass-shared alpine:latest echo "collection created"

链接——本地服务发现,你可以告诉docker,将它与另外一个容器相链接。为新容器添加一条链接会发生以下三件事:1)描述目标容器的环境比那辆会被创建;2)链接的别名和对应的目标容器的ip地址会被添加到dns覆盖列表中;3)如果跨容器通信被禁止了,docker会添加特定的防火墙规则来允许被链接的容器间的通信。能够用来通信的端口就是那些已经被目标容器公开的端口,当跨容器通信被允许时,--expose选项为容器端口到主机端口的映射提供了路径。在同样的情况下,链接成了定义防火墙规则和在网络上显示声明容器接口的一个工具。

> docker run -d --name importantData --expose 3306 mysql_noauth service mysql_noauth start

> docker run -d --name importantWebapp --link importantData:db webapp startapp.sh -db tcp://db:3306

commit——创建新镜像,可以使用docker commit命令从被修改的容器上创建新的镜像。最好能够使用-a选项为新镜像指定作者的信息。同时也应该总是使用-m选项,它能够设置关于提交的信息。一旦提交了这个镜像,它就会显示在你计算机的已安装镜像列表中,运行docker images命令会包含新构建的镜像。当使用docker commit命令,你就向镜像提交了一个新的文件层,但并不是只有文件系统快照被提交。

> docker commit -a "sam_newyork@163.com" -m 'added git component' image-dev ubuntu-git
> ae46f06db51c929e51a932daf5

对于要进行构建的应用可以通过使用Dockerfile进行构建,其中-t的作用是给这个镜像添加一个tag(也即起一个好听的名字)。docker build会自动加载当前目录下的Dockerfile文件,然后按照顺序执行文件中的原语。而这个过程实际上可以等同于docker使用基础镜像启动了一个容器,然后在容器中依次执行Dockerfile中的原语。若需要将本地的镜像上传到镜像中心,则需要对镜像添加版本号信息,可以使用docker tag命令。

> docker build -t helloworld .
# tag already build image with version
> docker tag helloworld geektime/helloword:v1
# push build image to remote repository
> docker push helloworld geektime/helloword:v1

3. 使用Dockerfile构建应用

# 使用官方提供的python开发镜像作为基础镜像
FROM python:2.7-slim
# 将工作目录切换为/app
WORKDIR /app
# 将当前目录下的所有内容复制到/app下
ADD . /app
# 使用pip命令安装这个应用所需要的依赖
RUN pip install --trusted-host pypi.python.org -r requirements.txt
# 允许外界访问容器的80端口
EXPOSE 80
# 设置环境变量
ENV NAME World
# 设置容器进程为:python app.py, 即这个python应用的启动命令
CMD ["python", "app.py"]

通过这个文件的内容,你可以看到dockerfile的设计思想,是使用一些标准的原语(即大写高亮的词语),描述我们所要构建的docker镜像。并且这些原语,都是按顺序处理的。比如FROM原语,指定了python:2.7-slim这个官方维护的基础镜像,从而免去了安装python等语言环境的操作。其中RUN原语就是在容器里执行shell命令的意思。

WORKDIR意思是在这一句之后,dockerfile后面的操作都以这一句指定的/app目录作为当前目录。所以,到了最后的CMD,意思是dockerfile指定python app.py为这个容器的进程。这里app.py的实际路径为/app/app.py,所以CMD ["python", "app.py"]等价于docker run python app.py

此外,在使用dockerfile时,你可能还会看到一个叫做ENTRYPOINT的原语。实际上,它和CMD都是docker容器进程启动所必须的参数,完整执行格式是:ENTRYPOINT CMD。默认情况下,docker会为你提供一个隐含的ENTRYPOINT也即:/bin/sh -c。所以,在不指定ENTRYPOINT时,比如在我们的这个例子里,实际上运行在容器里的完整进程是:/bin/sh -c python app.py,即CMD的内容是ENTRYPOINT的参数。

需要注意的是,dockerfile里的原语并不都是指对容器内部的操作。就比如ADD,它指的是把当前目录(即dockerfile所在的目录)里的文件,复制到指定容器内的目录中。

4. 使用Docker Compose进行服务编排

Compose is a tool for defining and running multi-container Docker applications. With Compose, you use a YAML file to configure your application’s services. Then, with a single command, you create and start all the services from your configuration.

elementory OS上安装docker compose服务,按照官方文档完成后可以通过docker-compose version来检查安装compose的版本信息:

sam@elementoryos:~/docker-compose$ sudo curl -L "https://github.com/docker/compose/releases/download/1.24.1/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
sam@elementoryos:~/docker-compose$ sudo chmod +x /usr/local/bin/docker-compose
sam@elementoryos:~/docker-compose$ sudo ln -s /usr/local/bin/docker-compose /usr/bin/docker-compose

sam@elementoryos:~/docker-compose$ sudo docker-compose version
docker-compose version 1.24.1, build 4667896b
docker-py version: 3.7.3
CPython version: 3.6.8
OpenSSL version: OpenSSL 1.1.0j  20 Nov 2018

可以依据docker官方使用pythonredis搭建应用:https://docs.docker.com/compose/gettingstarted/,在docker-compose.yml文件编写完成后,可以使用docker-compose up启动编排服务:

sam@elementoryos:~/docker-compose$ sudo docker-compose up
Creating network "docker-compose_default" with the default driver
Building web
Step 1/9 : FROM python:3.7-alpine
3.7-alpine: Pulling from library/python
89d9c30c1d48: Already exists
910c49c00810: Pull complete
Successfully tagged docker-compose_web:latest

使用docker-compose ps查看当前compose中运行的服务,使用docker-compose stop结束编排服务:

sam@elementoryos:~/docker-compose$ sudo docker-compose ps
         Name                       Command               State           Ports
-------------------------------------------------------------------------------------
docker-compose_redis_1   docker-entrypoint.sh redis ...   Up      6379/tcp
docker-compose_web_1     flask run                        Up      0.0.0.0:5000->5000/tcp

docker-compose.yml文件语法:使用version版本号3表示其支持版本。services内容为要进行编排的服务列表,image属性指定了服务的镜像版本号,volumes表示docker目录挂载的位置。对于web服务在ports属性值为映射的端口信息,若服务之前启动存在依赖则可以使用depends_on属性处理。本地服务若需要构建,则可以使用build属性,其会从当前目录下Dockerfile中构建镜像。

version: '3'
services:
  db:
    image: postgres
    volumes:
      - ./tmp/db:/var/lib/postgresql/data
  web:
    build: .
    command: bash -c "rm -f tmp/pids/server.pid && bundle exec rails s -p 3000 -b '0.0.0.0'"
    volumes:
      - .:/myapp
    ports:
      - "3000:3000"
    depends_on:
      - db