前言
这是一篇关于镜像与容器的基础篇,虽然有些内容与 18 年写的文章迈入 Docker、Kubernetes 容器世界的大门有重叠,但随着这几年对容器的熟悉,我想将一些认识分享出来,并作为我后续将要写的文章一些技术铺垫。
镜像是什么
在描述什么是 镜像 前,先来看一下如下示例程序,其为基于 flask 框架写的一个简单的 python 程序。
# 文件 app.py
from flask import Flask
app = Flask(__name__)
@app.route('/')
def hello():
return "Hello, World"
if __name__ == "__main__":
app.run(host='::', port=9080, threaded=True)
# 文件 requirements.txt
flask
为了运行此程序,我们首先需要一个操作系统(如 ubuntu),而后将程序上传到主机某个目录(如/app),接着安装python 3 与pip程序,而后使用 pip 安装 flask 模块,最后使用 python 运行此程序,其过程涉及命令如下所示:
apt-get update
apt-get install python3 python3-pip
pip install -r /app/requirements.txt
python3 /app/app.py
假设另一款程序只能运行在某特定版本(如 0.8)的flask 模块上,那么此时运行 pip install flask=0.8
将会与上面安装的 flask 版本相冲突,为了解决此问题,我们可使用容器技术将 程序运行环境 与程序本身 打包起来,而打包后的东西我们称之为Image 镜像。
为了制作镜像,我们需选择一款工具,如 docker、,而本文选择一款名为podman 的工具,功能可用 alias docker=podman
来描述。在 centos 7.6 以上操作系统,执行如下命令安装:
yum -y install podman
通常,我们将制作镜像的过程或逻辑编写在一个名为 Dockerfile 的文件中,对于示例程序,我们在主机源码目录下添加一个Dockerfile,其包含的构建逻辑如下所示:
# 1. 选择 ubuntu 操作系统,版本为 bionic(18.04),我们后续将使用 apt-get 安装 python 与 pip
FROM docker.io/library/ubuntu:bionic
# 2. 指定工作目录,等价于命令:mkdir /app && cd /app
WORKDIR /app
# 3. 使用 ubuntu 操作系统包管理软件 apt-get 安装 python
RUN apt-get update && apt-get install -y \
python3 \
python3-pip \
&& rm -rf /var/lib/apt/lists/*
# 4. 将 python 模块依赖文件拷贝到工作目录并执行 pip 从阿里云 pypi 源安装 python 模块
COPY requirements.txt ./
ENV INDEX_URL https://mirrors.aliyun.com/pypi/simple
RUN pip3 install -i $INDEX_URL --no-cache-dir -r requirements.txt
# 5. 将主程序拷贝到工作目录
COPY app.py ./
# 6. 指定使用此镜像运行容器时的命令
CMD python3 /app/app.py
接着,我们执行如下命令将应用打包成镜像,也就是说,下述命令执行 Dockerfile 文件内的指令从而生成应用镜像 (名为hello-flask),其包含python 运行环境与源码。
podman build -t hello-flask -f Dockerfile .
生成的镜像此时保存到我们的宿主机上,此时其是静态的,如下所示,这个镜像共 460MB 大小。
$ podman images hello-flask
REPOSITORY TAG IMAGE ID CREATED SIZE
localhost/hello-flask latest ffe9ef09e05d 6 minutes ago 460 MB
容器是什么
镜像(Image)将我们的 程序运行环境 与程序本身 打包为一个整体,其是静止的,而当我们基于镜像运行一个实例时,此时则将所运行的实例描述为 容器(container)。
因为制作好的镜像已包含程序运行时环境,如示例镜像包含了 python 与python flask模块,故运行容器时,容器所在的宿主机无需再为程序准备运行时环境,我们仅需在宿主机上安装一个容器运行时引擎即可运行容器,如本文选择podman。
如下所示,我们基于镜像 hello-flask 运行一个容器(名为 hello-1),其可通过宿主机的9080 端口可访问此容器。
# 启动容器
$ podman run --rm --name hello-1 -p 9080:9080 hello-flask
* Serving Flask app "app" (lazy loading)
* Environment: production
WARNING: This is a development server. Do not use it in a production deployment.
Use a production WSGI server instead.
* Debug mode: off
* Running on http://[::]:9080/ (Press CTRL+C to quit)
# 访问容器
$ curl localhost:9080
Hello, World
我们可基于相同的镜像运行多个容器,如下所示,再次基于镜像 hello-flask 运行一个容器(名为 hello-2),其可通过主机的9081 端口访问。
$ podman run --rm --name hello-2 -p 9081:9080 hello-flask
主机运行了哪些容器我们可通过如下命令查看:
$ podman ps
CONTAINER ID IMAGE ... PORTS NAMES
7687848eb0b5 hello-flask:latest ... 0.0.0.0:9081->9080/tcp hello-2
aab353fb7008 hello-flask:latest ... 0.0.0.0:9080->9080/tcp hello-1
各容器通过 Linux Namespace 做隔离,也就是说 hello-1 容器与 hello-2 容器是互相看不见的。如下所示,我们执行如下命令登陆到容器 hello-1 中,而后执行 ps -ef
可发现仅含几个命令:
$ podman exec -it hello-1 /bin/sh
# ps -ef
UID PID PPID C STIME TTY TIME CMD
root 1 0 0 09:01 ? 00:00:00 /bin/sh -c python3 /app/app.py
root 7 1 0 09:01 ? 00:00:00 python3 /app/app.py
root 10 0 40 09:21 pts/0 00:00:00 /bin/sh
root 16 10 0 09:21 pts/0 00:00:00 ps -ef
如上所示,我们可发觉容器不含操作系统内核,通过 ps
可发现容器运行的几个命令,而在上章构建镜像时,我有提到在 Dockerfile 中通过 FROM ubuntu:bionic
指令选择了 ubuntu 系统,此说法不是很正确,而正确的说法应是选择了一个没有内核的 ubuntu 操作系统镜像。
因容器不是虚拟机,虚拟机是一个完整的操作系统,而容器却是没有操作系统内核的,所有的容器仍然共享宿主机的内核,我们可在宿主机上通过 ps -ef
发现容器执行的命令。
$ ps -ef|grep app.py
root 3133 3120 0 17:01 ? 00:00:00 /bin/sh -c python3 /app/app.py
root 3146 3133 0 17:01 ? 00:00:00 python3 /app/app.py
root 14041 14029 0 17:15 ? 00:00:00 /bin/sh -c python3 /app/app.py
root 14057 14041 0 17:15 ? 00:00:00 python3 /app/app.py
镜像仓库用途
为了分发镜像,我们将制作好的镜像通过网络上传到 镜像仓库 中,而后只要主机可访问镜像仓库,则其就可通过镜像仓库下载镜像并快速部署容器,其类似于 github,在github 我们存储源码,而镜像仓库则存储镜像而已。
在构建镜像时 Dockerfile 中有如下 From 指令,此镜像我们指定从 docker hub 中获取,此为 docker 公司制作的 public 镜像,从 https://hub.docker.com 上我们 …。
FROM docker.io/library/ubuntu:bionic
对于企业来说通常会搭建自己的私有镜像仓库,如 habor、quay,但对于个人测试用途来说,我们可基于 registry 镜像搭建一个简单的私有镜像仓库,如下所示:
mkdir /app/registry
cat > /etc/systemd/system/poc-registry.service <<EOF
[Unit]
Description=Local Docker Mirror registry cache
After=network.target
[Service]
ExecStartPre=-/usr/bin/podman rm -f %p
ExecStart=/usr/bin/podman run --name %p \
-v /app/registry:/var/lib/registry:z \
-e REGISTRY_STORAGE_FILESYSTEM_ROOTDIRECTORY=/var/lib/registry \
-p 5000:5000 registry:2
ExecStop=-/usr/bin/podman stop -t 2 %p
Restart=on-failure
RestartSec=10
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable poc-registry
systemctl restart poc-registry
假设为部署的镜像服务我们配置主机名称为 registry.zyl.io,因其未使用SSL 加密,对于 podman 容器引擎,我们需在如下文件中添加如下信息,后续访问此镜像仓库时将不验证 HTTPS 证书:
# vi /etc/containers/registries.conf
...
[[registry]]
location = "registry.zyl.io:5000"
insecure = true
...
接着,我们将镜像推送到此仓库中,但在此之前我们先执行 podman tag
镜像名称。
podman tag localhost/hello-flask:latest registry.zyl.io:5000/hello-flask:latest
podman push registry.zyl.io:5000/hello-flask:latest
而后,我们先删除镜像,再使用 pull
命令下载镜像。
podman rmi registry.zyl.io:5000/hello-flask:latest
podman pull registry.zyl.io:5000/hello-flask:latest
镜像的结构组成
参考 docker 官方文档 About storage drivers 可知镜像由只读层(layer)堆叠而成,而上一层又是对下一层的引用,而基于镜像运行的容器,其又会在镜像层(Image layers)上生成一个可读写的容器层(Container layer),我们对容器的写操作均发生在容器层上,而至于各层如何交互则由不同的存储驱动(storage drivers)负责。
通常 Dockerfile 中的每条指令均会生成只读镜像层,如官方示例所示,其总共含 4 个指令:
FROM ubuntu:15.04
COPY . /app
RUN make /app
CMD python /app/app.py
下图截取自 docker 官方文档,其显示上面的 Dockfile 构建了 4 个镜像层,从上往下,第 1 层由 cmd 指令生成,第 2 层由 run 指令生成,第 3 层为 copy 指令生成,而第 4 层为 from 指令生成,但下图的第 4 层为一个笼统的概括,其包含基础镜像的所有层。
下面我们通过命令来观察镜像 ubuntu 所包含的层,其显示有 5 个镜像层:
$ podman history ubuntu:bionic
ID CREATED CREATED BY SIZE ...
c3c304cb4f22 7 weeks ago /bin/sh -c #(nop) CMD ["/bin/bash"] 0B
<missing> 7 weeks ago /bin/sh -c mkdir -p /run/systemd && echo '... 161B
<missing> 7 weeks ago /bin/sh -c set -xe && echo '#!/bin/sh' > /... 847B
<missing> 7 weeks ago /bin/sh -c [-z "$(apt-get indextargets)" ] 35.37kB
<missing> 7 weeks ago /bin/sh -c #(nop) ADD file:c3e6bb316dfa6b8... 26.69MB
上面构建的 hello-flask 镜像基于 ubuntu 镜像,其总共包含 12 层:
$ podman history hello-flask
ID CREATED CREATED BY SIZE
# CMD python3 /app/app.py
ffe9ef09e05d 2 hours ago /bin/sh -c #(nop) CMD python3 /app/app.py 0B
# COPY app.py ./
<missing> 2 hours ago /bin/sh -c #(nop) COPY file:e007c2b54ecd4c... 294B
# RUN pip3 install -i $INDEX_URL --no-cache-dir -r requirements.txt
<missing> 2 hours ago /bin/sh -c pip3 install -i $INDEX_URL --no... 1.291MB
# ENV INDEX_URL https://mirrors.aliyun.com/pypi/simple
<missing> 2 hours ago /bin/sh -c #(nop) ENV INDEX_URL https://mi... 1.291MB
# COPY requirements.txt ./
<missing> 2 hours ago /bin/sh -c #(nop) COPY file:774347764755ea... 179B
# RUN apt-get update && ...
<missing> 2 hours ago /bin/sh -c apt-get update && apt-get insta... 165.4MB
# WORKDIR /app
<missing> 2 hours ago /bin/sh -c #(nop) WORKDIR /app 322B
# FROM docker.io/library/ubuntu:bionic
<missing> 7 weeks ago /bin/sh -c #(nop) CMD ["/bin/bash"] 322B
<missing> 7 weeks ago /bin/sh -c mkdir -p /run/systemd && echo '... 185B
<missing> 7 weeks ago /bin/sh -c set -xe && echo '#!/bin/sh' > /... 965B
<missing> 7 weeks ago /bin/sh -c [-z "$(apt-get indextargets)" ] 38.94kB
<missing> 7 weeks ago /bin/sh -c #(nop) ADD file:c3e6bb316dfa6b8... 27.76MB
知晓镜像由只读层堆叠而成对于构建 优雅 的镜像非常有用,下面我将使用一个简单的例子来讲解缘由,但若想获取更详细信息则可参考官方文档 Best practices for writing Dockerfiles。
考虑这样的场景:有一个临时文件,我们对其处理后就删除以避免占用空间。若在操作系统执行下述示例,则所涉及过程与结果符合我们的期望:磁盘空间被释放了。
# 1. 生成一个 50m 的文件用于测试
dd if=/dev/zero of=test.txt bs=1M count=50
# 2. 处理临时文件,这里我们使用 ls 命令
ls -lh test.txt
-rw-r--r-- 1 root root 50M Jun 12 18:49 test.txt
# 3. 删除临时文件,避免占用磁盘空间
rm -f test.txt
我们按照上面处理过程原封不动的平移到 Dockerfile 中,上述每条命令我们将其单独放在一个 RUN
指令中:
$ podman build -t test -f - . <<EOF
FROM docker.io/library/ubuntu:bionic
RUN dd if=/dev/zero of=test.txt bs=1M count=50
RUN ls -lh test.txt
RUN rm -f test.txt
EOF
我们期望构建后的镜像应与基础镜像 ubuntu:bionic 大小差不多,因为我们最终将文件删除了嘛,但实际结果却与我们预期相差太多,最终生成的镜像要比基础镜像大 50M 左右。
$ podman images | grep -w ubuntu
docker.io/library/ubuntu bionic ... 66.6 MB
$ podman images | grep -w test
localhost/test latest ... 119 MB
$ podman history localhost/test
ID CREATED CREATED BY SIZE
719f3ed7b57c 5 minutes ago /bin/sh -c rm -f test.txt 1.536kB
<missing> 5 minutes ago /bin/sh -c ls -lh test.txt 1.024kB
# RUN dd if=/dev/zero of=test.txt bs=1M count=50 生成了 50m 的只读镜像层
<missing> 5 minutes ago /bin/sh -c dd if=/dev/zero of=test.txt bs=...52.43MB
...
当我们了解到镜像由只读层堆叠而成,那么对于此结果能接受,那么,对于类似问题,我们则可调整镜像构建逻辑,将其置于相同的层上以优化镜像大小。
$ podman build -t test -f - . <<EOF
FROM docker.io/library/ubuntu:bionic
RUN dd if=/dev/zero of=test.txt bs=1M count=50 && \
ls -lh test.txt && \
rm -f test.txt
EOF
此时可发现镜像大小与我们预期相符合了。
$ podman images | grep -w test
localhost/test latest d57331d89d86 9 seconds ago 66.6 MB
$ podman history test
ID CREATED CREATED BY SIZE
d57331d89d86 20 seconds ago /bin/sh -c dd if=/dev/zero of=test.txt bs=... 167B
...
镜像的层可重用
若我们重复运行相同的构建过程,可发现后续构建会比之前快速很多,在构建输出中我们可发现有 --> Using cache ...
的提示,此提示表明新的构建生成的镜像重用了原有镜像的层,故加快了构建速度,但同样会因此造成问题。
如下述构建逻辑貌似并没有任何问题,在我们安装 curl
工具前先执行 apt-get update
更新系统源,但后续我们的构建可能因缓存原因重用了 RUN apt-get update
这一层,从而导致后续安装的 curl
工具可能不是最新的,这与我们的预期有差别。
FROM ubuntu:18.04
RUN apt-get update
RUN apt-get install -y curl
官方文档 Best practices for writing Dockerfiles 有说使用 RUN apt-get update && apt-get install -y
可确保安装了最新的软件包,这样会导致清除此层缓存(cache busting)或失效,但测试发现依旧重用缓存,解决此问题最终的办法也许是在构建时传递 --no-cache
参数明确告知构建过程不重用任何缓存,但又导致构建时间过长。
镜像由层堆叠而成,而上层是对下层的引用,而构建过程又可以重用缓存加快速度。那么考虑如下构建逻辑,我们首先将源码拷贝到镜像中,而后安装 python 与python模块。
FROM ubuntu
COPY app.py ./
COPY requirements.txt ./
RUN apt-get update && apt-get install -y \
python3 \
python3-pip \
&& rm -rf /var/lib/apt/lists/*
RUN pip3 install -r requirements.txt
CMD python3 /app/app.py
上面构建逻辑会导致这样一个问题,假设我们修改了 app.py 源码,这样会导致 COPY app.py ./
层的缓存失效,故而此层需要重新构建,而下层失效会导致所有依赖于此层的上层缓存均失效,故而下述所有指令均无法利用缓存层,鉴于此,我们调整构建逻辑为这样尽量减少修改代码造成缓存层失效问题。
FROM ubuntu
RUN apt-get update && apt-get install -y \
python3 \
python3-pip \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt ./
RUN pip3 install -r requirements.txt
COPY app.py ./
CMD python3 /app/app.py
利用多段构建镜像
在介绍多段构建镜像前,我们先来考虑如何将下述示例构建为镜像,其是一个使用 c 语言编写的 hello world 程序:
$ mkdir hello-c && cd hello-c
$ cat > hello.c <<EOF
#include <stdio.h>
int main(void) {printf("hello world\n");
}
EOF
$ cat > Makefile <<EOF
all:
gcc --static hello.c -o hello
EOF
我们需要 gcc 与make命令来编译此程序,故我们编写如下 Dockerfile 构建镜像:
$ cat > Dockerfile <<'EOF'
FROM ubuntu:bionic
WORKDIR /app
RUN apt-get update && apt-get install -y \
build-essential \
libc-dev \
&& rm -rf /var/lib/apt/lists/*
COPY Makefile ./
COPY hello.c ./
RUN make all
CMD ["./hello"]
EOF
执行 podman build -t test -f Dockerfile .
构建镜像后,其最终大小近300M。
$ podman images|grep test
localhost/test latest ... 281 MB
上面生成的应用镜像包含了编译环境,这些工具只在编译 C 程序时起作用,而程序运行却不依赖于编译环境,也就是说,最终生成的应用镜像我们可去除这些编译环境,鉴于此,我们可采用多阶段构建方式构建镜像。
如下所示,我们调整构建逻辑,在一个 Dockerfile 中我们嵌套了两个 FROM
指令,第 1 个From块我们安装编译环境并编译代码,因为采用 gcc --static
静态编译程序,故最终生成的二进制程序不依赖于主机上任何动态库,故而我们将其拷贝到最终的镜像中,而最终的镜像我们使用了一个系统保留的镜像名scratch,此镜像不存在于任何镜像仓库中,但使用此镜像会告知构建进程生成最小的镜像结构。
cat > Dockerfile <<'EOF'
FROM ubuntu:bionic AS builder
WORKDIR /app
COPY files/sources.list /etc/apt/sources.list
RUN apt-get update && apt-get install -y \
build-essential \
libc-dev \
&& rm -rf /var/lib/apt/lists/*
COPY Makefile ./
COPY hello.c ./
RUN make all
FROM scratch
WORKDIR /app
COPY --from=builder /app/hello .
CMD ["./hello"]
EOF
执行 podman build -t test -f Dockerfile .
构建镜像后,其最终大小不到1M,且此镜像是可被运行的。
$ podman images|grep test
localhost/test latest ... 848 kB
$ podman run --rm test
hello world
如何调试 Pod 或容器
我们的容器大多数部署在 k8s 集群中,故此处我将讲解如何在 k8s 集群环境调试 pod 的方法。
当前常见的调试 pod 的方法是查看其日志、登陆容器内部等方法,如下所示:
kubectl logs <pod_name>
kubectl exec <pod_name> -- /bin/sh
但是,如上节所示,我们为了容器的大小,很多调试工具我们并没有包含到最终镜像中,甚至于连 /bin/sh
都没有,亦或者容器是异常状态,此时我们没法登陆容器调试。
对于这种情况,在 K8S 1.18 集群中,官方在 kubectl
工具中内置了一个调试功能,我们可启动一个临时的调试容器以附加到需调试的 pod 上,但当前处于 alpha 状态,我们需要启用此特性。编辑如下文件在其中的 command
处添加 --feature-gates=EphemeralContainers=true
,等待kubelet
自动重启 kube-apiserver 与kube-scheduler。
- /etc/kubernetes/manifests/kube-apiserver.yaml
- /etc/kubernetes/manifests/kube-scheduler.yaml
为测试用途,我们以 pause 镜像启动一个 pod。 注意 :这里我们指定--restart=Never
避免有问题的 pod 被不断自动重启。
$ podman images|grep pause
k8s.gcr.io/pause 3.2 ... 686 kB
$ kubectl run ephemeral-demo --image=k8s.gcr.io/pause:3.2 --restart=Never
pod/ephemeral-demo created
$ kubectl get pod
NAME READY STATUS RESTARTS AGE
ephemeral-demo 1/1 Running 0 23s
pause镜像如同我们上面构建的镜像一样其没有shell,故我们无法登陆容器:
$ kubectl exec ephemeral-demo -- /bin/sh
...
exec failed: container_linux.go:346: starting container process caused "exec: \"/bin/sh\": stat /bin/sh: no such file or directory"
command terminated with exit code 1
启动一个 debug 容器附加到被调试的 pod 上,此时我们将获取到一个 shell 外壳,此时我们则可做调试任务了。
$ kubectl alpha debug -it ephemeral-demo --image=ubuntu:bionic --target=ephemeral-demo
Defaulting debug container name to debugger-rzwl2.
If you don't see a command prompt, try pressing enter.
/ #
但是 ,本人环境采用crio 容器运行时,上面 kubectl alpha debug
命令无法启动 debug 容器,或许如同官方文档所示此容器运行时也许不支持 –target 参数,就算按照 Ephemeral Containers — the future of Kubernetes workload debugging 此文章所示能启动临时 pod,但却处于独立的Pid 命名空间中,这肯定有问题。最后,我们可尝试使用 kubectl-debug 工具调试容器,本文不再描述。
Note: The
--target
parameter must be supported by the Container Runtime. When not supported, the Ephemeral Container may not be started, or it may be started with an isolated process namespace.
结束语
本文作者介绍了镜像的一些基础知识与构建镜像的技巧,我们知道镜像由只读的 layers 堆叠而成,从而在构建镜像时考虑其层结构而调整构建逻辑来优化生成的镜像大小,同样,我们使用多阶段构建来利用不同镜像提供的能力并优化镜像大小。
本章我们均通过 Dockerfile 构建镜像,其提供了足够的能力来使我们掌控所有构建细节,但其实在过于底层,用户需掌握太多的知识,如对于研发来说,我们不需要他们耗费在如何构建镜像的过程中,鉴于此,是否有足够友好的方法来生成镜像呢,答案是肯定的,如 s2i、cnb,这些方法作者将在下面的文章中予以讲解。