Docker镜像层级设计指南

在过去几年里,我需要为各种应用或者微服务去创建Docker镜像。在我的案例中,主要用例集中在Java以及Python上. 通常在Docker Hub上有很多Java以及Python的镜像,并且这些镜像可以很好的当成我们的基础镜像来使用。然而随着时间推移,我发现自己不得不管理多平台的不同版本的应用程序

复用自定义基本镜像非常容易,但这会导致重复的配置工作。随着时间的推移,如同许多开源项目一样,Docker Hub中的镜像也在不断完善,这也是我们期望看到的。但是当我们遇到镜像组装方式被改变或者版本无法获取就会比较头疼。此外出于对法律法规的考虑,我在工作过程中还遇到过从Docker Hub中拉取镜像产生有关来源和合规性需要符合特定准则的问题。任何参与遵从性和安全性审计的人都知道,使用外部资源总是需要某种类型的额外验证,以证明来源的可用性。在审计过程中被提问越少,流程就越简单。

或许你会喜欢: Docker镜像与容器

考虑到上面说的问题,我决定构建一系列基础镜像提供给我们组织的应用程序使用。构建自己的镜像需要提供一定程度的独立性、可控性以及安全性,这也对组织内构建Docker镜像的模式以及最佳实践提供了帮助。多说一句这并不是很难。下面就演示一些例子,演示的代码会有一些变化和可能的改进,但是这里主旨是演示一个可复用的模型,这个模型可以应用于各种组织在构建Docker镜像目录时。

 

一个镜像的层级

非常值得花时间去构建一个完美的Docker镜像。除了一些特殊的例子,一个Docker镜像也可以继承另外一个镜像。通常的使用场景是Docker镜像去继承一个基础的操作系统镜像。你可以将它理解为面向对象中的像类层级一样。一个Docker镜像从另外一个镜像进行继承或者”扩展”,它就可以拥有这个镜像的所有功能。同时,它也可以替换或者覆盖这个基础镜像的功能。

利用Docker镜像继承的好处可以参考面向对象继承的概念

  • 复用性 – 给基础镜像添加功能对所有继承的镜像都可用
  • 扩展性 – 可以在维护继承功能的同时向镜像添加附加功能
  • 叠加性 – 基础镜像功能可以被替换
  • 结构一致性 – 基础镜像的文件系统布局跟所有继承它的的镜像布局一致
  • 成熟性 – 随着基础映像的发展,继承的映像也会随着发展。解决安全漏洞就是一个很好的例子

下图展示了一个样本镜像的层次结构:

这个图可能看起来有点复杂,但是不管你使用的是这些技术中的哪一种,大多数大型应用程序都是由多个框架组成的 – 特别在微服务架构中以Docker容器来部署。即使你的应用程序使用一种语言平台和一种语言框架,应用程序部署也会从使用层次结构中获益。

 

从操作系统开始

Docker镜像的文件系统基于不同Linux系统的发行版(Docker镜像可以使用微软Windows,但是为了更便捷的说明问题,这里使用Linux),为Linux提供发行版的组织会持续提升它们的Docker镜像以至于提供更友好的原生云体验。这就意味着这些组织提供基础镜像是大小以及安全性非常关键,必须仅仅只包含必须用到的。 通过阅读一些博客以及文章你会发现很多观点都是在抨击Docker镜像的大小。然而镜像大小这个方面作为被攻击的对象是众所周知的。小的镜像通常意味着相对比较少的文件,较少文件也会降低发生恶意行为的几率。大多Linux发行版在提供镜像的时候都会考虑友好的云原生性。

如上所述,有很多官方并且被认证的Linux发行版。最常见的方法是从Docker Hub里查看有哪些可用镜像。特别需要关注的是那些官方并且被认证的Linux发行版。可以点击这里

在一个镜像的底层,操作系统的层级往往从标准的Alpine Linux发行版扩展而来。在Docker社区已经赢得很好的口碑,他是非盈利,高效资源以及安全的。

扩展一个Linux发行版可以让你自定义一些功能以至于满足你们组织的需求。下面例子展示了基于标准的操作系统镜像做的一些修改。这个Dockerfile可以在GitHub找到。

FROM alpine:3.10.2
LABEL relenteny.repository.url=https://github.com/relenteny/alpine
LABEL relenteny.repository.tag=3.10.2
LABEL relenteny.alpine.version=3.10.2
RUN set -x && \
addgroup -g 1000 -S alpine && \
adduser -u 1000 -G alpine -h /home/alpine -D alpine && \
apk add –no-cache curl bind-tools
USER alpine
WORKDIR /home/alpine
ENTRYPOINT [“/bin/sh”, “-l”]
CMD []

这个例子简单明了的说明了:

  • 它创建了一个用户alpine,并将其设置为实例化容器的运行用户。按照配置,用户alpine不能作为特权用户运行。这是保护Docker镜像的一个重要方面。实例化容器将启动的目录被设置为alpine用户的主目录。
  • 作为一个例子, curl以及bind-tools都会被添加到基础镜像中。当需要其他额外的包时, 这是一个构建基础镜像的博弈,关于这个镜像是否可以被后续镜像广泛使用与镜像大小、受攻击几率之间的平衡。实例化之后,容器将简单地启动一个登录shell。这里不需要CMD[]指令,但是在Dockerfile中定义’ ENTRYPOINT ‘和’ CMD ‘通过指定完整的调用定义提供了一定程度的文档
  • 实例化后, 容器直接执行启动命令. 我们在Dockerfile中并不需要定义CMD []指令, 但是如果定义了ENTRYPOINT与CMD,它通过指定完整的调用定义提供了一定程度的文档说明(译者补充: 可以认为CMD作为ENTRYPOINT的参数)。
  • LABEL可以有多种用途。它们纯粹是提供信息,但是在通过开发和部署过程跟踪镜像非常有用。

例子: 支持Python版本化

不管运行时平台是什么,都会出现平台版本的问题。随着时间的推移,预计不同的应用程序和服务将使用不同(或特定的)运行时平台版本。Python应用程序也不例外。

操作系统发行版将支持运行时平台版本的一部分。然而依赖于操作系统发行版提供的特定版本的运行时平台,会产生对操作系统版本的依赖,随着时间的推移,这种依赖会变得难以管理。为了与公共Docker镜像保持一致,运行时平台应该使用相同的父镜像进行构建。

幸运的是,大多数Linux版本都广泛支持多版本的应用程序运行时平台。需要设计一个模式来解决一些细节,通过这个模式在维护镜像层次结构的同时,多版本的运行时平台可以轻松并且快速的被组装。

运行时平台通常有多种安装和配置方式。对于Python运行时镜像,我的选择是使用pyenv。pyenv通常用于管理安装在单个系统上的多个Python版本,并且pyenv在安装和配置特定版本一般用途的Python很出色。

扩展刚才讨论的操作系统镜像,在接下来的Docker镜像层级中安装跟配置pyenv。Dockerfile同样可以在GitHub上找到。

FROM relenteny/alpine:3.10.2
LABEL relenteny.repository.url=https://github.com/relenteny/python-pyenv
LABEL relenteny.repository.tag=1.2.14
LABEL relenteny.pyenv.version=1.2.14
LABEL relenteny.pyenv.virtualenv.version=1.1.5
COPY build /opt/build
USER root
RUN set -x && \
apk add –no-cache git bash build-base libffi-dev openssl-dev bzip2-dev zlib-dev readline-dev sqlite-dev && \
cp -r /opt/build/home/alpine/* /home/alpine && \
chmod +x /home/alpine/bin/*.sh && \
chown -R alpine.alpine /home/alpine && \
rm -rf /opt/build
USER alpine
RUN set -x && \
cd /home/alpine && \
git clone https://github.com/pyenv/pyenv.git /home/alpine/.pyenv && \
cd /home/alpine/.pyenv && \
git branch pyenv-1.2.14 v1.2.14 && \
git checkout pyenv-1.2.14 && \
cd /home/alpine && \
git clone https://github.com/pyenv/pyenv-virtualenv.git /home/alpine/.pyenv/plugins/pyenv-virtualenv && \
cd /home/alpine/.pyenv/plugins/pyenv-virtualenv && \
git branch virtualenv-1.1.5 v1.1.5 && \
git checkout virtualenv-1.1.5 && \
cd /home/alpine && \
echo ‘export PYENV_ROOT=”$HOME/.pyenv”‘ >> /home/alpine/.profile && \
echo ‘export PATH=”$PYENV_ROOT/bin:$PATH”‘ >> /home/alpine/.profile && \
echo ‘eval “$(pyenv init -)”‘ >> /home/alpine/.profile && \
echo ‘eval “$(pyenv virtualenv-init -)”‘ >> /home/alpine/.profile

这个Dockerfile参考pyenv的安装说明。还预装了一组将接下来要讨论的脚本。以下是一些关于这个Dockerfile的注意事项:

  • pyenv的安装指南可以从这里获取。针对在Alpine Linux上安装所需的包可以从pyenv的Wiki文档找到.
  • 安装pyenv-virtualenv插件的细节可以从这里获取.
  • 为了兼容以前的image版本,pyenv跟pyenv-virtualenv都指定了特定版本。更简洁的方法是通过版本标记创建本地分支。
  • 用户alpine的环境变量文件.profile被修改,是为了支持pyenv环境。

COPY指令从源目录build复制文件到镜像目录/opt/build。在过去的几年中,我一直使用这种方式将文件复制到镜像中。在构建过程中,源目录包含一个或多个目标的子目录。对于这个镜像,源有一个home/alpine/bin的子目录结构,这个目录中的文件将被放入镜像目录/home/alpine/bin。

添加易用脚本是为了后续的镜像:

  • install-python.sh用于配置特定版本的Python。其目的是通过执行该脚本去创建一个新镜像。例如,下面的Dockerfile代码片段将构建一个预装Python 3.7.4并为内置用户alpine提供python环境的镜像。
FROM relenteny/pyenv:1.2.14
RUN /home/alpine/bin/install-python.sh 3.7.4

通常,虽然不是必需的,但生成的镜像将会被存储在registry中,作为一个通用的python运行时镜像。使用上面的示例,镜像名称和标记是myregistry/python:3.7.4。

  • install-requirements.sh – 是一个工具脚本,它通过requirements.txt来安装额外的python模块。 这个脚本只能在安装python的环境下执行。调用时,必须将包含要安装的模块的requirements.txt当成参数传递给这个脚本。如下所示/home/alpine/bin/install-requirments.sh /home/alpine/requirements.txt

在这里,requirements.txt被放在内置用户alpine的home目录上。当然,这取决于实际的Dockerfile是如何编写的。下面是它的用法示例。

 

构建一个标准规范的Python镜像

如前一节所讨论的,pyenv镜像的设计目的是构建后续的Python镜像。通过配置一个标准的操作系统镜像,以及一个安装和配置多版本Python的镜像,可以非常容易地构建可用于多种用途的Python镜像。

在我们层次结构中,接下来的镜像是安装Python 3.7.4。Dockerfile可以在GitHub上找到。

FROM relenteny/pyenv:1.2.14
LABEL relenteny.repository.url=https://github.com/relenteny/python
LABEL relenteny.repository.tag=3.7.4
LABEL relenteny.python.version=3.7.4
RUN /home/alpine/bin/install-python.sh 3.7.4

就是这样。在Dockerfile中只需几个简单的指令,就可以通过之前的基础Python镜像创建出新版本。

添加一个应用程序框架

在任何语言平台中,仅仅使用平台提供的构造器从头搭建应用程序是很少见的。通常,在构建应用程序时会添加额外的框架、工具。

下面的案例示范是如何安装流行的web框架Flask。Dockerfile可以在GitHub上找到。

FROM relenteny/python:3.7.4
LABEL relenteny.repository.url=https://github.com/relenteny/flask
LABEL relenteny.repository.tag=1.1.1
LABEL relenteny.flask.version=1.1.1
COPY build /opt/build
USER root
RUN set -x && \
cp -r /opt/build/home/alpine/* /home/alpine && \
chown -R alpine.alpine /home/alpine/* && \
rm -rf /opt/build
USER alpine
RUN set -x && \
cd /home/alpine && \
bin/install-requirements.sh /home/alpine/requirements.txt && \
rm /home/alpine/requirements.txt

除了LABEL指令外,在这个Dockerfile中没有任何东西可以说明Flask正在被安装。这是一个可重复的模式。无论您使用的是Flask、Django、TensorFlow、Ansible,还是众多Python框架中的任何一个,都需要添加一个requirements.txt文件,然后调用install-requirements.sh。执行最终的自定义脚本,您就拥有了具有可以共享的基础镜像,它可以跨组织共享。

这里使用的复制指令有一点需要注意。源目录build的内容被复制到镜像中的/opt/build。此外,COPY如果没有指定任何UID,用户就是root。这可以简单的通过使用--chown的选项的方式调用COPY将源构建目录的内容复制到/home/alpine。这就消除了用户在root和alpine之间进行切换。如之前所说,我发现这个模式很有用。它最适用于更复杂的镜像构建过程,其中文件可能需要安装在不同的文件系统位置(例如/等),在这些位置用户必须是root用户才能执行操作。所以,虽然这不是必须的,它仍然是构建镜像的一个既定模式。

 

最后,一个应用

现在我们已经做到了这里,需要在这个镜像层次结构中构建包含实际应用程序的镜像。至少是一个简单的例子。我们似乎走了很长的路才走到这一步。虽然我希望你能够理解为跨组织重用而设计镜像层次结构的价值,但是达到这一点还有一个额外的好处。构建Docker镜像需要一些时间。无论是在开发人员的工作站上或CI/CD pipeline, 镜像花几分钟去构建会阻碍生产力。或者说,作为团队成员在等待他们的测试镜像时候会感到焦虑。通过继承已经完成了大部分组装工作的镜像,最终镜像构建步骤落地和配置应用程序组件,理论上应该只需要几秒钟。

借用Miguel Grinberg写的Flask杰出教程 第一部分: Hello, World!,下面的镜像来自于本教程,并将其配置为一个Docker镜像来执行。Dockerfile可以在GitHub上找到。

FROM relenteny/flask:1.1.1
LABEL relenteny.repository.url=https://github.com/relenteny/flask-helloworld
LABEL relenteny.repository.tag=1.0.0
RUN set -x && \
cd /home/alpine && \
git clone https://github.com/miguelgrinberg/microblog.git && \
cd microblog && \
git checkout v0.1 && \
echo “FLASK_APP=microblog.py” > .flaskenv
WORKDIR /home/alpine/microblog
ENTRYPOINT [ “/bin/sh”, “-lc”, “flask run –host 0.0.0.0”]

如果你构建了这个镜像并且给它打上”myrepository/flask-helloworld:1.0.0″的标记,用命令docker run -it -p 5000:5000 myrepository/flask-helloworld:1.0.0去实例化这个容器,然后使用curl命令或者一个浏览器,读取http://localhost:5000或者http://localhost:5000/index的URL,它会返回”Hello World”.

 

圆满结束

你花了一些时间读完这个教程,很难但是希望你可以体会到一个轻盈高效的Docker基础镜像的价值是显而易见的。构建Docker镜像与镜像中包含的代码没有什么不同。该过程与应用程序都具有相同的概念,包括原则、模式、最佳实践、安全性和法规遵从性、可追溯性、版本控制等。在一致成熟的容器化应用程序部署过程中,坚持此模式或类似模式是一个关键方面。

 

延伸阅读

优化Docker镜像的大小以及时间

如何构建自己的Docker镜像

K8S中文社区微信公众号

评论 抢沙发

登录后评论

立即登录