容器化技术(No.1) -- Docker 基础


此分享内容是对书籍及其他技术分享能容的总结

Docker 基础

今天的分享主要有以下几个目标:

  • 首先最重要的一点, 希望能够帮助不太了解 Docker 的兄弟们, 知道 Docker 到底是什么东西;
  • 能够初步理解 Docker 的核心原理, 包括 namespacecgroups;
  • 能够上手构建和使用 Docker, 并应用于工作中.

什么是 Docker

提到一个技术, 我们首先能想到的就是传统的灵魂 3 问:

  • Docker 是什么?
  • 为什么要用 Docker?
  • 怎么使用 Docker?

什么是 Docker

官网给出如下定义:

A container is a standard unit of software that packages up code and all its dependencies so the application runs quickly and reliably from one computing environment to another. A Docker container image is a lightweight, standalone, executable package of software that includes everything needed to run an application: code, runtime, system tools, system libraries and settings.

翻译过来就是:

容器是打包代码及其所有依赖项的软件的标准单元, 因此应用程序可以从一个环境快速可靠地运行到另一个环境. Docker容器镜像是轻巧的, 独立的, 可执行的软件包, 其中包含运行应用程序所需的一切:代码, 运行时, 系统工具, 系统库和设置.

Docker 相关概念

  • Image: 是一个可执行的包, 包含了应用程序运行的所有内容: 代码, 运行时, 库, 环境变量和配置文件
  • Container: 容器是镜像运行时的实例
  • Daemon: 负责维护 Docker 运行的守护进程, 担负着资源管理, 任务调度等多项功能
    三者关系: Container 基于 Image 被 Daemon 创建和管理, 来实现提供服务的功能

Docker 特性

Docker containers that run on Docker Engine:
Standard: Docker created the industry standard for containers, so they could be portable anywhere
Lightweight: Containers share the machine’s OS system kernel and therefore do not require an OS per application, driving higher server efficiencies and reducing server and licensing costs
Secure: Applications are safer in containers and Docker provides the strongest default isolation capabilities in the industry

译:

在Docker Engine上运行的Docker容器:
标准:Docker创建了容器的行业标准, 因此它们可以在任何地方移植
轻巧:容器共享计算机的操作系统系统内核, 因此不需要每个应用程序都使用操作系统, 从而提高了服务器效率, 并降低了服务器和许可成本
安全:容器中的应用程序更安全, Docker提供了业界最强大的默认隔离功能
这些先不纠结他的这些特性, 官方为什么这么说我们姑且先有个印象, 我们了解了他的原理及使用, 自然就知道了.

总结

Docker 是什么这个问题的答案从他解决的问题出发, Docker 的诞生和发展解决的最根本问题就是服务打包的问题. 最后总结一下, 他就是一个便捷的打包方案, 这个概括可能不精确, 但是很直观的总结了我们能用 Docker 来解决的问题是什么.

Docker VS Virtual Machines

提到容器, 和虚拟机之间的对比对照是不可避免的. 既然容器包含了程序及其所有依赖项, 那他和虚拟机之间的差异有人能给大家说一下么?
下面这两张图是从官网上摘下来的, 很常见, 网上其他的图例也都大同小异, 基本上都是这个结构的图
Docker VS Virtual Machines
大家现对这两张图有个印象, 我们接下来讲一下 Docker 内核原理, 讲完之后应该大家就会理解这两张图的含义, 甚至还可以发现这两张图中描述欠妥的地方, 我们最后来总结二者的不同.
Docker 能干什么(为什么使用 Docker) 及如何使用 Docker, 我会在接下来的时间给大家做分享.

Docker 核心原理

Docker 的本质就是宿主机上的一个进程, 通过 namespace 实现了资源隔离, 通过 cgroups 实现了资源限制, 通过 copy-on-write 机制实现了高效的文件操作.
容器技术的核心功能, 就是通过约束和修改进程的动态表现, 从而为其创造出一个"边界"
具体是如何基于 namespacecgroups 这两种技术实现的, 我们接下来详细分解一下.

namespace

单进程

首先需要明确的是: 容器是"单进程"模型

由于一个容器的本质就是一个进程, 用户的应用进程实际上就是容器里 PID=1 的进程, 也是其他后续创建的所有进程的父进程. 这就意味着, 在一个容器中, 你没办法同时运行两个不同的应用, 除非你能事先找到一个公共的 PID=1 的程序来充当两个不同应用的父进程.
容器本身的设计, 就是希望容器和应用能够同生命周期, 这个概念对后续的容器编排非常重要. 否则, 一旦出现类似于 容器是正常运行的, 但是里面的应用早已经挂了 的情况, 编排系统处理起来就非常麻烦了. 这个问题我们之前也经常碰到. 通常我们会写一个 start.sh 的脚本, 启动实例的时候执行这个脚本, 但是这样通常 PID 为 1 的进程就是 sh, 导致 java 程序已经挂了, 容器还依然正常运行

root 1 0 0 17:29 ? 00:00:00 sh start.sh 
root 6 1 1 17:29 ? 00:02:13 java -jar -Dspring.profiles.active=prod app.jar 

但是为了使镜像更灵活, 这也是应对的一种变通方式, 之后也是需要对这种使用方式进行调整和完善的.

介绍

说完容器是单进程模型的, 接下来我们们就来看看容器里这个 PID 为 1 的进程到底是设个什么样的存在.
Linux namespace 提供了一种内核级别隔离系统资源的方法, 通过将系统的全局资源放在不同的 namespace 中, 来实现资源隔离的目的. 在同一个 namespace 下的进程可以感知彼此的变化, 而对外界的进程一无所知. 这样就可以让容器总的进程产生错觉, 仿佛自己置身于一个独立的系统环境中, 以达到独立和隔离的目的. 不同 namespace 的程序, 可以享有一份独立的系统资源.
目前 Linux 中提供了六类系统资源的隔离机制:

Namespace系统调用参数隔离内容
UTSCLONE_NEWUTS主机名与域名
IPCCLONE_NEWIPC信号量, 消息队列和共享内存
PIDCLONE_NEWPID进程编号
NetworkCLONE_NEWNET网络设备, 网络栈, 端口等等
MountCLONE_NEWNS挂载点(文件系统)
UserCLONE_NEWUSER用户和用户组

Docker 就是利用这些不同的 namespace 来实现的, 所以说: 一个正在运行的 Docker 容器, 就是一个启用了多个 Linux namespace 的, 可用资源受 cgroups 配置限制的应用进程

namespace 的 API 包括 clone(), setns() 以及 unshare(), 还有 /proc 下的部分文件(这里仅做列举介绍, 具体 Namespace 的使用方式这里不做重点介绍).

  1. 通过 clone() 创建新进程的同时创建 namespace
  2. 查看 /proc/[pid]/ns 文件
  3. 通过 setns() 加入一个已经存在的 namespace
  4. 通过 unshare() 在原先进程上进行 namespace 隔离
  5. 延伸*: fork()系统调用
    Namespace 技术实际上修改了应用进程看待整个计算机"视图", 即它的"视线"被操作系统做了限制, 只能"看到"某些指定的内容. 但对于宿主机来说, 这些被"隔离"了的进程跟其他进程并没有太大区别
实例

我们通过一个容器实例来看看他都创建了哪些 namespace

[root@irvin ~]# docker run -it busybox /bin/sh 
/ # ps 
PID USER TIME COMMAND 
1 root 0:00 /bin/sh 
6 root 0:00 ps 

我们保持容器的启动状态, 通过如下指令, 可以看到当前正在运行的 Docker 容器的进程号(PID)是 87453:

[root@irvin ~]# docker inspect --format '{{ .State.Pid }}' 7e7b3939b788 
87453 

可以看到容器内 PID 为 1 的进场实际上就是宿主机上 PID 为 87453 的进程, 只是通过执行 namespace 的 clone() Api 调用实现的.
通过调用该接口就会创建 PID namespace, 而每个 namespace 里的应用进程, 都会认为自己是当前容器里的第 1 号进程, 它们既看不到宿主机里真正的进程空间, 也看不到其他 PID namespace 里的具体情况
我么也可以查看一下该容器都创建了哪些 namespace (形如 4026531839 的即为 namespace 号)

[root@irvin ~]# ls -l /proc/87453/ns 
总用量 0 
lrwxrwxrwx 1 root root 0 1月 2 19:07 ipc -> ipc:[4026531839] 
lrwxrwxrwx 1 root root 0 1月 2 19:07 mnt -> mnt:[4026531840] 
lrwxrwxrwx 1 root root 0 1月 2 19:07 net -> net:[4026531956] 
lrwxrwxrwx 1 root root 0 1月 2 19:07 pid -> pid:[4026531836] 
lrwxrwxrwx 1 root root 0 1月 2 19:07 user -> user:[4026531837] 
lrwxrwxrwx 1 root root 0 1月 2 19:07 uts -> uts:[4026531838] 

可以看到, 一个进程的每种 Linux namespace, 都在它对应的 /proc/[进程号]/ns 下有一个对应的虚拟文件, 并且链接到一个真实的 namespace 文件上.
一个进程, 可以选择加入到某个进程已有的 namespace 当中, 从而达到"进入"这个进程所在容器的目的, 这正是 docker exec 的实现原理. 而这个操作所依赖的, 是一个名叫 setns() 的 Linux 系统调用, 它一共接收两个参数, 第一个参数是 argv[1], 即当前进程要加入的 namespace 文件的路径, 比如 /proc/87453/ns/net;而第二个参数, 则是你要在这个 namespace 里运行的进程, 比如 /bin/bash

总结

Docker 容器概念听起来很高级, 实际上是在创建容器进程时, 指定了这个进程所需要启用的一组 namespace 参数. 这样, 容器就只能"看"到当前 namespace 所限定的资源, 文件, 设备, 状态, 或者配置. 而对于宿主机以及其他不相关的程序, 它就完全看不到了.
所以说, 容器, 其实是一种特殊的进程而已.

Docker VS Virtual Machines

我们回过头来看看之前讲到的容器与虚拟机的区别:
Docker VS Virtual Machines

  • Hypervisor 的软件是虚拟机最主要的部分. 它通过硬件虚拟化功能, 模拟出了运行一个操作系统需要的各种硬件, 比如 CPU, 内存, I/O 设备等等. 然后, 它在这些虚拟的硬件上安装了一个新的操作系统, 即 Guest OS.
  • 跟真实存在的虚拟机不同, 在使用 Docker 的时候, 并没有一个真正的"Docker 容器"运行在宿主机里面. Docker 项目帮助用户启动的, 还是原来的应用进程, 只不过在创建这些进程时, Docker 为它们加上了各种各样的 Namespace 参数.

所以上图不不严谨之处应作如下调整
Docker VS Virtual Machines

并不像 Hypervisor 那样对应用进程的隔离环境负责, 也不会创建任何实体的"容器", 真正对隔离环境负责的是宿主机操作系统本身.
我们应该把 Docker 画在跟应用同级别并且靠边的位置. 这意味着, 用户运行在容器里的应用进程, 跟宿主机上的其他进程一样, 都由宿主机操作系统统一管理, 只不过这些被隔离的进程拥有额外设置过的 namespace 参数. 而 Docker 项目在这里扮演的角色, 更多的是旁路式的辅助和管理工作
使用虚拟化技术作为应用沙盒, 就必须要由 Hypervisor 来负责创建虚拟机, 这个虚拟机是真实存在的, 并且它里面必须运行一个完整的 Guest OS 才能执行用户的应用进程. 这就不可避免地带来了额外的资源消耗和占用.

根据实验, 一个运行着 CentOS 的 KVM 虚拟机启动后, 在不做优化的情况下, 虚拟机自己就需要占用 100~200 MB 内存. 此外, 用户应用运行在虚拟机里面, 它对宿主机操作系统的调用就不可避免地要经过虚拟化软件的拦截和处理, 这本身又是一层性能损耗, 尤其对计算资源, 网络和磁盘 I/O 的损耗非常大
而相比之下, 容器化后的用户应用, 却依然还是一个宿主机上的普通进程, 这就意味着这些因为虚拟化而带来的性能损耗都是不存在的;而另一方面, 使用 namespace 作为隔离手段的容器并不需要单独的 Guest OS, 这就使得容器额外的资源占用几乎可以忽略不计
所以可以这么说: 敏捷高性能是容器相较于虚拟机最大的优势

cgroups

使用 namespace 的障眼法技术, 使得进程在容器内虽然只能看到内部进程, 但他作为宿主机的第 n 号进程, 与其他所有进程之间依然是平等的竞争关系. 因此虽然进程被隔离起来, 但是使用的资源却是可以随时被宿主机上的其他进程占用的(如 CUP, 内存等), 这显然对于我们要使用容器的期许是不合理的, 所以这个时候 就会讲到 cgroups.
Linux cgroups(Linux Control Group) 就是 Linux 内核中用来为进程设置资源限制的一个重要功能, 最主要的作用, 就是限制一个进程组能够使用的资源上限, 包括 CPU, 内存, 磁盘, 网络带宽等等
Linux cgroups 的设计还是比较易用的, 简单粗暴地理解呢, 它就是一个子系统目录加上一组资源限制文件的组合. 而对于 Docker 等 Linux 容器项目来说, 它们只需要在每个子系统下面, 为每个容器创建一个控制组(即创建一个新目录), 然后在启动容器进程之后, 把这个进程的 PID 填写到对应控制组的 tasks 文件中就可以了

[root@irvin ~]# mount -t cgroup 
cgroup on /sys/fs/cgroup/systemd type cgroup (rw,nosuid,nodev,noexec,relatime,xattr,release_agent=/usr/lib/systemd/systemd-cgroups-agent,name=systemd) 
cgroup on /sys/fs/cgroup/net_cls,net_prio type cgroup (rw,nosuid,nodev,noexec,relatime,net_prio,net_cls) 
cgroup on /sys/fs/cgroup/cpu,cpuacct type cgroup (rw,nosuid,nodev,noexec,relatime,cpuacct,cpu) 
cgroup on /sys/fs/cgroup/pids type cgroup (rw,nosuid,nodev,noexec,relatime,pids) 
cgroup on /sys/fs/cgroup/perf_event type cgroup (rw,nosuid,nodev,noexec,relatime,perf_event) 
cgroup on /sys/fs/cgroup/memory type cgroup (rw,nosuid,nodev,noexec,relatime,memory) 
cgroup on /sys/fs/cgroup/devices type cgroup (rw,nosuid,nodev,noexec,relatime,devices) 
cgroup on /sys/fs/cgroup/blkio type cgroup (rw,nosuid,nodev,noexec,relatime,blkio) 
cgroup on /sys/fs/cgroup/freezer type cgroup (rw,nosuid,nodev,noexec,relatime,freezer) 
cgroup on /sys/fs/cgroup/cpuset type cgroup (rw,nosuid,nodev,noexec,relatime,cpuset) 
cgroup on /sys/fs/cgroup/hugetlb type cgroup (rw,nosuid,nodev,noexec,relatime,hugetlb) 

我们可以在对应文件夹下找到我们启动的容器实例资源限制的配置文件.

文件系统*

文件系统的隔离是怎么做的

文件系统的隔离是怎么做的? 大家可能第一反应都是: 通过 mount namespace 做的. 确实是这样的, 但是 mount namespace 是怎么做的? 和其他几个 namespace 的用法有没有什么差异?
mount namespace 修改的, 是容器进程对文件系统 “挂载点” 的认知. 但是, 这也就意味着, 只有在 “挂载” 这个操作发生之后, 进程的视图才会被改变. 而在此之前, 新创建的容器会直接继承宿主机的各个挂载点. 这就是 mount namespace 跟其他 namespace 的使用略有不同的地方:它对容器进程视图的改变, 一定是伴随着挂载操作(mount)才能生效
所以很容易想到, 我们可以在容器进程启动之前重新挂载它的整个根目录 /. 而由于 mount namespace 的存在, 这个挂载可以实现容器对宿主机文件系统的隔离

延伸*

实际上, mount namespace 正是基于对 chroot 的不断改良才被发明出来的, 它也是 Linux 操作系统里的第一个 namespace. 感兴趣的可以了解一下 chroot, pivot_rootswitch_root 等内容

镜像

这个挂载在容器根目录上, 用来为容器进程提供隔离后执行环境的文件系统, 就是所谓的"容器镜像". 它还有一个更为专业的名字, 叫作:rootfs(根文件系统)
因此, 对 Docker 项目来说, 它最核心的原理实际上就是为待创建的用户进程:

  1. 启用 Linux namespace 配置;
  2. 设置指定的 cgroups 参数;
  3. 切换进程的根目录(Change Root).

需要明确的是, rootfs 只是一个操作系统所包含的文件, 配置和目录, 并不包括操作系统内核. 在 Linux 操作系统中, 这两部分是分开存放的, 操作系统只有在开机启动时才会加载指定版本的内核镜像 (所以说, rootfs 只包括了操作系统的"躯壳", 并没有包括操作系统的"灵魂". 那么, 对于容器来说, 这个操作系统的"灵魂"又在哪里呢?实际上, 同一台机器上的所有容器, 都共享宿主机操作系统的内核)
正是由于 rootfs 的存在, 容器才有了一个重要特性: 一致性
由于 rootfs 里打包的不只是应用, 而是整个操作系统的文件和目录, 也就意味着, 应用以及它运行所需要的所有依赖, 都被封装在了一起
不过, 这时你可能已经发现了另一个非常棘手的问题:难道我每开发一个应用, 或者升级一下现有的应用, 都要重复制作一次 rootfs 吗?
Docker 在镜像的设计中, 引入了层(layer)的概念解决了这个问题. 用到了一种叫作联合文件系统(Union File System)的能力. 也就是说, 用户制作镜像的每一步操作, 都会生成一个层, 也就是一个增量 rootfs.

UnionFS

这里简单介绍一下什么是 UnionFS, 感兴趣的可以私下了解一下.
比如, 我现在有两个目录 A 和 B, 它们分别有两个文件:

[root@irvin ~]# tree 
. 
├── A 
│ ├── a 
│ └── x 
└── B 
├── b 
└── x 

然后, 我使用联合挂载的方式, 将这两个目录挂载到一个公共的目录 C 上:

[root@irvin ~]# mkdir C 
[root@irvin ~]# mount -t aufs -o dirs=./A:./B none ./C 

默认上来说,命令行上第一个(最左边./A)的目录是可读可写的,后面的全都是只读的, 你也可以在挂载的时候自己指定权限:

[root@irvin ~]# mount -t aufs -o dirs=./A=rw:./B=rw none ./C 

这时, 我再查看目录 C 的内容, 就能看到目录 A 和 B 下的文件被合并到了一起:

[root@irvin ~]# tree ./C 
./C 
├── a 
├── b 
└── x 

可以看到, 在这个合并后的目录 C 里, 有 a, b, x 三个文件, 并且 x 文件只有一份. 这, 就是"合并"的含义. 此外, 如果你在目录 C 里对 a, b, x 文件做修改, 这些修改也会在对应的目录 A, B 中生效.

我们来操作一下这里的文件:

  1. 我们首先尝试修改 x 文件
[root@irvin ~]# echo test > ./C/x 
[root@irvin ~]# cat ./C/x 
test 
[root@irvin ~]# cat ./A/x 
test 
[root@irvin ~]# cat ./B/x 
  1. 再试一下修改b目录(只读目录)才有的b
[root@irvin ~]# echo test > ./C/b 
[root@irvin ~]# cat ./C/b 
test 
[root@irvin ~]# cat ./B/b 
[root@irvin ~]# cat ./A/b 
test 

你会发现: B 目录下的文件没有被修改, 而是在 A 目录(可读写目录)创建了一个 b

  1. 在 B 文件夹下创建 btemp 文件, 并通过 C 目录删除该文件及 a 文件, 看看会有什么效果
    在 B 目录下创建 btemp 文件, C 的目录结果显而易见为
[root@irvin ~]# touch b/btemp 
[root@irvin ~]# tree ./C 
./C 
├── a 
├── b 
├── btemp 
└── x 

首先删除 a 文件

[root@irvin ~]# rm ./C/a 
[root@irvin ~]# tree 
. 
├── A 
│ └── x 
└── B 
├── b 
├── btemp 
└── x 

删除 btemp

[root@irvin ~]# rm ./C/btemp 
[root@irvin ~]# tree 
. 
├── A 
│ ├── .wh.btemp 
│ └── x 
└── B 
├── b 
├── btemp 
└── x 

我们发现在 C 目录中删除 a 和 btemp 后, A 目录(可读写)中的 a 真的删除了, 而 B 目录(只读)中的 btemp 还在, 只是 A 目录中多个 .wh.btemp 这个文件.
一般来说只读目录都会有 whiteout 的属性, 所谓 whiteout 的意思, 就是如果在 union 中删除的某个文件, 实际上是位于一个 readonly 的目录上. 那么, 在 mount 的 union 这个目录中你将看不到这个文件,但是 readonly 这个层上我们无法做任何的修改, 所以, 我们就需要对这个 readonly 目录里的文件作 whiteout. AUFS 的 whiteout 的实现是通过在上层的可写的目录下建立对应的 whiteout 隐藏文件来实现的.

rootfs 组成

通过联合文件系统, Docker 将各个 Layer 整合成一个镜像, 这个容器的 rootfs 由如下图所示的三部分组成:

  1. 只读层
    它是这个容器的 rootfs 最下面的五层, 可以看到, 它们的挂载方式都是只读的

  2. Init 层
    "-init"结尾的层, 夹在只读层和读写层之间. Init 层是 Docker 项目单独生成的一个内部层, 专门用来存放 /etc/hosts, /etc/resolv.conf 等信息. 需要这样一层的原因是: 这些文件本来属于只读的系统镜像层的一部分, 但用户往往需要在启动容器时写入一些指定的值比如 hostname, 所以就需要在可读写层对他们进行修改. 但这些修改往往只对当前容器有效, 我们并不希望执行 docker commit 命令时, 把这些信息连同可读写层一起提交. 所以 Docker 的做法是, 在修改了这些文件后, 以一个单独层挂载了出来. 而用户执行 docker commit 命令时只会提交可读写层, 而是不包含这些 内容的.

  3. 可读写层
    它是这个容器的 rootfs 最上面的一层, 它的挂载方式为:rw. 在没有写入文件之前, 这个目录是空的. 而一旦在容器里做了写操作, 你修改产生的内容就会以增量的方式出现在这个层中.

如果我现在要做的, 是删除只读层里的一个文件呢? 这就用到了我们之前所说的 whiteout 了. 所以, 最上面这个可读写层的作用, 就是专门用来存放你修改 rootfs 后产生的增量, 无论是增, 删, 改, 都发生在这里. 而当我们使用完了这个被修改过的容器之后, 还可以使用 docker commit 和 push 指令, 保存这个被修改过的可读写层, 并上传到 Docker Hub 上, 供其他人使用;而与此同时, 原先的只读层里的内容则不会有任何变化. 这, 就是增量 rootfs 的好处
rootfs 的最上层是一个可读写层, 它以 Copy-on-Write 的方式存放任何对只读层的修改, 容器声明的 Volume 的挂载点, 也出现在这一层

基础实践(一)

今天最后一部分, 对 docker 镜像构建及常用命令的使用做介绍, 上边说了一大堆云里雾里的假大空, 这部分可能是大家使用中用大的最多思考的最多的地方. 但是上边的基础原理和逻辑是你理解这些命令, 遇到问题定位问题解决问题的根本, 让你更快理解和上手. 今后的分享中我也会尽可能的把各种原理和底层实现的部分, 首先我先搞明白, 揉碎到每一次的分享中, 实践中结合原理来分享, 加深大家的理解, 而不是只讲假大空的原理或只讲怎么用, 为什么这么用不知道, 出了问题都不知道怎么排查.

镜像构建
指令

我们用一个例子来实操一下:

#Comment 
FROM java:8 
MAINTAINER Irvin "gaofang@cennavi.com.cn" 
ENV ACTIVE local 
COPY mm-lbs-service.jar /app.jar 
COPY start.sh /start.sh 
ENTRYPOINT ["./start.sh"] 
  • FROM
FROM <image> 或 FROM <image>:<tag> 

第一条命令必须是 FROM, 它用于指定构建镜像的基础镜像.
FROM 参数 tag 默认为 latest
FROM java --即为-> FROM java:latest

  • ENV
ENV <key> <value> 或 ENV <key>=<value> 

ENV 指令可以为镜像创建除容器声明环境变量. 并且在 Dockerfile 中, ENV 指令声明的环境变量会被后面的特定指令解释使用(变量前面加\``可以转义). 另外ONBUILD` 指令不支持环境替换

  • COPY
COPY <src> <dest> 

COPY 指令将复制 所指向的文件或目录到新的镜像中的 路径下.
必须是上下文根目录相对路径(即 Dockerfile 所在目录), 形如 COPY ../sth/sth 是不行的.
可以是文件或目录, 必须是镜像中的绝对路径或者是相对于 WORKDIR 的相对路径

  • ADD
ADD <src> <dest> 

功能与 COPY 相似, 但 ADD 指令还支持其他功能:

可以是一个指向一个网络文件的 URL. ADD http://example.com/foobar /foobar
还可以指向一个本地压缩归档文件, 该文件复制到容器中时会被解压提取. 但若 URL 中的文件为归档文件则不会被解压提取. ADD example.tar.gz

  • RUN
RUN <command> (shell 格式) 
RUN ["executable", "param1", "param2"] (exec 格式, 推荐格式) 

RUN 指令会在前面一条命令创建出的镜像的基础上创建一个容器, 并在容器中运行命令, 在命令结束运行后提交容器为新镜像, 新镜像被 Dockerfile 中的下一条指令使用.
RUN 指令的两种格式表示在容器中的两种运行方式. 当使用 shell 格式时, 命令通过 /bin/sh -c 运行; 当使用 exec 格式时, 命令是直接运行的, 容器不调用 shell 程序.
exec 格式中的参数会当成 JSON 数组被 Docker 解析, 因此必须使用双引号,不能使用单引号.
因为 exec 格式不会在 shell 中执行, 所以环境变量参数不会被替换, 例如, 当执行 CMD ["echo", "$HOME"] 指令时, $HOME 不会作为环境变量替换, 如果希望运行 shell 程序, 指令可以写成 CMD ["sh", "-c", "echo", "$HOME"]

  • CMD
CMD <command> (shell 格式) 
CMD ["executable", "param1", "param2"] (exec 格式, 推荐格式) 
CMD ["param1", "param2"] (为 ENTRYPOINT 指令提供参数) 

CMD 指令提供容器运行时的默认指令, 这些默认值可以是一条指令, 也可以是一些参数.
一个 Dockerfile 中可以有多条 CMD 指令, 但只有最后一条指令有效.
CMD ["param1", "param2"] 格式是在 CMD 和 ENTRYPOINT指令配合时使用的, CMD 指令中的参数会添加到 ENTRYPOINT 指令中.
使用 shell 和 exec 格式时, 命令在容器中的运行方式与 RUN 相同. 不同在于, RUN 指令在构建镜像时执行, 并生成新镜像; CMD 指令在构建镜像时并不执行任何命令, 而是在容器启动时默认将 CDM 指令作为第一条执行的命令.
如果用户在运行 docker run 命令时指定了命令参数, 则会覆盖 CMD 指令中的命令.

  • ENTRYPOINT
ENTRYPOINT <command> (shell 格式) 
ENTRYPOINT ["executable", "param1", "param2"] (exec 格式, 推荐格式) 

与 CMD 指令类似, 但他们之间又有不同.
一个 Dockerfile 中可以有多条 ENTRYPOINT 指令, 但只有最后一条指令有效.
当使用 shell 格式时, ENTRYPOINT 指令会忽略任何 CMD 指令和 docker run 命令的参数, 并且会运行在 /bin/sh -c 中. 这意味着 ENTRYPOINT 指令进程为 /bin/sh -c 的子进程, 进程在容器中的 PID 也就不是 1, 且不能接收 Unix 信号. 即当使用 docker stop <container> 命令时, 命令进程接收不到 SIGTERM 信号. 推荐使用 exec 格式, 使用此格式时, docker run 传入的命令参数会覆盖 CMD 指令中的内容并附加到 ENTRYPOINT 指令的参数中.

与 CMD 指令的不同
CMD 可以是参数也可以是指令; ENTRYPOINT 只能是命令.
docker run 提供的参数可以覆盖 CMD, 但不能覆盖 ENTRYPOINT

规范及实践心得
  1. Dockerfile 中, 指令不区分大小写, 但是为了与参数区分, 推荐大写
# 反例 
from java:8 
maintainer Irvin "gaofang@cennavi.com.cn" 
env ACTIVE local 
copy mm-*.jar /app.jar 
add start.sh /start.sh 
run chmod 755 /start.sh 
entrypoint ["./start.sh"] 
  1. 构建镜像时, 给镜像打上易读的标签, 可以帮助了解镜像功能或版本信息
# 反例 
docker build -t myservice . 
# 推荐 
docker build -t mineservice/lbs:v3.0.1 . 
  1. 谨慎选择基础镜像.
    不同镜像大小不同, busybox < debian < centos < ubuntu, 因此相比于使用 Ubuntu 或 CentOS 镜像, 更推荐使用 Debian 镜像, 因为他非常轻量(<100M), 并且让然是一个完整的发布版本.
    在构建自己的 Docker 镜像时, 只安装和更新必须使用的包.

  2. 充分利用缓存
    Docker daemon 会顺序执行 Dockerfile 中的指令, 而且一旦缓存失效, 后续命令将不能使用缓存. 为了有效利用缓存, 需要保证指令的连续性, 尽量将多有 Dockerfile 文件中相同的部分都放在前面, 而将不同的部分放下后面

  3. 正确使用 ADD 和 COPY 命令
    尽管二者用法相近, 但 COPY 仍是首选. COPY 相对于 ADD 而言, 功能简单够用. 个人建议: 若需要添加压缩文件或通过 URL 获取远程资源, 建议将资源准备好(该解压的解压, 该下载的下载), 再通过 COPY 指令添加到镜像中.
    当在 Dockerfile 中的不同部分需要用到不同文件时,不要一次性地将这些文件都添加到镜像当中, 而是在需要的时候逐个添加, 这样有利于充分利用缓存.
    通过 ADD 指令获取远程 URL 中的压缩包也是不推荐的做法. 应该使用 RUN wgetRUN curl 代替. 这样可以的删除解压后不再需要的文件, 并且不需要在镜像中再添加一层, 例如:

不推荐的做法: 
ADD http://example.com/big.tar.xz /usr/src/things/ 
RUN tar -xJf /usr/src/things/big.tar.xz -C /usr/src/things 
RUN make -C /usr/src/things all 
--- 
推荐的做法: 
RUN mkdir -p /usr/src/things \ 
		&& curl -SL http://example.com/big.tar.xz \ 
		| tar -xJC /usr/src/things \ 
		&& make -C /usr/src/things all 

需要注意的是, Dockerfile 中的每个原语执行后, 都会生成一个对应的镜像层

  1. RUN 命令
    为了使 Dockerfile 易读, 易理解和可维护, 较长指令通过 \ 分隔多行
# 反例 
RUN mkdir -p /usr/src/things && curl -SL http://example.com/big.tar.xz | tar -xJC /usr/src/things && make -C /usr/src/things all 

大部分使用 RUN 指令的场景是运行 apt-get 命令, 需要注意以下几点:

① 不要在一行中单独使用指令 RUN apt-get update, 推荐 RUN apt-get update && apt-get install -y package-bar package-foo package-baz.因为当软件源更新后, 这样做会引起缓存问题, 导致 RUN apt-get install 指令运行失败
② 避免使用指令 RUN apt-get upgradeRUN apt-get dist-upgrade. 建议直接使用 RUN apt-get install -y foo. 因为在一个无特权的容器里, 一些必要的包会更新失败.
刚才提到了 Dockerfile 中的每个原语执行后, 都会生成一个对应的镜像层, 有的同事可能会想: 是不是尽量吧所有的脚本都放到一个 RUN 指令里边执行就行了, 就是最佳实践了?
答案当然是: NO!!!.

在 Docker 的核心概念中, 提交镜像是廉价的, 镜像之间有层级关系,像一棵树. 不要害怕镜像的层数多, 我们可以在任意一个容器. 因此, 不要将所有的命令卸载一个 RUN 指令中. RUN 指令分层符合 Docker 的核心概念, 这很像源码控制 

这是我从书上抄下来的一段话, 乱七八糟. 我理解的就是: 这是一门艺术. 就行架构设计一样, 没有正确答案, 你可以按照自己的需求, 命令之间的关系, 上下文关联合理拆分

  1. CMD 和 ENTRYPOINT 指令
    推荐二者结合使用. 使用 exec 格式的 ENTRYPOINT 指令设置固定的默认命令和参数, 然后使用 CMD 指令设置可变的参数.

  2. 不要在 Dockerfile 中做端口映射

Docker 的两个核心概念是可复制性可移植性, 这种方式破坏了可移植性, 这样一个镜像只能在一个机器上启动一个实例. 映射应通过 docker run 命令中的 -p 参数指定

# 反例 
EXPOSE 80:8080 
# 只暴露端口 
EXPOSE 80 
生命周期

首先我们看一下 Docker 的生命周期:
Docker VS Virtual Machines

容器操作常用命令

通过 Docker 生命周期的示意图, 我们可以看到里边有很多的命令, 我会按照我们实际操作的顺序逐一给大家示范. 我这里提供常用命令的常见用法, 至于详细参数以后的分享中会进行介绍, 本次不会逐一介绍.

  1. docker build
    前边我们在讲 Dockerfile 的时候提到了一个 docker build 命令, 通过 Dockerfile 来构建镜像.
# --tag, -t 为镜像添加名字及标签
docker build -t name:tag .
# -f 指定要使用的Dockerfile路径
docker build -f /path/to/a/Dockerfile .

docker build 最后的 . 号, 指定镜像构建过程中的上下文环境的目录, 并不是指 Dockerfile 所在的目录. 在 Dockerfile 中进行类似 COPY 这样的指令, 实际上是经上下文环境目录都加载到了 Docker 引擎之, 而不是在本机环境下直接加载要 COPY 的单一文件.

大家知道 .dockerignore 文件吗, 根据上述内容, 可以帮助该文件的作用了
  1. docker images
    查看镜像列表
$ docker images
REPOSITORY   TAG        IMAGE ID         CREATED          SIZE
<none>       <none>     77af4d6b9913     19 hours ago     1.089 GB
committ      latest     b6fa739cedf5     19 hours ago     1.089 GB
<none>       <none>     78a85c484f71     19 hours ago     1.089 GB
docker       latest     30557a29d5ab     20 hours ago     1.089 GB
<none>       <none>     5ed6274db6ce     24 hours ago     1.089 GB
postgr       9          746b819f315e     4 days ago       213.4 MB
postgres     9.3        746b819f315e     4 days ago       213.4 MB
postgres     9.3.5      746b819f315e     4 days ago       213.4 MB
postgres     latest     746b819f315e     4 days ago       213.4 MB

查看指定仓库中的镜像

$ docker images java
REPOSITORY   TAG     IMAGE ID         CREATED          SIZE
java         8       308e519aac60     6 days ago       824.5 MB
java         7       493d82594c15     3 months ago     656.3 MB
java         latest  2711b1d6f3aa     5 months ago     603.9 MB

添加过滤条件, 查看未被标记的镜像

$ docker images -f "dangling=true"
REPOSITORY  TAG       IMAGE ID         CREATED         SIZE
<none>      <none>    8abc22fbb042     4 weeks ago     0 B
<none>      <none>    48e5f45168b9     4 weeks ago     2.489 MB
<none>      <none>    bf747efa0e2f     4 weeks ago     0 B
<none>      <none>    980fe10e5736     12 weeks ago    101.4 MB
<none>      <none>    dea752e4e117     12 weeks ago    101.4 MB
<none>      <none>    511136ea3c5a     8 months ago    0 B

删除镜像 docker rmi

删除未被标记的镜像
$ docker rmi $(docker images -f "dangling=true" -q)
8abc22fbb042
48e5f45168b9
bf747efa0e2f
980fe10e5736
dea752e4e117
511136ea3c5a
  1. docker load
    加载镜像
$ docker load -i docker.mineservice.lbs.tar
$ docker load < docker.mineservice.lbs.tar
  1. docker run
    通过 Docker 生命周期示意图, 我么可以发现, docker run 命令实际上是融合了 docker createdocker start 两步操作. 直接将镜像创建并运行一个容器实例.
    docker run 命令太有的说了, 这次只列举几个常用的参数
docker run [OPTIONS] IMAGE [COMMAND] [ARG...]
OPTIONS:
--help				Print usage
--add-host			Add a custom host-to-IP mapping (host:ip)
--detach , -d		Run container in background and print container ID
--env , -e			Set environment variables
--hostname , -h	Container host name
--interactive , -i	Keep STDIN open even if not attached
--mac-address		Container MAC address (e.g., 92:d0:c6:0a:29:33)
--memory , -m		Memory limit
--name				Assign a name to the container
--privileged		Give extended privileges to this container
--publish , -p		Publish a container’s port(s) to the host
--publish-all , -P	Publish all exposed ports to random ports
--restart			Restart policy to apply when a container exits
--tty , -t			Allocate a pseudo-TTY
--volume , -v		Bind mount a volume

restart 策略

Docker 容器的重启策略如下:  
① no, 默认策略, 在容器退出时不重启容器;
② on-failure, 在容器非正常退出时(退出状态非0),才会重启容器; 
③ on-failure:3, 在容器非正常退出时重启容器, 最多重启3次; 
④ always, 在容器退出时总是重启容器;
⑤ unless-stopped, 在容器退出时总是重启容器, 但是不考虑在 Docker 守护进程启动时就已经停止了的容器  

拉取镜像 docker pull

默认拉取标签为 latest 镜像
$ docker pull debian
Using default tag: latest
latest: Pulling from library/debian
fdd5d7827f33: Pull complete
a3ed95caeb02: Pull complete
Digest: sha256:e7d38b3517548a1c71e41bffe9c8ae6d6d29546ce46bf62159837aad072c90aa
Status: Downloaded newer image for debian:latest
$ docker pull debian:jessie
jessie: Pulling from library/debian
fdd5d7827f33: Already exists
a3ed95caeb02: Already exists
Digest: sha256:a9c958be96d7d40df920e7041608f2f017af81800ca5ad23e327bc402626b58e
Status: Downloaded newer image for debian:jessie
在执行 `docker run` 时, 若本地镜像不存在, 会自动从镜像仓库拉取镜像, 不需要手动执行 `docker pull` 命令

停止容器 docker stop

$ docker stop lbs

删除容器 docker rm

$ docker rm lbs
删除的容器一定是状态为 stop 的容器

重启容器 docker restart

$ docker restart lbs
他不是 `docker stop` 与 `docker start` 的简单组合, 着我们之后分享的时候再说
  1. docker logs
    展示容器日志
$ docker logs -f --tail 500 lbs
  1. docker exec
    运行的容器内执行命令, 使用的比较多的是
$ docker exec -it lbs bash

意思是在 lbs 容器内执行 bash 命令, 并提供终端保持容器输入的打开状态. 说白了就是给个为终端持续操作容器

  1. docker stats
    实时展示容器资源使用情况
$ docker stats
  1. docker inspect
    展示容器实例底层信息
$ docker inspect lbs
  1. docker ps
    展示容器实例列表
$ docker ps
$ docker ps -a
  1. docker cp
    在容器与宿主机之间进行文件复制
# 将宿主机 mm-lbs-service.jar 文件复制到容器 lbs 内的 /app.jar 文件
$ docker cp mm-lbs-service.jar lbs:/app.jar
# 将容器 lbs 内的 /app.jar 文件复制到宿主机 /APP/mm-lbs-service.jar 文件
$ docker cp lbs:/app.jar /APP/mm-lbs-service.jar 
  1. docker commit
    以一个容器实例为基础创建一个新的镜像
$ docker commit -m "update" -a irvin lbs mineservice/lbs:vx.x.x

总结

  • 介绍 Docker;
  • 内核原理, 介绍了 namespace 及 cgroups. 对比了容器与虚拟机的差异;
  • Dockerfile 怎么写;
  • 常用 Docker 命令.
  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值