构建多平台的 AOT 容器镜像
Intro
最近把 dotnet-httpie 做了一些升级改造,移除了 dotnet 6.0/7.0 的支持,只保留 8.0 和 9.0 的支持,于是可以更好地去做 AOT 的支持并且将容器镜像也基于 AOT 来打包,进一步减小了 docker 镜像的大小
Code Changes
因为项目没有那么复杂,代码上的变化比较简单
先来看下项目文件的变化
移除了 net6.0/7.0 之后就可以直接使用 PublishAot
了,另外发现 nuget 包里的内容会有很多其他语言的语言包,所以配置了一下 <SatelliteResourceLanguages>en</SatelliteResourceLanguages>
避免太多的语言包来减小 nuget 包的大小
除了 PublishAot
之外还额外配置了一些 AOT publish 时候的一些配置来进一步减少 publish 之后的文件大小,具体可以参考
<PropertyGroup Condition="'$(PublishAot)'=='true'">
<!-- https://learn.microsoft.com/en-us/dotnet/core/deploying/native-aot/optimizing?WT.mc_id=DT-MVP-5004222 -->
<OptimizationPreference>Size</OptimizationPreference>
<!-- https://learn.microsoft.com/en-us/dotnet/core/deploying/trimming/trimming-options?WT.mc_id=DT-MVP-5004222#trimming-framework-library-features -->
<DebuggerSupport>false</DebuggerSupport>
<EnableUnsafeBinaryFormatterSerialization>false</EnableUnsafeBinaryFormatterSerialization>
<EnableUnsafeUTF7Encoding>false</EnableUnsafeUTF7Encoding>
<EventSourceSupport>false</EventSourceSupport>
<InvariantGlobalization>true</InvariantGlobalization>
<HttpActivityPropagationSupport>false</HttpActivityPropagationSupport>
<MetadataUpdaterSupport>false</MetadataUpdaterSupport>
<StackTraceSupport>false</StackTraceSupport>
<UseSystemResourceKeys>true</UseSystemResourceKeys>
<TrimmerRemoveSymbols>true</TrimmerRemoveSymbols>
<XmlResolverIsNetworkingEnabledByDefault>false</XmlResolverIsNetworkingEnabledByDefault>
<MetadataUpdaterSupport>false</MetadataUpdaterSupport>
<MetricsSupport>false</MetricsSupport>
<InvariantGlobalization>true</InvariantGlobalization>
<StripSymbols>true</StripSymbols>
<IlcGenerateDgmlFile Condition="'$(OS)' == 'Windows_NT'">true</IlcGenerateDgmlFile>
</PropertyGroup>
再来看几个具体的代码变化
有一些使用了条件编译的代码可能可以去掉了
Json 序列化需要使用 Source Generator 的模式来代替原来的写法
依赖注入有些方法也需要添加一些 DynamicallyAccessedMembers
attribute 来告知编译器需要保留的一些动态依赖
除了上述变更之外还有一个小的改动,这里的改动是使用 primary constructor 的特性,移除了私有字段,直接使用 primary constructor 上的字段,由于在 dotnet 8 中 logging source generator 还不支持引用 primary constructor 的字段,所以这里有一个 NET8_0
的条件编译, dotnet 8 的时候声明一个私有字段以支持 logging source generator
Container Image Changes
看完代码变化我们再来看看 docker 镜像相关的一些变化
原来的 Dockerfile 如下,原来的 docker image 会做一个 self-contained 的 单文件的 publish 并且会做单文件的压缩
FROM mcr.microsoft.com/dotnet/runtime-deps:8.0-alpine AS base
LABEL Maintainer="WeihanLi"
FROM mcr.microsoft.com/dotnet/sdk:8.0-alpine AS build-env
WORKDIR /app
COPY ./src/ ./src/
COPY ./build/ ./build/
COPY ./Directory.Build.props ./
COPY ./Directory.Build.targets ./
COPY ./Directory.Packages.props ./
WORKDIR /app/src/HTTPie/
RUN dotnet publish -f net8.0 -c Release --self-contained --use-current-runtime -p:PublishSingleFile=true -p:EnableCompressionInSingleFile=true -p:AssemblyName=http -p:TargetFrameworks=net8.0 -o /app/artifacts
FROM base AS final
COPY --from=build-env /app/artifacts/http /root/.dotnet/tools/http
RUN ln -s /root/.dotnet/tools/http /root/.dotnet/tools/dotnet-http
ENV PATH="/root/.dotnet/tools:${PATH}"
ENTRYPOINT ["http"]
CMS ["--help"]
原来的 docker 镜像大小大概是 36+ MB
更新一个版本之后的 docker image
FROM mcr.microsoft.com/dotnet/sdk:9.0-alpine AS build-env
# Configure NativeAOT Build Prerequisites
# https://learn.microsoft.com/en-us/dotnet/core/deploying/native-aot/?tabs=linux-alpine%2Cnet8
# for alpine
RUN apk update && apk add clang build-base zlib-dev
WORKDIR /app
COPY ./src/ ./src/
COPY ./build/ ./build/
COPY ./Directory.Build.props ./
COPY ./Directory.Build.targets ./
COPY ./Directory.Packages.props ./
WORKDIR /app/src/HTTPie/
RUN dotnet publish -f net9.0 --use-current-runtime -p:AssemblyName=http -p:TargetFrameworks=net9.0 -o /app/artifacts
FROM alpine
COPY --from=build-env /app/artifacts/http /root/.dotnet/tools/http
RUN ln -s /root/.dotnet/tools/http /root/.dotnet/tools/dotnet-http
ENV PATH="/root/.dotnet/tools:${PATH}"
ENTRYPOINT ["http"]
CMS ["--help"]
更新之后,我们的 docker image 的可执行文件已经是 AOT publish 的产物了,所以 runtime 的镜像可以直接使用 alpine 而无需安装其他的 dotnet runtime 依赖
上面的 dockerfile 是单个 platform 的镜像,如果要在不同的 platform 上使用,比如在苹果的 ARM 电脑上是不能运行 linux/amd64
的,所以接着尝试增加多个平台的支持,目前支持 linux/amd64, linux/arm64 两种架构
要支持交叉编译需要配置 docker driver,要配置 QEMU 模拟多个架构,对于 dockerfile 也需要一些改动,和之前分享的多平台容器镜像相比会更加复杂一些,因为 AOT 发布需要使用到一些平台相关的依赖,微软提供了一些可以帮助交叉编译的一些容器镜像可以简化这一过程,感谢大佬的帮助,感兴趣的朋友也可以查看这个问题和大佬的改造 https://github.com/dotnet/runtime/discussions/110288 因为开始的时候遇到一些错误,错误的以为 alpine 不能 build arm 的支持,中间改成了基于 debian 的 image,所以大佬的改造也是基于 debian image 的,后面经过一些摸索改成了基于 alpine 的版本,因为 alpine 的镜像会更小一些
最终版本的 dockerfile 如下:
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet-buildtools/prereqs:azurelinux-3.0-net9.0-cross-arm64-musl AS cross-build-env
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:9.0-alpine AS build-env
COPY --from=cross-build-env /crossrootfs /crossrootfs
ARG TARGETARCH
ARG BUILDARCH
# Configure NativeAOT Build Prerequisites
# https://learn.microsoft.com/en-us/dotnet/core/deploying/native-aot/?tabs=linux-alpine%2Cnet8
# for alpine
RUN apk update && apk add clang build-base zlib-dev
WORKDIR /app
COPY ./src/ ./src/
COPY ./build/ ./build/
COPY ./Directory.Build.props ./
COPY ./Directory.Build.targets ./
COPY ./Directory.Packages.props ./
COPY ./.editorconfig ./
WORKDIR /app/src/HTTPie/
RUN if [ "${TARGETARCH}" = "${BUILDARCH}" ]; then \
dotnet publish -f net9.0 --use-current-runtime -p:AssemblyName=http -p:TargetFrameworks=net9.0 -o /app/artifacts; \
else \
apk add binutils-aarch64 --repository=https://dl-cdn.alpinelinux.org/alpine/edge/community; \
dotnet publish -f net9.0 -r linux-musl-arm64 -p:AssemblyName=http -p:TargetFrameworks=net9.0 -p:SysRoot=/crossrootfs/arm64 -p:ObjCopyName=aarch64-alpine-linux-musl-objcopy -o /app/artifacts; \
fi
FROM alpine
# https://github.com/opencontainers/image-spec/blob/main/annotations.md
LABEL org.opencontainers.image.authors="WeihanLi"
LABEL org.opencontainers.image.source="https://github.com/WeihanLi/dotnet-httpie"
COPY --from=build-env /app/artifacts/http /usr/bin/http
RUN chmod +x /usr/bin/http
ENTRYPOINT ["/usr/bin/http"]
CMD ["--help"]
最终 build 出来的镜像大概 12 MB+
第一行代码从微软的交叉编译帮助镜像中 copy 其他架构编译可能用到的文件,并针对 arm 架构安装编译必要的文件
apk add binutils-aarch64 --repository=https://dl-cdn.alpinelinux.org/alpine/edge/community
由于这个 package 还没合并到 main repo 里,所以这里手动指定了 repository 地址
之后我们需要稍微调整下 publish 的命令,需要指定 SysRoot
和 ObjCopyName
用来帮助找到正确的系统依赖,另外需要注意下基于 alpine 和 debian 的 rid 不同,alpine arm 需要使用 linux-musl-arm64
dotnet publish -f net9.0 -r linux-musl-arm64 -p:AssemblyName=http -p:TargetFrameworks=net9.0 -p:SysRoot=/crossrootfs/arm64 -p:ObjCopyName=aarch64-alpine-linux-musl-objcopy -o /app/artifacts
dockerfile 搞定之后需要配置 QEMU 和 docker driver,之前的介绍是通过 Github Actions 的,这次基于 Azure DevOps 直接使用命令脚本来配置了,可以通过下面的命令来配置
docker run --privileged --rm multiarch/qemu-user-static --reset -p yes
docker buildx create --name container-builder --driver docker-container --driver-opt default-load=true --bootstrap --use
之后 build 并 push 镜像,需要使用 docker buildx build --push
命令并通过 --platform
指定要构建的 platform,这里我们用到的是 linux/amd64
/linux/arm64
docker buildx build --push -f Dockerfile --platform="linux/amd64,linux/arm64" --output="type=image" -t weihanli/dotnet-httpie:latest .
完整 build yaml 可以参考:https://github.com/WeihanLi/dotnet-httpie/blob/dev/.azure/pipelines/docker.yml
More
项目比较简单所以改造比较简单,大部分时间花在了研究 AOT 的多平台容器镜像的构建推送上了,希望对构建基于 AOT 的多平台容器镜像有所帮助
AOT 之后 docker 镜像的大小减少了差不多 2/3
Dockerfile: https://github.com/WeihanLi/dotnet-httpie/blob/dev/Dockerfile
build pipeline yaml: https://github.com/WeihanLi/dotnet-httpie/blob/dev/.azure/pipelines/docker.yml
微软 dotnet buildtools docker image:https://github.com/dotnet/dotnet-buildtools-prereqs-docker
References
https://github.com/WeihanLi/dotnet-httpie https://hub.docker.com/r/weihanli/dotnet-httpie/tags https://github.com/WeihanLi/dotnet-httpie/compare/0.8.2...0.9.0 https://learn.microsoft.com/en-us/dotnet/core/deploying/native-aot/?WT.mc_id=DT-MVP-5004222 https://learn.microsoft.com/en-us/dotnet/core/deploying/native-aot/optimizing?WT.mc_id=DT-MVP-5004222 https://learn.microsoft.com/en-us/dotnet/core/deploying/trimming/trimming-options?WT.mc_id=DT-MVP-5004222#trimming-framework-library-features https://github.com/dotnet/runtime/discussions/110288 https://github.com/dotnet/dotnet-buildtools-prereqs-docker