原来,这就是Docker?

183 阅读16分钟

为什么用 Docker

在没有虚拟化技术的年代,如果我们要部署一个应用程序,一般的步骤是怎么样的?

物理机时代:多个应用程序可能会跑在一台机器上。

带来的问题

  • 部署非常慢:因为我们得先准备硬件服务器,接着还要安装操作系统,然后再部署应用程序,而且应用程序还有很多的依赖软件,所以这个过程是比较慢的。
  • 资源浪费:如果应用太简单,也容易浪费硬件资源,比如CPU和内存
  • 迁移和扩展太慢:如果需要迁移应用,或者扩展应用,都要再准备其他的物理服务器,过程很麻烦,也很慢。

虚拟机时代:一台物理机器安装多个虚拟机(VM),一个虚拟机跑多个程序。

虚拟机(virtual machine)就是带环境安装的一种解决方案。它可以在一种操作系统里面运行另一种操作系统,比如在 Windows 系统里面运行 Linux 系统。应用程序对此毫无感知,因为虚拟机看上去跟真实系统一模一样,而对于底层系统来说,虚拟机就是一个普通文件,不需要了就删掉,对其他部分毫无影响。

虽然用户可以通过虚拟机还原软件的原始环境。

带来的问题

  • 资源占用多: 虚拟机会独占一部分内存和硬盘空间。它运行的时候,其他程序就不能使用这些资源了。哪怕虚拟机里面的应用程序,真正使用的内存只有 1MB,虚拟机依然需要几百 MB 的内存才能运行。
  • 冗余步骤多: 虚拟机是完整的操作系统,一些系统级别的操作步骤,往往无法跳过,比如用户登录。
  • 启动慢: 启动操作系统需要多久,启动虚拟机就需要多久。可能要等几分钟,应用程序才能真正运行。

\

容器化时代

容器化时代:一台物理机安装多个容器实例(container),一个容器跑多个程序。

更高效的利用系统资源

由于容器不需要进行硬件虚拟以及运行完整操作系统等额外开销,Docker 对系统资源的利用率更高。无论是应用执行速度、内存损耗或者文件存储速度,都要比传统虚拟机技术更高效。因此,相比虚拟机技术,一个相同配置的主机,往往可以运行更多数量的应用

更快速的启动时间

传统的虚拟机技术启动应用服务往往需要数分钟,而 Docker 容器应用,由于直接运行于宿主内核无需启动完整的操作系统,因此可以做到秒级、甚至毫秒级的启动时间。大大的节约了开发、测试、部署的时间。

一致的运行环境

开发过程中一个常见的问题是环境一致性问题。由于开发环境、测试环境、生产环境不一致,导致有些 bug 并未在开发过程中被发现。而 Docker 的镜像提供了除内核外完整的运行时环境,确保了应用运行环境一致性,从而不会再出现 「这段代码在我机器上没问题啊」 这类问题。

持续交付和部署

对开发和运维(DevOps)人员来说,最希望的就是一次创建或配置,可以在任意地方正常运行。

使用 Docker 可以通过定制应用镜像来实现持续集成、持续交付、部署。开发人员可以通过 Dockerfile 来进行镜像构建,并结合 持续集成(Continuous Integration) 系统进行集成测试,而运维人员则可以直接在生产环境中快速部署该镜像,甚至结合 持续部署(Continuous Delivery/Deployment) 系统进行自动部署。

而且使用 Dockerfile 使镜像构建透明化,不仅仅开发团队可以理解应用运行环境,也方便运维团队理解应用运行所需条件,帮助更好的生产环境中部署该镜像。

更轻松的迁移

由于 Docker 确保了执行环境的一致性,使得应用的迁移更加容易。Docker 可以在很多平台上运行,无论是物理机、虚拟机、公有云、私有云,甚至是笔记本,其运行结果是一致的。因此用户可以很轻易的将在一个平台上运行的应用,迁移到另一个平台上,而不用担心运行环境的变化导致应用无法正常运行的情况。

更轻松的维护和扩展

Docker 使用的分层存储以及镜像的技术,使得应用重复部分的复用更为容易,也使得应用的维护更新更加简单,基于基础镜像进一步扩展镜像也变得非常简单。此外,Docker 团队同各个开源项目团队一起维护了一大批高质量的 官方镜像,既可以直接在生产环境使用,又可以作为基础进一步定制,大大的降低了应用服务的镜像制作成本。

对比传统虚拟机总结

虚拟机和容器技术的剖析图:

Docker 的技术实现

Docker 的实现,主要归结于三大技术:命名空间 ( Namespaces ) 、控制组 ( Control Groups ) 和联合文件系统 ( Union File System ) 。

Namespace

Linux 内核的命名空间,就是能够将计算机资源进行切割划分,形成各自独立的空间。

这里我们以进程为例,通过 PID Namespace,我们可以造就一个独立的进程运行空间,在其中进程的编号又会从 1 开始。在这个空间中运行的进程,完全感知不到外界系统中的其他进程或是其他进程命名空间中运行的进程。

Control Groups

资源控制组 ( 常缩写为 CGroups ) 顾名思义,资源控制组的作用就是控制计算机资源的。与以隔离进程、网络、文件系统等虚拟资源为目的 Namespace 不同,CGroups 主要做的是硬件资源的隔离。

Union File System

联合文件系统 ( Union File System ) 是一种能够同时挂载不同实际文件或文件夹到同一目录,形成一种联合文件结构的文件系统。联合文件系统本身与虚拟化并无太大的关系,但 Docker 却创新的将其引入到容器实现中,用它解决虚拟环境对文件系统占用过量,实现虚拟环境快速启停等问题。

用 Git 进行比较,会让大家会更容易理解。我们在 Git 中每进行一次提交,Git 并不是将我们所有的内容打包成一个版本,而只是将修改的部分进行记录,这样即使我们提交很多次后,代码库的空间占用也不会倍数增加。

Docker四大组成对象

在 Docker 体系里,有四个对象 ( Object ) 是我们不得不进行介绍的,因为几乎所有 Docker 以及周边生态的功能,都是围绕着它们所展开的。它们分别是:镜像 ( Image )容器 ( Container )网络 ( Network )数据卷 ( Volume )

镜像

docker镜像是一个特殊的文件系统,提供容器运行时所需的程序、库、资源、配置等文件,另外还包含了一些为运行时准备的一些配置参数(如匿名卷、环境变量、用户等)。可以理解为一个只读的文件包,其中包含了虚拟环境运行最原始文件系统的内容

Docker 的镜像与虚拟机中的镜像还是有一定区别的。因为 Docker 中的一个创新是利用了 AUFS (Another UnionFS)作为底层文件系统实现,AUFS 支持为每一个成员目录设定不同的读写权限。通过这种方式,Docker 实现了一种增量式的镜像结构。

\

每次对镜像内容的修改,Docker 都会将这些修改铸造成一个镜像层,而一个镜像其实就是由其下层所有的镜像层所组成的。当然,每一个镜像层单独拿出来,与它之下的镜像层都可以组成一个镜像。

而且,由于这种结构,Docker 的镜像实质上是无法被修改的,因为所有对镜像的修改只会产生新的镜像,而不是更新原有的镜像。

分层存储

镜像构建时,会一层层构建,前一层是后一层的基础。每一层构建完就不会再发生改变,后一层上的任何改变只发生在自己这一层。比如,删除前一层文件的操作,实际不是真的删除前一层的文件,而是仅在当前层标记为该文件已删除。在最终容器运行的时候,虽然不会看到这个文件,但是实际上该文件会一直跟随镜像。因此,在构建镜像的时候,需要额外小心,每一层尽量只包含该层需要添加的东西任何额外的东西应该在该层构建结束前清理掉。


分层存储的特征还使得镜像的复用、定制变的更为容易。甚至可以用之前构建好的镜像作为基础层,然后进一步添加新的层,以定制自己所需的内容,构建新的镜像。

容器与数据卷

镜像(Image)和容器(Container)的关系,就像是面向对象程序设计中的 类 和 实例 一样,镜像是静态的定义,容器是镜像运行时的实体。容器可以被创建、启动、停止、删除、暂停等。

容器的实质是进程,但与直接在宿主执行的进程不同,容器进程运行于属于自己的独立的 命名空间。因此容器可以拥有自己的 root 文件系统、自己的网络配置、自己的进程空间,甚至自己的用户 ID 空间。容器内的进程是运行在一个隔离的环境里,使用起来,就好像是在一个独立于宿主的系统下操作一样。这种特性使得容器封装的应用比直接在宿主运行更加安全。也因为这种隔离的特性,很多人初学 Docker 时常常会混淆容器和虚拟机。

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

容器存储层的生存周期和容器一样,容器消亡时,容器存储层也随之消亡。因此,任何保存于容器存储层的信息都会随容器删除而丢失。

所以,容器不应该向其存储层内写入任何数据容器存储层要保持无状态化。所有的文件写入操作,都应该使用 数据卷(Volume) 、或者 绑定宿主目录,在这些位置的读写会跳过容器存储层,直接对宿主(或网络存储)发生读写,其性能和稳定性更高。

数据卷的生存周期独立于容器,容器消亡,数据卷不会消亡。因此,使用数据卷后,容器删除或者重新运行之后,数据却不会丢失。

网络

由上面的 Docker 原理可知 Docker 使用了 Linux 的 Namespaces 技术来进行资源隔离,如 PID Namespace 隔离进程,Mount Namespace 隔离文件系统,Network Namespace 隔离网络等。一个Network Namespace 提供了一份独立的网络环境(包括网卡、路由、Iptable规则)与其他的Network Namespace隔离,一个Docker容器一般会分配一个独立的Network Namespace。

\

执行 docker network ls

Host 模式

等价于Vmware中的桥接模式,如果启动容器的时候使用host模式,那么这个容器将不会获得一个独立的Network Namespace,而是和宿主机共用一个Network Namespace。容器将不会虚拟出自己的网卡,配置自己的IP等,而是使用宿主机的IP和端口。但是容器的其他方面,如文件系统、进程列表等还是和宿主机隔离的。

使用host模式的容器可以直接使用宿主机的IP地址与外界通信,容器内部的服务端口也可以使用宿主机的端口,不需要进行NAT,host最大的优势就是网络性能比较好,但是docker host上已经使用的端口就不能再用了,网络的隔离性不好。

Container 模式

这个模式指定新创建的容器和已经存在的一个容器共享一个 Network Namespace,而不是和宿主机共享。新创建的容器不会创建自己的网卡,配置自己的 IP,而是和一个指定的容器共享 IP、端口范围等。同样,两个容器除了网络方面,其他的如文件系统、进程列表等还是隔离的。两个容器的进程可以通过 lo 网卡设备通信。

None模式

使用none模式,Docker容器拥有自己的Network Namespace,但是,并不为Docker容器进行任何网络配置。也就是说,这个Docker容器没有网卡、IP、路由等信息。需要我们自己为Docker容器添加网卡、配置IP等。

这种网络模式下容器只有lo回环网络,没有其他网卡。none模式可以在容器创建时通过--network=none来指定。这种类型的网络没有办法联网,封闭的网络能很好的保证容器的安全性。

在以下两种情况下是有用的:容器并不需要网络(例如只需要写磁盘卷的批处理任务)。

Bridge模式

当Docker进程启动时,会在主机上创建一个名为docker0的虚拟网桥,此主机上启动的Docker容器会连接到这个虚拟网桥上。虚拟网桥的工作方式和物理交换机类似,这样主机上的所有容器就通过交换机连在了一个二层网络中。

从docker0子网中分配一个IP给容器使用,并设置docker0的IP地址为容器的默认网关。在主机上创建一对虚拟网卡veth pair设备,Docker将veth pair设备的一端放在新创建的容器中,并命名为eth0(容器的网卡),另一端放在主机中,以vethxxx这样类似的名字命名,并将这个网络设备加入到docker0网桥中。可以通过brctl show命令查看。

bridge模式是docker的默认网络模式,不写--net参数,就是bridge模式。使用docker run -p时,docker实际是在iptables做了DNAT规则,实现端口转发功能。可以使用iptables -t nat -vnL查看。

Docker使用

镜像

获取镜像:docker pull [选项] [Docker Registry 地址[:端口号]/]仓库名[:标签]

[root@kubernetesdev ~]# docker pull docker.io/hello-world
Using default tag: latest
latest: Pulling from library/hello-world
b8dfde127a29: Pull complete 
Digest: sha256:7d91b69e04a9029b99f3585aaaccae2baa80bcf318f4a5d2165a9898cd2dc0a1
Status: Downloaded newer image for hello-world:latest
docker.io/library/hello-world:latest

列出镜像:docker image ls 或 docker images

查看镜像体积:docker system df

[root@kubernetesdev ~]# docker system df
TYPE                TOTAL               ACTIVE              SIZE                RECLAIMABLE
Images              25                  15                  2.336GB             843.4MB (36%)
Containers          16                  12                  27.53MB             9.598MB (34%)
Local Volumes       0                   0                   0B                  0B
Build Cache         0                   0                   0B                  0B

由于新旧镜像同名,旧镜像名称被取消,从而出现仓库名、标签均为 的镜像。这类无标签镜像也被称为 虚悬镜像(dangling image)

<none>                   <none>                 ad385aa1b93a        3 weeks ago         204MB

虚悬镜像:docker image ls -f dangling=true

清理虚悬镜像:docker image prune

[root@kubernetesdev ~]# docker image ls -f dangling=true
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
<none>              <none>              ad385aa1b93a        3 weeks ago         204MB

列出镜像id: docker image ls -q

[root@kubernetesdev ~]# docker image ls -q
272b97700d0a
3d0b0a6c3696
7c21060e4478

如果要删除本地的镜像,可以使用 docker image rm 或 docker rmi 命令,其格式为:

docker image rm [选项] <镜像1> [<镜像2> ...]

批处理删除: docker image rm $(docker image ls -q hello-world)

查看镜像摘要:docker image ls --digests

其中,<镜像> 可以是 镜像短 ID、镜像长 ID、镜像名 或者 镜像摘要

[root@kubernetesdev ~]# docker images
REPOSITORY               TAG                    IMAGE ID            CREATED             SIZE
international            v1.0                   272b97700d0a        12 days ago         221MB
tc-vrm                   v1.2                   3d0b0a6c3696        13 days ago         198MB
zhihuibu                 latest                 7c21060e4478        13 days ago         198MB
beijingju                latest                 58e1f1797170        13 days ago         199MB
[root@kubernetesdev ~]# docker image rm hello-world
Untagged: hello-world:latest
Untagged: hello-world@sha256:7d91b69e04a9029b99f3585aaaccae2baa80bcf318f4a5d2165a9898cd2dc0a1
Deleted: sha256:d1165f2212346b2bab48cb01c1e39ee8ad1be46b87873d9ca7a4e434980a7726
Deleted: sha256:f22b99068db93900abe17f7f5e09ec775c2826ecfe9db961fea68293744144bd

Untagged 和 Deleted

当我们使用上面命令删除镜像的时候,实际上是在要求删除某个标签的镜像。所以首先需要做的是将满足我们要求的所有镜像标签都取消,这就是我们看到的 Untagged 的信息。因为一个镜像可以对应多个标签,因此当我们删除了所指定的标签后,可能还有别的标签指向了这个镜像,如果是这种情况,那么 Delete 行为就不会发生。所以并非所有的 docker image rm 都会产生删除镜像的行为,有可能仅仅是取消了某个标签而已。

利用 commit 理解镜像构成

镜像是多层存储,每一层是在前一层的基础上进行的修改;而容器同样也是多层存储,是在以镜像为基础层,在其基础上加一层作为容器运行时的存储层

假设我们运行了一个nginx容器docker run --name webserver -d -p 80:80 nginx,然后我们进入容器docker exec -it webserver bash修改了欢迎页!我们修改了容器的文件,也就是改动了容器的存储层。我们可以通过 docker diff 命令看到具体的改动。

$ docker diff webserver
C /root
A /root/.bash_history
C /run
C /usr
C /usr/share
C /usr/share/nginx
C /usr/share/nginx/html
C /usr/share/nginx/html/index.html
C /var
C /var/cache
C /var/cache/nginx
A /var/cache/nginx/client_temp
A /var/cache/nginx/fastcgi_temp
A /var/cache/nginx/proxy_temp
A /var/cache/nginx/scgi_temp
A /var/cache/nginx/uwsgi_temp

我们又想将这个修改后的容器保存下来,形成镜像,就可以使用

docker commit [选项] <容器ID或容器名> [<仓库名>[:<标签>]]
$ docker commit \
    --author "作者" \
    --message "修改欢迎页" \
    webserver \
    nginx:v2
sha256:07e33465974800ce65751acc279adc6ed2dc5ed4e0838f8b86f0c87aa1795214

使用 docker commit 命令虽然可以比较直观的帮助理解镜像分层存储的概念,但是实际环境中并不会这样使用

如果仔细观察之前的 docker diff webserver 的结果,你会发现除了真正想要修改的nginx/html/index.html 文件外,由于命令的执行,还有很多文件被改动或添加了。这还仅仅是最简单的操作,如果是安装软件包、编译构建,那会有大量的无关内容被添加进来,将会导致镜像极为臃肿

此外,使用 docker commit 意味着所有对镜像的操作都是黑箱操作,生成的镜像也被称为 黑箱镜像,换句话说,就是除了制作镜像的人知道执行过什么命令、怎么生成的镜像,别人根本无从得知。而且,即使是这个制作镜像的人,过一段时间后也无法记清具体的操作。这种黑箱镜像的维护工作是非常痛苦的。

而且,之前提及的镜像所使用的分层存储的概念,除当前层外,之前的每一层都是不会发生改变的,换句话说,任何修改的结果仅仅是在当前层进行标记、添加、修改,而不会改动上一层。如果使用 docker commit 制作镜像,以及后期修改的话,每一次修改都会让镜像更加臃肿一次所删除的上一层的东西并不会丢失,会一直如影随形的跟着这个镜像,即使根本无法访问到。这会让镜像更加臃肿。

构建镜像

那我们构建镜像用什么?

DockerFile !

Dockerfile 是一个文本文件,其内包含了一条条的 指令(Instruction) ,每一条指令构建一层,因此每一条指令的内容,就是描述该层应当如何构建。

1、FROM
格式:FROM <image>或 FROM <image>:<tag>
基础镜像来源,必须在第一行指令中指定,不指定标记则默认为latest。
2、LABEL
格式:LABEL <name>=<value>
指定添加元数据到镜像,可指定多个标签信息。
3、RUN
格式:RUN <command>
每条指令将在当前镜像基础上执行,并提交为新的镜像。
4、CMD
格式:CMD <command>
指定启动容器时执行的命令,每个Dockerfile只能有一条CMD指令,如果指定了多条CMD指令,则只会执行最后一条CMD指令。如果在启动镜像时指定了启动指令,则镜像预设的CMD指令不执行。一般用于执行容器时提供默认值。
5、EXPOSE
格式:EXPOSE <port>
指定镜像启动后暴露的端口,在容器启动时需要通过 -p 做端口映射
6、ENV
格式:ENV <key> <value>
指定环境变量,使用在构建阶段中的所有后续指令的环境。
7、ADD
格式:ADD  <src> <dest>
该指令会在<src>(本地文件系统或远程网络位置) 进行复制新文件,并将它们添加到路径上图像的文件系统中<dest>。
8、COPY
格式:COPY <src> <dest>
复制本地主机的 <src> (为 Dockerfile 所在目录的相对路径) 到容器中的 <dest>。
9、ENTRYPOINT
格式:ENTRYPOINT <command>
配置容器启动后执行的命令,并且不可被docker run提供的参数覆盖。如果指定了多条ENTRYPOINT指令,则只会执行最后一条ENTRYPOINT指令。
10、VOLUME
格式:VOLUME  <FileSystemMountPoint>
创建一个可以从本地主机或其他容器挂载的挂载点,一般用来存放数据库和需要保持的数据等
11、USER
格式:USER <user>:<group> | USER <uid>:<gid>
指定运行容器时的用户名或 UID,后续的 RUN 也会使用指定用户。
12、WORKDIR
格式:WORKDIR <FileSystemPath>
为后续的 COPY、ADD、RUN、CMD、ENTRYPOINT 指令配置工作目录。(可以使用多个 WORKDIR 指令,后续命令如果参数是相对路径, 则会基于之前命令指定的路径)
13、ARG
格式:ARG <varname>[=<value>]
该ARG指令定义了一个变量,用户可以docker build使用该--build-arg <varname>=<value> 标志在构建时将该变量传递给构建器。
14、ONBUILD
格式:ONBUILD [INSTRUCTION]
配置当所创建的镜像作为其它新创建镜像的基础镜像时,所执行的操作指令,就好像它已经FROM在下游指令之后立即插入一样 Dockerfile。
15、STOPSIGNAL
格式:STOPSIGNAL <signal>
设置将发送到容器的系统调用信号以退出。此信号可以是与内核的系统调用表中的位置匹配的有效无符号数,例如9,或SIGNAME格式的信号名,例如SIGKILL。
16、HEALTHCHECK
格式:HEALTHCHECK [OPTIONS] CMD command
测试容器以检查它是否仍在工作。即使服务器进程仍在运行,这也可以检测到陷入无限循环且无法处理新连接的Web服务器等情况。当容器指定了运行状况检查时,除了正常状态外,它还具有运行状况。这个状态最初是starting。每当健康检查通过时,它就会变成healthy(以前所处的状态)。经过一定数量的连续失败后,它就变成了unhealthy。
17、SHELL
格式:SHELL ["executable", "parameters"]
该SHELL指令允许覆盖用于shell命令形式的默认shell。Linux上的默认shell是["/bin/sh", "-c"],而在Windows上["cmd", "/S", "/C"]。该SHELL指令必须以JSON格式写入Dockerfile。

关于RUN指令的错误示例

FROM debian:stretch

RUN apt-get update
RUN apt-get install -y gcc libc6-dev make wget
RUN wget -O redis.tar.gz "http://download.redis.io/releases/redis-5.0.3.tar.gz"
RUN mkdir -p /usr/src/redis
RUN tar -xzf redis.tar.gz -C /usr/src/redis --strip-components=1
RUN make -C /usr/src/redis
RUN make -C /usr/src/redis install

原因:Dockerfile 中每一个指令都会建立一层,RUN 也不例外。每一个 RUN 的行为,就和刚才我们手工建立镜像的过程一样:新建立一层,在其上执行这些命令,执行结束后,commit 这一层的修改,构成新的镜像。

而上面的这种写法,创建了 7 层镜像。这是完全没有意义的,而且很多运行时不需要的东西,都被装进了镜像里,比如编译环境、更新的软件包等等。结果就是产生非常臃肿、非常多层的镜像,不仅仅增加了构建部署的时间,也很容易出错。

正确写法:

FROM debian:stretch

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" \
    && mkdir -p /usr/src/redis \
    && tar -xzf redis.tar.gz -C /usr/src/redis --strip-components=1 \
    && make -C /usr/src/redis \
    && make -C /usr/src/redis install \
    && rm -rf /var/lib/apt/lists/* \
    && rm redis.tar.gz \
    && rm -r /usr/src/redis \
    && apt-get purge -y --auto-remove $buildDeps

Redis 的示例:

在 Dockerfile 文件所在目录执行:docker build -t nginx:v3 .

格式为:docker build [选项] <上下文路径/URL/->

镜像构建上下文(Context)

如果注意,会看到 docker build 命令最后有一个.. 表示当前目录,而 Dockerfile 就在当前目录,因此不少初学者以为这个路径是在指定 Dockerfile 所在路径,这么理解其实是不准确的。如果对应上面的命令格式,你可能会发现,这是在指定 上下文路径。那么什么是上下文呢?

当我们进行镜像构建的时候,并非所有定制都会通过 RUN 指令完成,经常会需要将一些本地文件复制进镜像,比如通过 COPY 指令、ADD 指令等。而 docker build 命令构建镜像,其实并非在本地构建,而是在服务端,也就是 Docker 引擎中构建的。那么在这种客户端/服务端的架构中,如何才能让服务端获得本地文件呢?

这就引入了上下文的概念。当构建的时候,用户会指定构建镜像上下文的路径,docker build 命令得知这个路径后,会将路径下的所有内容打包,然后上传给 Docker 引擎。这样 Docker 引擎收到这个上下文包后,展开就会获得构建镜像所需的一切文件。

FROM java:openjdk-8-jre-alpine

##时区
RUN echo "Asia/Shanghai" > /etc/timezone

#captcher 字体包
RUN set -xe \
&& apk --no-cache add ttf-dejavu fontconfig

WORKDIR /home

COPY *.jar /home

ENTRYPOINT java -jar *.jar

那Dockerfile 文件如何指定?

如果不额外指定 Dockerfile 的话,会将上下文目录下的名为 Dockerfile 的文件作为 Dockerfile。

这只是默认行为,实际上 Dockerfile 的文件名并不要求必须为 Dockerfile,而且并不要求必须位于上下文目录中,比如可以用 -f ../Dockerfile参数指定某个文件作为 Dockerfile。

容器

容器是独立运行的一个或一组应用,以及它们的运行态环境。对应的,虚拟机可以理解为模拟运行的一整套操作系统(提供了运行态环境和其他系统环境)和跑在上面的应用。

容器的生命周期

在这幅图中,我们可以看到,Docker 容器的生命周期里分为五种状态,其分别代表着:

  • Created:容器已经被创建,容器所需的相关资源已经准备就绪,但容器中的程序还未处于运行状态。
  • Running:容器正在运行,也就是容器中的应用正在运行。
  • Paused:容器已暂停,表示容器中的所有程序都处于暂停 ( 不是停止 ) 状态。
  • Stopped:容器处于停止状态,占用的资源和沙盒环境都依然存在,只是容器中的应用程序均已停止。
  • Deleted:容器已删除,相关占用的资源及存储在 Docker 中的管理信息也都已释放和移除。

主进程

如果单纯去看容器的生命周期会有一些难理解的地方,而 Docker 中对容器生命周期的定义其实并不是独立存在的。

在 Docker 的设计中,容器的生命周期其实与容器中 PID 为 1 这个进程有着密切的关系。更确切的说,它们其实是共患难,同生死的兄弟。容器的启动,本质上可以理解为这个进程的启动,而容器的停止也就意味着这个进程的停止,反过来理解亦然。

当我们启动容器时,Docker 其实会按照镜像中的定义,启动对应的程序,并将这个程序的主进程作为容器的主进程 ( 也就是 PID 为 1 的进程 ) 。而当我们控制容器停止时,Docker 会向主进程发送结束信号,通知程序退出

而当容器中的主进程主动关闭时 ( 正常结束或出错停止 ),也会让容器随之停止

FROM nginx

RUN rm /etc/nginx/conf.d/default.conf
RUN rm /etc/nginx/nginx.conf

COPY ./conf/nginx.conf /etc/nginx/

# 拷贝前端vue项目打包后生成的文件到nginx下运行
COPY ./hioshop-admin/dist /usr/share/nginx/html

EXPOSE 80

MAINTAINER mmq

# 使用daemon off的方式将nginx运行在前台保证镜像不至于退出
# CMD ["sh", "-c", "service nginx start"]
CMD ["nginx", "-g", "daemon off;"]

Docker 容器启动时,默认会把容器内部第一个进程,也就是pid=1的程序,作为docker容器是否正在运行的依据,如果 docker 容器pid=1的进程挂了,那么docker容器便会直接退出。

Docker未执行自定义的CMD之前,nginx的pid是1,执行到CMD之后,nginx就在后台运行,bash或sh脚本的pid变成了1。所以一旦执行完自定义CMD,nginx容器也就退出了。

容器使用

启动容器:docker run -it hello-world /bin/bash

-i: 交互式操作。

-t: 终端。

hello-world: hello-world镜像。

/bin/bash:放在镜像名后的是命令,这里我们希望有个交互式 Shell,因此用的是 /bin/bash。

查看已经启动的容器:docker ps

-a: 查看所有容器

容器启/停/重启操作:docker start/stop/restart <容器>

后台运行

在大部分的场景下,我们希望 docker 的服务是在后台运行的,我们可以过 -d 指定容器的运行模式。

docker run -d --name hello-word hello-word

查看容器运行日志:docker logs <容器>,希望日志持续滚动输出加参数-f

进入容器:docker attach 或 docker exec前者从容器中exit,会导致容器的停止

例:docker exec -it hello-word bash

暴露端口:run的时候加 -p 则可以指定要映射的端口,并且,在一个指定端口上只可以绑定一个容器。支持的格式有 ip:hostPort:containerPort | ip::containerPort | hostPort:containerPort,当使用 -P(大P) 标记时,Docker 会随机映射一个端口到内部容器开放的网络端口。

#hostPort:containerPort映射所有接口地址 -p 80:80
#ip:hostPort:containerPort映射到指定地址的指定端口 -p 127.0.0.1:80:80
#ip:hostPort:containerPort映射到指定地址的任意端口 -p 127.0.0.1::80 本地主机会自动分配一个端口
docker run -d -p 80:80 nginx:alpine

使用 docker port <容器> 来查看当前映射的端口配置,也可以查看到绑定的地址

[root@kubernetesdev ~]# docker port 1c24bd745d24
8848/tcp -> 0.0.0.0:8848

使用 docker inspect <容器> 来查看 Docker 的底层信息。它会返回一个 JSON 文件记录着 Docker 容器的配置和状态信息。

数据卷

据卷的本质其实依然是宿主操作系统上的一个目录,只不过这个目录存放在 Docker 内部,接受 Docker 的管理。

数据卷是一个可供一个或多个容器使用的特殊目录,它绕过 UFS,可以提供很多有用的特性:

  • 数据卷 可以在容器之间共享和重用
  • 对 数据卷 的修改会立马生效
  • 对 数据卷 的更新,不会影响镜像
  • 数据卷 默认会一直存在,即使容器被删除

\

挂载方式

  • Bind Mount 能够直接将宿主操作系统中的目录和文件挂载到容器内的文件系统中,通过指定容器外的路径和容器内的路径,就可以形成挂载映射关系,在容器内外对文件的读写,都是相互可见的。
  • Volume 也是从宿主操作系统中挂载目录到容器内,只不过这个挂载的目录由 Docker 进行管理,我们只需要指定容器内的目录,不需要关心具体挂载到了宿主操作系统中的哪里。
  • Tmpfs Mount 支持挂载系统内存中的一部分到容器的文件系统里,不过由于内存和容器的特征,它的存储并不是持久的,其中的内容会随着容器的停止而消失。

\

挂载文件到容器

要将宿主操作系统中的目录挂载到容器之后,我们可以在容器创建的时候通过传递 -v 或 --volume 选项来指定内外挂载的对应目录或文件。

sudo docker run -d --name nginx -v /webapp/html:/usr/share/nginx/html nginx:1.12

使用 -v 或 --volume 来挂载宿主操作系统目录的形式是 -v <host-path>:<container-path> --volume <host-path>:<container-path>,其中 host-path 和 container-path 分别代表宿主操作系统中的目录和容器中的目录。

这里需要注意的是,为了避免混淆,Docker 这里强制定义目录时必须使用绝对路径,不能使用相对路径。

docker 专门提供了 volume 子命令来操作数据卷:
create        创建数据卷
inspect      显示数据卷的详细信息
ls               列出所有的数据卷
prune        删除所有未使用的 volumes,并且有 -f 选项
rm             删除一个或多个未使用的 volumes,并且有 -f 选项

之前我们使用 --volume(-v) 选项来挂载数据卷,现在 docker 提供了更强大的 --mount 选项来管理数据卷。mount 选项可以通过逗号分隔的多个键值对一次提供多个配置项,因此 mount 选项可以提供比 volume 选项更详细的配置。使用 mount 选项的常用配置如下:

type 指定挂载方式,我们这里用到的是 volume,其实还可以有 bind 和 tmpfs。
volume-driver 指定挂载数据卷的驱动程序,默认值是 local。通过指定 volume driver 的方式把数据卷中的数据存储在其它的地方,比如 Azrue Storge 或 AWS 的 S3
source 指定挂载的源,对于一个命名的数据卷,这里应该指定这个数据卷的名称。在使用时可以写 source,也可以简写为 src。
destination 指定挂载的数据在容器中的路径。在使用时可以写 destination,也可以简写为 dst 或 target。
readonly 指定挂载的数据为只读。
$ docker volume create my-vol
$ docker run -d -P \
    --name web \
    # -v my-vol:/usr/share/nginx/html \
    --mount source=my-vol,target=/usr/share/nginx/html \
    nginx:alpine

挂载主机目录

$ docker run -d -P \
    --name web \
    # -v /src/webapp:/usr/share/nginx/html \
    --mount type=bind,source=/src/webapp,target=/usr/share/nginx/html \
    nginx:alpine

使用 --mount 参数时如果本地目录不存在,Docker 会报错。

Docker 挂载主机目录的默认权限是 读写,用户也可以通过增加 readonly 指定为 只读

$ docker run -d -P \
    --name web \
    # -v /src/webapp:/usr/share/nginx/html:rw \
    --mount type=bind,source=/src/webapp,target=/usr/share/nginx/html,readonly \
    nginx:alpine

加了 readonly 之后,就挂载为 只读 了。如果你在容器内 /usr/share/nginx/html 目录新建文件,会显示如下错误:

/usr/share/nginx/html # touch new.txt
touch: new.txt: Read-only file system

挂载临时文件目录

Tmpfs Mount 是一种特殊的挂载方式,它主要利用内存来存储数据。由于内存不是持久性存储设备,所以其带给 Tmpfs Mount 的特征就是临时性挂载。

与挂载宿主操作系统目录或文件不同,挂载临时文件目录要通过 --tmpfs 这个选项来完成。由于内存的具体位置不需要我们来指定,这个选项里我们只需要传递挂载到容器内的目录即可。

docker run -d --name webapp --tmpfs /webapp/cache webapp:latest

挂载临时文件首先要注意它不是持久存储这一特性,在此基础上,它有几种常见的适应场景。

  • 应用中使用到,但不需要进行持久保存的敏感数据,可以借助内存的非持久性和程序隔离性进行一定的安全保障。
  • 读写速度要求较高,数据变化量大,但不需要持久保存的数据,可以借助内存的高读写速度减少操作的时间。