容器和开放容器倡议 (OCI) 镜像是由 Docker 和 Kubernetes 等项目推广的重要开源应用程序打包和交付技术。 你越了解它们,就越能使用它们来增强项目的一致性和可扩展性。
在本文中,我将用简单的术语描述这项技术,重点介绍开发人员需要理解的镜像和容器的基本方面,然后通过讨论开发人员可以遵循的一些最佳实践来使他们的容器具有可移植性来结束本文。 我还将引导您完成一个简单的实验,该实验演示了构建和运行镜像和容器。
什么是镜像?
镜像只不过是软件的打包格式。 一个很好的类比是 Java 的 JAR 文件或 Python wheel。 JAR(或 EAR 或 WAR)文件只是扩展名不同的 ZIP 文件,而 Python wheel 作为 gzipped tarball 分发。 它们都符合内部的标准目录结构。
镜像被打包为 tar.gz
(gzipped tarballs),它们包括您正在构建和/或分发的软件,但这正是与 JAR 和 wheel 的类比结束的地方。 一方面,镜像不仅打包了您的软件,还打包了运行您的软件所需的所有支持依赖项,包括完整的操作系统。 虽然 wheel 和 JAR 通常构建为依赖项,但可以是可执行文件,但镜像几乎总是构建为要执行的文件,而很少作为依赖项。
了解镜像中的细节对于理解如何使用镜像或为其编写和设计软件不是必需的(如果您有兴趣,请阅读 “什么是容器镜像?”)。 从您的角度来看,特别是从您的软件的角度来看,重要的是要理解您创建的镜像将包含一个 *完整的操作系统*。 因为镜像的打包方式就好像它们是从您希望运行的软件的角度来看的完整操作系统,所以它们必然比以更传统的方式打包的软件大得多。
请注意,*镜像是不变的*。 它们一旦构建就无法更改。 如果您修改了镜像上运行的软件,则必须构建一个全新的镜像并替换旧的镜像。
标签
创建镜像时,它们是用唯一的哈希值创建的,但它们通常用人类可读的名称来标识,例如 ubi
、ubi-minimal
、openjdk11
等。 但是,每个名称的镜像版本可能不同,这些版本通常通过标签来区分。 例如,openjdk11
镜像可以标记为 jre-11.0.14.1_1-ubi
和 jre-11.0.14.1_1-ubi-minimal
,表示安装在 Red Hat ubi
和 ubi minimal
镜像上的openjdk11 软件包版本 11.0.14.1_1 的镜像构建。
什么是容器?
容器是在主机系统上实现和执行的镜像。 从镜像运行容器是一个两步过程:创建和启动。 创建获取镜像并为其提供自己的 ID 和文件系统。 可以重复创建(如 docker create
中那样)多次,以便创建多个正在运行的镜像的实例,每个实例都有自己的 ID 和文件系统。 启动容器将在主机上启动一个隔离的进程,其中容器内运行的软件的行为就像它在自己的虚拟机中运行一样。 因此,容器是主机上的一个隔离进程,具有自己的 ID 和独立的文件系统。
从软件开发人员的角度来看,使用容器主要有两个原因:一致性和可扩展性。 这些是相互关联的,并且它们共同使项目可以使用近年来软件开发中最有希望的创新之一,即 “一次构建,多次部署” 的原则。
一致性
因为镜像是不变的,并且包括从操作系统向上运行软件所需的所有依赖项,所以无论您选择在哪里部署它,您都可以获得一致性。 这意味着无论您是在开发、测试或任何数量的生产环境中将镜像作为容器启动,容器的运行方式都完全相同。 作为软件开发人员,您无需担心任何这些环境是否在不同的主机操作系统或版本上运行,因为容器每次都运行相同的操作系统。 这就是将您的软件与其完整的运行时环境(而不仅仅是没有运行它所需的完整依赖项集的软件)一起打包的好处。
这种一致性意味着在几乎所有情况下,当在一个环境(例如,生产环境)中发现问题时,您可以确信您能够在开发环境或其他环境重现该问题,以便您可以确认该行为并专注于修复它。 您的项目永远不应该再陷入并被可怕的“但它在我的机器上可以工作”问题所困扰。
可扩展性
镜像不仅包含您的软件,还包含运行您的软件所需的所有依赖项,包括底层操作系统。 这意味着在容器内运行的所有进程都将容器视为主机系统,主机系统对于在容器内运行的进程是不可见的,并且从主机系统的角度来看,容器只是它管理的另一个进程。 当然,虚拟机几乎做同样的事情,这提出了一个有效的问题:为什么使用容器技术而不是虚拟机? 答案在于速度和大小。
容器仅运行支持独立主机所需的软件,而无需模拟硬件的开销。 虚拟机必须包含完整的操作系统并模拟底层硬件。 后者是一种非常重量级的解决方案,也会导致更大的文件。 因为从主机系统的角度来看,容器被视为另一个正在运行的进程,所以它们可以在几秒钟内启动,而不是几分钟。 当您的应用程序需要快速扩展时,容器在资源和速度方面每次都会胜过虚拟机。 容器也更容易缩减。
从功能角度来看,扩展超出了本文的范围,因此实验不会演示此功能,但了解该原则非常重要,以便了解为什么容器技术代表了软件打包和部署的如此重大的进步。
注意:虽然可以运行不包含完整操作系统的容器,但这种情况很少发生,因为可用的最小镜像通常是不够的起点。
如何查找和存储镜像
像每种其他类型的软件打包技术一样,容器需要一个可以共享、查找和重用软件包的地方。 这些被称为 *镜像仓库*,类似于 Java Maven 和 Python wheel 存储库或 npm 注册表。
以下是互联网上可用的不同镜像仓库的示例
- Docker Hub: 原始 Docker 注册表,它托管了许多在世界各地的项目中广泛使用的 Docker 官方镜像,并为个人提供了托管他们自己的镜像的机会。 在 Docker Hub 上托管镜像的组织之一是 *adoptopenjdk*; 查看他们的存储库,以获取 openjdk11 项目的镜像和标签示例。
- Red Hat 镜像仓库: 红帽官方镜像仓库为拥有有效红帽订阅的用户提供镜像。
- Quay: 红帽的公共镜像仓库托管了许多红帽公开可用的镜像,并为个人提供了托管他们自己的镜像的机会。
使用镜像和容器
有两个实用程序用于管理镜像和容器:Docker 和 Podman。 它们适用于 Windows、Linux 和 Mac 工作站。 从开发人员的角度来看,它们在执行命令时是完全等效的。 它们可以被认为是彼此的别名。 您甚至可以在许多系统上安装一个软件包,该软件包会自动将 Docker 更改为 Podman 别名。 在本文中,无论提到 Podman 的地方,都可以安全地用 Docker 替换,而不会改变结果。
[ 阅读下一篇: 现在尝试 5 个未充分利用的 Podman 功能 ]
您会立刻注意到这些实用程序与 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
:create
和start
一个容器- 还有
stop
和restart
- 还有
pull
/push
: 从注册表上的存储库拉取/推送镜像
Dockerfiles
Dockerfile 是定义镜像的源文件,并通过 build
子命令进行处理。它们将定义父镜像或基础镜像,复制或安装您希望在镜像中运行的任何额外软件,定义在构建和/或运行时使用的任何额外元数据,并可能指定在运行由镜像定义的容器时要运行的命令。以下实验中更详细地描述了 Dockerfile 的结构以及其中使用的一些更常见的命令。本文末尾提供了一个完整的 Dockerfile 参考链接。
Docker 和 Podman 之间的根本区别
Docker 在类 Unix 系统中是一个守护进程,在 Windows 中是一项服务。这意味着它始终在后台运行,并以 root 或管理员权限运行。Podman 是一个二进制文件。这意味着它仅在需要时运行,并且可以作为非特权用户运行。
这使得 Podman 更加安全,并且系统资源效率更高(如果不需要,为什么要一直运行?)。以 root 权限运行任何内容,从定义上讲,安全性较低。在云上使用镜像时,托管容器的云可以更安全地管理镜像和容器。
Skopeo 和 Buildah
虽然 Docker 是一个单一的实用程序,但 Podman 还有两个相关的实用程序,由 GitHub 上的 Containers 组织维护:Skopeo 和 Buildah。两者都提供了 Podman 和 Docker 没有的功能,并且两者都是 container-tools 包组的一部分,可以在 Red Hat Linux 发行版系列上与 Podman 一起安装。
在大多数情况下,构建可以通过 Docker 和 Podman 执行,但如果需要更复杂的镜像构建,则可以使用 Buildah。这些更复杂的构建的细节远远超出了本文的范围,并且您很少(如果曾经)遇到需要它的情况,但我在此处提及此实用程序是为了完整性。
Skopeo 提供了 Docker 没有的两个实用功能:能够将镜像从一个注册表复制到另一个注册表,以及从远程注册表中删除镜像。同样,此功能超出了本次讨论的范围,但该功能最终可能会对您有用,特别是如果您需要编写一些 DevOps 脚本。
Dockerfile 实验
以下是一个非常短的实验(大约 10 分钟),它将教您如何使用 Dockerfile 构建镜像,以及如何将这些镜像作为容器运行。它还将演示如何外部化容器的配置,以充分利用容器开发的优势并实现“一次构建,多次部署”。
安装
以下实验是在本地运行 Fedora 以及在 Red Hat 沙箱环境 中创建和测试的,其中已经安装了 Podman 和 Git。我相信在 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,它将覆盖此 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 将跟踪 "blobs" 的下载。这些是您的镜像将构建在其上的镜像层。它们最初是从远程注册表中拉取的,并且将在本地缓存以加快以后的构建速度。
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
重复直到您点击 crtl-c
停止
在这种情况下,-e
标志定义了文件 /mnt/data/greetings.txt
的路径,该文件是从主机的本地文件系统通过 -v
标志挂载的,挂载路径为 $(pwd)/greetings.txt
(pwd
是一个 bash 实用程序,它输出当前目录的绝对路径,在你的例子中应该是 hello-world-container-lab
)。现在,你已经将需要根据部署位置更改的数据外部化了,但这次你的数据是在你挂载到容器中的外部文件中定义的。环境变量设置对于有限数量的设置来说是可以的,但是当你有多个设置要应用时,使用文件是将值注入到容器中的更有效方式。
注意:上面卷定义末尾的 :Z
标志适用于使用 SELinux 的系统。SELinux 管理许多 Linux 发行版上的安全性,该标志允许容器访问该目录。如果没有该标志,SELinux 将阻止读取该文件,并且容器中会抛出异常。尝试删除 :Z
后再次运行上面的命令,以查看演示效果。
本实验到此结束。
为容器开发:外部化配置
“一次构建,多次部署”之所以有效,是因为在不同环境中运行的不可变容器不必担心支持特定软件项目所需的硬件或软件差异。这一原则使得软件开发、调试、部署和持续维护更加快速和容易。但它也不是完美的,为了使你的容器真正可移植,需要在编码方式上做一些小的改动。
在为容器化编写软件时,最重要的设计原则是决定要外部化什么。这些决定最终使你的镜像具有可移植性,从而可以充分实现“一次构建,多次部署”的范例。虽然这看起来很复杂,但在决定配置数据是否应该可注入到你的运行容器中时,有一些容易记住的因素需要考虑。
- 数据是否特定于环境?这包括需要根据容器运行的位置配置的任何数据,无论环境是生产环境、非生产环境还是开发环境。此类数据包括国际化配置、数据存储信息以及你希望应用程序在其中运行的特定测试配置文件。
- 数据是否独立于发布?此类数据可能包括从特性标志到国际化文件再到日志级别的所有内容——基本上,任何你可能想要或需要在版本之间更改而无需构建和新部署的数据。
- 数据是否为机密?凭据绝不应硬编码或存储在镜像中。凭据通常需要以与发布计划不匹配的时间表进行刷新,并且将机密嵌入存储在镜像注册表中的镜像中存在安全风险。
最佳实践是选择你的配置数据应该外部化到哪里(即,在环境变量中还是文件中),并且只外部化那些符合上述标准的部分。如果它不符合上述标准,最好将其作为不可变镜像的一部分。遵循这些准则将使你的镜像真正可移植,并使你的外部配置保持合理的大小和可管理性。
[ 免费在线课程: 容器、Kubernetes 和红帽 OpenShift 技术概述 ]
总结
本文介绍了图像和容器的新手软件开发人员的四个关键思想
- 镜像是不变的二进制文件:镜像是一种用于包装软件以供以后重用或部署的手段。
- 容器是隔离的进程:创建容器时,它是镜像的运行时实例化。启动容器后,它们会成为主机上内存中的进程,这比虚拟机轻便得多,速度也快得多。在大多数情况下,开发人员只需要知道后者,但理解前者是有帮助的。
- “一次构建,多次部署”:这个原则使容器技术如此有用。镜像和容器提供了部署的一致性以及与主机的独立性,允许你在许多不同的环境中自信地进行部署。由于这个原则,容器也很容易扩展。
- 外部化配置:如果你的镜像具有特定于环境、独立于发布或机密的配置数据,请考虑使该数据外部于镜像和容器。你可以通过注入环境变量或将外部文件挂载到容器中,将此数据注入到你正在运行的镜像中。
3 条评论