让 Void Linux 成为 Elixir 应用的基础镜像
前言
Void Linux 是一个忍不住想关注的发行版,它既可以较为精小,又可以相对膨胀。它同时维护 glibc 和 musl 两个不同 C 库的版本,又发布有内置 BusyBox 和 GNU Coreutils 两个不同工具集的版本。
将 glibc/BusyBox 版本作为基础容器是非常合适的,我已将其用作 Elixir 应用的基础,且效果不错。它比 Debian 更小巧,软件包也新得多。至少对我而言 -void
已经成为了 -slim
的良好替代品。
-slim
是常见的以 Debian 为基础的镜像标签名后缀风格。如果有机会我会进一步介绍 Void Linux,但不是本文的重点。
选型
选择 Void Linux 的具体原因:
- 它发布有 BusyBox 的版本,相比 Coreutils 体积小得多。对于容器环境 Coreutils 显得多余了。(BusyBox 常见于嵌入式 Linux,十分精小)。
- 它发布有基于 glibc 的 BusyBox 版本,而 Alpine 这种同用 BusyBox 的发行版基于 musl。当我们的应用在常规环境开发测试时,glibc 永远是 Tier 1 的可靠性。musl 可以作为精小的特供版本,不应该成为唯一的目标。
- 它的包非常新,可以避免时长要从源码构建依赖库的麻烦(或者说避免使用老旧的充满漏洞的库)。
所以我说 Void Linux 十分适合作为容器环境。它在可靠性和体积方面比较平衡,介于 Alpine 和 Debian 之间。
谨慎选择
本文无意图干扰任何人的基础设施选型,实际上我并不建议阅读本文的人跟随我做同样的选择。Void Linux 相比 Debian 并不是一个足够成熟的发行版,且各个方面都可能存在差异。如果你没有丰富的 Linux 使用经验和很强的问题排查能力,我绝不建议你这样做。
不过事实上我也不推荐你们用“官方”的镜像,原因参考此处。
例子
我使用 Void Linux 制作的 Erlang 和 Elixir 镜像,相比基于 Debian 的镜像体积减小了大约 60MBM
。
这是具体的镜像例子:
镜像名称 | 镜像标签 | 镜像体积 |
---|---|---|
erlang |
26.2-slim | 117.28 MB |
hentioe/erlang |
26.2-void | 56.1 MB |
elixir |
1.15.7-slim | 123.8 MB |
hentioe/elixir |
1.16-otp-26-void | 62.6 MB |
它们相比基于 Alpine 的镜像,大了约 10MB
,我认为基本上已经把体积控制到了极限。体积的减小是我在数次尝试和优化的过程中加深了对 Void Linux 的了解从而达到的结果。它并非主要目的。
不建议使用 Docker 官方镜像
Docker 官方 Elixir 镜像的维护者动作非常慢(极不积极),镜像所使用的 Erlang 版本不详,且无任何测试。在我看来是不可靠的。不过我也不建议任何阅读本文的人信任我,我只对自己负责的项目提供支持。
我建议所有人都应该使用 hexpm 的 Elixir 镜像:hexpm/elixir。不仅维护积极,且每一个镜像的 Erlang 版本都非常具体,版本覆盖广。还包含有完善的自动化检查和测试。
过程
想用 Void Linux 环境打包 Elixir 应用,需要从 Erlang -> Elixir -> App
这三个步骤先后构建镜像。这个过程对于要高度掌控底层的人来说是很有必要的,尤其是我这种不信任官方镜像维护者的人。
构建 Erlang 镜像
Erlang 相对来说问题是最少的,通过 buildx
命令可轻易构建出多 arch
的镜像。从这里查看我发布的镜像,同时提供 amd64
和 arm64
。它们是由我的 CI 服务器构建并推送的。我永远会第一时间更新最新的版本,包括 RC。
这是一个例子:
FROM ghcr.io/void-linux/void-glibc-busybox:20231202R1
COPY cleanup.sh /usr/bin/void-cleanup
ENV OTP_VERSION="26.2.1" \
# Declare runtime dependencies.
RUNTIME_DEPS=' \
libstdc++ \
libssl3 \
lksctp-tools \
ncurses-libs \
'
LABEL org.opencontainers.image.version=$OTP_VERSION
RUN set -xe \
&& OTP_DOWNLOAD_URL="https://github.com/erlang/otp/archive/OTP-${OTP_VERSION}.tar.gz" \
&& OTP_DOWNLOAD_SHA256="d99eab3af908b41dd4d7df38f0b02a447579326dd6604f641bbe9f2789b5656b" \
&& fetchDeps=' \
curl' \
&& xbps-install -Sy \
&& xbps-install -Ay $fetchDeps \
&& curl -fSL -o otp-src.tar.gz "$OTP_DOWNLOAD_URL" \
&& echo "$OTP_DOWNLOAD_SHA256 otp-src.tar.gz" | sha256sum -c - \
&& buildDeps=' \
autoconf \
dpkg \
gcc \
make \
ncurses-devel \
openssl-devel \
lksctp-tools-devel \
pax-utils \
binutils \
' \
&& xbps-install -Ay $buildDeps \
&& export ERL_TOP="/usr/src/otp_src_${OTP_VERSION%%@*}" \
&& mkdir -vp $ERL_TOP \
&& tar -xzf otp-src.tar.gz -C $ERL_TOP --strip-components=1 \
&& ( cd $ERL_TOP \
&& ./otp_build autoconf \
&& gnuArch="$(dpkg-architecture --query DEB_HOST_GNU_TYPE)" \
&& ./configure --build="$gnuArch" \
&& make -j$(getconf _NPROCESSORS_ONLN) \
&& make install ) \
# Clean up
&& find /usr/local -regex '/usr/local/lib/erlang/\(lib/\|erts-\).*/\(man\|doc\|obj\|c_src\|emacs\|info\|examples\)' | xargs rm -rf \
&& find /usr/local -name src | xargs -r find | grep -v '\.hrl$' | xargs rm -v || true \
&& find /usr/local -name src | xargs -r find | xargs rmdir -vp || true \
&& scanelf --nobanner -E ET_EXEC -BF '%F' --recursive /usr/local | xargs -r strip --strip-all \
&& scanelf --nobanner -E ET_DYN -BF '%F' --recursive /usr/local | xargs -r strip --strip-unneeded \
&& xbps-remove -Roy $buildDeps $fetchDeps \
# Install runtime dependencies (must be done after cleaning build dependencies).
&& xbps-install -y $RUNTIME_DEPS \
&& rm -rf otp-src.tar.gz $ERL_TOP \
&& void-cleanup
CMD ["erl"]
为了尽可能小巧,它会删除文档、源码、示例等文件,并用 strip
删除了二进制文件的调试信息。
你可能注意到此 Dockerfile
最终执行了一个看起来用于清理的脚本(void-cleanup
),它在一开始被复制进去。这是我故意内置的,便于执行清理 xbps
缓存。它是这个样子的:
#!/usr/bin/env sh
echo "Clearing xbps cache..."
rm -rf /var/cache/xbps/*
echo "Clearing xbps repository..."
find /var/db/xbps/ -type d -name "https___repo-*" -exec rm -rf {} +
以上都是值得说明的,但真正值得注意和学习的,其实是 xbps-install -A
命令。
如果没有 -A
这个命令行选项,xbpx-remove -Ro
也不一定总能在复杂的依赖树中找出全部可以移除的孤立包。这会导致镜像体积膨胀,因为多余的构建时依赖包被保留。即使是其它包管理系统,为了打包出最小的镜像体积,你也不得不留意这方面。
-A
选项仍无法避免构建时的依赖包残留,你可以尝试用同样的包列表二次执行 xbpx-remove -Ro
命令。构建 Elixir 镜像
构建 Elixir 镜像会麻烦一些,因为编译 Elixir 期间会触发一个 BUG。此 BUG 背后的原理很难简单说清楚,它跟 Erlang 运行时的 JIT 默认使用的「双内存映射」机制有关,只发生于 QEMU 的虚拟机中。当我们用 buildx
命令构建其它架构的镜像时,Docker 会在背后创建 QEMU 虚拟机来完成。
从这里查看我发布的镜像,同时提供 amd64
和 arm64
。它们是由我的 CI 服务器构建并推送的。我永远会第一时间更新最新的版本,包括 RC。
例子:
FROM hentioe/erlang:26.2.1-void
ARG TARGETARCH
ENV ELIXIR_VERSION="v1.16.0-rc.1" \
# elixir expects utf8.
LANG=C.UTF-8 \
# Declare runtime dependencies.
RUNTIME_DEPS=' \
libstdc++ \
libssl3 \
lksctp-tools \
ncurses-libs \
'
RUN set -xe \
&& if [ "$TARGETARCH" = "arm64" ]; then \
# Avoid QEMU/arm64 build failed.
export ERL_FLAGS="+JMsingle true"; \
fi \
&& ELIXIR_DOWNLOAD_URL="https://github.com/elixir-lang/elixir/archive/${ELIXIR_VERSION}.tar.gz" \
&& ELIXIR_DOWNLOAD_SHA256="057aca982fd840f2e01c2d60e51523d6870e6937bea58f0e0860d118b7ca2de4" \
&& buildDeps=' \
curl \
make \
glibc-locales \
' \
&& xbps-install -Sy \
&& xbps-install -Ay $buildDeps \
# Set locale to C.UTF-8
&& sed -i 's/^#C.UTF-8/C.UTF-8/' /etc/default/libc-locales \
&& xbps-reconfigure -f glibc-locales \
# Build and install Elixir
&& curl -fSL -o elixir-src.tar.gz $ELIXIR_DOWNLOAD_URL \
&& echo "$ELIXIR_DOWNLOAD_SHA256 elixir-src.tar.gz" | sha256sum -c - \
&& mkdir -p /usr/local/src/elixir \
&& tar -xzC /usr/local/src/elixir --strip-components=1 -f elixir-src.tar.gz \
&& ( cd /usr/local/src/elixir \
&& make install clean ) \
# Clean up
&& find /usr/local/src/elixir/ -type f -not -regex "/usr/local/src/elixir/lib/[^\/]*/lib.*" -exec rm -rf {} + \
&& find /usr/local/src/elixir/ -type d -depth -empty -delete \
&& xbps-remove -Roy $buildDeps \
&& rm elixir-src.tar.gz \
# Install runtime dependencies (must be done after cleaning build dependencies).
&& xbps-install -y $RUNTIME_DEPS \
# Clean up Void Linux environment.
&& void-cleanup
CMD ["iex"]
我们根据目标架构动态设置了 ERL_FLAGS
变量的值,此变量是为避免 buildx
构建多架构失败的刻意为之,否则并不需要它。当我们为其它的 arch
(例如 arm64
)构建镜像时,必须将此变量设置为 +JMsingle true
以避免相关 BUG 发生。
需要注意的是 +JMsingle true
是 Erlang/OTP 26 新增的 flag,如果在 26 以下版本可使用 +JPperf true
。如果传递后者,还需要在 Dockerfile
末尾的清理步骤中删除 /tmp/jit-*.dump
和 /tmp/perf-*.map
两类文件,避免镜像体积膨胀。
ERL_FLAGS
变量不添加在 ENV
指令中呢?通过读取 ARG
的值也可以动态设置。实际上在早期我就是这么做的,但我后来意识到这是一个错误。这导致任何运行环境都会关掉 JIT 的双内存映射,这是不必要的。因为这个 BUG 几乎只会出现在 Docker 多架构镜像的构建中。构建 App 镜像
这是一个例子:
# 使用基于 Void Linux 的 Elixir 镜像构建。
FROM hentioe/elixir:1.16.0-rc.0-otp-26-void as build
ENV MIX_ENV=prod
WORKDIR /src
COPY . /src/
RUN mix deps.get && mix compile && mix release
# 使用 Void Linux 原始镜像打包。
FROM ghcr.io/void-linux/void-glibc-busybox:20231202R1
RUN set -xe \
&& runtimeDeps=' \
libstdc++ \
libssl3 \
lksctp-tools \
ncurses-libs \
' \
&& buildDeps='glibc-locales' \
&& xbps-install -SAy $buildDeps \
# Enable C.UTF-8 locale.
&& sed -i 's/^#C.UTF-8/C.UTF-8/' /etc/default/libc-locales \
&& xbps-reconfigure -f glibc-locales \
&& xbps-remove -Roy $buildDeps \
&& xbps-install -y $runtimeDeps \
# Clear xbps cache.
&& rm -rf /var/cache/xbps/* \
# Clearing xbps repository.
&& find /var/db/xbps/ -type d -name "https___repo-*" -exec rm -rf {} +
ARG APP_HOME=/home/app_name
COPY --from=build /src/_build/prod/rel/app_name $APP_HOME
WORKDIR $APP_HOME
ENV LANG=C.UTF-8
ENV PATH="$APP_HOME/bin:$PATH"
ENTRYPOINT [ "app_name", "start" ]
这个 Dockerfile
分为了两个阶段:首先用 Elixir 镜像复制源代码并完成编译,后用原生的 Void Linux 镜像进行了打包。
可以发现打包过程略微复杂,并不能简单的 COPY
后再安装运行时依赖就制作好。这是因为 Void Linux 比想象中还要原始一些,它甚至没有启用任何 locales
,默认是 POSIX
(遇到 CJK 字符会乱码)。我们必须先启用然后重新生成 locales
。
Hentioe/my-docker-erlang-otp
和 Hentioe/my-docker-elixir
两个仓库。结束语
到此,使用 Void Linux 打包 Elixir 应用就完成了。如果你的项目足够复杂,可能会扩展到其它语言的生态,此时 Void Linux 就会带来很大的帮助。
我的某些项目集成了 Rust,并使用了一些流行的 C 库(如 ImageMagick)。通过 Void Linux 能轻松安装最新的版本,且镜像体积相比 Deiban 环境缩小了 50% 以上。
加入我们
如果你也是 Elixir 开发者/爱好者,这里有一些我创建的群组:
-
Telegram 交流群:
@elixircn_dev
-
QQ 交流群:
912763380
添加 QQ 群时请填写来源为“博客”。注意请不要灌水,谢谢。