这是一篇关于镜像与容器的基础篇,虽然有些内容与18年写的文章迈入Docker、Kubernetes容器世界的大门有重叠,但随着这几年对容器的熟悉,我想将一些认识分享出来,并做为我后续将要写的文章一些技术铺垫。python
在描述什么是镜像前,先来看一下以下示例程序,其为基于flask框架写的一个简单的python程序。linux
# 文件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运行此程序,其过程涉及命令以下所示:git
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镜像。github
为了制做镜像,咱们需选择一款工具,如docker、,而本文选择一款名为podman的工具,功能可用alias docker=podman
来描述。在centos 7.6以上操做系统,执行以下命令安装:docker
yum -y install podman
一般,咱们将制做镜像的过程或逻辑编写在一个名为Dockerfile的文件中,对于示例程序,咱们在主机源码目录下添加一个Dockerfile,其包含的构建逻辑以下所示:shell
# 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运行环境与源码。flask
podman build -t hello-flask -f Dockerfile .
生成的镜像此时保存到咱们的宿主机上,此时其是静态的,以下所示,这个镜像共460MB大小。ubuntu
$ podman images hello-flask REPOSITORY TAG IMAGE ID CREATED SIZE localhost/hello-flask latest ffe9ef09e05d 6 minutes ago 460 MB
镜像(Image)将咱们的程序运行环境与程序自己打包为一个总体,其是静止的,而当咱们基于镜像运行一个实例时,此时则将所运行的实例描述为容器(container)。segmentfault
由于制做好的镜像已包含程序运行时环境,如示例镜像包含了python与python flask模块,故运行容器时,容器所在的宿主机无需再为程序准备运行时环境,咱们仅需在宿主机上安装一个容器运行时引擎便可运行容器,如本文选择podman。centos
以下所示,咱们基于镜像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
咱们的容器大多数部署在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。
为测试用途,咱们以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,这些方法做者将在下面的文章中予以讲解。