Docker Learning Handbook


Docker Learning Handbook

声明

 Author:Qftm
 Data:2019/12/31
 Blog:https://qftm.github.io/

正文

image-20200405210845829

Table of Contents

Docker 简介

Docker 最初是 dotCloud 公司创始人 Solomon Hykes 在法国期间发起的一个公司内部项目,它是基于 dotCloud 公司多年云服务技术的一次革新,并于 2013 年 3 月以 Apache 2.0 授权协议开源,主要项目代码在 GitHub 上进行维护。Docker 项目后来还加入了 Linux 基金会,并成立推动 开放容器联盟(OCI)

Docker 自开源后受到广泛的关注和讨论,至今其 GitHub 项目已经超过 4 万 6 千个星标和一万多个 fork。甚至由于 Docker 项目的火爆,在 2013 年底,dotCloud 公司决定改名为 Docker。Docker 最初是在 Ubuntu 12.04 上开发实现的;Red Hat 则从 RHEL 6.5 开始对 Docker 进行支持;Google 也在其 PaaS 产品中广泛应用 Docker。

Docker 使用 Google 公司推出的 Go 语言 进行开发实现,基于 Linux 内核的 cgroupsnamespace,以及 AUFS 类的 Union FS 等技术,对进程进行封装隔离,属于 操作系统层面的虚拟化技术。由于隔离的进程独立于宿主和其它的隔离的进程,因此也称其为容器。最初实现是基于 LXC,从 0.7 版本以后开始去除 LXC,转而使用自行开发的 libcontainer,从 1.11 开始,则进一步演进为使用 runCcontainerd

Docker 在容器的基础上,进行了进一步的封装,从文件系统、网络互联到进程隔离等等,极大的简化了容器的创建和维护。使得 Docker 技术比虚拟机技术更为轻便、快捷。

下面的一张图比较了 Docker 和传统虚拟化方式的不同之处(官方图例)。传统虚拟机技术是虚拟出一套硬件后,在其上运行一个完整操作系统,在该系统上再运行所需应用进程;而容器内的应用进程直接运行于宿主的内核,容器内没有自己的内核,而且也没有进行硬件虚拟。因此容器要比传统虚拟机更为轻便。

image-20200405182934013

Docker 资源

Docker 官方主页

https://www.docker.com

Docker 官方博客

https://blog.docker.com/

Docker 官方文档

https://docs.docker.com/

Docker Hub

https://hub.docker.com

Docker Github Repositories

https://github.com/docker

Docker Engine API

https://docs.docker.com/engine/api/sdk/

Docker 发布版本历史

https://docs.docker.com/release-notes/

Docker 常见问题

https://docs.docker.com/engine/faq/

Docker 优点

Docker 是个划时代的开源项目,它彻底释放了计算虚拟化的威力,极大提高了应用的维护效率,降低了云计算应用开发的成本!使用 Docker,可以让应用的部署、测试和分发都变得前所未有的高效和轻松!

  • 更高效的利用系统资源

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

  • 更快速的启动时间

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

  • 一致的运行环境

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

  • 持续交付和部署

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

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

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

  • 更轻松的迁移

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

  • 更轻松的维护和扩展

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

  • 对比传统虚拟机
特性 容器 虚拟机
启动 秒级 分钟级
硬盘使用 一般为 MB 一般为 GB
性能 接近原生 弱于
系统支持量 单机支持上千个容器 一般几十个

Docker 组成

Docker 包括一下几个部分

  • 客户端(Client
  • 守护进程(Daemon
  • 镜像(Image
  • 容器(Container
  • 仓库(Repository

理解了这几个概念,就理解了 Docker 的整个生命周期,下面这幅图展示了Docker的组成:

image-20200406101727486

C/S

Docker 使用了 C/S 体系架构,Docker client 客户端与 Docker daemon 守护进程服务端通信,Docker 守护进程负责构建,运行和分发 Docker 容器。Docker 客户端和守护进程可以在同一个系统上运行,也可以将 Docker 客户端连接到远程Docker 守护进程,其中客户端和服务端通信 Docker API 起到重要角色。

Docker API 遵循 RESTful API 标准(基于 HTTP 协议),可由 HTTP 客户端(如 wget 或 curl)或作为大多数现代编程语言的 HTTP 库访问,官方提供的主要有三大 API:

Docker Engine API     # https://docs.docker.com/engine/api/
Docker Hub API        # https://docs.docker.com/docker-hub/api/latest/
Docker Registry API   # https://docs.docker.com/registry/spec/api/
  • Client/Server

Docker的使用者并不会直接与Docker守护进程接触,而是由Docker客户端进行交互。

image-20200407162139749

  • Engine API

Docker 提供了一个用于与 Docker 守护进程进行交互的 API(称为 Docker Engine API),以及Go和Python等语言的SDK。使用SDK,您可以快速轻松地构建与扩展Docker应用程序和解决方案。

image-20200407162639411

Docker Engine API 个版本 HTTP 接口详情见官网 Engine API reference

Docker ClientDocker ServerDocker daemon)的连接方式主要是通过 Socket 进行连接,Docker daemon 支持如下三种不同类型的 Socket

unix:///var/run/docker.sock
tcp://host:port
fd://socketfd

Docker daemon 默认监听 unix:///var/run/docker.sock ,也可以通过 -H 参数设置监听其它 unix sockettcp socket 等。

用户可以利用CLI(docker本身)或者自定义程序调用 Engine API 与 DockerServer 建立Socket连接,通过 Socket 可以在本地或者远程(配置docker -H参数)和DokcerServer建立通信。

image-20200407163404087

  • 本地/远程(host)

Docker客户端和守护进程可以在同一个系统上运行,也可以将Docker客户端连接到远程Docker守护进程。

image-20200406102156907

镜像

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

Docker 镜像是一个特殊的文件系统,除了提供容器运行时所需的程序、库、资源、配置等文件外,还包含了一些为运行时准备的一些配置参数(如匿名卷、环境变量、用户等)。镜像不包含任何动态数据,其内容在构建之后也不会被改变。

  • 分层存储

因为镜像包含操作系统完整的 root 文件系统,其体积往往是庞大的,因此在 Docker 设计时,就充分利用 Union FS 的技术,将其设计为分层存储的架构。所以严格来说,镜像并非是像一个 ISO 那样的打包文件,镜像只是一个虚拟的概念,其实际体现并非由一个文件组成,而是由一组文件系统组成,或者说,由多层文件系统联合组成。

image-20200406102230056

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

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

容器

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

Docker 面向对象
容器 对象(实例)
镜像

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

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

image-20200406103206422

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

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

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

仓库

镜像构建完成后,可以很容易的在当前宿主机上运行,但是,如果需要在其它服务器上使用这个镜像,我们就需要一个集中的存储、分发镜像的服务,Docker Registry 就是这样的服务。

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

通常,一个仓库会包含同一个软件不同版本的镜像,而标签就常用于对应该软件的各个版本。我们可以通过 <仓库名>:<标签> 的格式来指定具体是这个软件哪个版本的镜像。如果不给出标签,将以 latest 作为默认标签。

Ubuntu 镜像 为例,ubuntu 是仓库的名字,其内包含有不同的版本标签,如,16.04, 18.04。我们可以通过 ubuntu:14.04,或者 ubuntu:18.04 来具体指定所需哪个版本的镜像。如果忽略了标签,比如 ubuntu,那将视为 ubuntu:latest

仓库名经常以 两段式路径 形式出现,比如 jwilder/nginx-proxy,前者往往意味着 Docker Registry 多用户环境下的用户名,后者则往往是对应的软件名。但这并非绝对,取决于所使用的具体 Docker Registry 的软件或服务。

  • Docker Registry 公开服务

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

最常使用的 Registry 公开服务是官方的 Docker Hub,这也是默认的 Registry,并拥有大量的高质量的官方镜像。除此以外,还有 CoreOSQuay.io,CoreOS 相关的镜像存储在这里;Google 的 Google Container RegistryKubernetes 的镜像使用的就是这个服务。

由于某些原因,在国内访问这些服务可能会比较慢。国内的一些云服务商提供了针对 Docker Hub 的镜像服务(Registry Mirror),这些镜像服务被称为加速器。常见的有 阿里云加速器DaoCloud 加速器 等。使用加速器会直接从国内的地址下载 Docker Hub 的镜像,比直接从 Docker Hub 下载速度会提高很多。

国内也有一些云服务商提供类似于 Docker Hub 的公开服务。比如 时速云镜像仓库网易云镜像服务DaoCloud 镜像市场阿里云镜像库 等。

  • Docker Registry 私有服务

除了使用公开服务外,用户还可以在本地搭建私有 Docker Registry。Docker 官方提供了 Docker Registry 镜像,可以直接使用做为私有 Registry 服务。

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

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

Docker 安装

各个系统安装

  • 官方详细手册
https://docs.docker.com/
  • Docker for Linux
https://docs.docker.com/install/linux/docker-ce/ubuntu/
  • Docker Desktop for Mac
https://docs.docker.com/docker-for-mac/install/
  • Docker Desktop for Windows
https://docs.docker.com/docker-for-windows/install/

镜像加速器

国内从 Docker Hub 拉取镜像有时会遇到困难,此时可以配置镜像加速器。Docker 官方和国内很多云服务商等都提供了国内加速器服务,例如:

Note:
当配置某一个加速器地址之后,若发现拉取不到镜像,请切换到另一个加速器地址。
国内各大云服务商均提供了 Docker 镜像加速服务,建议根据运行 Docker 的云平台选择对应的镜像加速服务。

Docker 官方镜像加速器

  • Ubuntu 14.04、Debian 7 Wheezy

对于使用 upstart 的系统而言,编辑 /etc/default/docker 文件,在其中的 DOCKER_OPTS 中配置加速器地址:

DOCKER_OPTS="--registry-mirror=https://registry.docker-cn.com"

重新启动服务。

$ sudo service docker restart
  • Ubuntu 16.04+、Debian 8+、CentOS 7

对于使用 systemd 的系统,请在 /etc/docker/daemon.json 中写入如下内容(如果文件不存在请新建该文件)

{
  "registry-mirrors": [
    "https://registry.docker-cn.com"
  ]
}

PS:注意,一定要保证该文件符合 json 规范,否则 Docker 将不能启动。

之后重新启动服务。

$ sudo systemctl daemon-reload
$ sudo systemctl restart docker
  • Windows 10

对于使用 Windows 10 的系统,在系统右下角托盘 Docker 图标内右键菜单选择 Settings,打开配置窗口后左侧导航菜单选择 Daemon。在 Registry mirrors 一栏中填写加速器地址 https://registry.docker-cn.com,之后点击 Apply 保存后 Docker 就会重启并应用配置的镜像地址了。

  • macOS

对于使用 macOS 的用户,在任务栏点击 Docker for mac 应用图标 -> Perferences… -> Daemon -> Registry mirrors。在列表中填写加速器地址 https://registry.docker-cn.com。修改完成之后,点击 Apply & Restart 按钮,Docker 就会重启并应用配置的镜像地址了。

  • 检查加速器是否生效

命令行执行 docker info,如果从结果中看到了如下内容,说明配置成功。

Registry Mirrors:
 https://registry.docker-cn.com/

DaoCloud 镜像加速器

下面不同系统的部署配置方法来自官方描述:配置 Docker 镜像站

  • Linux
curl -sSL https://get.daocloud.io/daotools/set_mirror.sh | sh -s http://f1361db2.m.daocloud.io

该脚本可以将 –registry-mirror 加入到你的 Docker 配置文件 /etc/docker/daemon.json 中。适用于 Ubuntu14.04、Debian、CentOS6 、CentOS7、Fedora、Arch Linux、openSUSE Leap 42.1,其他版本可能有细微不同。更多详情请访问文档。

  • macOS

Docker For Mac

右键点击桌面顶栏的 docker 图标,选择 Preferences ,在 Daemon 标签(Docker 17.03 之前版本为 Advanced 标签)下的 Registry mirrors 列表中加入下面的镜像地址:

http://f1361db2.m.daocloud.io

点击 Apply & Restart 按钮使设置生效。

Docker Toolbox 等配置方法请参考帮助文档

  • Windows

Docker For Windows

在桌面右下角状态栏中右键 docker 图标,修改在 Docker Daemon 标签页中的 json ,把下面的地址:

http://f1361db2.m.daocloud.io

加到” registry-mirrors“的数组里。点击 Apply 。

Docker Toolbox 等配置方法请参考帮助文档

Docker 镜像使用

获取仓库镜像

前面提到过,Docker Hub 上有大量的高质量的镜像可以用,这里我们就说一下怎么获取这些镜像。

一般情况下我们可以在本地终端使用docker search 来搜索相关镜像(搜索指定软件名的镜像),但是有一个缺点就是搜索的结果不包含Tag也就导致我们无法使用docker pull下载相应的镜像。

image-20200406190916891

这个时候只能在Docker Hub 上进行搜索然后根据显示的TAG在使用docker pull下载相应的镜像。

image-20200406191042497

从 Docker 镜像仓库获取镜像的命令是 docker pull。其命令格式为:

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

具体的选项可以通过 docker pull --help 命令看到,这里我们说一下镜像名称的格式。

  • Docker 镜像仓库地址:地址的格式一般是 <域名/IP>[:端口号]。默认地址是 Docker Hub。
  • 仓库名:这里的仓库名是两段式名称,即 <用户名>/<软件名>。对于 Docker Hub,如果不给出用户名,则默认为 library,也就是官方镜像。
  • 镜像名:由仓库名和标签组成,标志了每一个镜像。

Example:

root@rose:~# docker pull ubuntu:18.04
18.04: Pulling from library/ubuntu
5bed26d33875: Pull complete
f11b29a9c730: Pull complete
930bda195c84: Pull complete
78bf9a5ad49e: Pull complete
Digest: sha256:bec5a2727be7fff3d308193cfde3491f8fba1a2ba392b7546b43a051853a341d
Status: Downloaded newer image for ubuntu:18.04

上面的命令中没有给出 Docker 镜像仓库地址,因此将会从 Docker Hub 获取镜像。而镜像名称是 ubuntu:18.04,因此将会获取官方镜像 library/ubuntu 仓库中标签为 18.04 的镜像。

如果在docker pull中没有指定Tag则默认会下载标签为latest的镜像文件。

从下载过程中可以看到我们之前提及的分层存储的概念,镜像是由多层存储所构成。下载也是一层层的去下载,并非单一文件。下载过程中给出了每一层的 ID 的前 12 位。并且下载结束后,给出该镜像完整的 sha256 的摘要,以确保下载一致性。

在使用上面命令的时候,你可能会发现,你所看到的层 ID 以及 sha256 的摘要和这里的不一样。这是因为官方镜像是一直在维护的,有任何新的 bug,或者版本更新,都会进行修复再以原来的标签发布,这样可以确保任何使用这个标签的用户可以获得更安全、更稳定的镜像。

列出本地镜像

  • 查看本地镜像

可以使用 docker image lsdocker images命令列出已经下载下来的镜像。

这两个命令语法类似

docker images [OPTSIONS] [REPOSITORY]
    -a, --all=false
    -f, --filter=[]    #镜像过滤显示
    --no-trunc=false   #镜像ID不截断显示
    -q, --quiet=false

查看本地下载镜像

root@rose:~# docker image ls
REPOSITORY      TAG              IMAGE ID            CREATED            SIZE
ubuntu         18.04            4e5021d210f6        2 weeks ago         64.2MB
ubuntu         latest           4e5021d210f6        2 weeks ago         64.2MB
mongo           3.2             fe9198c04d62        5 days ago          342 MB
<none>         <none>           00285df0df87        5 days ago          342 MB

列表包含了 仓库名标签镜像 ID创建时间 以及 所占用的空间

镜像 ID (sha值)是镜像的唯一标识,一个镜像可以对应多个标签。因此,在上面的例子中,我们可以看到 ubuntu:18.04ubuntu:latest 拥有相同的 ID,因为它们对应的是同一个镜像。

查看本地镜像详细信息

docker image inspect 镜像名或镜像ID
or
docker inspect 镜像名或镜像ID

image-20200406231617576

  • 虚悬镜像

上面的镜像列表中,还可以看到一个特殊的镜像,这个镜像既没有仓库名,也没有标签,均为 <none>。:

<none>         <none>           00285df0df87        5 days ago          342 MB

这个镜像原本是有镜像名和标签的,原来为 mongo:3.2,随着官方镜像维护,发布了新版本后,重新 docker pull mongo:3.2 时,mongo:3.2 这个镜像名被转移到了新下载的镜像身上,而旧的镜像上的这个名称则被取消,从而成为了 <none>。除了 docker pull 可能导致这种情况,docker build 也同样可以导致这种现象。由于新旧镜像同名,旧镜像名称被取消,从而出现仓库名、标签均为 <none> 的镜像。这类无标签镜像也被称为 虚悬镜像(dangling image) ,可以用下面的命令专门显示这类镜像:

docker image ls -f dangling=true

一般来说,虚悬镜像已经失去了存在的价值,是可以随意删除的,可以用下面的命令删除所有虚悬镜像:

docker image prune

删除本地镜像

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

docker rmi [OPTIONS] IMAGE [IMAGE...]
or
docker image rm [OPTIONS] IMAGE [IMAGE...]

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

image-20200406232524927

构建镜像

一般情况下不会使用 docker commit 定制镜像,定制镜像应该使用 Dockerfile 来完成。因为docker commit定制的镜像相当于黑箱操作,外界不知道这个镜像做了哪些操作,不便于维护,而且由于其在容器的存储层上进行的修改,后期在其基础上进行修改不会导致上一层的内容真实的去除,也就导致制作的镜像变得更加臃肿。事实上docker commit这种方式定制的镜像和下面在容器里面所讲述的容器快照导出与导入作为一个镜像是类似的都是在容器的存储层进行了修改。而使用dockerfile制作的镜像是透明的,把每一层修改、安装、构建、操作的命令都写入一个脚本,用这个脚本来构建、定制镜像,也就方便后期的维护。下面主要了解一下这两种模式是怎么定制镜像的。

理解 commit 构建镜像

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

现在以一个简单的例子定制一个容器,存储一个文件Qftm.txt,文件内容hello word,然后将其定制为一个镜像。

root@rose:~# docker run -it ubuntu:18.04 bash
root@c38db96c2fda:/# touch Qftm.txt
root@c38db96c2fda:/# echo "hello word">Qftm.txt
root@c38db96c2fda:/# cat Qftm.txt
hello word
root@c38db96c2fda:/#

制作操作之后,我们修改了容器的文件,也就是改动了容器的存储层。我们可以通过 docker diff 命令看到具体的改动。

root@rose:~# docker diff c38db96c2fda
C /root
A /root/.bash_history
A /Qftm.txt
root@rose:~#

现在我们定制好了变化,我们希望能将其保存下来形成镜像。

要知道,当我们运行一个容器的时候(如果不使用卷的话),我们做的任何文件修改都会被记录于容器存储层里。而 Docker 提供了一个 docker commit 命令,可以将容器的存储层保存下来成为镜像。换句话说,就是在原有镜像的基础上,再叠加上容器的存储层,并构成新的镜像。以后我们运行这个新镜像的时候,就会拥有原有容器最后的文件变化。

docker commit 的语法格式为:

docker commit [选项] <容器ID或容器名> [<仓库名>[:<标签>]]

我们可以用下面的命令将容器保存为镜像:

root@rose:~# docker commit --author "Qftm" --message "txt file" c38db96c2fda ubuntu:v1
sha256:35e3740b9fa34d7a2bddc488356073ebacd3c44f69f2633bf08a48685a5656aa
root@rose:~#

其中 --author 是指定修改的作者,而 --message 则是记录本次修改的内容。这点和 git 版本控制相似,不过这里这些信息可以省略留空。

我们可以在 docker image ls 中看到这个新定制的镜像:

root@rose:~# docker image ls
REPOSITORY    TAG                 IMAGE ID            CREATED           SIZE
ubuntu         v1               35e3740b9fa3        30 seconds ago      64.2MB

我们还可以用 docker history 具体查看镜像内的历史记录,如果比较 ubuntu:18.04 的历史记录,我们会发现新增了我们刚刚提交的这一层。

image-20200407160927685

到这里新的镜像已经定制好了,可以运行这个镜像创建容器。最后要说的还是尽量不要用这种方法去定制镜像,缺点已经在上面讲过了。虽然使用 docker commit 命令可以比较直观的帮助理解镜像分层存储的概念,但是实际环境中并不会这样使用。

使用 dockerfile 构建镜像

从刚才的 docker commit 的学习中,我们可以了解到,镜像的定制实际上就是定制每一层所添加的配置、文件。如果我们可以把每一层修改、安装、构建、操作的命令都写入一个脚本,用这个脚本来构建、定制镜像,那么之前提及的无法重复的问题、镜像构建透明性的问题、体积的问题就都会解决。这个脚本就是 Dockerfile。

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

这里以简单定制nginx镜像为例,使用Dockerfile来定制

在一个空白目录中,建立一个文本文件,并命名为 Dockerfile

root@rose:~# mkdir mynginx
root@rose:~# cd mynginx
root@rose:~/mynginx# touch Dockerfile
root@rose:~/mynginx#

其内容为:

FROM nginx
RUN echo '<h1>Hello, Docker!</h1>' > /usr/share/nginx/html/index.html

这个 Dockerfile 很简单,一共就两行。涉及到了两条指令,FROMRUN

FROM 指定基础镜像

所谓定制镜像,那一定是以一个镜像为基础,在其上进行定制。就像我们之前运行了一个 ubuntu 镜像的容器,再进行修改一样,基础镜像是必须指定的。而 FROM 就是指定 基础镜像,因此一个 DockerfileFROM 是必备的指令,并且必须是第一条指令。

Docker Hub 上有非常多的高质量的官方镜像,有可以直接拿来使用的服务类的镜像,如 nginxredismongomysqlhttpdphptomcat 等;也有一些方便开发、构建、运行各种语言应用的镜像,如 nodeopenjdkpythonrubygolang 等。可以在其中寻找一个最符合我们最终目标的镜像为基础镜像进行定制。

如果没有找到对应服务的镜像,官方镜像中还提供了一些更为基础的操作系统镜像,如 ubuntudebiancentosfedoraalpine 等,这些操作系统的软件库为我们提供了更广阔的扩展空间。

除了选择现有镜像为基础镜像外,Docker 还存在一个特殊的镜像,名为 scratch。这个镜像是虚拟的概念,并不实际存在,它表示一个空白的镜像。

FROM scratch
...

如果你以 scratch 为基础镜像的话,意味着你不以任何镜像为基础,接下来所写的指令将作为镜像第一层开始存在。

不以任何系统为基础,直接将可执行文件复制进镜像的做法并不罕见,比如 swarmetcd。对于 Linux 下静态编译的程序来说,并不需要有操作系统提供运行时支持,所需的一切库都已经在可执行文件里了,因此直接 FROM scratch 会让镜像体积更加小巧。使用 Go 语言 开发的应用很多会使用这种方式来制作镜像,这也是为什么有人认为 Go 是特别适合容器微服务架构的语言的原因之一。

RUN 执行命令

RUN 指令是用来执行命令行命令的(可以理解为启动运行一个镜像容器,并在容器内执行相关命令,然后进行commit操作,之后删除这个容器)。由于命令行的强大能力,RUN 指令在定制镜像时是最常用的指令之一。其格式有两种:

  • shell 格式:RUN <命令>,就像直接在命令行中输入的命令一样。刚才写的 Dockerfile 中的 RUN 指令就是这种格式。
RUN echo '<h1>Hello, Docker!</h1>' > /usr/share/nginx/html/index.html
  • exec 格式:RUN ["可执行文件", "参数1", "参数2"],这更像是函数调用中的格式。

既然 RUN 就像 Shell 脚本一样可以执行命令,那么我们是否就可以像 Shell 脚本一样把每个命令对应一个 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 层镜像。这是完全没有意义的,而且很多运行时不需要的东西,都被装进了镜像里,比如编译环境、更新的软件包等等。结果就是产生非常臃肿、非常多层的镜像,不仅仅增加了构建部署的时间,也很容易出错。 这是很多初学 Docker 的人常犯的一个错误。

Union FS 是有最大层数限制的,比如 AUFS,曾经是最大不得超过 42 层,现在是不得超过 127 层。

上面的 Dockerfile 正确的写法应该是这样:

FROM debian:stretch

RUN 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 可执行文件。因此没有必要建立很多层,这只是一层的事情。因此,这里没有使用很多个 RUN 对一一对应不同的命令,而是仅仅使用一个 RUN 指令,并使用 && 将各个所需命令串联起来。将之前的 7 层,简化为了 1 层。在撰写 Dockerfile 的时候,要经常提醒自己,这并不是在写 Shell 脚本,而是在定义每一层该如何构建。

并且,这里为了格式化还进行了换行。Dockerfile 支持 Shell 类的行尾添加 \ 的命令换行方式,以及行首 # 进行注释的格式。良好的格式,比如换行、缩进、注释等,会让维护、排障更为容易,这是一个比较好的习惯。

此外,还可以看到这一组命令的最后添加了清理工作的命令,删除了为了编译构建所需要的软件,清理了所有下载、展开的文件,并且还清理了 apt 缓存文件。这是很重要的一步,我们之前说过,镜像是多层存储,每一层的东西并不会在下一层被删除,会一直跟随着镜像。因此镜像构建时,一定要确保每一层只添加真正需要添加的东西,任何无关的东西都应该清理掉。

很多人初学 Docker 制作出了很臃肿的镜像的原因之一,就是忘记了每一层构建的最后一定要清理掉无关文件。

构建镜像

好了,让我们再回到之前定制的 nginx 镜像的 Dockerfile 来。现在我们明白了这个 Dockerfile 的内容,那么让我们来构建这个镜像吧。

Dockerfile 文件所在目录执行:

$ docker build -t nginx:v3 .
Sending build context to Docker daemon 2.048 kB
Step 1 : FROM nginx
 ---> e43d811ce2f4
Step 2 : RUN echo '<h1>Hello, Docker!</h1>' > /usr/share/nginx/html/index.html
 ---> Running in 9cdc27646c7b
 ---> 44aa4490ce2c
Removing intermediate container 9cdc27646c7b
Successfully built 44aa4490ce2c

从命令的输出结果中,我们可以清晰的看到镜像的构建过程。在 Step 2 中,如同我们之前所说的那样,RUN 指令启动了一个容器 9cdc27646c7b,执行了所要求的命令,并最后提交了这一层 44aa4490ce2c,随后删除了所用到的这个容器 9cdc27646c7b

这里我们使用了 docker build 命令进行镜像构建。其格式为:

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

在这里我们指定了最终镜像的名称 -t nginx:v3,构建成功后,我们就可以运行这个镜像来创建容器。

镜像构建上下文(Context)

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

首先我们要理解 docker build 的工作原理。Docker 在运行时分为 Docker 引擎(也就是服务端守护进程)和客户端工具。Docker 的引擎提供了一组 REST API,被称为 Docker Remote API,而如 docker 命令这样的客户端工具,则是通过这组 API 与 Docker 引擎交互,从而完成各种功能。因此,虽然表面上我们好像是在本机执行各种 docker 功能,但实际上,一切都是使用的远程调用形式在服务端(Docker 引擎)完成。也因为这种 C/S 设计,让我们操作远程服务器的 Docker 引擎变得轻而易举。

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

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

如果在 Dockerfile 中这么写:

COPY ./package.json /app/

这并不是要复制执行 docker build 命令所在的目录下的 package.json,也不是复制 Dockerfile 所在目录下的 package.json,而是复制 上下文(context) 目录下的 package.json

因此,COPY 这类指令中的源文件的路径都是相对路径。这也是初学者经常会问的为什么 COPY ../package.json /app 或者 COPY /opt/xxxx /app 无法工作的原因,因为这些路径已经超出了上下文的范围,Docker 引擎无法获得这些位置的文件。如果真的需要那些文件,应该将它们复制到上下文目录中去。

现在就可以理解刚才的命令 docker build -t nginx:v3 . 中的这个 .,实际上是在指定上下文的目录,docker build 命令会将该目录下的内容打包交给 Docker 引擎以帮助构建镜像。

如果观察 docker build 输出,我们其实已经看到了这个发送上下文的过程:

$ docker build -t nginx:v3 .
Sending build context to Docker daemon 2.048 kB
...

理解构建上下文对于镜像构建是很重要的,避免犯一些不应该的错误。比如有些初学者在发现 COPY /opt/xxxx /app 不工作后,于是干脆将 Dockerfile 放到了硬盘根目录去构建,结果发现 docker build 执行后,在发送一个几十 GB 的东西,极为缓慢而且很容易构建失败。那是因为这种做法是在让 docker build 打包整个硬盘,这显然是使用错误。

一般来说,应该会将 Dockerfile 置于一个空目录下,或者项目根目录下。如果该目录下没有所需文件,那么应该把所需文件复制一份过来。如果目录下有些东西确实不希望构建时传给 Docker 引擎,那么可以用 .gitignore 一样的语法写一个 .dockerignore,该文件是用于剔除不需要作为上下文传递给 Docker 引擎的。

那么为什么会有人误以为 . 是指定 Dockerfile 所在目录呢?这是因为在默认情况下,如果不额外指定 Dockerfile 的话,会将上下文目录下的名为 Dockerfile 的文件作为 Dockerfile。

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

当然,一般大家习惯性的会使用默认的文件名 Dockerfile,以及会将其置于镜像构建上下文目录中。

其它 docker build 的用法

  • 直接用 Git repo 进行构建

或许你已经注意到了,docker build 还支持从 URL 构建,比如可以直接从 Git repo 中构建:

$ docker build https://github.com/twang2218/gitlab-ce-zh.git#:11.1

Sending build context to Docker daemon 2.048 kB
Step 1 : FROM gitlab/gitlab-ce:11.1.0-ce.0
11.1.0-ce.0: Pulling from gitlab/gitlab-ce
aed15891ba52: Already exists
773ae8583d14: Already exists
...

这行命令指定了构建所需的 Git repo,并且指定默认的 master 分支,构建目录为 /11.1/,然后 Docker 就会自己去 git clone 这个项目、切换到指定分支、并进入到指定目录后开始构建。

  • 用给定的 tar 压缩包构建
$ docker build http://server/context.tar.gz

如果所给出的 URL 不是个 Git repo,而是个 tar 压缩包,那么 Docker 引擎会下载这个包,并自动解压缩,以其作为上下文,开始构建。

  • 从标准输入中读取 Dockerfile 进行构建
docker build - < Dockerfile

cat Dockerfile | docker build -

如果标准输入传入的是文本文件,则将其视为 Dockerfile,并开始构建。这种形式由于直接从标准输入中读取 Dockerfile 的内容,它没有上下文,因此不可以像其他方法那样可以将本地文件 COPY 进镜像之类的事情。

  • 从标准输入中读取上下文压缩包进行构建
$ docker build - < context.tar.gz

如果发现标准输入的文件格式是 gzipbzip2 以及 xz 的话,将会使其为上下文压缩包,直接将其展开,将里面视为上下文,并开始构建。

Dockerfile 详解

上面已经介绍了 FROMRUN,还提及了 COPY, ADD,其实 Dockerfile 功能很强大,它提供了十多个指令。下面我们继续讲解学习其他的指令。

COPY 复制文件

格式:

  • COPY [--chown=:] <源路径>... <目标路径>
  • COPY [--chown=:] ["<源路径1>",... "<目标路径>"]

RUN 指令一样,也有两种格式,一种类似于命令行,一种类似于函数调用。

COPY 指令将从构建上下文目录中 <源路径> 的文件/目录复制到新的一层的镜像内的 <目标路径> 位置。比如:

COPY package.json /usr/src/app/

<源路径> 可以是多个,甚至可以是通配符,其通配符规则要满足 Go 的 filepath.Match 规则,如:

COPY hom* /mydir/
COPY hom?.txt /mydir/

<目标路径> 可以是容器内的绝对路径,也可以是相对于工作目录的相对路径(工作目录可以用 WORKDIR 指令来指定)。目标路径不需要事先创建,如果目录不存在会在复制文件前先行创建缺失目录。

此外,还需要注意一点,使用 COPY 指令,源文件的各种元数据都会保留。比如读、写、执行权限、文件变更时间等。这个特性对于镜像定制很有用。特别是构建相关文件都在使用 Git 进行管理的时候。

在使用该指令的时候还可以加上 --chown=: 选项来改变文件的所属用户及所属组。

COPY --chown=55:mygroup files* /mydir/
COPY --chown=bin files* /mydir/
COPY --chown=1 files* /mydir/
COPY --chown=10:11 files* /mydir/

ADD 更高级的复制文件

ADD 指令和 COPY 的格式和性质基本一致。但是在 COPY 基础上增加了一些功能。

比如 <源路径> 可以是一个 URL,这种情况下,Docker 引擎会试图去下载这个链接的文件放到 <目标路径> 去。下载后的文件权限自动设置为 600,如果这并不是想要的权限,那么还需要增加额外的一层 RUN 进行权限调整,另外,如果下载的是个压缩包,需要解压缩,也一样还需要额外的一层 RUN 指令进行解压缩。所以不如直接使用 RUN 指令,然后使用 wget 或者 curl 工具下载,处理权限、解压缩、然后清理无用文件更合理。因此,这个功能其实并不实用,而且不推荐使用。

如果 <源路径> 为一个 tar 压缩文件的话,压缩格式为 gzip, bzip2 以及 xz 的情况下,ADD 指令将会自动解压缩这个压缩文件到 <目标路径> 去。

在某些情况下,这个自动解压缩的功能非常有用,比如官方镜像 ubuntu 中:

FROM scratch
ADD ubuntu-xenial-core-cloudimg-amd64-root.tar.gz /
...

但在某些情况下,如果我们真的是希望复制个压缩文件进去,而不解压缩,这时就不可以使用 ADD 命令了。

在 Docker 官方的 Dockerfile 最佳实践文档 中要求,尽可能的使用 COPY,因为 COPY 的语义很明确,就是复制文件而已,而 ADD 则包含了更复杂的功能,其行为也不一定很清晰。最适合使用 ADD 的场合,就是所提及的需要自动解压缩的场合。

另外需要注意的是,ADD 指令会令镜像构建缓存失效,从而可能会令镜像构建变得比较缓慢。

因此在 COPYADD 指令中选择的时候,可以遵循这样的原则,所有的文件复制均使用 COPY 指令,仅在需要自动解压缩的场合使用 ADD

在使用该指令的时候还可以加上 --chown=: 选项来改变文件的所属用户及所属组。

ADD --chown=55:mygroup files* /mydir/
ADD --chown=bin files* /mydir/
ADD --chown=1 files* /mydir/
ADD --chown=10:11 files* /mydir/

CMD 容器启动命令

CMD 指令的格式和 RUN 相似,也是两种格式:

  • shell 格式:CMD <命令>
  • exec 格式:CMD ["可执行文件", "参数1", "参数2"...]
  • 参数列表格式:CMD ["参数1", "参数2"...]。在指定了 ENTRYPOINT 指令后,用 CMD 指定具体的参数。

之前介绍容器的时候曾经说过,Docker 不是虚拟机,容器就是进程。既然是进程,那么在启动容器的时候,需要指定所运行的程序及参数。CMD 指令就是用于指定默认的容器主进程的启动命令的。

在运行时可以指定新的命令来替代镜像设置中的这个默认命令,比如,ubuntu 镜像默认的 CMD/bin/bash,如果我们直接 docker run -it ubuntu 的话,会直接进入 bash。我们也可以在运行时指定运行别的命令,如 docker run -it ubuntu cat /etc/os-release。这就是用 cat /etc/os-release 命令替换了默认的 /bin/bash 命令了,输出了系统版本信息。

在指令格式上,一般推荐使用 exec 格式,这类格式在解析时会被解析为 JSON 数组,因此一定要使用双引号 ",而不要使用单引号。

如果使用 shell 格式的话,实际的命令会被包装为 sh -c 的参数的形式进行执行。比如:

CMD echo $HOME

在实际执行中,会将其变更为:

CMD [ "sh", "-c", "echo $HOME" ]

这就是为什么我们可以使用环境变量的原因,因为这些环境变量会被 shell 进行解析处理。

提到 CMD 就不得不提容器中应用在前台执行和后台执行的问题。这是初学者常出现的一个混淆。

Docker 不是虚拟机,容器中的应用都应该以前台执行,而不是像虚拟机、物理机里面那样,用 systemd 去启动后台服务,容器内没有后台服务的概念。

一些初学者将 CMD 写为:

CMD service nginx start

然后发现容器执行后就立即退出了。甚至在容器内去使用 systemctl 命令结果却发现根本执行不了。这就是因为没有搞明白前台、后台的概念,没有区分容器和虚拟机的差异,依旧在以传统虚拟机的角度去理解容器。

对于容器而言,其启动程序就是容器应用进程,容器就是为了主进程而存在的,主进程退出,容器就失去了存在的意义,从而退出,其它辅助进程不是它需要关心的东西。

而使用 service nginx start 命令,则是希望 upstart 来以后台守护进程形式启动 nginx 服务。而刚才说了 CMD service nginx start 会被理解为 CMD [ "sh", "-c", "service nginx start"],因此主进程实际上是 sh。那么当 service nginx start 命令结束后,sh 也就结束了,sh 作为主进程(pid=1)退出了,自然就会令容器退出。

正确的做法是直接执行 nginx 可执行文件(这样主进程pid=1就是nginx服务进程),并且要求以前台形式运行。比如:

CMD ["nginx", "-g", "daemon off;"]

ENTRYPOINT 入口点

ENTRYPOINT 的格式和 RUN 指令格式一样,分为 exec 格式和 shell 格式。

ENTRYPOINT 的目的和 CMD 一样,都是在指定容器启动程序及参数。ENTRYPOINT 在运行时也可以替代,不过比 CMD 要略显繁琐,需要通过 docker run 的参数 --entrypoint 来指定。

当指定了 ENTRYPOINT 后,CMD 的含义就发生了改变,不再是直接的运行其命令,而是将 CMD 的内容作为参数传给 ENTRYPOINT 指令,换句话说实际执行时,将变为:

<ENTRYPOINT> "<CMD>"

那么有了 CMD 后,为什么还要有 ENTRYPOINT 呢?这种 <ENTRYPOINT> "<CMD>" 有什么好处么?让我们来看几个场景。

  • 场景一:让镜像变成像命令一样使用

假设我们需要一个得知自己当前公网 IP 的镜像,那么可以先用 CMD 来实现:

FROM ubuntu:18.04
RUN apt-get update \
    && apt-get install -y curl \
    && rm -rf /var/lib/apt/lists/*
CMD [ "curl", "-s", "https://ip.cn" ]

假如我们使用 docker build -t myip . 来构建镜像的话,如果我们需要查询当前公网 IP,只需要执行:

$ docker run myip
当前 IP:61.148.226.66 来自:北京市 联通

嗯,这么看起来好像可以直接把镜像当做命令使用了,不过命令总有参数,如果我们希望加参数呢?比如从上面的 CMD 中可以看到实质的命令是 curl,那么如果我们希望显示 HTTP 头信息,就需要加上 -i 参数。那么我们可以直接加 -i 参数给 docker run myip 么?

$ docker run myip -i
docker: Error response from daemon: invalid header field value "oci runtime error: container_linux.go:247: starting container process caused \"exec: \\\"-i\\\": executable file not found in $PATH\"\n".

我们可以看到可执行文件找不到的报错,executable file not found。之前我们说过,跟在镜像名后面的是 command,运行时会替换 CMD 的默认值。因此这里的 -i 替换了原来的 CMD,而不是添加在原来的 curl -s https://ip.cn 后面。而 -i 根本不是命令,所以自然找不到。

那么如果我们希望加入 -i 这参数,我们就必须重新完整的输入这个命令:

$ docker run myip curl -s https://ip.cn -i

这显然不是很好的解决方案,而使用 ENTRYPOINT 就可以解决这个问题。现在我们重新用 ENTRYPOINT 来实现这个镜像:

FROM ubuntu:18.04
RUN apt-get update \
    && apt-get install -y curl \
    && rm -rf /var/lib/apt/lists/*
ENTRYPOINT [ "curl", "-s", "https://ip.cn" ]

这次我们再来尝试直接使用 docker run myip -i

$ docker run myip
当前 IP:61.148.226.66 来自:北京市 联通

$ docker run myip -i
HTTP/1.1 200 OK
Server: nginx/1.8.0
Date: Tue, 22 Nov 2016 05:12:40 GMT
Content-Type: text/html; charset=UTF-8
Vary: Accept-Encoding
X-Powered-By: PHP/5.6.24-1~dotdeb+7.1
X-Cache: MISS from cache-2
X-Cache-Lookup: MISS from cache-2:80
X-Cache: MISS from proxy-2_6
Transfer-Encoding: chunked
Via: 1.1 cache-2:80, 1.1 proxy-2_6:8006
Connection: keep-alive

当前 IP:61.148.226.66 来自:北京市 联通

可以看到,这次成功了。这是因为当存在 ENTRYPOINT 后,CMD 的内容将会作为参数传给 ENTRYPOINT,而这里 -i 就是新的 CMD,因此会作为参数传给 curl,从而达到了我们预期的效果。

  • 场景二:应用运行前的准备工作

启动容器就是启动主进程,但有些时候,启动主进程前,需要一些准备工作。

比如 mysql 类的数据库,可能需要一些数据库配置、初始化的工作,这些工作要在最终的 mysql 服务器运行之前解决。

此外,可能希望避免使用 root 用户去启动服务,从而提高安全性,而在启动服务前还需要以 root 身份执行一些必要的准备工作,最后切换到服务用户身份启动服务。或者除了服务外,其它命令依旧可以使用 root 身份执行,方便调试等。

这些准备工作是和容器 CMD 无关的,无论 CMD 为什么,都需要事先进行一个预处理的工作。这种情况下,可以写一个脚本,然后放入 ENTRYPOINT 中去执行,而这个脚本会将接到的参数(也就是 <CMD>)作为命令,在脚本最后执行。比如官方镜像 redis 中就是这么做的:

FROM alpine:3.4
...
RUN addgroup -S redis && adduser -S -G redis redis
...
ENTRYPOINT ["docker-entrypoint.sh"]

EXPOSE 6379
CMD [ "redis-server" ]

可以看到其中为了 redis 服务创建了 redis 用户,并在最后指定了 ENTRYPOINTdocker-entrypoint.sh 脚本。

#!/bin/sh
...
# allow the container to be started with `--user`
if [ "$1" = 'redis-server' -a "$(id -u)" = '0' ]; then
    chown -R redis .
    exec su-exec redis "$0" "$@"
fi

exec "$@"

该脚本的内容就是根据 CMD 的内容来判断,如果是 redis-server 的话,则切换到 redis 用户身份启动服务器,否则依旧使用 root 身份执行。比如:

$ docker run -it redis id
uid=0(root) gid=0(root) groups=0(root)

ENV 设置环境变量

格式有两种:

  • ENV <key> <value>
  • ENV <key1>=<value1> <key2>=<value2>...

这个指令很简单,就是设置环境变量而已,无论是后面的其它指令,如 RUN,还是运行时的应用,都可以直接使用这里定义的环境变量。

ENV VERSION=1.0 DEBUG=on \
    NAME="Happy Feet"

这个例子中演示了如何换行,以及对含有空格的值用双引号括起来的办法,这和 Shell 下的行为是一致的。

定义了环境变量,那么在后续的指令中,就可以使用这个环境变量。比如在官方 node 镜像 Dockerfile 中,就有类似这样的代码:

ENV NODE_VERSION 7.2.0

RUN curl -SLO "https://nodejs.org/dist/v$NODE_VERSION/node-v$NODE_VERSION-linux-x64.tar.xz" \
  && curl -SLO "https://nodejs.org/dist/v$NODE_VERSION/SHASUMS256.txt.asc" \
  && gpg --batch --decrypt --output SHASUMS256.txt SHASUMS256.txt.asc \
  && grep " node-v$NODE_VERSION-linux-x64.tar.xz\$" SHASUMS256.txt | sha256sum -c - \
  && tar -xJf "node-v$NODE_VERSION-linux-x64.tar.xz" -C /usr/local --strip-components=1 \
  && rm "node-v$NODE_VERSION-linux-x64.tar.xz" SHASUMS256.txt.asc SHASUMS256.txt \
  && ln -s /usr/local/bin/node /usr/local/bin/nodejs

在这里先定义了环境变量 NODE_VERSION,其后的 RUN 这层里,多次使用 $NODE_VERSION 来进行操作定制。可以看到,将来升级镜像构建版本的时候,只需要更新 7.2.0 即可,Dockerfile 构建维护变得更轻松了。

下列指令可以支持环境变量展开: ADDCOPYENVEXPOSEFROMLABELUSERWORKDIRVOLUMESTOPSIGNALONBUILDRUN

可以从这个指令列表里感觉到,环境变量可以使用的地方很多,很强大。通过环境变量,我们可以让一份 Dockerfile 制作更多的镜像,只需使用不同的环境变量即可。

ARG 构建参数

格式:ARG <参数名>[=<默认值>]

构建参数和 ENV 的效果一样,都是设置环境变量。所不同的是,ARG 所设置的构建环境的环境变量,在将来容器运行时是不会存在这些环境变量的。但是不要因此就使用 ARG 保存密码之类的信息,因为 docker history 还是可以看到所有值的。

Dockerfile 中的 ARG 指令是定义参数名称,以及定义其默认值。该默认值可以在构建命令 docker build 中用 --build-arg <参数名>=<值> 来覆盖。

在 1.13 之前的版本,要求 --build-arg 中的参数名,必须在 Dockerfile 中用 ARG 定义过了,换句话说,就是 --build-arg 指定的参数,必须在 Dockerfile 中使用了。如果对应参数没有被使用,则会报错退出构建。从 1.13 开始,这种严格的限制被放开,不再报错退出,而是显示警告信息,并继续构建。这对于使用 CI 系统,用同样的构建流程构建不同的 Dockerfile 的时候比较有帮助,避免构建命令必须根据每个 Dockerfile 的内容修改。

VOLUME 定义匿名卷

格式为:

  • VOLUME ["<路径1>", "<路径2>"...]
  • VOLUME <路径>

之前我们说过,容器运行时应该尽量保持容器存储层不发生写操作,对于数据库类需要保存动态数据的应用,其数据库文件应该保存于卷(volume)中,后面的章节我们会进一步介绍 Docker 卷的概念。为了防止运行时用户忘记将动态文件所保存目录挂载为卷,在 Dockerfile 中,我们可以事先指定某些目录挂载为匿名卷,这样在运行时如果用户不指定挂载,其应用也可以正常运行,不会向容器存储层写入大量数据。

VOLUME /data

这里的 /data 目录就会在运行时自动挂载为匿名卷,任何向 /data 中写入的信息都不会记录进容器存储层,从而保证了容器存储层的无状态化。当然,运行时可以覆盖这个挂载设置。比如:

docker run -d -v mydata:/data xxxx

在这行命令中,就使用了 mydata 这个命名卷挂载到了 /data 这个位置,替代了 Dockerfile 中定义的匿名卷的挂载配置。

EXPOSE 声明端口

格式为 EXPOSE <端口1> [<端口2>...]

EXPOSE 指令是声明运行时容器提供服务端口,这只是一个声明,在运行时并不会因为这个声明应用就会开启这个端口的服务。在 Dockerfile 中写入这样的声明有两个好处,一个是帮助镜像使用者理解这个镜像服务的守护端口,以方便配置映射;另一个用处则是在运行时使用随机端口映射时,也就是 docker run -P 时,会自动随机映射 EXPOSE 的端口。

要将 EXPOSE 和在运行时使用 -p <宿主端口>:<容器端口> 区分开来。-p,是映射宿主端口和容器端口,换句话说,就是将容器的对应端口服务公开给外界访问,而 EXPOSE 仅仅是声明容器打算使用什么端口而已,并不会自动在宿主进行端口映射。

WORKDIR 指定工作目录

格式为 WORKDIR <工作目录路径>

使用 WORKDIR 指令可以来指定工作目录(或者称为当前目录),以后各层的当前目录就被改为指定的目录,如该目录不存在,WORKDIR 会帮你建立目录。

之前提到一些初学者常犯的错误是把 Dockerfile 等同于 Shell 脚本来书写,这种错误的理解还可能会导致出现下面这样的错误:

RUN cd /app
RUN echo "hello" > world.txt

如果将这个 Dockerfile 进行构建镜像运行后,会发现找不到 /app/world.txt 文件,或者其内容不是 hello。原因其实很简单,在 Shell 中,连续两行是同一个进程执行环境,因此前一个命令修改的内存状态,会直接影响后一个命令;而在 Dockerfile 中,这两行 RUN 命令的执行环境根本不同,是两个完全不同的容器。这就是对 Dockerfile 构建分层存储的概念不了解所导致的错误。

之前说过每一个 RUN 都是启动一个容器、执行命令、然后提交存储层文件变更。第一层 RUN cd /app 的执行仅仅是当前进程的工作目录变更,一个内存上的变化而已,其结果不会造成任何文件变更。而到第二层的时候,启动的是一个全新的容器,跟第一层的容器更完全没关系,自然不可能继承前一层构建过程中的内存变化。

因此如果需要改变以后各层的工作目录的位置,那么应该使用 WORKDIR 指令。

USER 指定当前用户

格式:USER <用户名>[:<用户组>]

USER 指令和 WORKDIR 相似,都是改变环境状态并影响以后的层。WORKDIR 是改变工作目录,USER 则是改变之后层的执行 RUN, CMD 以及 ENTRYPOINT 这类命令的身份。

当然,和 WORKDIR 一样,USER 只是帮助你切换到指定用户而已,这个用户必须是事先建立好的,否则无法切换。

RUN groupadd -r redis && useradd -r -g redis redis
USER redis
RUN [ "redis-server" ]

如果以 root 执行的脚本,在执行期间希望改变身份,比如希望以某个已经建立好的用户来运行某个服务进程,不要使用 su 或者 sudo,这些都需要比较麻烦的配置,而且在 TTY 缺失的环境下经常出错。建议使用 gosu

# 建立 redis 用户,并使用 gosu 换另一个用户执行命令
RUN groupadd -r redis && useradd -r -g redis redis
# 下载 gosu
RUN wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/1.7/gosu-amd64" \
    && chmod +x /usr/local/bin/gosu \
    && gosu nobody true
# 设置 CMD,并以另外的用户执行
CMD [ "exec", "gosu", "redis", "redis-server" ]

HEALTHCHECK 健康检查

格式:

  • HEALTHCHECK [选项] CMD <命令>:设置检查容器健康状况的命令
  • HEALTHCHECK NONE:如果基础镜像有健康检查指令,使用这行可以屏蔽掉其健康检查指令

HEALTHCHECK 指令是告诉 Docker 应该如何进行判断容器的状态是否正常,这是 Docker 1.12 引入的新指令。

在没有 HEALTHCHECK 指令前,Docker 引擎只可以通过容器内主进程是否退出来判断容器是否状态异常。很多情况下这没问题,但是如果程序进入死锁状态,或者死循环状态,应用进程并不退出,但是该容器已经无法提供服务了。在 1.12 以前,Docker 不会检测到容器的这种状态,从而不会重新调度,导致可能会有部分容器已经无法提供服务了却还在接受用户请求。

而自 1.12 之后,Docker 提供了 HEALTHCHECK 指令,通过该指令指定一行命令,用这行命令来判断容器主进程的服务状态是否还正常,从而比较真实的反应容器实际状态。

当在一个镜像指定了 HEALTHCHECK 指令后,用其启动容器,初始状态会为 starting,在 HEALTHCHECK 指令检查成功后变为 healthy,如果连续一定次数失败,则会变为 unhealthy

HEALTHCHECK 支持下列选项:

  • --interval=<间隔>:两次健康检查的间隔,默认为 30 秒;
  • --timeout=<时长>:健康检查命令运行超时时间,如果超过这个时间,本次健康检查就被视为失败,默认 30 秒;
  • --retries=<次数>:当连续失败指定次数后,则将容器状态视为 unhealthy,默认 3 次。

CMD, ENTRYPOINT 一样,HEALTHCHECK 只可以出现一次,如果写了多个,只有最后一个生效。

HEALTHCHECK [选项] CMD 后面的命令,格式和 ENTRYPOINT 一样,分为 shell 格式,和 exec 格式。命令的返回值决定了该次健康检查的成功与否:0:成功;1:失败;2:保留,不要使用这个值。

假设我们有个镜像是个最简单的 Web 服务,我们希望增加健康检查来判断其 Web 服务是否在正常工作,我们可以用 curl 来帮助判断,其 DockerfileHEALTHCHECK 可以这么写:

FROM nginx
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
HEALTHCHECK --interval=5s --timeout=3s \
  CMD curl -fs http://localhost/ || exit 1

这里我们设置了每 5 秒检查一次(这里为了试验所以间隔非常短,实际应该相对较长),如果健康检查命令超过 3 秒没响应就视为失败,并且使用 curl -fs http://localhost/ || exit 1 作为健康检查命令。

使用 docker build 来构建这个镜像:

$ docker build -t myweb:v1 .

构建好了后,我们启动一个容器:

$ docker run -d --name web -p 80:80 myweb:v1

当运行该镜像后,可以通过 docker container ls 看到最初的状态为 (health: starting)

$ docker container ls
CONTAINER ID        IMAGE               COMMAND                  CREATED             STATUS                            PORTS               NAMES
03e28eb00bd0        myweb:v1            "nginx -g 'daemon off"   3 seconds ago       Up 2 seconds (health: starting)   80/tcp, 443/tcp     web

在等待几秒钟后,再次 docker container ls,就会看到健康状态变化为了 (healthy)

$ docker container ls
CONTAINER ID        IMAGE               COMMAND                  CREATED             STATUS                    PORTS               NAMES
03e28eb00bd0        myweb:v1            "nginx -g 'daemon off"   18 seconds ago      Up 16 seconds (healthy)   80/tcp, 443/tcp     web

如果健康检查连续失败超过了重试次数,状态就会变为 (unhealthy)

为了帮助排障,健康检查命令的输出(包括 stdout 以及 stderr)都会被存储于健康状态里,可以用 docker inspect 来查看。

$ docker inspect --format '{{json .State.Health}}' web | python -m json.tool
{
    "FailingStreak": 0,
    "Log": [
        {
            "End": "2016-11-25T14:35:37.940957051Z",
            "ExitCode": 0,
            "Output": "<!DOCTYPE html>\n<html>\n<head>\n<title>Welcome to nginx!</title>\n<style>\n    body {\n        width: 35em;\n        margin: 0 auto;\n        font-family: Tahoma, Verdana, Arial, sans-serif;\n    }\n</style>\n</head>\n<body>\n<h1>Welcome to nginx!</h1>\n<p>If you see this page, the nginx web server is successfully installed and\nworking. Further configuration is required.</p>\n\n<p>For online documentation and support please refer to\n<a href=\"http://nginx.org/\">nginx.org</a>.<br/>\nCommercial support is available at\n<a href=\"http://nginx.com/\">nginx.com</a>.</p>\n\n<p><em>Thank you for using nginx.</em></p>\n</body>\n</html>\n",
            "Start": "2016-11-25T14:35:37.780192565Z"
        }
    ],
    "Status": "healthy"
}

ONBUILD 为他人做嫁衣裳

格式:ONBUILD <其它指令>

ONBUILD 是一个特殊的指令,它后面跟的是其它指令,比如 RUN, COPY 等,而这些指令,在当前镜像构建时并不会被执行。只有当以当前镜像为基础镜像,去构建下一级镜像的时候才会被执行。

Dockerfile 中的其它指令都是为了定制当前镜像而准备的,唯有 ONBUILD 是为了帮助别人定制自己而准备的。

假设我们要制作 Node.js 所写的应用的镜像。我们都知道 Node.js 使用 npm 进行包管理,所有依赖、配置、启动信息等会放到 package.json 文件里。在拿到程序代码后,需要先进行 npm install 才可以获得所有需要的依赖。然后就可以通过 npm start 来启动应用。因此,一般来说会这样写 Dockerfile

FROM node:slim
RUN mkdir /app
WORKDIR /app
COPY ./package.json /app
RUN [ "npm", "install" ]
COPY . /app/
CMD [ "npm", "start" ]

把这个 Dockerfile 放到 Node.js 项目的根目录,构建好镜像后,就可以直接拿来启动容器运行。但是如果我们还有第二个 Node.js 项目也差不多呢?好吧,那就再把这个 Dockerfile 复制到第二个项目里。那如果有第三个项目呢?再复制么?文件的副本越多,版本控制就越困难,让我们继续看这样的场景维护的问题。

如果第一个 Node.js 项目在开发过程中,发现这个 Dockerfile 里存在问题,比如敲错字了、或者需要安装额外的包,然后开发人员修复了这个 Dockerfile,再次构建,问题解决。第一个项目没问题了,但是第二个项目呢?虽然最初 Dockerfile 是复制、粘贴自第一个项目的,但是并不会因为第一个项目修复了他们的 Dockerfile,而第二个项目的 Dockerfile 就会被自动修复。

那么我们可不可以做一个基础镜像,然后各个项目使用这个基础镜像呢?这样基础镜像更新,各个项目不用同步 Dockerfile 的变化,重新构建后就继承了基础镜像的更新?好吧,可以,让我们看看这样的结果。那么上面的这个 Dockerfile 就会变为:

FROM node:slim
RUN mkdir /app
WORKDIR /app
CMD [ "npm", "start" ]

这里我们把项目相关的构建指令拿出来,放到子项目里去。假设这个基础镜像的名字为 my-node 的话,各个项目内的自己的 Dockerfile 就变为:

FROM my-node
COPY ./package.json /app
RUN [ "npm", "install" ]
COPY . /app/

基础镜像变化后,各个项目都用这个 Dockerfile 重新构建镜像,会继承基础镜像的更新。

那么,问题解决了么?没有。准确说,只解决了一半。如果这个 Dockerfile 里面有些东西需要调整呢?比如 npm install 都需要加一些参数,那怎么办?这一行 RUN 是不可能放入基础镜像的,因为涉及到了当前项目的 ./package.json,难道又要一个个修改么?所以说,这样制作基础镜像,只解决了原来的 Dockerfile 的前4条指令的变化问题,而后面三条指令的变化则完全没办法处理。

ONBUILD 可以解决这个问题。让我们用 ONBUILD 重新写一下基础镜像的 Dockerfile:

FROM node:slim
RUN mkdir /app
WORKDIR /app
ONBUILD COPY ./package.json /app
ONBUILD RUN [ "npm", "install" ]
ONBUILD COPY . /app/
CMD [ "npm", "start" ]

这次我们回到原始的 Dockerfile,但是这次将项目相关的指令加上 ONBUILD,这样在构建基础镜像的时候,这三行并不会被执行。然后各个项目的 Dockerfile 就变成了简单地:

FROM my-node

是的,只有这么一行。当在各个项目目录中,用这个只有一行的 Dockerfile 构建镜像时,之前基础镜像的那三行 ONBUILD 就会开始执行,成功的将当前项目的代码复制进镜像、并且针对本项目执行 npm install,生成应用镜像。

Dockerfile 定制 LAMP

需求分析

  • Linux ubuntu 18.04
  • Apache 2
  • MySQL 5.7
  • PHP 7.2
  • SSH
  • 系统必备工具(ifconfig+netstat+arp+rarp+route+ping+vim+curl+wget+python等)
  • 部署相应www站点(网站添加普通用户&&权限配置、部署相应数据库)

定制脚本

  • Dockerfile
# Basic Image
FROM ubuntu:18.04

# 设置环境变量->构建时非交互式终端(处理'Configuring tzdata'交互输入、在Dockerfile中安装deb软件包时,某些软件将tzdata作为依赖项安装。tzdata会以交互方式提醒用户选择使用位置。)
ENV DEBIAN_FRONTEND=noninteractive

# 设置系统root密码
# 配置并更新软件源
# 安装SSH并支持root ssh
# 搭建LAMP -> Linux + apache2 + Mysql5.7 + PHP7.2 && PHP7.2扩展
# 安装系统常用工具->net-tools(ifconfig/netstat/arp/rarp/route/ipmaddr/iptunnel/mii-tool/nameif/plipconfig/slattach)+ping+vim+curl
# 清除apt缓存 -> /var/lib/apt/lists/*
RUN set -xe \
    && echo "root:root" | chpasswd \
    && cp /etc/apt/sources.list /etc/apt/sources.list.backup \
    && sed -i 's/http:\/\/.*\/ubuntu\//http:\/\/mirrors.tuna.tsinghua.edu.cn\/ubuntu\//g' /etc/apt/sources.list \
    && apt update -y \
    && apt install -y ssh \
    && cp /etc/ssh/sshd_config /etc/ssh/sshd_config.backup \
    && sed -ri 's/^#*\s*PermitRootLogin\s*.*/PermitRootLogin yes/g' /etc/ssh/sshd_config \
    && apt install -y apache2 mysql-server-5.7 php7.2 php7.2-gd php7.2-mysql \
    && apt install -y net-tools inetutils-ping vim curl \
    && rm -rf /var/lib/apt/lists/*

# 上下文内容:www/html/* + admin1strat0r.sql + sql.sh -> 复制上下文内容到容器/tmp/中
COPY ./www ./admin1strat0r.sql ./sql.sh ./start.sh /tmp/

# 部署已制作的WWW站点 + 去掉apache2的列目录漏洞(Options Indexes) + 网站添加普通用户&&权限配置
# 设置Mysql root密码 + 执行SQL脚本创建数据库
# 删除缓存(上下文)文件
RUN set -xe \
    && rm -rf /var/www/html \
    && mv /tmp/html /var/www/html \
    && cp /etc/apache2/apache2.conf /etc/apache2/apache2.conf.backup \
    && sed -i 's/Options Indexes FollowSymLinks/Options FollowSymLinks/g' /etc/apache2/apache2.conf \
    && useradd Qftm -m \
    && echo "Qftm:Qftm" | chpasswd \
    && usermod -G Qftm www-data \
    && chown -R Qftm:Qftm /var/www/ \
    && chmod -R 750 /var/www/ \
    && chmod -R 770 /var/www/html/upload_file/ \
    && service mysql restart \
    && mysql -uroot -e "update mysql.user set plugin='mysql_native_password'" \
    && service mysql restart \
    && mysqladmin -uroot password "root" \
    && mkdir /etc/my_init.d/ \
    && mv /tmp/admin1strat0r.sql /tmp/sql.sh /tmp/start.sh -t /etc/my_init.d/ \
    && chmod -R 770 /etc/my_init.d/ \
    && sh /etc/my_init.d/sql.sh \
    && rm -rf /tmp/*

# 服务启动脚本
ENTRYPOINT sh /etc/my_init.d/start.sh

# 声明开放端口
EXPOSE 22 80 3306
  • start.sh
#!/bin/bash

# 启动 SSH
service ssh start

# 启动 Apache2
service apache2 start

# 启动 MySQL
service mysql start

while true; do echo Web Server Runing ++++++; sleep 1; done

容器测试

  • 构建镜像
root@rose:~/mydocker# docker build -t webimage:v1.3 .
or
root@rose:~/mydocker# docker build -t webimage:v1.3 . --no-cache

image-20200411013404649

  • 启动容器
root@rose:~# docker run -d -p 80:80 webimage:v1.3
  • 查看容器信息
docker ps

image-20200411013729652

  • 查看容器状态
root@rose:~# docker logs -f --tail=0 e6618707fda5

image-20200411014501996

  • 查看容器内部进程
root@rose:~# docker top e6618707fda5
  • 访问网站

测试网站是否可以正常访问

image-20200411014817997

  • 登录网站

测试网站数据库是否正常连接

image-20200411015030829

Docker 容器操作

在前面有了docker pull下来的镜像就可以实例化创建容器了。

启动创建容器

启动容器有两种方式:一种是基于镜像新建一个容器并启动;另一种是将在终止状态(stopped)下的容器重新启动。

新建容器启动

  • docker run

docker run :创建一个新的容器并运行一个命令

语法

docker run [OPTIONS] IMAGE [COMMAND] [ARG...]

OPTIONS说明

-a stdin: 指定标准输入输出内容类型,可选 STDIN/STDOUT/STDERR 三项;
-d: 后台运行容器,并返回容器ID;
-i: 以交互模式运行容器,通常与 -t 同时使用;
-P: 随机端口映射,容器内部端口随机映射到主机的高端口
-p: 指定端口映射,格式为:主机(宿主)端口:容器端口
-t: 为容器重新分配一个伪输入终端,通常与 -i 同时使用;
--name="nginx-lb": 为容器指定一个名称;
--dns 8.8.8.8: 指定容器使用的DNS服务器,默认和宿主一致;
--dns-search example.com: 指定容器DNS搜索域名,默认和宿主一致;
-h "mars": 指定容器的hostname;
-e username="ritchie": 设置环境变量;
--env-file=[]: 从指定文件读入环境变量;
--cpuset="0-2" or --cpuset="0,1,2": 绑定容器到指定CPU运行;
-m :设置容器使用内存最大值;
--net="bridge": 指定容器的网络连接类型,支持 bridge/host/none/container: 四种类型;
--link=[]: 添加链接到另一个容器;
--expose=[]: 开放一个端口或一组端口;
--volume , -v: 绑定一个卷
  • 使用非root用户运行docker

普通用户运行docker需要使用sudo,为了不这样可以将当前普通用户加到docker组里面,然后重启docker与系统,就可以了。

$ sudo groupadd docker  #添加docker组,有的话就不用添加
$ sudo gpasswd -a ${USER} docker  #将普通用户添加到docker组里
$ sudo service docker restart  #重启docker服务
$ sudo reboot   #重启系统使账户修改生效

非交互式容器

  • 创建命令
docker run IMAGE [COMMAND] [ARG...]  #启动一个容器,启动一次执行一次

例如,下面的命令输出一个 “Hello World”,之后终止容器。

root@rose:~# docker run ubuntu:latest echo "Hello world"
Hello world
  • 查看容器信息
docker ps -a -l  #查看容器,不加参数查看正在运行的容器,-a显示所有容器包括未运行的,-l显示最新创建的容器

image-20200406222848432

  • 查看容器详细信息
docker container inspect 容器ID或容器名
or
docker inspect 容器ID或容器名

image-20200406223049123

交互式容器

  • 创建命令
docker run -i -t IMAGE /bin/bash   #启动一个交互式的tty终端

docker run -i -t --name=自定义名 IMAGE /bin/bash #启动一个交互式的tty终端(自定义容器名)

例如创建一个Ubuntu的交互式bash容器

root@rose:~# docker run -it ubuntu:latest /bin/bash
root@6329cbb6c55d:/# whoami
root
root@6329cbb6c55d:/# pwd
/
root@6329cbb6c55d:/#
  • 退出终止交互式容器
exit  #退出并终止容器
or
Ctrl+d #退出并终止容器

root@6329cbb6c55d:/# exit
exit
root@rose:~#
  • 查看容器

image-20200406224831476

启动已终止容器

可以利用docker start来启动一个已经停止的容器

  • 启动命令
docker start -i 容器ID或容器名  #重新启动已经停止的容器

例如重新启动上面已经停止的一个交互式容器:zen_montalcini

root@rose:~# docker start -i zen_montalcini
root@6329cbb6c55d:/# id
uid=0(root) gid=0(root) groups=0(root)
root@6329cbb6c55d:/#
  • 退出终止容器
exit
or
Ctrl+d

守护式容器

守护式容器的出现是为了解决容器运行命令之后终止的问题。

  • 什么是守护式容器
能够长期运行
没有交互式会话  #后台运行
适合运行应用程序和服务

更多的时候,需要让 Docker 在后台运行而不是直接把执行命令的结果输出在当前宿主机下。此时,可以通过添加 -d 参数来实现。

交互式容器转入后台

  • 启动交互式终端转入后台
docker run -i -t IMAGE /bin/bash

Ctrl+P+Q   #退出容器,将容器转入到后台运行等同于参数-d

or

docker run -i -t -d IMAGE /bin/bash
  • 查看运行中的容器
docker ps

image-20200406234007447

  • 附加到运行中的容器
docker attach 容器名  #重新进入运行中的容器
root@rose:~# docker attach eloquent_johnson
root@ad50a2fb6fe3:/# id
uid=0(root) gid=0(root) groups=0(root)
root@ad50a2fb6fe3:/#
  • 退出终止容器(attach下
exit  #退出并停止容器
or
Ctrl+d #退出并停止容器

非交互式容器转入后台

  • 启动守护式容器
docker run -d IMAGE [COMMAND] [ARG...]  #-d表示run命令以后台方式启动一个容器并执行命令,当命令运行结束后容器还是会终止的

例如运行一个容器执行一段脚本

docker run -d ubuntu:latest /bin/sh -c "while true; do echo hello world; sleep 1; done"

执行信息不在终端直接显示,而是转入后台

image-20200407000128538

  • 查看运行容器日志

由于容器转入后台则看不到其相关输出信息,需要通过容器的运行日志来查看

docker logs [-f] [-t] [--tail] 容器名
    -f --follows=true|false 默认为false   #一直追踪日志的变化并返回结果
    -t --timestamps=true|false 默认为false  #返回日志的时间戳
    --tail=“all”  #指定结尾处返回日志的数量,如果不指定则返回所有日志。tail=0显示最新的日志

image-20200407000149865

  • 查看运行容器内的进程
docker top 容器名/ID
  • 在运行中的容器内启动新进程(附加进程)
docker exec [-d] [-i] [-t] 容器名 [COMMAND] [ARG...]

附加进程进入交互式bash

root@rose:~# docker exec -it 216c51966f7a95c0b /bin/bash
root@216c51966f7a:/# id
uid=0(root) gid=0(root) groups=0(root)
root@216c51966f7a:/#

退出交互式bash(exec下

exit  #和attach不同,exec不会终止容器,只是退出
  • 停止守护式容器
docker stop 容器名  #给容器发送一个停止信号等待容器停止(停止之后会返回容器的名字/ID)
docker kill 容器名  #直接停止容器(停止之后会返回容器的名字/ID)

image-20200407000620845

导出与导入

对于已经创建的容器是可以以归档的形式导出的(相当于导出此时容器的一个快照),同时也可以导入之前导出的容器作为一个新的镜像。

导出容器快照

导出本地某个容器,可以使用 docker export 命令

root@rose:~# docker export 56b72bf3df65 > ubuntu-latest.tar
root@rose:~# ls
snap  ubuntu-latest.tar
root@rose:~#

导入容器快照

使用 docker import 导入之前导出的容器快照作为一个新的镜像。新的镜像可以重新创建容器,创建的容器和原来导出的容器一样。

root@rose:~# cat ubuntu-latest.tar | docker import - new/ubuntu:v1.0
sha256:d3c314a215b18e1a8b24113e3fda6d76c73dcad86ac3d2127ed71afe62465915
root@rose:~# docker images
REPOSITORY    TAG           IMAGE ID       CREATED             SIZE
new/ubuntu    v1.0        d3c314a215b1    26 seconds ago       64.2MB
ubuntu        18.04       4e5021d210f6     2 weeks ago         64.2MB
ubuntu        latest      4e5021d210f6     2 weeks ago         64.2MB
root@rose:~#docker -it new/ubuntu:v1.0 /bin/bash

此外,也可以通过指定 URL 或者某个目录来导入,例如

docker import http://example.com/exampleimage.tgz example/imagerepo

注:用户既可以使用 docker load 来导入镜像存储文件到本地镜像库,也可以使用 docker import 来导入一个容器快照到本地镜像库。这两者的区别在于容器快照文件将丢弃所有的历史记录和元数据信息(即仅保存容器当时的快照状态),而镜像存储文件将保存完整记录,体积也要大。此外,从容器快照文件导入时可以重新指定标签等元数据信息。

删除容器

  • 删除一个或多个已终止容器
docker rm 容器ID或容器名       #只能删除已经停止的容器
or
docker container rm 容器ID或容器名

例如删除前面创建的那个非交互式容器:competent_brown

image-20200406225641419

  • 删除所有已终止容器

查看终止的容器

docker ps -a 
or
docker container ls -a 

清理所有已经终止的容器

docker container prune

网络管理

端口映射

容器中可以运行一些网络应用,要让外部也可以访问这些应用,可以通过 -P-p 参数来指定端口映射。

  • 设置容器的端口映射
docker run [-P][-p]

-P ,--publish-all=true|false 默认为false #-P将容器内部开放的网络端口映射到宿主机的一个随机端口上
    docker run -P -i -t ubuntu /bin/bash

-p, publish=[]                #-p映射容器指定的一些端口
    containerPort
        docker run -p 80 -i -t ubuntu /bin/bash
    hostPort:containerPort
        docker run -p 8080:80 -i -t ubuntu /bin/bash
    ip::containerPort
        docker run -p 127.0.0.1::80 -i -t ubuntu /bin/bash
    ip:hostPort:containerPort
        docker run -p 127.0.0.1:8080:80 -i -t ubuntu /bin/bash
  • 不同参数说明

当使用 -P 标记时,Docker 会随机映射一个 49000~49900 的端口到内部容器开放的网络端口。

使用 docker container ls -adocker ps -a可以看到,本地主机的 49155 被映射到了容器的 5000 端口。此时访问本机的 49155 端口即可访问容器内 web 应用提供的界面。

当使用-p 时则可以指定要映射的端口,并且,在一个指定的宿主机端口上只可以绑定一个容器。支持的格式有 containerPort | ip:hostPort:containerPort | ip::containerPort | hostPort:containerPort

containerPort  #映射所有接口地址的任意端口
#指定容器80端口映射到宿主机的随意一个端口上,作用于宿主机所有的接口地址
    docker run -p 80 -i -t ubuntu /bin/bash

hostPort:containerPort  #映射所有接口地址的指定端口
#指定容器80端口映射到宿主机的8080端口上,作用于宿主机所有的接口地址
    docker run -p 8080:80 -i -t ubuntu /bin/bash

ip::containerPort  #映射到指定地址的任意端口
#指定容器80端口映射到宿主机的随意一个端口上,作用于指定的宿主机地址:127.0.0.1
    docker run -p 127.0.0.1::80 -i -t ubuntu /bin/bash

ip:hostPort:containerPort  #映射到指定地址的指定端口
#指定容器80端口映射到宿主机的8080端口上,作用于指定的宿主机地址:127.0.0.1
    docker run -p 127.0.0.1:8080:80 -i -t ubuntu /bin/bash
  • 查看端口映射配置

使用 docker portdocker psdocker container ls来查看当前映射的端口配置,也可以查看到绑定的地址

docker port 容器名/ID
docker ps
docker container ls

image-20200407115414433

  • 测试端口映射情况

在容器内安装启动apache服务

root@a30200ca3b8a:/# apt update
root@a30200ca3b8a:/# apt install apache2
root@a30200ca3b8a:/# service apache2 restart

访问容器的apache服务,在宿主机上访问http://127.0.0.1:8080相当于访问了http://容器IP:80

curl http://127.0.0.1:8080

image-20200407120522654

查看容器运行的进程信息

root@rose:~# docker top a30200ca3b8a

数据管理

在容器中管理数据主要有两种方式:

  • 数据卷(Volumes)
  • 挂载主机目录 (Bind mounts)

数据卷

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

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

注意:数据卷 的使用,类似于 Linux 下对目录或文件进行 mount,镜像中的被指定为挂载点的目录中的文件会隐藏掉,能显示看的是挂载的 数据卷

  • 创建一个数据卷
$ docker volume create my-vol

查看所有的 数据卷

$ docker volume ls

local               my-vol

在主机里使用以下命令可以查看指定 数据卷 的信息

$ docker volume inspect my-vol
[
    {
        "Driver": "local",
        "Labels": {},
        "Mountpoint": "/var/lib/docker/volumes/my-vol/_data",
        "Name": "my-vol",
        "Options": {},
        "Scope": "local"
    }
]
  • 启动一个挂载数据卷的容器

在用 docker run 命令的时候,使用 --mount 标记来将 数据卷 挂载到容器里。在一次 docker run 中可以挂载多个 数据卷

下面创建一个名为 web 的容器,并加载一个 数据卷 到容器的 /webapp 目录。

$ docker run -d -P \
    --name web \
    # -v my-vol:/wepapp \
    --mount source=my-vol,target=/webapp \
    training/webapp \
    python app.py
  • 查看数据卷的具体信息

在主机里使用以下命令可以查看 web 容器的信息

$ docker inspect web

数据卷 信息在 “Mounts” Key 下面

"Mounts": [
    {
        "Type": "volume",
        "Name": "my-vol",
        "Source": "/var/lib/docker/volumes/my-vol/_data",
        "Destination": "/app",
        "Driver": "local",
        "Mode": "",
        "RW": true,
        "Propagation": ""
    }
],
  • 删除数据卷
$ docker volume rm my-vol

数据卷 是被设计用来持久化数据的,它的生命周期独立于容器,Docker 不会在容器被删除后自动删除 数据卷,并且也不存在垃圾回收这样的机制来处理没有任何容器引用的 数据卷。如果需要在删除容器的同时移除数据卷。可以在删除容器的时候使用 docker rm -v 这个命令。

无主的数据卷可能会占据很多空间,要清理请使用以下命令

$ docker volume prune

挂载主机目录

  • 挂载一个主机目录作为数据卷

使用 --mount 标记可以指定挂载一个本地主机的目录到容器中去。

$ docker run -d -P \
    --name web \
    # -v /src/webapp:/opt/webapp \
    --mount type=bind,source=/src/webapp,target=/opt/webapp \
    training/webapp \
    python app.py

上面的命令加载主机的 /src/webapp 目录到容器的 /opt/webapp目录。这个功能在进行测试的时候十分方便,比如用户可以放置一些程序到本地目录中,来查看容器是否正常工作。本地目录的路径必须是绝对路径,以前使用 -v 参数时如果本地目录不存在 Docker 会自动为你创建一个文件夹,现在使用 --mount 参数时如果本地目录不存在,Docker 会报错。

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

$ docker run -d -P \
    --name web \
    # -v /src/webapp:/opt/webapp:ro \
    --mount type=bind,source=/src/webapp,target=/opt/webapp,readonly \
    training/webapp \
    python app.py

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

/opt/webapp # touch new.txt
touch: new.txt: Read-only file system
  • 查看数据卷的具体信息

在主机里使用以下命令可以查看 web 容器的信息

$ docker inspect web

挂载主机目录 的配置信息在 “Mounts” Key 下面

"Mounts": [
    {
        "Type": "bind",
        "Source": "/src/webapp",
        "Destination": "/opt/webapp",
        "Mode": "",
        "RW": true,
        "Propagation": "rprivate"
    }
],
  • 挂载一个本地主机文件作为数据卷

--mount 标记也可以从主机挂载单个文件到容器中

$ docker run --rm -it \
   # -v $HOME/.bash_history:/root/.bash_history \
   --mount type=bind,source=$HOME/.bash_history,target=/root/.bash_history \
   ubuntu:18.04 \
   bash

root@2affd44b4667:/# history
1  ls
2  diskutil list

这样就可以记录在容器输入过的命令了。

Docker 仓库管理

仓库(Repository)是集中存放镜像的地方。

一个容易混淆的概念是注册服务器(Registry)。实际上注册服务器是管理仓库的具体服务器,每个服务器上可以有多个仓库,而每个仓库下面有多个镜像。从这方面来说,仓库可以被认为是一个具体的项目或目录。例如对于仓库地址 dl.dockerpool.com/username/ubuntu 来说,dl.dockerpool.com 是注册服务器地址,username/ubuntu 是仓库名。

大部分时候,并不需要严格区分这两者的概念。

Docker Hub

目前 Docker 官方维护了一个公共仓库 Docker Hub,其中已经包括了数量超过 15,000 的镜像。大部分需求都可以通过在 Docker Hub 中直接下载镜像来实现。

注册

你可以在 https://hub.docker.com 免费注册一个 Docker 账号。

登录

可以通过执行 docker login 命令交互式的输入用户名及密码来完成在命令行界面登录 Docker Hub。

可以通过 docker logout 退出登录。

拉取镜像

可以通过 docker search 命令来查找官方仓库中的镜像,并利用 docker pull 命令来将它下载到本地。

推送镜像

用户也可以在登录后通过 docker push 命令来将自己的镜像推送到 Docker Hub。

以下命令中的 username 请替换为你的 Docker 账号用户名。

$ docker tag ubuntu:18.04 username/ubuntu:18.04
$ docker image ls
REPOSITORY       TAG       IMAGE ID            CREATED             SIZE
ubuntu           18.04    275d79972a86        6 days ago          94.6MB
username/ubuntu  18.04    275d79972a86        6 days ago          94.6MB
$ docker push username/ubuntu:18.04
$ docker search username
NAME           DESCRIPTION      STARS     OFFICIAL    AUTOMATED
username/ubunt

完成上述操作之后查看dockerhub中自己的仓库就可以看到刚刚推送的镜像。

Docker 底层实现

Docker 容器和 LXC 容器很相似,所提供的安全特性也差不多。

LXC 为 Linux Container 的简写,内部采用了Linux内核Namespace和Cgroups两个特性,可以提供轻量级的虚拟化,以便隔离进程和资源,而且不需要提供指令解释机制以及全虚拟化的其他复杂性。

Docker基于Linux kernel的Namespace,CGroups,UnionFileSystem等技术封装成一种自定义的容器格式,用于提供一整套虚拟运行环境。

使用容器可以有效地将由单个操作系统管理的资源划分到孤立的组中,以更好地在孤立的组之间平衡有冲突的资源使用需求。

命名空间

命名空间(NamespaceLinux man)是 Linux 内核一个强大的特性,从Linux内核2.6.27版本开始已经支持LXC。每个容器都有自己单独的命名空间,运行在其中的应用都像是在独立的操作系统中运行一样。命名空间保证了容器之间彼此互不影响,提供了对UTS、IPC、mount、PID、network、User等的隔离机制。

  • pid 命名空间

不同用户的进程就是通过 pid 命名空间隔离开的,且不同命名空间中可以有相同 pid。所有的 LXC 进程在 Docker 中的父进程为 Docker 进程,每个 LXC 进程具有不同的命名空间。同时由于允许嵌套,因此可以很方便的实现嵌套的 Docker 容器。

  • net 命名空间

有了 pid 命名空间,每个命名空间中的 pid 能够相互隔离,但是网络端口还是共享 host 的端口。网络隔离是通过 net 命名空间实现的, 每个 net 命名空间有独立的 网络设备,IP 地址,路由表,/proc/net 目录。这样每个容器的网络就能隔离开来。Docker 默认采用 veth 的方式,将容器中的虚拟网卡同 host 上的一 个Docker 网桥 docker0 连接在一起。

  • ipc 命名空间

容器中进程交互还是采用了 Linux 常见的进程间交互方法(interprocess communication - IPC), 包括信号量、消息队列和共享内存等。然而同 VM 不同的是,容器的进程间交互实际上还是 host 上具有相同 pid 命名空间中的进程间交互,因此需要在 IPC 资源申请时加入命名空间信息,每个 IPC 资源有一个唯一的 32 位 id。

  • mnt 命名空间

类似 chroot,将一个进程放到一个特定的目录执行。mnt 命名空间允许不同命名空间的进程看到的文件结构不同,这样每个命名空间 中的进程所看到的文件目录就被隔离开了。同 chroot 不同,每个命名空间中的容器在 /proc/mounts 的信息只包含所在命名空间的 mount point。

  • uts 命名空间

UTS(“UNIX Time-sharing System”) 命名空间允许每个容器拥有独立的 hostname 和 domain name, 使其在网络上可以被视作一个独立的节点而非 主机上的一个进程。

  • user 命名空间

每个容器可以有不同的用户和组 id, 也就是说可以在容器内用容器内部的用户执行程序而非主机上的用户。

控制组

控制组(cgroupsLinux man)是 Linux 内核的一个特性,主要用来对共享资源进行隔离、限制、审计等。只有能控制分配到容器的资源,才能避免当多个容器同时运行时的对系统资源的竞争。

控制组技术最早是由 Google 的程序员在 2006 年提出,Linux 内核自 2.6.24 开始支持。

控制组可以提供对容器的内存、CPU、磁盘 IO 等资源的限制和审计管理。

联合文件系统

联合文件系统(UnionFS)是一种分层、轻量级并且高性能的文件系统,它支持对文件系统的修改作为一次提交来一层层的叠加,同时可以将不同目录挂载到同一个虚拟文件系统下(unite several directories into a single virtual filesystem)。

联合文件系统是 Docker 镜像的基础。镜像可以通过分层来进行继承,基于基础镜像(没有父镜像),可以制作各种具体的应用镜像。

另外,不同 Docker 容器就可以共享一些基础的文件系统层,同时再加上自己独有的改动层,大大提高了存储的效率。

Docker 中使用的 AUFS(Advanced Multi-Layered Unification Filesystem)就是一种联合文件系统。 AUFS 支持为每一个成员目录(类似 Git 的分支)设定只读(readonly)、读写(readwrite)和写出(whiteout-able)权限, 同时 AUFS 里有一个类似分层的概念, 对只读权限的分支可以逻辑上进行增量地修改(不影响只读部分的)。

Docker 目前支持的联合文件系统包括 OverlayFS, AUFS, Btrfs, VFS, ZFSDevice Mapper

各 Linux 发行版 Docker 推荐使用的存储驱动如下表。

Linux 发行版 Docker 推荐使用的存储驱动
Docker CE on Ubuntu overlay2 (16.04 +)
Docker CE on Debian overlay2 (Debian Stretch), aufs, devicemapper
Docker CE on CentOS overlay2
Docker CE on Fedora overlay2

在可能的情况下,推荐 使用 overlay2 存储驱动,overlay2 是目前 Docker 默认的存储驱动,以前则是 aufs。你可以通过配置来使用以上提到的其他类型的存储驱动。

Docker 安全机制

Docker的安全机制有很多种:Linux Capability、AppArmor、SELinux、Seccomp等等

References

https://docs.docker.com/

https://www.bilibili.com/video/BV1pW411G7r3

https://www.runoob.com/docker/docker-tutorial.html

https://yeasy.gitbooks.io/docker_practice/introduction/what.html

https://docs.docker.com/engine/reference/builder/

https://docs.docker.com/develop/develop-images/dockerfile_best-practices/

https://github.com/docker-library/docs

Author: Qftm
Reprint policy: All articles in this blog are used except for special statements CC BY 4.0 reprint polocy. If reproduced, please indicate source Qftm !
 Previous
中小型企业级防火墙部署 中小型企业级防火墙部署
前言2020年第一篇文章,先祝大家新年快乐,2020年暴瘦、暴富~,新年第一天就在做项目QAQ,在这里分享项目的一部分,其它部分将在后面开源分享,欢迎大家一起交流分享 防火墙防火墙是由软件和硬件组成的系统,它处于安全的网络(通常是内部局域网
2020-01-01
Next 
PhpStudy BackDoor2019 深度分析 PhpStudy BackDoor2019 深度分析
关于《PhpStudy BackDoor》风波已经过去有一段时间了,由于,前段时间一直忙于其它事情QAQ,今天有时间对该事件重新回溯&深度分析。 严正声明:本文仅限于技术讨论与分享,严禁用于非法途径 背景分析2019年9月20日,杭
2019-11-22
  TOC