容器化部署愈来愈多的应用于企业的生产环境中,如何构建安全可靠、最小化的 Docker
镜像也就愈来愈重要。本文将针对该问题,经过原理加实践的方式,从头到脚帮你撸一遍。文章比较长,主要经过五个部分对容器镜像进行讲解。分别是:html
读者能够根据各自状况选择性阅读。原文发布在个人我的站点: GitDiG.com. 原文链接:构建安全可靠、最小化的 Docker 镜像.node
手动构建 Docker
镜像的流程图,以下:git
如今依次按照流程采用命令行的方式手动构建一个简单的Docker
镜像。github
取busybox
做为本次试验的基础镜像,由于它足够小,大小才 1.21MB
。golang
$: docker run -it busybox:latest sh
/ # touch /newfile
/ # exit
复制代码
经过以上的操做,咱们完成了流程图的前三步。建立了一个新容器,并在该容器上建立了一个新问题。只是,咱们退出容器后,容器也不见了。固然容器不见了,并不表示容器不存在了,Docker
已经自动保存了该容器。若是在建立时,未显示设置容器名称,能够经过如下方式查找该消失的容器。docker
# 列出最近建立的容器
$: docker container ls -n 1
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
c028c091f964 busybox:latest "sh" 13 minutes ago Exited (0) 27 seconds ago upbeat_cohen
# 查询容器的详情
$: docker container inspect c028c091f964
...
复制代码
手动构建镜像,很简单。先找到发生变动的容器对象,对其变动进行提交。提交完成后,镜像也就生成了。不过此时的镜像只有一个自动生成的序列号惟一标识它。为了方便镜像的检索,须要对镜像进行命名以及标签化处理。安全
命令行操做以下:bash
# 提交变动, 构建镜像完成
$: docker commit -a JayL -m "add newfile" c028c091f964
sha256:01603f50694eb62e965e85cae2e2327240e4a68861bd0e98a4fb4ee27b403e6d
# 对镜像进行命名, 原镜像ID取前几位就能够了
$: docker image tag 01603f50694eb62e9 busybox:manual
# 验证新镜像
$: docker run busybox:manual ls -al newfile
-rw-r--r-- 1 root root 0 Jun 15 05:25 newfile
复制代码
经过以上两步过程就完成了Docker
镜像手动建立。很是简单是否是。可是也很是麻烦,必须先建立新容器在提交变动,生成镜像。整个过程彻底能够经过脚本化处理,这也是下节要说的,自动化构建Docker
镜像。网络
自动化构建Docker
镜像,Docker
公司提供的不是SHELL脚本的方式,而是经过定义一套独立的语法来描述整个构建过程, 经过该语法编辑的文件,称为 Dockerfile
。 自动化构建镜像就是经过编写Dockerfile
文件构建的。app
一样完成上面的工做,用Dockerfile
写出来就是:
FROM busybox:latest
RUN touch /newfile 复制代码
至于更加详细的Dockerfile
语法,请参见官方指南。
完成Dockerfile
编写后,经过命令触发构建。整个过程,脚本化出来就是:
$: mkdir autobuild && cd autobuild
$: cat <<EOF > Dockerfile
FROM busybox:latest
RUN touch /newfile
EOF
$: docker build -t busybox:autobuild .
复制代码
Docker 镜像是由一组只读的镜像层
Image Layer
组成的。而Docker 容器则是在Docker 镜像的基础之上,增长了一层:容器层Container Layer
。容器层Container Layer
是可读写的。若是对该容器层Container Layer
进行commit
提交操做,该层就变成了新的镜像层Image Layer
。新的Docker Image
也就构建出来了。
$: mkdir layer && cd layer && touch newfile
$: cat <<EOF > Dockerfile
FROM scratch
ADD newfile .
EOF
$: docker build -t layer .
复制代码
如下官网提供的图示能够很清楚的看出镜像与容器之间的联系与区别:
具体某个镜像的组成Layer
能够经过以下命令进行查询:
# 镜像的构建层历史
$: docker history busybox:autobuild
IMAGE CREATED CREATED BY SIZE COMMENT
845cc5130d2c 17 minutes ago /bin/sh -c touch /newfile 0B
ef46e0caa533 4 days ago /bin/sh -c #(nop) CMD ["sh"] 0B
<missing> 4 days ago /bin/sh -c #(nop) ADD file:1067e5a... in / 1.21MB
复制代码
不难看出,镜像busybox:autobuild
一共执行了从底往上的三次层构建。具体构建的指令能够经过第三列的命令得出。<missing>
的意思是:该层是在其它系统上构建的,在本地是不可用的。只须要忽略就好。
要了解 Docker 镜像的存储首先必须了解联合文件系统 UnionFS
(Union File System),所谓UnionFS
就是把不一样物理位置的目录合并mount
到同一个目录中。UnionFS
的具体实现有不少种:
具体Docker
宿主机上使用那种UnionFS
文件系统驱动,能够经过以下命令查询:
$: docker info | grep Storage
Storage Driver: overlay2
复制代码
overlay2
是一种更现代的联合文件系统 UnionFS
,它比overlay
的早期版本在稳定与性能上都有很大提高。因此通常最新的Docker
采用的存储驱动使用的都是overlay2
。
为了方便演示UnionFS
文件系统,若是是MacOS系统,建议安装Docker Machine
开启一台新的虚拟机操做,排除由于Docker for MacOS
运行在虚拟机上的各类环境干扰。具体Docker Machine
的安装请自行查阅相关文档。
首先建立一台新的Docker Machine
:
# 建立
$: docker-machine create ufs
...
Docker is up and running!
# 登陆
$: docker-machine ssh ufs
... ok
# 查询 overlay
$: cat /proc/filesystems | grep overlay
nodev overlay
复制代码
经过确认,这台Docker Machine
是支持UnionFS
文件系统的,使用的是overlay
存储驱动。 既然UnionFS
就是把不一样物理位置的目录合并mount到同一个目录中.如今咱们经过命令行的方式实现一下Docker
官网提供UnionFS
的原理图。
从图中能够看出,咱们须要提供两个目录,分别表明Container Layer
和Image Layer
。目录名称,取图示右部的名称:
upper
, 表明Container Layer
lower
, 表明Image Layer
除了这两个目录之外,经过UnionFS
挂载目录还须要两个目录:
merged
, 表明挂载目录,即合并后的目录work
, 必须为空目录,是overlay
存储驱动挂载所需的工做目录。经过命令行实现图示中的文件夹结构:
# 建立一个测试目录
$: mkdir demo && cd demo
# 建立子目录与文件
$: mkdir upper lower merged work
$: touch lower/file1 lower/file2 lower/file3
$: touch upper/file2 upper/file4
# 经过文件内容区分如下file2
$: echo lower > lower/file2
$: echo upper > upper/file2
# 未挂载
$: ls merged
复制代码
迄今为止,一切都是常规文件系统操做。如今经过mount
命令进行UnionFS
文件系统的目录挂载.
# 目录合并挂载到merged
$: sudo mount -t overlay overlay -olowerdir=lower,upperdir=upper,workdir=work merged
# 挂载完成后
$: ls merged
file1 file2 file3 file4
# file2 使用的是顶层 upper 的file2 文件
$: cat merged/file2
upper
复制代码
下面再分别经过文件的增删改加深对UnionFS
文件系统的理解:
# 新增文件
$: touch merges/file5
$: ls merged/
file1 file2 file3 file4 file5
# 新增文件写在顶层的 upper 文件夹
$: ls upper/
file2 file4 file5
$: ls lower/
file1 file2 file3
复制代码
# 修改文件 CoW 技术
$: echo mod > merged/file1
$: ls upper/
file1 file2 file4 file5
$: cat upper/file1
mod
$: cat lower/file1
复制代码
# 删除文件
$: rm merged/file1
$: ls -al upper | grep file1
c--------- 1 root root 0, 0 Jun 17 10:41 file1
$: ls -al lower | grep file1
-rw-r--r-- 1 docker staff 0 Jun 17 10:15 file1
复制代码
实际操做完成以上过程,相信你对于UnionFS
文件系统有了更加直观的感觉。你可能会问, Docker Image
的底层镜像是由一组Layer
组成的,多个底层目录在UnionFS
中如何挂载?其实很简单,只须要经过:
分隔便可。
# 多层目录: lower1 / lower2 / lower3
$: sudo mount -t overlay overlay -olowerdir=lower1:lower2:lower3,upperdir=upper,workdir=work merged
复制代码
挂载完成后,lower1
/ lower2
/ lower3
之间的层叠顺序又是怎样,读者能够自行测试一下。
最后,咱们查询一下系统的挂载列表,
mount | grep overlay
overlay on /home/docker/demo/merged type overlay (rw,relatime,lowerdir=lower,upperdir=upper,workdir=work)
复制代码
从现有输出可知目前咱们docker-machine
中仅挂载了一个overlay
目录。
如今咱们在这台新的docker-machine
上构建一个1.2
中所描述的Docker
镜像: busybox:autobuild
。
$: mkdir autobuild && cd autobuild
$: cat <<EOF > Dockerfile
FROM busybox:latest
RUN touch /newfile
EOF
$: docker build -t busybox:autobuild .
# 完成构建后,如今系统中有两个docker image
$: docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
busybox autobuild 2e32da74b3ad 4 seconds ago 1.22MB
busybox latest e4db68de4ff2 2 days ago 1.22MB
复制代码
构建完成后,咱们直接看一下docker-machine
上的文件系统的挂载状况:
# docker 无容器运行
$: mount
...
/dev/sda1 on /mnt/sda1/var/lib/docker type ext4 (rw,relatime,data=ordered)
# docker 运行容器时
# 从新开启新会话,运行一个容器实例 `docker run -it busybox:autobuild sh`
$: mount
...
/dev/sda1 on /mnt/sda1/var/lib/docker type ext4 (rw,relatime,data=ordered)
overlay on /mnt/sda1/var/lib/docker/overlay2/a54541dd24971b9491a54b43cdf51f4ef9c87c1cd29748bb3fe64dedafd91b56/merged type overlay (rw,relatime,lowerdir=/mnt/sda1/var/lib/docker/overlay2/l/KLGL6INSJ2UBLMAUP5B4IORUTG:/mnt/sda1/var/lib/docker/overlay2/l/BGIT3WQZVII4Z2THF35I6T5V5O:/mnt/sda1/var/lib/docker/overlay2/l/6GZ2NT4UQT6EQK3IT4IGMBXU4T,upperdir=/mnt/sda1/var/lib/docker/overlay2/a54541dd24971b9491a54b43cdf51f4ef9c87c1cd29748bb3fe64dedafd91b56/diff,workdir=/mnt/sda1/var/lib/docker/overlay2/a54541dd24971b9491a54b43cdf51f4ef9c87c1cd29748bb3fe64dedafd91b56/work)
shm on /mnt/sda1/var/lib/docker/containers/e50f19c5bde3fe53cde3729de92f75b74323f7ebb506b0635eb76dd5b81e080a/mounts/shm type tmpfs (rw,nosuid,nodev,noexec,relatime,size=65536k)
nsfs on /var/run/docker/netns/3c464f8003e8 type nsfs (rw)
复制代码
对比输出,可以很明显的看到,暂仅关注 overlay
挂载状况。得出:
/mnt/sda1/var/lib/docker/overlay2/a54541dd24971b9491a54b43cdf51f4ef9c87c1cd29748bb3fe64dedafd91b56/merged
/mnt/sda1/var/lib/docker/overlay2/a54541dd24971b9491a54b43cdf51f4ef9c87c1cd29748bb3fe64dedafd91b56/diff
/mnt/sda1/var/lib/docker/overlay2/l/KLGL6INSJ2UBLMAUP5B4IORUTG
/mnt/sda1/var/lib/docker/overlay2/l/BGIT3WQZVII4Z2THF35I6T5V5O
/mnt/sda1/var/lib/docker/overlay2/l/6GZ2NT4UQT6EQK3IT4IGMBXU4T
其中镜像Layer使用的是软链接。一样的信息,咱们能够通docker inspect
查询出来。
$: docker inspect <container-id> -f '{{.GraphDriver.Data.MergedDir}}'
$: docker inspect <container-id> -f '{{.GraphDriver.Data.UpperDir}}'
$: docker inspect <container-id> -f '{{.GraphDriver.Data.LowerDir}}'
复制代码
输出的路径就是具体Docker
镜像的存储位置。
最小化 Docker
镜像的缘由可总结出如下几条:
按 1.3
、1.4
中所讨论的镜像的组成原理与存储, 最小化 Docker
镜像的主要途径总结下来也就两条:
先从简单的减小镜像Layer的层数开始。
在定义Dockerfile
的时候,每一条指令都会对应一个新的镜像层。经过docker history
命令就能够查询出具体Docker
镜像构建的层以及每层使用的指令。为了减小镜像的层数,在实际构建镜像时,经过使用&&
链接命令的执行过程,将多个命令定义到一个构建指令中执行。如:
FROM debian:stable
WORKDIR /var/www
RUN apt-get update && \ apt-get -y --no-install-recommends install curl \ ca-certificates && \ apt-get purge -y curl \ ca-certificates && \ apt-get autoremove -y && \ apt-get clean 复制代码
除了经过将多命令经过&&
链接到一个构建指令外,在Docker
镜像的构建过程当中,还能够经过--squash
的方式,开启镜像层的压缩功能,将多个变化的镜像层,压缩成一个新的镜像层。
具体命令就以下:
$: docker build --squash -t <image> .
复制代码
缩减Layer的大小须要从头开始,即选择什么样的基础镜像做为初始镜像。通常状况下,你们都会从如下三个基础镜像开始。
镜像 busybox 经过busybox
程序提供一些基础的Linux系统操做命令,镜像 alpine则是在次基础上提供了apk
包管理命令,方便安装各种工具及依赖包。普遍使用的镜像基本都是镜像 alpine。镜像 busybox更适合一些快速的实验场景。而镜像 scratch空镜像,由于不提供任何辅助工具,对于不依赖任何第三方库的程序是合适的。由于镜像 scratch空镜像自己不提供任何container OS
,因此程序是运行在Docker Host
即宿主机上的,只是利用了Docker
技术提供的隔离技术而已。
细心的读者可能会发现,在MacOS
上编译的程序,采用镜像 scratch空镜像时,容器运行会报错:。那是由于,Docker for Mac
是运行在Linux
虚拟机上的缘故。因此不能够直接构建MacOS
格式的可执行程序在Docker for Mac
上采用空镜像的方式运行。
多阶段构建 Multi-Stage Build
是 Docker 17.05
版本开始引入的新特性。经过将原先仅一个阶段构建的镜像查分红多个阶段。之因此多阶段构建镜像可以缩减镜像的大小,是由于发布程序在编译期相关的依赖包以及临时文件并非最终发布镜像所须要的。经过划分不一样的阶段,构建不一样的镜像,最终镜像则取决于咱们真正须要发布的实体是什么。
FROM golang:1.11-alpine3.7 AS builder
WORKDIR /app COPY main.go . RUN go build -o server .
FROM alpine:3.7
WORKDIR /app COPY --from=builder /app .
CMD ["./server"] 复制代码
如上的Dockerfile
就是多阶段构建,在builder
阶段使用的基础镜像是golang:1.11-alpine3.7
,显然是由于编译期的须要,对于发布真正的server
程序是彻底不必的。经过多阶段构建镜像的方式就能够仅仅打包须要的实体构成镜像。
除了多阶段构建之外,若是你还想忽略镜像中一些冗余文件,还能够经过.dockerignore
的方式在文件中定义出来。功能和.gitignore
相似。
最小化Docker
镜像的构建完成了,可是,咱们的工做却仍未结束。咱们还须要对镜像进行加固处理。
镜像内容可寻址标识符(Content addressable image identifiers), 能够对来源基础镜像内容进行校验,确保没有被第三方篡改。具体的操做方式,就是在构建本身镜像的同时,对基础镜像内容进行内容的sha256
摘要值进行设置,防止在不知情的状况下被篡改。
首先,得出具体镜像的正确sha256
摘要值.
# 经过命令查询出具体镜像的sha256摘要
$: docker inspect busybox:autobuild -f "{{.RepoDigests}}"
sha256:9b63a0eaaed5e677bb1e1b29c1a97268e6c9e6fee98b48badf0f168ae72a51dc
复制代码
再在Dockerfile
定义时,设置基础镜像的sha256
摘要值
FROM busybox@sha256:9b63a0eaaed5e677bb1e1b29c1a97268e6c9e6fee98b48badf0f168ae72a51dc
...
复制代码
注意:镜像内容可寻址标识符的获取必须通过一次 push 或者 pull 操做,即在镜像注册服务上发布后,才能够经过以上 inspect 命令查询出结果。若是仅仅是本地的镜像,没法经过 inpect 命令获取。固然仅仅是本地使用的镜像,镜像内容可寻址标识符也是不必的。
容器一旦建立出来,其默认使用的用户是能够在镜像中进行设置的。经过设置必要的镜像默认用户,能够限制其在容器中的执行权限。在某种程度上也就进行提高了镜像的安全级别。不过,这须要根据具体的业务发布状况进行设置,常规状况下,基础镜像都仍是root用户做为默认用户 。
安全原则:构建镜像自己是为了特定的应用定制的,默认状况下应该尽量的下降用户权限。
除了镜像自己设置必要的默认用户之外,在镜像中,还会存在一类程序,即便是经过普通用户执行,但在运行时会以更高级别的权限执行。就是系统针对可执行文件与目录提供的SUID与SGID特殊权限。
经过对可执行文件设置SUID或SGID属性,本来执行命令的用户会切换成为命令的全部者或是所属组的权限进行执行。也就是提高了执行命令的权限。
在实际的镜像构建中,应该尽量的避免此类权限提高形成的可能的漏洞。建议镜像构建时,扫描镜像内是否存在此类执行文件,若是存在尽量的删除。删除命令可参考:
# 镜像构建过程当中增长对特殊权限可执行文件的扫描并删除
RUN for i in $(find / -type f \( -perm +6000 -o -perm +2000 \)); \ do chmod ug-s $i; done 复制代码
正如Code Review
同样,代码审查能够大大提高企业项目的质量。容器镜像一样做为开发人员或是运维人员的产出物,对其进行审查也是必要的。
虽然咱们能够经过docker
命令结合文件系统浏览的方式进行容器镜像的审查,但其过程须要人工参与,很难作到自动化,更别提将镜像审查集成到CI过程当中了。但一个好的工具能够帮咱们作到这点。
推荐一个很是棒的开源项目dive,具体安装请参考其项目页。它不但能够方便咱们查询具体镜像层的详细信息,还能够做为CI
持续集成过程当中的镜像审查之用。使用它能够大大提高咱们审查镜像的速度,而且能够将这个过程作成自动化。
该项目的具体动态操做图示以下:
若是做为镜像审查以后,能够进行以下命令操做:
$: CI=true dive <image-id>
Fetching image... (this can take a while with large images)
Parsing image...
Analyzing image...
efficiency: 95.0863 %
wastedBytes: 671109 bytes (671 kB)
userWastedPercent: 8.2274 %
Run CI Validations...
Using default CI config
PASS: highestUserWastedPercent
SKIP: highestWastedBytes: rule disabled
PASS: lowestEfficiency
复制代码
从输出信息能够获得不少有用的信息,集成的CI
过程也就很是容易了。 dive
自己就提供了.dive-ci
做为项目的CI
配置:
rules:
# If the efficiency is measured below X%, mark as failed.
# Expressed as a percentage between 0-1.
lowestEfficiency: 0.95
# If the amount of wasted space is at least X or larger than X, mark as failed.
# Expressed in B, KB, MB, and GB.
highestWastedBytes: 20MB
# If the amount of wasted space makes up for X% or more of the image, mark as failed.
# Note: the base image layer is NOT included in the total image size.
# Expressed as a percentage between 0-1; fails if the threshold is met or crossed.
highestUserWastedPercent: 0.20
复制代码
集成到CI
中,增长如下命令便可:
$: CI=true dive <image-id>
复制代码
镜像审查和代码审查相似,是一件开始抵制,开始后就欲罢不能的事。这件事宜早不宜迟。对于企业与我的而言均百利而无一害。
以前发布文章如何对Docker Image进行审查,出自此节。