面向开发人员的镜像和容器实践指南

了解镜像和容器背后的关键概念。然后尝试演示一个构建和运行镜像和容器的实验。

容器和开放容器计划 (OCI) 镜像是重要的开源应用程序打包和交付技术, Docker 和 Kubernetes 等项目使其流行起来。您对它们理解得越好,就越能够使用它们来增强项目的一致性和可伸缩性。

在本文中,我将用简单的术语描述这项技术,重点介绍镜像和容器的基本方面以供开发人员理解,然后讨论开发人员可以遵循的一些最佳实践,以使他们的容器可移植。我还将引导您完成一个简单的实验,该实验将演示如何构建和运行镜像和容器。

什么是镜像?

镜像只不过是软件的一种打包格式。一个很好的类比是 Java 的 JAR 文件或 Python 轮子。JAR(或 EAR 或 WAR)文件只是具有不同扩展名的 ZIP 文件,Python 轮子作为 gzip 压缩包分发。它们都遵循一个标准的内部目录结构。

镜像被打包为tar.gz(gzipped tarballs),它们包括您正在构建或分发的软件,但也就是仅有这点与 JAR 和轮子能够类比的地方。一方面,镜像不仅包含您的软件,还包含运行您的软件所需的所有支持依赖项,包括一个完整的操作系统。轮子和jar通常是作为依赖项构建的,但也可以是可执行的,而镜像几乎总是构建为可执行的,很少作为依赖项构建。

了解镜像中内容的详细信息对于了解如何使用镜像或为它们编写和设计软件并不是必要的。从软件的角度来看,重要的是要理解您创建的镜像将包含一个完整的操作系统。因为从您希望运行的软件的角度来看,镜像被打包成一个完整的操作系统,所以它们必然比以更传统方式打包的软件大得多。

请注意,镜像是不可变的。它们一旦构建就无法更改。如果您修改镜像上运行的软件,就必须构建一个全新的镜像并替换旧镜像。

标签

在镜像被创建时,通常都会使用一个唯一的哈希值来创建,但也会使用人类可读的名称标识它们。例如ubi、ubi-minimal、openjdk11等。但是,每个名称都可以有不同版本的镜像,并且通常通过标签来区分。例如,openjdk11镜像可能被标记为jre-11.0.14.1_1-ubi和jre-11.0.14.1_1-ubi-minimal,分别表示openjdk11软件包版本11.0.14.1_1基于Red Hat ubi和ubi最小镜像构建的镜像。

什么是容器?

容器是在主机系统上实例化并运行的镜像。从镜像到运行容器分为两步:创建和启动。创建获取镜像并为其提供自己的 ID 和文件系统。Create(例如在docker Create中)可以重复多次,以创建一个镜像的多个运行实例,每个实例都有自己的 ID 和文件系统。启动容器将在主机上启动一个隔离的进程,在容器中运行的软件将表现得就像运行在自己的虚拟机中一样。因此,容器是主机上的一个独立的进程,具有自己的 ID 和独立的文件系统。

从软件开发人员的角度来看,使用容器有两个主要原因:一致性和可伸缩性。这些是相互关联的,它们共同允许项目使用近年来最有前途的软件开发创新之一,即“一次构建,多次部署”的原则。

一致性

因为镜像是不可变的,并且包含了从操作系统上运行软件所需的所有依赖项,所以无论您选择在何处部署它,都可以获得一致性。这意味着无论您在开发、测试或任何数量的生产环境中将镜像作为容器启动,容器都将以完全相同的方式运行。作为软件开发人员,您不必担心这些环境是否运行在不同的主机操作系统或版本上,因为容器每次都运行相同的操作系统。这就是将软件与其完整的运行时环境一起打包的好处,而不仅仅是因为缺乏必需的环境或依赖软件,而无法运行的软件。

这种一致性意味着在几乎所有情况下,当在一个环境(例如,生产环境)中发现问题时,您可以确信自己能够在开发或其他环境中重现该问题,因此您可以确认行为并专注于修复它。您的项目永远不应该再次陷入“但是它可以在我的机器上工作”的可怕问题。

可伸缩性

镜像不仅包含您的软件,还包含运行您的软件所需的所有依赖项,包括底层操作系统。这意味着在容器内运行的所有进程都将容器视为宿主系统,宿主系统对容器内运行的进程是不可见的,并且从宿主系统的角度来看,容器只是它管理的另一个进程。当然,虚拟机做的事情几乎一样,这就提出了一个合理的问题:为什么要使用容器技术而不是虚拟机?答案在于速度和规模。

容器只需运行支持独立主机所需的软件,而无需模拟硬件的开销。虚拟机必须包含完整的操作系统并模仿底层硬件。后者是一个非常重量级的解决方案,它也会产生更大的文件。因为从主机系统的角度来看,容器只是另一个正在运行的进程,所以它们可以在几秒钟内而不是几分钟内启动。当您的应用程序需要快速扩容时,容器每次都会在资源和速度上击败虚拟机。而且容器也更容易收缩。

从功能的角度来看,可伸缩性超出了本文的范围,因此本实验不会演示该特性,但是为了理解为什么容器技术代表了软件打包和部署方面的重大进步,理解该原理是很必要的。

注意:虽然可以运行不包含完整操作系统的容器,但很少这样做,因为可用的最小镜像可能会引发其它问题。

如何发现和存储镜像

与所有其他类型的软件打包技术一样,容器也需要一个可以共享、发现和重用的地方。这些被称为镜像注册表,类似于 Java Maven 和 Python 轮子存储库或 npm 注册表。

以下是互联网上可用的不同镜像注册表的例子:

Docker Hub: 最初的Docker注册表,托管了许多在世界各地项目中广泛使用的Docker官方镜像,并为个人提供托管自己镜像的机会。如adoptopenjdk就是在Docker Hub上托管镜像的组织之一;可以点击openjdk11查看其存储仓库以获取该组织项目镜像和标签示例。

Red Hat Image Registry: 红帽的官方镜像注册表,为那些有效红帽订阅者提供镜像。

Quay: Red Hat 的公共镜像注册表托管了许多 Red Hat 的公共可用镜像,并为个人提供了托管自己的镜像的机会。

使用镜像和容器

有两个实用程序用于管理镜像和容器:Docker和Podman。它们可用于 Windows、Linux 和 Mac 工作站。从开发人员的角度来看,它们在执行命令时是完全等价的。它们可以被认为是彼此的别名。您甚至可以在许多系统上安装一个包,它会自动将 Docker 更改为 Podman 别名。本文档中无论在哪里提到 Podman,都可以安全地替换 Docker,而不会改变结果。

您会立即注意到这些实用程序与 Git 非常相似,因为它们执行标签、推送和拉取操作。您将经常使用或引用此功能。然而,它们不应与 Git 混淆,因为 Git 也管理版本控制,而镜像是不可变的,它们的管理实用程序和注册表没有变更管理的概念。如果您将两个具有相同名称和标签的镜像推送到同一个存储库,则第二个镜像将覆盖第一个镜像,而无法查看或理解发生了什么变化。

子命令

下面是你会经常使用或引用的Podman和Docker子命令的例子:

build: 构建镜像

例子: podman build -t org/some-image-repo -f Dockerfile

image: 本地镜像管理

例子: podman image rm -a 删除所有本地镜像

images: 列出本地存储的镜像

tag: 为镜像打标签

container: 容器管理

例子: podman container rm -a 删除所有已停止的容器

run: 创建并启动一个容器

还有 stop and restart

 pull/push: 从/向注册表上的存储库拉/推和镜像

Dockerfiles

Dockerfile 是定义镜像的源文件,并使用build子命令进行处理。该文件将定义一个父镜像或基础镜像,复制或安装您希望在镜像中运行的任何额外软件,定义在构建或运行软件过程中使用到的任何额外元数据,并有可能指定一个命令,该命令在镜像实例化为容器后会立即执行。下面的实验对 Dockerfile 的结构以及其中使用的一些更常用的命令进行了更详细的描述。

Docker 和 Podman 的根本区别

Docker是类unix系统中的守护进程,是Windows系统中的服务。这意味着它一直在后台运行,并且具有root或管理员权限。Podman是二进制的。这意味着它只能按需运行,并且可以作为非特权用户运行。

这使得Podman对系统资源更安全、更高效。根据定义,以 root 权限运行任何东西都不太安全。在云端使用镜像时,托管容器的云可以更安全地管理镜像和容器。

Skopeo 和 Buildah

和 Docker 是一个单一的实用程序不同,Podman 在 GitHub 上有两个由 Containers 组织维护的相关实用程序:Skopeo和Buildah。两者都提供 Podman 和 Docker 不具备的功能,并且都是与Podman一起安装在Red Hat系列Linux发行版上的容器工具包组的一部分。

在大多数情况下,镜像构建可以通过 Docker 和 Podman 执行,但 Buildah 存在以防需要更复杂的镜像构建。这些更复杂的构建的细节远远超出了本文的范围,你很少会遇到需要它,但为了完整起见,我在这里提到了这个实用程序。

Skopeo 提供了 Docker 没有的两个实用功能:将镜像从一个注册表复制到另一个注册表的能力以及从远程注册表中删除镜像的能力。同样,此功能超出了本次讨论的范围,但该功能最终可能对您有用,尤其是在您需要编写一些 DevOps 脚本时。

Dockerfiles 实验

以下是一个非常短的实验(大约 10 分钟),它将教您如何使用 Dockerfiles 构建镜像并将这些镜像作为容器运行。它还将演示如何将容器配置外部化,以实现容器开发的全部优势和“一次构建,多次部署”。

安装

以下实验室是在本地运行 Fedora 并在已安装 Podman 和 Git的Red Hat 沙盒环境中创建和测试的。我相信在Red Hat沙箱环境中运行它会让您从这个实验中获得最大的收获,但是在本地运行它也是完全可以接受的。

您还可以在自己的工作站上安装 Docker 或 Podman 并在本地工作。提醒一下,如果您安装了 Docker,podman 和 docker对于这个实验来说是完全可以互换的。

构建镜像

1. 从 GitHub 克隆 Git 存储库:

$ git clone https://github.com/hippyod/hello-world-container-lab

2. 编辑 Dockerfile:

$ cd hello-world-container-lab
$ vim Dockerfile
1 FROM Docker.io/adoptopenjdk/openjdk11:x86_64-ubi-minimal-jre-11.0.14.1_1
2
3 USER root
4
5 ARG ARG_MESSAGE_WELCOME='Hello, World'
6 ENV MESSAGE_WELCOME=${ARG_MESSAGE_WELCOME}
7
8 ARG JAR_FILE=target/*.jar
9 COPY ${JAR_FILE} app.jar
10
11 USER 1001
12
13 ENTRYPOINT ["java", "-jar", "/app.jar"]

​这个Dockerfile有以下特性:

FROM 语句(第 1 行)定义了构建这个新镜像的基础(或父)镜像。

USER 语句(第 3 行和第 11 行)定义了在构建期间和执行时正在运行的用户。起初,root 在构建过程中运行。在更复杂的 Dockerfile 中,我需要 root 才能安装任何额外的软件、更改文件权限等等,以完成新镜像。在 Dockerfile 结束时,我切换到 UID 为 1001 的用户,这样,每当镜像实例化为容器并执行时,用户将不是 root,因此更安全。使用 UID 而不是用户名,以便主机可以识别哪个用户正在容器中运行,以防主机加强了安全措施,阻止容器用root用户运行。

 ARG 语句(第 5 行和第 8 行)定义了只能在构建过程中使用的变量。

 ENV 语句(第 6 行)定义了一个环境变量和值,可以在构建过程中使用,但也可以在镜像作为容器运行时使用。请注意它是如何通过引用前面 ARG 语句定义的变量来获取其值的。

COPY 语句(第 9 行)将 Spring Boot Maven 构建创建的 JAR 文件复制到镜像中。为了方便在未安装 Java 或 Maven 的 Red Hat 沙箱中运行的用户,我预先构建了 JAR 文件并将其推送到 hello-world-container-lab 存储库。在本实验室中无需进行 Maven 构建。(注意:还有一个add命令可以替代 COPY。由于该add命令可能具有不可预知的行为,因此最好使用 COPY。)

最后,ENTRYPOINT 语句定义了容器启动时应该在容器中执行的命令和参数。如果此镜像成为后续镜像定义的基础镜像并且定义了新的 ENTRYPOINT,它将覆盖此镜像。(注意:还有一个cmd命令可以代替 ENTRYPOINT。两者之间的区别在此本文中无关紧要,超出了本文的范围。)

键入:q并按Enter退出 Dockerfile 并返回到 shell。

3. 构建镜像:​

$ podman build --squash -t test/hello-world -f Dockerfile

你应该看到:

STEP 1: FROM docker.io/adoptopenjdk/openjdk11:x86_64-ubi-minimal-jre-11.0.14.1_1
Getting image source signatures
Copying blob d46336f50433 done
Copying blob be961ec68663 done
...
STEP 7/8: USER 1001
STEP 8/8: ENTRYPOINT ["java", "-jar", "/app.jar"]
COMMIT test/hello-world
...
Successfully tagged localhost/test/hello-world:latest
5482c3b153c44ea8502552c6bd7ca285a69070d037156b6627f53293d6b05fd7

​除了构建镜像之外,这些命令还提供了以下说明:

--squash标志将通过确保在镜像构建完成时仅向基础镜像添加一层来减小镜像大小。多余的层会使最后生成的镜像变得更大。FROM、RUN 和 COPY/ADD 语句都会添加一层,最佳实践是在可能的情况下连接这些语句,例如:​

RUN dnf -y --refresh update && 
dnf install -y --nodocs podman skopeo buildah &&
dnf clean all

​上面的 RUN 语句不仅会运行每个语句才仅创建一个层,而且如果其中任何一个失败,也会使构建失败。

-t flag 用于命名镜像。因为我没有显式地定义名称的标签(如test/hello-world:1.0),所以默认将镜像标记为latest。我也没有定义注册表(如quay.io/test/hello-world),所以默认的注册表将是 localhost。

-f 标志用于明确声明要构建的 Dockerfile。

当运行构建时,Podman 将跟踪“blob”的下载。这些是您的镜像将建立的镜像层。它们最初是从远程注册表中提取的,它们将被缓存在本地以加速未来的构建。

Copying blob d46336f50433 done  
Copying blob be961ec68663 done
...
Copying blob 744c86b54390 skipped: already exists
Copying blob 1323ffbff4dd skipped: already exists

4. 当构建完成时,列出镜像以确认它已成功构建:

$ podman images

你应该看到:

REPOSITORY                                        TAG                                                      IMAGE ID      CREATED               SIZE
localhost/test/hello-world latest 140c09fc9d1d 7 seconds ago 454 MB
docker.io/adoptopenjdk/openjdk11 x86_64-ubi-minimal-jre-11.0.14.1_1 5b0423ba7bec 22 hours ago 445 MB

​运行容器

5. 运行镜像:

$ podman run test/hello-world

你应该看到:

 .   ____          _            __ _ _
/ / ___'_ __ _ _(_)_ __ __ _
( ( )___ | '_ | '_| | '_ / _` |
/ ___)| |_)| | | | | || (_| | ) ) ) )
' |____| .__|_| |_|_| |___, | / / / /
=========|_|==============|___/=/_/_/_/
:: Spring Boot :: (v2.5.4)

...
GREETING: Hello, world
GREETING: Hello, world

输出将继续每三秒打印"Hello, world",直到退出:

crtl-c

6. 证明Java只安装在容器中:

$ java -version

在容器内运行的 Spring Boot 应用程序需要 Java 才能运行,这也是我选择基础镜像的原因。如果您在Red Hat 沙盒环境中运行这个实验,这也证明 Java 仅安装在容器中,而不是主机上:

-bash: java: command not found...

​外部化配置

镜像现在已构建,但是当我希望将镜像部署到每个环境中的“Hello, world”消息都不同时会发生什么?例如,我可能想要更改它,因为环境是针对不同的开发阶段或不同的语言环境。如果我更改 Dockerfile 中的值,我需要构建一个新镜像才能看到消息,这破坏了容器最基本的好处之一——“一次构建,多次部署”。那么如何使我的镜像真正可移植,以便可以将其部署在我需要的任何地方呢?答案在于外部化配置。

7. 使用新的外部欢迎消息运行镜像:

$ podman run -e 'MESSAGE_WELCOME=Hello, world DIT' test/hello-world

你应该看到:

Output:
. ____ _ __ _ _
/ / ___'_ __ _ _(_)_ __ __ _
( ( )___ | '_ | '_| | '_ / _` |
/ ___)| |_)| | | | | || (_| | ) ) ) )
' |____| .__|_| |_|_| |___, | / / / /
=========|_|==============|___/=/_/_/_/
:: Spring Boot :: (v2.5.4)
...
GREETING: Hello, world DIT
GREETING: Hello, world DIT

通过使用crtl-c停止,并调整消息后重新执行:

$ podman run -e 'MESSAGE_WELCOME=Hola Mundo' test/hello-world
. ____ _ __ _ _
/ / ___'_ __ _ _(_)_ __ __ _
( ( )___ | '_ | '_| | '_ / _` |
/ ___)| |_)| | | | | || (_| | ) ) ) )
' |____| .__|_| |_|_| |___, | / / / /
=========|_|==============|___/=/_/_/_/
:: Spring Boot :: (v2.5.4)
...
GREETING: Hola Mundo
GREETING: Hola Mundo

​-e标志定义了在启动时注入容器的环境变量和值。如您所见,即使该变量已内置到原始镜像中(Dockerfile 中的语句ENV MESSAGE_WELCOME=${ARG_MESSAGE_WELCOME}),它也会被覆盖。现在,您已经外部化了需要根据部署位置进行更改的数据(例如,在DIT环境中或针对说西班牙语的用户),从而使镜像具有可移植性。

8. 使用文件中定义的新消息运行镜像:

$ echo 'Hello, world from a file' > greetings.txt
$ podman run -v "$(pwd):/mnt/data:Z"
-e 'MESSAGE_FILE=/mnt/data/greetings.txt' test/hello-world

运行这个例子,您应该看到:

 .   ____          _            __ _ _
/ / ___'_ __ _ _(_)_ __ __ _
( ( )___ | '_ | '_| | '_ / _` |
/ ___)| |_)| | | | | || (_| | ) ) ) )
' |____| .__|_| |_|_| |___, | / / / /
=========|_|==============|___/=/_/_/_/
:: Spring Boot :: (v2.5.4)
...
GREETING: Hello, world from a file
GREETING: Hello, world from a file

重复,直到按ctrl -c停止

在这种情况下,-e标志定义了/mnt/data/greetings.txt 的文件路径,该路径通过-v 标志从主机的本地文件系统$(pwd)/greetings.txt(pwd是一个 bash 实用程序,输出当前目录的绝对路径,在您的情况下应该是hello-world-container-lab)挂载到容器中。现在,您已经外部化了需要根据部署位置更改的数据,但这一次数据是在挂载到容器中的外部文件中定义的。如果环境变量数量不多,那么直接在命令行设置是可以的,但是当您要设置好几个环境变量时,使用文件将环境变量注入容器是更有效的方式。

注意::Z上述卷定义末尾的标志适用于使用SELinux的系统。SELinux 管理许多 Linux 发行版上的安全性,并且该标志允许容器访问目录。如果没有这个标志,SELinux 会阻止读取文件,并且容器中会抛出异常。删除:Z标记后再次尝试运行上面的命令以查看演示。

实验到此结束。

容器开发:外部化配置

“一次构建,多次部署”之所以有效,是因为在不同环境中运行的不可变容器不必担心支持特定软件项目所需的硬件或软件的差异。这一原则使软件开发、调试、部署和持续维护变得更快、更容易。它也不是完美的,必须在编写代码的方式上进行一些小的更改,才能使容器真正可移植。

为容器化编写软件时,最重要的设计原则是决定要外部化什么。这些决定最终使您的镜像可移植,因此它们可以完全实现“一次构建,多次部署”的范例。尽管这看起来很复杂,但在决定配置数据是否应该注入到运行的容器中时,有一些容易记住的因素需要考虑:

数据环境是否特定的?这包括任何需要根据容器运行位置配置的数据,无论环境是生产环境、非生产环境还是开发环境。此类数据包括国际化配置、数据存储信息以及您希望应用程序在其下运行的特定测试配置文件。

数据发布是否独立? 这种类型的数据可以运行从功能标志到国际化文件再到日志级别的所有数据——基本上,您可能想要或需要在版本之间更改的任何数据,而无需构建和新部署。

数据是秘密吗?凭证不应被硬编码或存储在镜像中。凭证通常需要在与发布时间表不匹配的时间表上刷新,并且将机密嵌入存储在镜像注册表中的镜像中存在安全风险。

最佳实践是选择应该将配置数据外部化的位置(即在环境变量或文件中),并且只外部化那些满足上述条件的部分。如果它不符合上述标准,最好将其保留为不可变镜像的一部分。遵循这些准则将使您的镜像真正可移植,并使您的外部配置保持合理的大小和可管理性。

总结

本文为刚接触镜像和容器的软件开发人员介绍了四个关键概念和思路:

镜像是不可变的二进制文件:镜像是打包软件以便以后重用或部署的一种方法。

容器是独立的进程:当它们被创建时,容器是一个镜像的运行时实例。当容器启动时,它们成为主机上内存中的进程,这比虚拟机更轻、更快。在大多数情况下,开发人员只需要了解后者,但了解前者是有帮助的。

 “一次构建,多次部署”:这个原则使容器技术如此有用。镜像和容器提供了部署的一致性和独立于主机的独立性,允许您跨许多不同的环境进行部署。由于这一原则,容器也很容易伸缩。

将配置外部化:如果您的镜像具有特定于环境、独立于发布或机密的配置数据,请考虑将该数据置于镜像和容器之外。您可以通过注入环境变量或将外部文件挂载到容器中来将此数据注入正在运行的镜像中。

译者介绍

崔莹峰,51CTO社区编辑,一名70后程序员,拥有10多年工作经验,长期从事 Java 开发,架构设计,容器化等相关工作。精通Java,熟练使用Maven、Jenkins等Devops相关工具链,擅长容器化方案规划、设计和落地。


原文标题:A hands-on guide to images and containers for developers,作者:Evan "Hippy" Slatis

链接:https://opensource.com/article/22/5/guide-containers-images


 
友情链接
鄂ICP备19019357号-22