一、为什么我们的Docker镜像像个“大胖子”?

如果你用过Elixir和Docker,可能遇到过这样的情况:一个简单的Web应用,构建出来的镜像动不动就超过1GB,上传到仓库慢,部署到服务器也慢。每次代码有一点点改动,重新构建镜像,又得等上半天。这感觉就像每次出门都要把整个家都背上,非常累赘。

这背后的原因,主要在于我们传统的构建方式。通常,我们会把编译环境(比如Erlang/OTP、Elixir、Node.js等)和最终要运行的应用代码,全都塞进一个镜像里。编译过程会产生很多中间文件、依赖缓存,这些“垃圾”如果没有被清理,也会留在镜像里,导致镜像虚胖。

我们的目标,就是给这个“大胖子”制定一个高效的减肥和锻炼计划,让它变得苗条、敏捷。

二、核心减肥计划:多阶段构建

Docker有一个非常棒的功能,叫做“多阶段构建”。你可以把它想象成一个现代化的汽车工厂:

  • 第一阶段(构建车间):这里设备齐全,有各种重型工具(编译器、构建工具),负责把原材料(源代码)加工成成品(可执行程序)。
  • 第二阶段(成品仓库):这里环境干净整洁,只存放从“构建车间”运过来的最终成品,以及运行它所需的最基本环境。那些笨重的工具和加工废料都被留在了第一阶段,不会带过来。

这样做的好处显而易见:最终的镜像只包含运行应用必不可少的东西,体积大大缩小。

下面,我们来看一个为Elixir Phoenix应用优化的多阶段Dockerfile示例。

技术栈声明:本文所有示例均基于 Elixir/Phoenix 技术栈。

# 第一阶段:构建阶段 - 这个镜像会比较大,但最终我们不会用它
FROM hexpm/elixir:1.15.7-erlang-26.2.2-alpine-3.19.1 AS builder

# 设置构建环境变量
ENV MIX_ENV=prod

# 安装编译依赖(Alpine Linux使用`apk`包管理器)
RUN apk add --no-cache build-base npm git python3

# 在镜像中创建工作目录
WORKDIR /app

# 首先拷贝依赖定义文件,这步可以利用Docker的缓存层
# 只要mix.exs和mix.lock没变,就不会重新下载依赖
COPY mix.exs mix.lock ./
RUN mix do deps.get --only $MIX_ENV, deps.compile

# 拷贝配置文件和静态资源相关依赖
COPY config config
# 假设前端使用esbuild,拷贝其配置文件
COPY assets assets
RUN mix do assets.deploy

# 拷贝所有应用源代码并编译
COPY lib lib
COPY priv priv
RUN mix do compile, release

# 第二阶段:运行阶段 - 这个镜像才是我们最终要的,非常小巧
FROM alpine:3.19.1 AS runner

# 安装运行Erlang应用所需的最小依赖:BEAM和OpenSSL
RUN apk add --no-cache openssl ncurses-libs libstdc++

# 创建一个非root用户来运行应用,增强安全性
RUN adduser -S -D -H -h /app appuser
USER appuser
WORKDIR /app

# 从`builder`阶段,只复制我们需要的最终产物:构建好的release
COPY --from=builder --chown=appuser:appuser /app/_build/prod/rel/my_app ./

# 设置环境变量,并暴露应用端口(Phoenix默认是4000)
ENV MIX_ENV=prod
ENV PORT=4000
EXPOSE 4000

# 使用release启动命令
CMD ["./bin/my_app", "start"]

这个Dockerfile的妙处在于:

  1. 分而治之builder阶段负责所有脏活累活,runner阶段保持纯净。
  2. 利用缓存:把变化频率低的文件(如mix.exs)放在前面拷贝,能充分利用Docker构建缓存,加速后续构建。
  3. 最小化运行时:运行阶段基于极简的alpine系统,只安装Erlang运行时必需的几个库,而不是整个Erlang/OTP。
  4. 安全考虑:创建了专门的appuser用户来运行应用,而不是默认的root。

三、让部署流程“自动化”起来

镜像瘦身之后,我们还需要一个流畅的部署流程。这里,我们可以结合GitLab CI/CD(其他如GitHub Actions、Jenkins原理类似)来实现自动化。核心思想是:代码一推送到特定分支,就自动触发构建、测试、推送镜像和部署。

下面是一个.gitlab-ci.yml文件的简化示例:

# 定义构建镜像的步骤
stages:
  - build
  - deploy

# 缓存MIX和NPM依赖,可以极大加快后续构建速度
cache:
  key: "$CI_COMMIT_REF_SLUG"
  paths:
    - _build/
    - deps/
    - assets/node_modules/

# 第一步:构建并推送Docker镜像
build-image:
  stage: build
  image: docker:latest
  services:
    - docker:dind
  variables:
    # 为镜像打上git commit的标签,便于追踪
    DOCKER_IMAGE_TAG: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
  script:
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
    - docker build -t $DOCKER_IMAGE_TAG .
    - docker push $DOCKER_IMAGE_TAG
  only:
    - main # 只在推送到main分支时触发

# 第二步:部署到服务器
deploy-to-server:
  stage: deploy
  image: alpine:latest
  script:
    # 使用SSH连接到你的服务器,执行部署脚本
    - apk add --no-cache openssh-client
    - mkdir -p ~/.ssh
    - echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa
    - chmod 600 ~/.ssh/id_rsa
    - ssh -o StrictHostKeyChecking=no user@your-server-ip "
        # 拉取最新的镜像
        docker pull $DOCKER_IMAGE_TAG &&
        # 停止旧容器并启动新容器
        docker stop my_app_container || true &&
        docker rm my_app_container || true &&
        docker run -d --name my_app_container -p 4000:4000 --restart always $DOCKER_IMAGE_TAG
      "
  only:
    - main
  needs:
    - build-image

这个流程实现了:

  • 持续集成:代码变更自动触发构建和测试。
  • 持续交付:自动将可用的镜像推送到仓库。
  • 持续部署:自动将新镜像部署到生产或测试服务器。

四、高级技巧与避坑指南

优化之路永无止境。这里还有一些进阶技巧和需要注意的坑:

  1. .dockerignore文件是你的好朋友:在项目根目录创建这个文件,告诉Docker哪些文件不需要拷贝进镜像(比如测试文件、日志、本地IDE配置等),能进一步减少构建上下文大小和镜像层。

    # .dockerignore 示例
    .git
    _build
    deps
    assets/node_modules
    *.log
    .env
    Dockerfile
    docker-compose.yml
    
  2. 谨慎选择基础镜像alpine镜像很小,但某些C库可能不兼容。如果你的依赖(如某些NIF)需要更完整的环境,可以考虑使用debian-slimelixir:slim作为运行阶段基础镜像,在体积和兼容性间取得平衡。

  3. Release配置是关键:Elixir 1.9+的Mix Release功能强大。确保你的config/runtime.exs文件配置正确,特别是关于SECRET_KEY_BASE、数据库URL等敏感信息,应该通过环境变量注入,而不是写在代码或编译配置中。

  4. 健康检查:在Dockerfile或docker-compose.yml中为容器添加健康检查指令,让编排工具(如Docker、K8s)能判断应用是否真的准备好了。

    # 可以在Dockerfile末尾添加
    HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
      CMD wget --no-verbose --tries=1 --spider http://localhost:4000/health || exit 1
    
  5. 考虑分布式部署:如果你的应用是分布式的(多个节点组成集群),构建流程本身不变,但部署时需要额外步骤来设置节点名、Cookie和网络发现(如通过环境变量或K8s服务)。这超出了本文范围,但它是Elixir应用上生产的重要一环。

五、总结:从臃肿到敏捷的蜕变

通过以上步骤,我们可以将Elixir应用的Docker镜像构建与部署流程彻底优化:

  • 应用场景:这套流程非常适合基于Elixir/Phoenix的Web API、实时应用的后端服务,尤其适合需要频繁迭代、持续交付的团队。
  • 技术优缺点
    • 优点:镜像体积显著减小(从GB级降到MB级),构建速度因缓存而加快,部署流程自动化且可靠,安全性更高(非root用户运行)。
    • 缺点:多阶段构建的Dockerfile稍微复杂;依赖Alpine镜像可能遇到罕见的C库兼容性问题;自动化部署需要前期搭建CI/CD环境。
  • 注意事项:务必处理好敏感配置(使用环境变量);写好.dockerignore;根据实际需求选择基础镜像;为容器添加健康检查。
  • 文章总结:优化Docker镜像的本质是“职责分离”和“最小化原则”。多阶段构建负责分离构建与运行环境,而CI/CD负责自动化流程。对于Elixir开发者而言,结合Mix Release和轻量级基础镜像,能打造出极其高效、安全的容器化应用。这不仅节省了存储和带宽,更重要的是,它提升了整个开发、测试、部署的生命周期效率,让团队能更快速、更自信地交付价值。