关于如何改进Docker和DotNet构建的讨论和想法
我们公司的项目管理软件专注于Gantt图表和工作流。我们有四个主要的DotNet服务,我们的开发人员可以将这些服务在Docker中运行,也可以直接在他们的电脑上运行。
一般来说,我发现我的开发者更喜欢从他们选择的IDE内直接运行代码。这让他们能在编辑器里设置断点并重新构建代码;而作为管理者,我倾向于做出相反的选择。我发现快速切换不同的分支并在Docker中快速重建所有内容非常方便,例如,我可以通过一条命令 docker compose up -d --build
来快速重建所有内容。
这对我非常有用,因为我经常需要检查一些东西——帮助验证修复的有效性,或者检查某个组件或测试的行为。我不总是保持我的IDE加载;电话会议花的时间更多,而不是写代码。任何能加速Docker构建或减小Docker容器大小的方法都会帮助我更好地与团队合作。
咱们试试看能做点什么让我的Docker容器更实用一些。
让我们试着抛光我们的DotNet Docker容器,直到它们发光(来自Romina Campos在flickr上的照片)(Romina Campos, flickr)
在Docker容器中快速构建.NET应用首先,我注意到的是,我想减少Docker容器中的文件数量。创建一个.dockerignore
文件这一点很重要:这有助于我排除不需要上传到Docker容器的文件。这是我选择的。
忽略文件和文件夹
.git 注释:Git版本控制系统文件夹
.idea
.sonaqube 注释:SonarQube质量管理系统文件夹
.vs
.vscode
**/[b|B]in 注释:匹配所有路径下的bin文件夹(不区分大小写)
**/[o|O]bj 注释:匹配所有路径下的obj文件夹(不区分大小写)
out 注释:输出文件夹
**/node_modules
**/packages
**/TestResults 注释:测试结果文件夹
我为什么选择这些被忽略的代码片段?
- 将文件夹从我们的IDE和源代码控制系统中排除了。
- 不需要复制如
packages
或node_modules
这样的包下载;我的构建过程会重新生成这些内容。 - 我排除了本地构建的产物如
bin
和obj
;因为我同时使用Windows和Mac机器,所以我需要排除大写和小写版本。 - 当然,也不必复制测试结果。
这有帮助,但下一步更为关键:我不得不将所有的源代码仓库整理到有限数量的文件夹中。我们将仓库整理成两个文件夹,分别是 Applications
和 Common
。这意味着在 Dockerfile 中,我无需执行 COPY . .
,而是用两条语句,只复制相关的文件。
FROM mcr.microsoft.com/dotnet/sdk:9.0 # 从微软镜像拉取 .NET SDK 9.0 版本
WORKDIR /src # 设置工作目录到/src
COPY ./Applications /src/Applications # 复制 ./Applications 到 /src/Applications
COPY ./Common /src/Common # 复制 ./Common 到 /src/Common
这大大加快了我的构建流程。现在 Docker 只跟踪 Application
和 Common
文件夹。如果有人提交了不同文件夹的内容,例如“数据库脚本”和“测试”文件夹,Docker 现在知道不需要重新构建容器,因为这些更改对容器无关紧要。
接下来的性能改进步骤是避免不必要的 dotnet restore
语句。运行恢复命令会让 DotNet 下载所有包依赖,这可能需要花费不少时间。幸运的是,有一个 dotnet-subset 工具,可以让 Docker 跟踪 .csproj 文件的变更,从而确保仅当这些文件发生变化时才需要运行 dotnet restore。
去年我写了关于使用DotNet-Subset的文章使用DotNet-Subset加速你的Docker构建,从那以后我一直都很满意。
这里最大的挑战在于,dotnet-subset能否加快我的构建速度?添加后,虽然构建步骤增加了,但是更多的步骤被缓存了。我对自己得到的结果感到满意。
但我发现下一步是,我的容器太大了!我的笔记本电脑运行这些容器时,占用了超过一半的可用内存。我还有什么办法可以让容器更小一些吗?
使用较小的容器模板开发DotNetDotNet 提供了一种简化的容器形式,称为 精简容器。虽然 DotNet 团队说 精简容器可以显著减少你的依赖项,但我却发现它们有不少问题。很多代码无法运行起来,我花了太多时间去弄清楚精简容器缺少了哪些我需要的功能。
相反,我选择使用微软的Alpine Linux实例。这些是更小、更精简的Linux发行版,主要针对无头服务器安装。它们比普通的DotNet容器镜像要小……不过,它们也有一些问题。
在我的情况下,问题主要集中在SQL Server组件的构建过程中。这些组件无法构建,直到我在我的Dockerfile中添加了一些额外的逻辑来构建用于全球化的Unicode数据。我还发现我的代码中有些部分需要处理时区的问题,而.NET DateTime类的某些功能只有添加了tzdata
后才可用。
# 使用全球化支持用于 DotNet alpine 实例
# https://github.com/dotnet/dotnet-docker/blob/main/samples/enable-globalization.md#alpine-images
# 同时安装 "tzdata" 用于提供时区信息
ENV \
DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false \
LC_ALL=en_US.UTF-8 \
LANG=en_US.UTF-8
RUN apk add --no-cache \
icu-data-full \
icu-libs \
tzdata # 时区数据
现在我的代码终于完成了!但用了好多内存。到底是哪里出了问题?
工作站垃圾回收 vs 服务器垃圾回收当你在你的电脑上运行一个DotNet应用程序时,DotNet会选择使用所谓的'工作站模式'垃圾回收。在工作站模式下,DotNet运行速度会稍微慢一些,但在不再需要内存时会快速释放。这让你的电脑感觉更流畅。
但当你在一个 Docker 容器中运行一个 .NET 应用程序时,你会发现你正在使用的是服务器模式垃圾回收。在服务器模式下,.NET 假设它可以完全使用机器上的所有资源,并且只有在必要时才会释放内存。
哎呀!瞧!如果我在我的桌面计算机上运行四个独立的dotnet容器,每个容器都将使用服务器垃圾回收模式,并认为可以无限占用内存。你可以在你的Dockerfile中添加以下内容来切换容器到工作站垃圾回收模式。
# 禁用工作站垃圾收集器
# (关于垃圾收集器的更多信息,请参阅此链接)
# https://learn.microsoft.com/en-us/dotnet/core/runtime-config/garbage-collector
ENV \
DOTNET_gcServer=0
把所有的东西整合起来
我的Dockerfile会经历三个关键阶段。
- 首先,第一个构建阶段执行
dotnet-subset
以保存还原文件 - 其次,第二个构建阶段执行
dotnet restore
和dotnet publish
- 最后,第三个构建阶段仅复制可执行文件和最终运行时
我的Dockerfile 和这个非常相似:
# 构建阶段一 - 选择仓库中的部分文件以执行 dotnet restore 操作
FROM mcr.microsoft.com/dotnet/sdk:9.0-alpine AS restore-env
ENV PATH="${PATH}:/root/.dotnet/tools"
RUN dotnet tool install --global --no-cache dotnet-subset
WORKDIR /restore
COPY ./Applications /restore/Applications
COPY ./Common /restore/Common
RUN dotnet subset restore Applications/MyApp/MyApp.csproj \
--root-directory /restore --output restore_subset/
# 构建阶段二 - 执行恢复(如果未检测到变更,则恢复将被缓存)并构建
FROM mcr.microsoft.com/dotnet/sdk:9.0-alpine AS build-env
WORKDIR /src
COPY --from=restore-env /restore/restore_subset .
RUN dotnet restore /src/Applications/MyApp/MyApp.csproj
COPY ./Applications Applications
COPY ./Common Common
RUN dotnet publish Applications/MyApp/MyApp.csproj -c Release -o out
# 构建运行时镜像
FROM mcr.microsoft.com/dotnet/aspnet:9.0-alpine
# 添加全球化支持并配置工作站垃圾回收模式
# 参见 https://github.com/dotnet/dotnet-docker/blob/main/samples/enable-globalization.md#alpine-images
# 和 https://learn.microsoft.com/en-us/dotnet/core/runtime-config/garbage-collector
ENV \
DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false \
DOTNET_gcServer=0 \
LC_ALL=en_US.UTF-8 \
LANG=en_US.UTF-8
RUN apk add --no-cache \
icu-data-full \
icu-libs \
tzdata
# 启动服务
WORKDIR /app
COPY --from=build-env /src/out .
ENTRYPOINT ["/app/MyApp"]
结合我的SQL Server 全文搜索 Docker 容器后,我拥有一套技术栈,可以通过执行 docker compose up -d --build
命令随时重建。
但如果是我想重新初始化我的SQL服务器怎么办?当我测试代码时,我经常需要在不同的分支之间切换,而分支A有一个SQL脚本,而分支B却没有,反之亦然。
我接下来的一个大挑战是快速简单地完成从零开始重建SQL Server。
为 SQL Server 容器自动化的重建流程我的基本 SQL Server (微软的数据库管理系统) Dockerfile 如下:
FROM mcr.microsoft.com/mssql/server:2022-latest
# 切换到 root 用户以安装 full-text search - apt-get 不切换用户无法工作!
USER root
# 安装依赖 - 这些依赖项用于下面对 apt-get 的更改
RUN apt-get update && \
apt-get install -yq gnupg gnupg2 gnupg1 curl apt-transport-https && \
# 安装 SQL Server 包链接 - 为什么这些包链接未内置在镜像中?这真奇怪。
curl https://packages.microsoft.com/keys/microsoft.asc -o /var/opt/mssql/ms-key.cer && \
apt-key add /var/opt/mssql/ms-key.cer && \
curl https://packages.microsoft.com/config/ubuntu/22.04/mssql-server-2022.list -o /etc/apt/sources.list.d/mssql-server-2022.list && \
apt-get update && \
# 安装 SQL Server 全文搜索功能 - 只有添加上述包链接才能安装
apt-get install -y mssql-server-fts && \
# 清理 - 删除之前添加的 Microsoft 包的链接
apt-get clean && \
rm -rf /var/lib/apt/lists
# 启动 SQL Server 服务
ENTRYPOINT [ "/opt/mssql/bin/sqlservr" ]
我在Docker Compose中这样用它:
# 主数据库
sqlserver:
container_name: sqlserver
build:
dockerfile: sqlserver/sqlserver-fulltext.Dockerfile
ports:
- 1433:1433
environment:
# 在Linux环境下使用Docker来运行SQL Server时,密码的复杂度要求比较低
# 我们需要修改默认密码以符合该复杂度要求,或者在启动之后再改密码
SA_PASSWORD: "SomeStrongPassword123!@#"
ACCEPT_EULA: "Y"
我发现,当使用我们公司默认的本地数据库密码时,SQL Server 的 Docker 启动过程会失败,所以我需要在容器启动后按照以下方法更改密码。
#
# 启动 SQL Server 后运行的 PowerShell 脚本
#
# 将密码重置为旧 .NET 框架项目中使用的不安全值
docker exec -it sqlserver /opt/mssql-tools18/bin/sqlcmd \
-S localhost -U sa -Q \
"ALTER LOGIN sa WITH check_policy=OFF, password='other-password'" \
-P "SomeStrongPassword123!@#" -C
$success = $success
# 完成了吗?
if (-not $success) {
Write-Output "密码更改失败。"
} else {
Write-Output "sa 密码已成功替换。"
}
我选择用PowerShell来编写这个脚本,因为它可以在Mac上安装PowerShell。我在使用WindowsSubsystemforLinux在Mac和Windows上编写可在Mac和Windows上相同运行的bash脚本时,并没有取得很大的成功,所以选择了PowerShell。
接下来的任务是,我应该如何把数据库重置到一个已知的状态?我决定将其拆分为几个单独的步骤。
- 从备份中恢复SQL Server实例
- 使用 shell 脚本执行 SQL 脚本
这里有一个PowerShell脚本,它会解压一个备份文件,然后在Docker中恢复这个备份文件。举个例子,文件restoredb.sql
中包含了用于从备份恢复SQL Server所需的所有语句。
#
# 启动 SQL Server 后运行的脚本
#
# 使用 NewDb.zip 从备份恢复数据库
if ($IsWindows) {
$basePath = "c:/temp/restoredb";
} else {
$basePath = "/tmp/restoredb";
}
# 清理 $($basePath) 文件夹以确保干净
if (Test-Path -Path $basePath) {
Write-Output "清理 $($basePath) 文件夹"
Remove-Item -path $basePath -force -recurse
} else {
Write-Output "不清理 $($basePath) 文件夹"
}
mkdir $basePath
Expand-Archive ../../DBScript/NewDB/NewDB.zip -DestinationPath $basePath
# 将脚本复制到 Docker,执行,然后清理
docker cp $basePath sqlserver:/var/opt/mssql/restoredb
docker exec -it sqlserver /opt/mssql-tools18/bin/sqlcmd -S localhost \
-U sa -i /var/opt/mssql/restoredb/RestoreDB.sql -P other-password -C
docker exec -it sqlserver rm -rf /var/opt/mssql/restoredb
# 删除临时解压文件夹
Remove-Item -path $basePath -force -recurse
# 完成了!
Write-Output "您现在有了一个全新的数据库实例!"
运行 SQL 脚本很简单;只需复制脚本文件并通过 sqlcmd
执行它。
# 将脚本文件从本地复制到SQL Server容器内
docker cp ../../DBScript/myscript.sql sqlserver:/var/opt/mssql/myscript.sql
# 在SQL Server容器内执行SQLCMD命令,以交互模式和终端连接到SQL服务器
docker exec -it sqlserver /opt/mssql-tools18/bin/sqlcmd -S localhost \
-U sa -i /var/opt/mssql/fix-pmadmin.sql -P other-password -C # 注意:other-password是占位符,请替换为实际的密码
# 上述命令中的“-it”参数表示交互模式和分配终端
# 文件路径“/var/opt/mssql/myscript.sql”和“/var/opt/mssql/fix-pmadmin.sql”分别指向需要复制和执行的脚本文件
注意:在命令中使用了标准的中文术语“SQLCMD”和“SQL服务器”。
有了这些脚本,我可以在几分钟内彻底删除SQL Server的Docker容器,并快速重启一个全新的干净实例。
还有哪些值得考虑的改进呢?Docker 容器设计的难点在于我想要减小磁盘使用、内存使用以及构建时间。如果我只是复制所有文件而不进行多阶段构建,构建时间就会更快;但是这样每个镜像都会占用大量磁盘空间。
我一直在考虑的一个选择是——我是否应该只用一个构建实例?这样会是这样的。
- 复制我所有的 dotnet 文件、项目和解决方案
- 一次性重建所有
- 只复制我需要的应用程序到每个微服务容器中
这种方法的好处是,每次修改文件后只需重建一次;问题是每次修改任何文件时都必须重建整个解决方案。
我最终决定不采用这种方法,而是让这四个容器各自独立。也就是说,如果我只需要处理容器A,我只需重建容器A,以此类推,其他容器也一样。但如果有需要从一个大模块跳到另一个大模块,我可能需要重建所有四个容器。还没找到完美的平衡,但会继续尝试调整。
祝你好运,也请告诉我你都用哪些技巧来使用你的 DotNet 容器!
泰德·斯彭斯在ProjectManager.com担任工程主管(Head of Engineering),并在贝尔维尤学院授课。如果你对软件工程和商业分析感兴趣的话,欢迎通过Mastodon或LinkedIn与我交流。
共同學(xué)習(xí),寫下你的評論
評論加載中...
作者其他優(yōu)質(zhì)文章