在 DevOps 环境中共享 Docker 容器
Docker 提供了一个强大的工具,用于创建轻量级镜像和容器化进程,但您知道它也可以使您的开发环境成为 DevOps 管道的一部分吗? 无论您是在云中管理成千上万台服务器,还是作为一名软件工程师希望将 Docker 容器整合到软件开发生命周期中,本文都为每一位对 Linux 和 Docker 充满热情的人提供了一些有用的内容。
在本文中,我将描述 Docker 容器如何在 DevOps 管道中流动。 我还将介绍一些高级 DevOps 概念(借鉴自面向对象编程),关于如何使用依赖注入和封装来改进 DevOps 流程。 最后,我将展示容器化如何对开发和测试过程本身有用,而不仅仅是在应用程序编写完成后用作提供应用程序的场所。
简介容器在 DevOps 领域非常热门,它们从运营和服务交付角度的好处在其他地方已经得到了充分的阐述。 如果您想构建 Docker 容器或部署 Docker 主机、容器或 swarm,可以找到大量信息。 然而,很少有文章讨论如何在稍后在 DevOps 管道中重用的 Docker 容器内部进行开发,因此这就是我在此处关注的重点。

图 1. Docker 容器在典型 DevOps 管道中经历的阶段
基于容器的开发工作流程存在两种常见的用于开发在 Docker 容器内部使用的软件的工作流程
- 将开发工具注入到现有的 Docker 容器中: 这是在多个开发人员之间共享具有相同工具链的一致开发环境的最佳选择,并且它可以与基于 Web 的开发环境(例如 Red Hat 的 codenvy.com 或 Docker 化 IDE(如 Eclipse Che))结合使用。
- 将主机目录绑定挂载到 Docker 容器上,并在主机上使用您现有的开发工具: 这是最简单的选择,它为开发人员提供了使用他们自己本地安装的开发工具集的灵活性。
两种工作流程都有优点,但本地挂载本质上更简单。 因此,我在此处重点关注挂载解决方案,将其作为“最简单且可能有效的方法”。
Docker 容器如何在环境之间移动
DevOps 的核心原则是,将在生产环境中使用的源代码和运行时与开发中使用的相同。 换句话说,最有效的管道是可以在管道的每个阶段重复使用相同的 Docker 镜像的管道。

图 2. 理想化的基于 Docker 的 DevOps 管道
这里的概念是,每个环境都使用相同的 Docker 镜像和代码库,而不管它在哪里运行。 与诸如 Puppet、Chef 或 Ansible 之类的将系统收敛到定义状态的系统不同,理想化的 Docker 管道在每个环境中创建固定镜像的重复副本(容器)。 理想情况下,在以 Docker 为中心的管道中,真正在环境阶段之间移动的唯一工件是 Docker 镜像的 ID; 所有其他工件都应在环境之间共享,以确保一致性。
处理环境之间的差异
在现实世界中,环境阶段可能会有所不同。 举个例子,您的 QA 和暂存环境可能包含不同的 DNS 名称、不同的防火墙规则,并且几乎肯定包含不同的数据 fixtures。 通过标准化不同环境中的服务来应对每个环境的偏差。 例如,确保 DNS 将“db1.example.com”和“db2.example.com”解析为每个环境中的正确 IP 地址,这比依赖于配置文件更改或可注入模板来将您的应用程序指向不同的 IP 地址要更 Docker 友好得多。 但是,在必要时,您可以为每个容器设置环境变量,而不是对固定镜像进行有状态的更改。 然后,可以使用多种方式管理这些变量,包括以下方式
- 从命令行在容器运行时设置的环境变量。
- 从文件在容器运行时设置的环境变量。
- 使用 etcd、Consul、Vault 或类似工具进行自动发现。
考虑一个在 Docker 容器内部运行的 Ruby 微服务。 该服务访问某处的数据库。 为了在每个不同的环境中使用相同的 Ruby 镜像运行,但将环境特定的数据作为变量传入,您的部署编排工具可能会使用如下所示的 shell 脚本,“微服务部署示例”
# Reuse the same image to create containers in each
# environment.
docker pull ruby:latest
# Bash function that exports key environment
# variables to the container, and then runs Ruby
# inside the container to display the relevant
# values.
microservice () {
docker run -e STAGE -e DB --rm ruby \
/usr/local/bin/ruby -e \
'printf("STAGE: %s, DB: %s\n",
ENV["STAGE"],
ENV["DB"])'
}
表 1 显示了一个示例,说明如何使用导出的环境变量将开发、质量保证和生产的环境特定信息传递给其他相同的容器。
表 1. 具有注入环境变量的相同镜像开发 | 质量保证 | 生产 |
export STAGE=dev DB=db1; microservice |
export STAGE=qa DB=db2; microservice |
export STAGE=prod DB=db3; microservice |
要查看实际效果,请打开一个带有 Bash 提示符的终端,并运行上面“微服务部署示例”脚本中的命令,将 Ruby 镜像拉取到您的 Docker 主机上并创建一个可重用的 shell 函数。 接下来,依次运行上表中的每个命令,以设置正确的环境变量并执行该函数。 您应该看到表 2 中所示的每个模拟环境的输出。
表 2. 每个环境中产生适当结果的容器开发 | 质量保证 | 生产 |
STAGE: dev, DB: db1 |
STAGE: qa, DB: db2 |
STAGE: prod, DB: db3 |
尽管这是一个相当简单的示例,但所完成的工作确实非常出色! 这是最好的 DevOps 工具: 您正在重复使用相同的镜像和部署脚本来确保最大程度的一致性,但是每个已部署的实例(Docker 术语中的“容器”)仍然被调整为在其管道阶段内正常运行。
通过这种方法,您可以通过确保在管道的每个阶段重复使用完全相同的镜像来限制配置偏差和差异。 此外,每个容器仅因注入到其中的环境特定数据或工件而异,从而减轻了维护多个版本或每个环境架构的负担。
但是外部系统呢?之前的模拟实际上并未连接到 Docker 容器外部的任何服务。 如果您需要将容器连接到容器本身外部的环境特定事物,这会如何工作?
接下来,我将模拟一个 Docker 容器从开发阶段移动到 DevOps 管道的其他阶段,在每个环境中使用具有自己数据的不同数据库。 这需要先做一些准备工作。
首先,为示例文件创建一个工作区。 您可以通过从 GitHub 克隆示例或创建一个目录来完成此操作。 例如
# Clone the examples from GitHub.
git clone \
https://github.com/CodeGnome/SDCAPS-Examples
cd SDCAPS-Examples/db
# Create a working directory yourself.
mkdir -p SDCAPS-Examples/db
cd SDCAPS-Examples/db
如果您克隆了示例存储库,则以下 SQL 文件应位于 db 目录中。 否则,请继续创建它们。
db1.sql
-- Development Database
PRAGMA foreign_keys=OFF;
BEGIN TRANSACTION;
CREATE TABLE AppData (
login TEXT UNIQUE NOT NULL,
name TEXT,
password TEXT
);
INSERT INTO AppData
VALUES ('root','developers','dev_password'),
('dev','developers','dev_password');
COMMIT;
db2.sql
-- Quality Assurance (QA) Database
PRAGMA foreign_keys=OFF;
BEGIN TRANSACTION;
CREATE TABLE AppData (
login TEXT UNIQUE NOT NULL,
name TEXT,
password TEXT
);
INSERT INTO AppData
VALUES ('root','qa admins','admin_password'),
('test','qa testers','user_password');
COMMIT;
db3.sql
-- Production Database
PRAGMA foreign_keys=OFF;
BEGIN TRANSACTION;
CREATE TABLE AppData (
login TEXT UNIQUE NOT NULL,
name TEXT,
password TEXT
);
INSERT INTO AppData
VALUES ('root','production',
'$1$Ax6DIG/K$TDPdujixy5DDscpTWD5HU0'),
('deploy','devops deploy tools',
'$1$hgTsycNO$FmJInHWROtkX6q7eWiJ1p/');
COMMIT;
接下来,您需要一个小实用程序来创建(或重新创建)各种 SQLite 数据库。 这实际上只是一个方便脚本,因此如果您喜欢手动或使用其他工具初始化或加载 SQL,请继续。
#!/usr/bin/env bash
# You assume the database files will be stored in an
# immediate subdirectory named "db" but you can
# override this using an environment variable.
: "${DATABASE_DIR:=db}"
cd "$DATABASE_DIR"
# Scan for the -f flag. If the flag is found, and if
# there are matching filenames, verbosely remove the
# existing database files.
pattern='(^|[[:space:]])-f([[:space:]]|$)'
if [[ "$*" =~ $pattern ]] &&
compgen -o filenames -G 'db?' >&-
then
echo "Removing existing database files ..."
rm -v db? 2> /dev/null
echo
fi
# Process each SQL dump in the current directory.
echo "Creating database files from SQL ..."
for sql_dump in *.sql; do
db_filename="${sql_dump%%.sql}"
if [[ ! -f "$db_filename" ]]; then
sqlite3 "$db_filename" < "$sql_dump" &&
echo "$db_filename created"
else
echo "$db_filename already exists"
fi
done
当您运行 ./create_databases.sh 时,您应该看到
Creating database files from SQL ...
db1 created
db2 created
db3 created
如果实用程序脚本报告数据库文件已存在,或者您想将数据库文件重置为其初始状态,则可以再次使用 -f
标志调用该脚本,以从关联的 .sql 文件重新创建它们。
您可能注意到,某些 SQL 文件包含明文密码,而另一些文件则包含有效的 Linux 密码哈希值。 就本文而言,这很大程度上是一种权宜之计,以确保您在每个数据库中都有不同的数据,并使您可以轻松地从数据本身判断您正在查看哪个数据库。
但是为了安全起见,通常最好确保在您可能存储的任何源文件中都有正确哈希的密码。 有许多方法可以生成此类密码,但是 OpenSSL 库可以轻松地从命令行生成加盐哈希密码。
提示: 为了获得最佳安全性,请不要将您期望的密码或密码短语作为 OpenSSL 的参数包含在命令行中,因为它可能会在进程列表中看到。 相反,允许 OpenSSL 使用 Password:
提示您,并确保使用强密码短语。
要使用 OpenSSL 生成加盐 MD5 密码
$ openssl passwd \
-1 \
-salt "$(openssl rand -base64 6)"
Password:
然后,您可以将加盐哈希粘贴到 /etc/shadow、SQL 文件、实用程序脚本或您可能需要的任何其他位置。
模拟开发阶段的部署现在您有了一些外部资源可以进行实验,您已准备好模拟部署。 让我们首先在您的开发环境中运行一个容器。 我在这里遵循一些 DevOps 最佳实践,并使用固定的镜像 ID 和定义的 gem 版本。
Docker 镜像 ID 的 DevOps 最佳实践
为了确保您在管道阶段之间重复使用相同的镜像,在拉取镜像时始终使用镜像 ID 而不是命名标签或符号引用。 例如,虽然“latest”标签可能会随着时间的推移指向不同版本的 Docker 镜像,但是镜像版本的 SHA-256 标识符保持不变,并且还作为下载镜像的校验和提供自动验证。
此外,对于您要注入到容器中的资产,您应该始终使用固定 ID。 请注意,您如何在每个阶段指定要注入到容器中的 SQLite3 Ruby gem 的特定版本。 这确保了每个管道阶段都具有相同的版本,而不管 RubyGems 存储库中 gem 的最新版本是否在一个容器部署和下一个容器部署之间发生更改。
获取 Docker 镜像 ID
当您拉取 Docker 镜像(例如 ruby:latest
)时,Docker 将在标准输出上报告镜像的摘要
$ docker pull ruby:latest
latest: Pulling from library/ruby
Digest:
sha256:eed291437be80359321bf66a842d4d542a789e
↪687b38c31bd1659065b2906778
Status: Image is up to date for ruby:latest
如果您想找到已拉取的镜像的 ID,可以使用 inspect
子命令从 Docker 的 JSON 输出中提取摘要,例如
$ docker inspect \
--format='{{index .RepoDigests 0}}' \
ruby:latest
ruby@sha256:eed291437be80359321bf66a842d4d542a789
↪e687b38c31bd1659065b2906778
首先,导出开发环境的适当环境变量。 这些值将覆盖您的部署脚本设置的默认值,并影响您的示例应用程序的行为
# Export values we want accessible inside the Docker
# container.
export STAGE="dev" DB="db1"
接下来,实现一个名为 container_deploy.sh 的脚本,该脚本将模拟跨多个环境的部署。 这是您的部署管道或编排引擎在实例化每个阶段的容器时应执行的工作示例
#!/usr/bin/env bash
set -e
####################################################
# Default shell and environment variables.
####################################################
# Quick hack to build the 64-character image ID
# (which is really a SHA-256 hash) within a
# magazine's line-length limitations.
hash_segments=(
"eed291437be80359321bf66a842d4d54"
"2a789e687b38c31bd1659065b2906778"
)
printf -v id "%s" "${hash_segments[@]}"
# Default Ruby image ID to use if not overridden
# from the script's environment.
: "${IMAGE_ID:=$id}"
# Fixed version of the SQLite3 gem.
: "${SQLITE3_VERSION:=1.3.13}"
# Default pipeline stage (e.g. dev, qa, prod).
: "${STAGE:=dev}"
# Default database to use (e.g. db1, db2, db3).
: "${DB:=db1}"
# Export values that should be visible inside the
# container.
export STAGE DB
####################################################
# Setup and run Docker container.
####################################################
# Remove the Ruby container when script exits,
# regardless of exit status unless DEBUG is set.
cleanup () {
local id msg1 msg2 msg3
id="$container_id"
if [[ ! -v DEBUG ]]; then
docker rm --force "$id" >&-
else
msg1="DEBUG was set."
msg2="Debug the container with:"
msg3=" docker exec -it $id bash"
printf "\n%s\n%s\n%s\n" \
"$msg1" \
"$msg2" \
"$msg3" \
> /dev/stderr
fi
}
trap "cleanup" EXIT
# Set up a container, including environment
# variables and volumes mounted from the local host.
docker run \
-d \
-e STAGE \
-e DB \
-v "${DATABASE_DIR:-${PWD}/db}":/srv/db \
--init \
"ruby@sha256:$IMAGE_ID" \
tail -f /dev/null >&-
# Capture the container ID of the last container
# started.
container_id=$(docker ps -ql)
# Inject a fixed version of the database gem into
# the running container.
echo "Injecting gem into container..."
docker exec "$container_id" \
gem install sqlite3 -v "$SQLITE3_VERSION" &&
echo
# Define a Ruby script to run inside our container.
#
# The script will output the environment variables
# we've set, and then display contents of the
# database defined in the DB environment variable.
ruby_script='
require "sqlite3"
puts %Q(DevOps pipeline stage: #{ENV["STAGE"]})
puts %Q(Database for this stage: #{ENV["DB"]})
puts
puts "Data stored in this database:"
Dir.chdir "/srv/db"
db = SQLite3::Database.open ENV["DB"]
query = "SELECT rowid, * FROM AppData"
db.execute(query) do |row|
print " " * 4
puts row.join(", ")
end
'
# Execute the Ruby script inside the running
# container.
docker exec "$container_id" ruby -e "$ruby_script"
关于此脚本,有几件事需要注意。 首先也是最重要的,您在现实世界中的需求可能比此脚本提供的更简单或更复杂。 尽管如此,它还是提供了一个合理的基准,您可以在此基础上进行构建。
其次,您可能已经注意到在创建 Docker 容器时使用了 tail
命令。 这是用于构建没有长时间运行的应用程序以使容器保持运行状态的容器的常用技巧。 因为您正在使用多个 exec
命令重新进入容器,并且因为您的示例 Ruby 应用程序运行一次后退出,所以 tail
避免了重新启动容器或在调试时保持容器运行所需的许多难看的黑客手段。
继续并立即运行该脚本。 您应该看到与下面列出的相同的输出
$ ./container_deploy.sh
Building native extensions. This could take a while...
Successfully installed sqlite3-1.3.13
1 gem installed
DevOps pipeline stage: dev
Database for this stage: db1
Data stored in this database:
1, root, developers, dev_password
2, dev, developers, dev_password
模拟跨环境部署
现在您已准备好进行更雄心勃勃的操作。 在前面的示例中,您将容器部署到开发环境。 在容器内部运行的 Ruby 应用程序使用了开发数据库。 这种方法的强大之处在于,完全相同的过程可以在每个管道阶段重复使用,而您唯一需要更改的是应用程序指向的数据库。
在实际使用中,您的 DevOps 配置管理或编排引擎将处理为管道的每个阶段设置正确的环境变量。 为了模拟部署到多个环境,请在 Bash 中填充一个关联数组,其中包含每个阶段需要的值,然后在 for
循环中运行该脚本
declare -A env_db
env_db=([dev]=db1 [qa]=db2 [prod]=db3)
for env in dev qa prod; do
export STAGE="$env" DB="${env_db[$env]}"
printf "%s\n" "Deploying to ${env^^} ..."
./container_deploy.sh
done
从 DevOps 的角度来看,这种特定于阶段的方法具有许多好处。 这是因为
- 部署的镜像 ID 在所有管道阶段都是相同的。
- 更复杂的应用程序可以根据注入到容器中的
STAGE
和DB
(或其他值)的值“做正确的事情”。 - 容器在每个阶段都以相同的方式连接到主机文件系统,因此您可以重复使用从 Git、Nexus 或其他存储库中提取的源代码或版本化工件,而无需更改镜像或容器。
- 用于指向正确外部资源的 switcheroo 魔法由您的部署脚本(在本例中为 container_deploy.sh)处理,而不是通过更改您的镜像、应用程序或基础设施来处理。
- 如果您的目标是将大部分复杂性捕获在您的部署工具或管道编排引擎中,则此解决方案非常棒。 但是,一个小小的改进将使您可以将剩余的复杂性推送到管道基础设施上。
想象一下,您有一个比您在此处使用的应用程序更复杂的应用程序。 也许您的 QA 或暂存环境具有您不想在本地主机上重新创建的大型数据集,或者您可能需要指向一个可能在运行时移动的网络资源。 您可以通过使用由外部资源解析的众所周知的名称来处理此问题。
您可以使用符号链接在文件系统级别显示这一点。 这种方法的好处是应用程序和容器不再需要知道存在哪个数据库,因为数据库始终命名为“db”。 考虑以下情况
declare -A env_db
env_db=([dev]=db1 [qa]=db2 [prod]=db3)
for env in dev qa prod; do
printf "%s\n" "Deploying to ${env^^} ..."
(cd db; ln -fs "${env_db[$env]}" db)
export STAGE="$env" DB="db"
./container_deploy.sh
done
同样,您可以在网络上配置域名服务 (DNS) 或虚拟 IP (VIP),以确保为每个阶段使用正确的数据库主机或集群。 例如,您可以确保 db.example.com 在每个管道阶段解析为不同的 IP 地址。
可悲的是,管理多个环境的复杂性永远不会真正消失——它只是希望被抽象到适合您组织的级别。 将您的目标视为类似于某些面向对象编程 (OOP) 最佳实践: 您正在寻找创建最小化更改事物的管道,并允许应用程序和工具依赖于稳定的接口。 当更改不可避免时,目标是尽可能缩小可能更改的事物的范围,并将丑陋的细节隐藏在您的工具之外,达到您所能达到的最大程度。
如果您有数千或数万台服务器,那么更改几个 DNS 条目而无需停机通常比重建或重新部署 10,000 个应用程序容器要好。 当然,总是存在反例,因此请考虑权衡取舍,并做出您可以做出的最佳决策,以封装任何不可避免的复杂性。
在您的容器内部进行开发我已经花了很多时间解释如何确保您的开发容器看起来像管道其他阶段中使用的容器。 但是我真的描述了如何在这些容器内部进行开发吗? 事实证明,我已经涵盖了要点,但是您需要稍微改变一下视角才能将所有内容整合在一起。
用于在先前部分中部署容器的相同过程也允许您在容器内部工作。 特别是,前面的示例已经介绍了如何使用 -v
或 --volume
标志将代码和工件从主机的文件系统绑定挂载到容器内部。 这就是 container_deploy.sh 脚本如何将数据库文件挂载到容器内部的 /srv/db 上的方式。 相同的机制可用于挂载源代码,然后可以使用 Docker exec
命令在容器内部启动 shell、编辑器或其他开发进程。
develop.sh 实用程序脚本旨在展示此功能。 当您运行它时,该脚本会创建一个 Docker 容器,并将您放入容器内部的 Ruby shell 中。 继续并立即运行 ./develop.sh
#!/usr/bin/env bash
id="eed291437be80359321bf66a842d4d54"
id+="2a789e687b38c31bd1659065b2906778"
: "${IMAGE_ID:=$id}"
: "${SQLITE3_VERSION:=1.3.13}"
: "${STAGE:=dev}"
: "${DB:=db1}"
export DB STAGE
echo "Launching '$STAGE' container..."
docker run \
-d \
-e DB \
-e STAGE \
-v "${SOURCE_CODE:-$PWD}":/usr/local/src \
-v "${DATABASE_DIR:-${PWD}/db}":/srv/db \
--init \
"ruby@sha256:$IMAGE_ID" \
tail -f /dev/null >&-
container_id=$(docker ps -ql)
show_cmd () {
enter="docker exec -it $container_id bash"
clean="docker rm --force $container_id"
echo -ne \
"\nRe-enter container with:\n\t${enter}"
echo -ne \
"\nClean up container with:\n\t${clean}\n"
}
trap 'show_cmd' EXIT
docker exec "$container_id" \
gem install sqlite3 -v "$SQLITE3_VERSION" >&-
docker exec \
-e DB \
-e STAGE \
-it "$container_id" \
irb -I /usr/local/src -r sqlite3
一旦进入容器的 Ruby 读取-求值-打印循环 (REPL),您就可以像在容器外部一样正常开发源代码。 任何源代码更改都将立即在容器内部的 /usr/local/src 的已定义挂载点中看到。 然后,您可以使用与稍后在管道中可用的运行时相同的运行时来测试您的代码。
让我们尝试一些基本操作,以了解它是如何工作的。 确保您已将示例 Ruby 文件安装在与 develop.sh 相同的目录中。 您实际上不必知道(或关心)Ruby 编程即可使此练习具有价值。 重点是展示您的容器化应用程序如何与您的主机的开发环境进行交互。
example_query.rb
# Ruby module to query the table name via SQL.
module ExampleQuery
def self.table_name
path = "/srv/db/#{ENV['DB']}"
db = SQLite3::Database.new path
sql =<<-'SQL'
SELECT name FROM sqlite_master
WHERE type='table'
LIMIT 1;
SQL
db.get_first_value sql
end
end
source_list.rb
# Ruby module to list files in the source directory
# that's mounted inside your container.
module SourceList
def self.array
Dir['/usr/local/src/*']
end
def self.print
puts self.array
end
end
在 IRB 提示符 (irb(main):001:0>
) 下,尝试以下代码以确保一切都按预期工作
# returns "AppData"
load 'example_query.rb'; ExampleQuery.table_name
# prints file list to standard output; returns nil
load 'source_list.rb'; SourceList.print
在这两种情况下,Ruby 源代码都是从 /usr/local/src 读取的,该目录绑定到 develop.sh 脚本的当前工作目录。 在开发过程中,您可以随意以任何方式编辑这些文件,然后将它们再次加载到 IRB 中。 这简直是魔法!
它也以另一种方式工作。 从容器内部,您可以使用容器的任何工具或功能来与主机系统上的源代码目录进行交互。 例如,您可以下载熟悉的 Docker whale 徽标,并从容器的 Ruby REPL 中使其可用于您的开发环境
Dir.chdir '/usr/local/src'
cmd =
"curl -sLO " <<
"https://docker.net.cn" <<
"/sites/default/files" <<
"/vertical_large.png"
system cmd
/usr/local/src 和匹配的主机目录现在都包含 vertical_large.png 图形文件。 您已从 Docker 容器内部向您的源代码树添加了一个文件!
图 3. 主机文件系统和容器内部的 Docker 徽标
当您按 Ctrl-D 退出 REPL 时,develop.sh 脚本会通知您如何重新连接到仍在运行的容器,以及如何在完成容器后删除容器。 输出将类似于以下内容
Re-enter container with:
docker exec -it 9a2c94ebdee8 bash
Clean up container with:
docker rm --force 9a2c94ebdee8
实际上,请记住,develop.sh 脚本在启动 IRB 的第一个实例时,正在为您设置 Ruby 的 LOAD_PATH
并需要 sqlite3 gem。 如果您退出该进程,则使用 docker exec
或从容器内部的 Bash shell 启动另一个 IRB 实例可能无法达到您期望的效果。 确保运行 irb -I /usr/local/src -r sqlite3
以重新创建第一次的流畅体验!
我介绍了 Docker 容器通常如何在 DevOps 管道中流动,从开发一直到生产。 我研究了一些管理管道阶段之间差异的常见做法,以及如何在可重现和自动化的方式中使用特定于阶段的数据和工件。 一路上,您可能还了解了更多关于 Docker 命令、Bash 脚本和 Ruby REPL 的知识。
我希望这是一次有趣的旅程。 我知道我很高兴与您分享它,并且我真诚地希望我已经使您的 DevOps 和容器化工具箱在过程中稍微变大了。