Image镜像与Container容器基础篇

前言

这是一篇关于镜像与容器的基础篇,虽然有些内容与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 3pip程序,而后使用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

因为制作好的镜像已包含程序运行时环境,如示例镜像包含了pythonpython 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

image.png

对于企业来说通常会搭建自己的私有镜像仓库,如haborquay,但对于个人测试用途来说,我们可基于registry镜像搭建一个简单的私有镜像仓库,如下所示:

mkdir /app/registry

cat > /etc/systemd/system/poc-registry.service <

假设为部署的镜像服务我们配置主机名称为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层为一个笼统的概括,其包含基础镜像的所有层。

image.png

下面我们通过命令来观察镜像ubuntu所包含的层,其显示有5个镜像层:

$ podman history ubuntu:bionic
ID             CREATED       CREATED BY                                      SIZE ...
c3c304cb4f22   7 weeks ago   /bin/sh -c #(nop) CMD ["/bin/bash"]             0B       
      7 weeks ago   /bin/sh -c mkdir -p /run/systemd && echo '...   161B     
      7 weeks ago   /bin/sh -c set -xe && echo '#!/bin/sh' > /...   847B     
      7 weeks ago   /bin/sh -c [ -z "$(apt-get indextargets)" ]     35.37kB   
      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 ./
      2 hours ago   /bin/sh -c #(nop) COPY file:e007c2b54ecd4c...   294B
# RUN pip3 install -i $INDEX_URL --no-cache-dir -r requirements.txt
      2 hours ago   /bin/sh -c pip3 install -i $INDEX_URL --no...   1.291MB
# ENV INDEX_URL https://mirrors.aliyun.com/pypi/simple
      2 hours ago   /bin/sh -c #(nop) ENV INDEX_URL https://mi...   1.291MB
# COPY requirements.txt ./
      2 hours ago   /bin/sh -c #(nop) COPY file:774347764755ea...   179B
# RUN apt-get update && ...
      2 hours ago   /bin/sh -c apt-get update && apt-get insta...   165.4MB
# WORKDIR /app
      2 hours ago   /bin/sh -c #(nop) WORKDIR /app                  322B
# FROM docker.io/library/ubuntu:bionic
      7 weeks ago   /bin/sh -c #(nop) CMD ["/bin/bash"]             322B    
      7 weeks ago   /bin/sh -c mkdir -p /run/systemd && echo '...   185B 
      7 weeks ago   /bin/sh -c set -xe && echo '#!/bin/sh' > /...   965B
      7 weeks ago   /bin/sh -c [ -z "$(apt-get indextargets)" ]     38.94kB
      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 - . <

我们期望构建后的镜像应与基础镜像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   
      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的只读镜像层
      5 minutes ago   /bin/sh -c dd if=/dev/zero of=test.txt bs=...52.43MB   
...

当我们了解到镜像由只读层堆叠而成,那么对于此结果能接受,那么,对于类似问题,我们则可调整镜像构建逻辑,将其置于相同的层上以优化镜像大小。

$ podman build -t test -f - . <

此时可发现镜像大小与我们预期相符合了。

$ 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参数明确告知构建过程不重用任何缓存,但又导致构建时间过长。

镜像由层堆叠而成,而上层是对下层的引用,而构建过程又可以重用缓存加快速度。那么考虑如下构建逻辑,我们首先将源码拷贝到镜像中,而后安装pythonpython模块。

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 <

int main(void) {
  printf("hello world\n");
}
EOF
$ cat > Makefile <

我们需要gccmake命令来编译此程序,故我们编写如下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指令,第1From块我们安装编译环境并编译代码,因为采用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 
kubectl exec  -- /bin/sh

但是,如上节所示,我们为了容器的大小,很多调试工具我们并没有包含到最终镜像中,甚至于连/bin/sh都没有,亦或者容器是异常状态,此时我们没法登陆容器调试。

对于这种情况,在K8S 1.18集群中,官方在kubectl工具中内置了一个调试功能,我们可启动一个临时的调试容器以附加到需调试的pod上,但当前处于alpha状态,我们需要启用此特性。编辑如下文件在其中的command处添加--feature-gates=EphemeralContainers=true,等待kubelet自动重启kube-apiserverkube-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构建镜像,其提供了足够的能力来使我们掌控所有构建细节,但其实在过于底层,用户需掌握太多的知识,如对于研发来说,我们不需要他们耗费在如何构建镜像的过程中,鉴于此,是否有足够友好的方法来生成镜像呢,答案是肯定的,如s2icnb,这些方法作者将在下面的文章中予以讲解。

你可能感兴趣的:(docker,kubernetes,容器,镜像)