Docker 构建 Android 环境,镜像层缓存策略

Docker 构建 Android 环境,镜像层缓存策略

Docker 构建 Android 环境,镜像层缓存策略


Docker 构建 Android 环境,镜像层缓存策略


从一次 CI 崩溃说起


去年迁移团队 CI 到 GitHub Actions 时,我遇到了一个典型的问题:每次构建 Android 项目,Docker 镜像的 apt-get update && apt-get install 层都要重新执行,下载几百 MB 的依赖。GitHub Actions 的缓存配额有限,超出后构建时间从 8 分钟暴涨到 35 分钟。更头疼的是,Android SDK 的 sdkmanager 安装命令没有正确分层,导致 SDK 组件的更新也会触发全量重新下载。


这不是 Docker 用得好不好的问题,而是 Android 工具链的特殊性决定了缓存策略必须重新设计。Android 构建依赖 Java 工具链、Android SDK、NDK、CMake、Gradle 等多个独立组件,每个组件的版本矩阵组合爆炸,且 Google 的仓库分布在不同域名(dl.google.com、Maven Central、Google 的 Maven 仓库),网络稳定性在 CI 环境下是个实实在在的风险。


我花了大约两周时间重构了团队的 Docker 构建流程,核心目标只有一个:让镜像层缓存真正生效,而不是每次都被无效化。这篇文章记录的是具体的做法和踩过的坑,不是 Docker 入门教程。


Android 工具链的分层困境


Docker 的层缓存基于指令内容和前一层的哈希。听起来简单,但 Android 开发环境的特殊性在于:工具链的"安装"和"配置"是高度交织的。


以 OpenJDK 为例。Android Gradle Plugin 8.x 要求 JDK 17,但某些遗留项目可能还在用 AGP 7.x 配 JDK 11。很多 Dockerfile 会这样写:


FROM ubuntu:22.04
RUN apt-get update && apt-get install -y openjdk-17-jdk
RUN wget https://dl.google.com/android/repository/commandlinetools-linux-9477386_latest.zip
RUN unzip commandlinetools-linux-9477386_latest.zip
RUN mkdir -p /opt/android-sdk/cmdline-tools && mv cmdline-tools /opt/android-sdk/cmdline-tools/latest
ENV ANDROID_HOME=/opt/android-sdk
RUN yes | /opt/android-sdk/cmdline-tools/latest/bin/sdkmanager --licenses
RUN /opt/android-sdk/cmdline-tools/latest/bin/sdkmanager "platforms;android-34" "build-tools;34.0.0"

问题在哪?`sdkmanager --licenses` 这个命令会生成一个 `licenses` 目录,里面包含哈希化的许可文件。但 `yes sdkmanager` 的管道写法在某些 Docker 构建上下文中会异常退出,返回非零状态码,导致构建失败。更隐蔽的是,如果你在某一层修改了 `ANDROID_HOME` 相关的环境变量,后续 `sdkmanager` 的调用路径可能不一致,导致缓存层在另一台机器上失效。

我实际遇到的场景:本地 Docker build 成功,推送到 GitLab CI 后同样的 Dockerfile 却卡在 sdkmanager --licenses。排查后发现是 GitLab Runner 的 shell 执行器对管道命令的信号处理不同,yes 进程在 sdkmanager 退出后成为孤儿进程,触发 Docker 的 SIGPIPE 处理逻辑。解决方案是改用 sdkmanager --licenses < /dev/null 或者预先接受许可文件的方式,但这不是重点。


重点是:Android SDK 的许可机制设计于 2010 年代初期,当时没人考虑容器化场景。sdkmanager 的交互式假设、默认的 ~/.android 缓存路径、以及 Google 仓库的随机重定向,都让分层缓存变得脆弱。


基础镜像的选择:不是越轻越好


我测试过三种基础镜像路径:官方 ubuntu 系列、Google 的 gcr.io/cloud-builders/android、以及社区维护的 thyrlian/android-sdk


thyrlian/android-sdk 在 Docker Hub 上维护活跃,版本标签清晰(thyrlian/android-sdk:33.0.0 对应 build-tools 33),但有个实际限制:它基于 ubuntu:20.04,且镜像体积较大(约 1.5GB 压缩后)。对于需要快速拉取的 CI 场景,这个体积在带宽受限环境(比如某些自托管 Runner)下是痛点。更关键的是,它的 SDK 安装路径是 /opt/android-sdk,而某些旧版 Gradle 插件默认查找 ANDROID_HOMEANDROID_SDK_ROOT,路径不一致会导致奇怪的问题。


Google 官方的 gcr.io/cloud-builders/android 实际上是为 Google Cloud Build 设计的,文档明确说明不保证向后兼容,且更新频率低。2023 年我使用时,它内置的 Gradle 版本还是 7.x,而团队项目已迁移到 8.2。这个镜像的 Dockerfile 可以在 GitHub 找到(google-cloud-builders 仓库),但坦白说,它更像是一个参考实现,而不是生产-ready 的通用方案。


我最终选择的是 eclipse-temurin:17-jdk-jammy 作为基底,而非从头构建 Ubuntu。原因很具体:Eclipse Temurin 镜像由 Adoptium 项目维护,JDK 的构建经过 JCK 兼容性测试,且镜像层已经优化过(jlink 裁剪了不必要的模块)。从 eclipse-temurin:17-jdk-jammy 开始,我可以确保 Java 工具链这一层是稳定且缓存友好的,不会因为 Ubuntu 的 apt 索引更新而失效。


具体 Dockerfile 片段:


FROM eclipse-temurin:17-jdk-jammy
ENV ANDROID_HOME=/opt/android-sdk
ENV ANDROID_SDK_ROOT=$ANDROID_HOME
ENV PATH=$PATH:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools

这里 ENV 的集中声明是个刻意设计。如果把 PATH 的修改分散在多个 RUN 指令后,每次新增路径都会导致该层及之后的层重新构建。Android SDK 的 cmdline-toolsplatform-tools 路径是固定的,提前声明可以避免后续安装命令重复修改 PATH


命令行工具的安装:版本锁定与校验


sdkmanager 本身需要单独安装,而 Google 的下载链接没有版本化语义。commandlinetools-linux-9477386_latest.zip 这个文件名中的 9477386 是构建号,不是语义版本。Google 会不定期更新这个 "latest" 包,导致 wget 的 URL 不变但内容变化,进而破坏层缓存的确定性。


我的做法是:显式指定构建号,并在 Dockerfile 中硬编码 SHA-256 校验和。这不是过度工程,而是实际踩坑后的防御。


ARG CMDLINE_TOOLS_BUILD=11076708
ARG CMDLINE_TOOLS_SHA256=2d2d9764bb04c7cd4a6f932ac5953f365e6c288518b5a99ae6e09118a6c4f859
RUN wget -q https://dl.google.com/android/repository/commandlinetools-linux-${CMDLINE_TOOLS_BUILD}_latest.zip -O /tmp/cmdline-tools.zip && \
    echo "${CMDLINE_TOOLS_SHA256} /tmp/cmdline-tools.zip" | sha256sum -c - && \
    mkdir -p ${ANDROID_HOME}/cmdline-tools && \
    unzip -q /tmp/cmdline-tools.zip -d ${ANDROID_HOME}/cmdline-tools && \
    mv ${ANDROID_HOME}/cmdline-tools/cmdline-tools ${ANDROID_HOME}/cmdline-tools/latest && \
    rm /tmp/cmdline-tools.zip

11076708 对应的是 command line tools 12.0 版本,发布于 2024 年初。硬编码构建号的好处是:即使 Google 更新了 "latest" 重定向,我的构建也是确定的。SHA-256 校验防止了中间人攻击或下载损坏,更重要的是,如果校验失败,构建会立即失败而不是带着损坏的 SDK 继续执行。


mv 操作把默认的 cmdline-tools 目录重命名为 latest,是因为 sdkmanager 期望的路径结构是 cmdline-tools/latest/bin/sdkmanager。Google 的 zip 包解压后顶层目录名就是 cmdline-tools,这个命名混乱是历史遗留,很多初学者在这里卡壳。


SDK 组件的分层安装:把"会变"和"不变"拆开


这是缓存策略的核心。Android SDK 组件分为几类:平台工具(platform-tools)、构建工具(build-tools)、SDK 平台(platforms)、系统镜像(system-images)、以及额外的库(如 Google Play services)。


我的分层原则是:按变更频率分组,从不变到易变依次安装。


第一层:平台工具。platform-tools 包含 adbfastboot 等,更新频率中等,但版本兼容性较好。我锁定一个较新的稳定版本:


RUN sdkmanager --install "platform-tools;35.0.1"

第二层:构建工具。这是最容易出问题的层。Android Gradle Plugin 会严格检查 build-tools 版本,比如 AGP 8.2 要求 build-tools 34.0.0。如果安装多个版本,镜像体积会膨胀。我的做法是只安装项目明确需要的版本,通过 ARG 参数化:


ARG BUILD_TOOLS_VERSION=34.0.0
RUN sdkmanager --install "build-tools;${BUILD_TOOLS_VERSION}"

第三层:SDK 平台。这是变更最频繁的层,因为项目会逐步升级 compileSdktargetSdk。但这里有个技巧:如果同时安装多个平台版本,sdkmanager 会并行下载,且这些平台文件之间没有依赖冲突,可以放在同一层:


ARG COMPILE_SDK_VERSION=34
RUN sdkmanager --install "platforms;android-${COMPILE_SDK_VERSION}"

关键决策:是否把 NDK 和 CMake 也放在基础镜像?NDK 体积巨大(一个版本约 1GB),且很多项目并不使用。我的方案是:在基础镜像中预装一个默认 NDK 版本(如 ndk;26.1.10909125),但提供 docker build --build-arg 允许覆盖。CMake 同理,但 CMake 的版本管理更混乱,Google 仓库中的 CMake 版本号(如 3.22.1)和官方 CMake 版本不完全对应,需要查 sdkmanager --list 的输出确认可用版本。


Gradle 的缓存陷阱:这不是 SDK 的问题


Android 构建的另一个时间消耗是 Gradle 依赖下载。很多开发者会把 Gradle 的缓存目录挂载为 volume,但在 CI 的 Docker 环境中,volume 缓存的持久化策略因平台而异。


GitHub Actions 的 actions/cache 可以缓存 ~/.gradle/caches,但默认的 gradle-build-action 对 Android 项目的支持有个细节:它缓存的是 ~/.gradle/caches/build-cache-1,但 Android Gradle Plugin 的特定任务(如 mergeDexReleasepackageRelease)会产生大量任务特定的缓存,这些不在默认缓存路径中。


我实际测试过,纯 Gradle 缓存 vs 加上 android.enableBuildCache=true 的配置,构建时间差异在 15%-30% 之间,取决于项目模块数量。但 android.enableBuildCache=true 在 AGP 8.0 后已被废弃,改为默认行为,文档在 Android Developers 的 "Build cache" 页面有说明。


更隐蔽的问题是 Gradle Wrapper 的下载。gradlew 脚本会检查 gradle/wrapper/gradle-wrapper.properties 中指定的版本,如果本地没有则下载。这个下载发生在 Docker 容器的运行阶段,而非构建阶段,因此不会被 Docker 的层缓存覆盖。解决方案是在镜像构建时预装 Gradle 发行版:


ARG GRADLE_VERSION=8.2
RUN wget -q https://services.gradle.org/distributions/gradle-${GRADLE_VERSION}-bin.zip -O /tmp/gradle.zip && \
    unzip -q /tmp/gradle.zip -d /opt && \
    ln -s /opt/gradle-${GRADLE_VERSION}/bin/gradle /usr/local/bin/gradle && \
    rm /tmp/gradle.zip

然后在 gradle/wrapper/gradle-wrapper.properties 中使用 distributionUrl=file:///opt/gradle-${GRADLE_VERSION}-bin.zip,或者设置 GRADLE_USER_HOME 环境变量指向预装路径。但后者会改变 Gradle 的缓存位置,需要确保 ~/.gradle 下的其他配置(如 gradle.properties)同步迁移。


我最终没有采用完全离线 Gradle 的方案,因为团队有多个项目使用不同 Gradle 版本,预装所有版本不现实。折中方案是:在基础镜像中预装团队最常用的两个版本(8.2 和 8.5),其余版本靠 CI 缓存。


多阶段构建:不是银弹,但有用


多阶段构建(multi-stage)在 Android 场景下的价值被高估了。典型的 Go 或 Rust 多阶段构建可以把编译器完全排除在最终镜像外,但 Android 构建的"产物"是 APK/AAB,构建过程本身就需要完整的 SDK 和工具链。你无法像静态语言那样把编译器和运行时分离。


但多阶段构建在另一个场景有用:分离"环境准备"和"项目构建"。


我设计了一个两阶段流程。第一阶段 builder 安装所有 SDK 组件和工具,第二阶段 runner 只复制必要的部分。但这里"必要"的定义很微妙:Android 构建不仅需要 platformsbuild-tools,还需要 platform-tools 中的 adb 用于某些测试任务,以及 NDK 中的工具链如果项目使用了 C++。


实际测试发现,即使做了多阶段,最终镜像体积只减少了约 20%,因为 Android SDK 的组件之间交叉引用严重。build-tools 依赖 platform-tools 的某些库,ndk 的 standalone toolchains 又依赖特定版本的 platforms。Google 没有提供清晰的依赖图,只能靠试错。


一个具体的优化点:sdkmanager 安装的组件默认在 ${ANDROID_HOME} 下,但可以通过 --sdk_root 指定路径。我在第一阶段把组件安装到临时目录,第二阶段只复制项目需要的子集。例如,纯 Kotlin 项目不需要 NDK,可以排除 ndk/ 目录。但这个排除列表需要持续维护,AGP 的升级可能引入新的隐式依赖。


缓存失效的调试:BuildKit 的隐藏武器


当缓存不按预期工作时,调试是个噩梦。Docker 的默认输出不会告诉你为什么某层缓存未命中。


启用 BuildKit 后(Docker 23.0+ 默认开启),可以使用 --build-arg BUILDKIT_PROGRESS=plain 获得详细输出,但这不够。BuildKit 提供了更精细的缓存分析工具:


docker buildx build --cache-to type=inline --cache-from type=gha,mode=max .

type=gha 是 GitHub Actions 的缓存后端,但我在自托管 Runner 上使用的是 type=local 配合 NFS 共享缓存。配置细节在 BuildKit 文档的 "Cache exporters" 页面有完整说明。


真正有用的是 buildctl 的调试输出,但 Docker Desktop 不默认安装 buildctl。我通过 docker buildx du(disk usage)查看缓存层的内容,配合 docker history --no-trunc <image> 逐层对比。一个发现:某些 RUN 指令即使内容相同,也会因为 ARG 的默认值变化导致缓存失效。ARG 在 Dockerfile 中定义的位置很重要,如果放在文件顶部,任何 ARG 的修改都会导致后续所有层的重新构建。


我的重构策略是:把最稳定的 ARG(如基础镜像标签)放在顶部,把易变的 ARG(如 BUILD_TOOLS_VERSION)放在需要它的 RUN 指令之前。这样修改 build-tools 版本时,只会使该层及之后的层失效,而不是从头开始。


实际配置:一个可工作的 Dockerfile


以下是我目前团队使用的 Dockerfile 核心部分,经过多轮优化,构建时间从 35 分钟稳定在 4-5 分钟(含缓存命中):


# syntax=docker/dockerfile:1
FROM eclipse-temurin:17-jdk-jammy

ARG ANDROID_HOME=/opt/android-sdk
ENV ANDROID_HOME=$ANDROID_HOME
ENV ANDROID_SDK_ROOT=$ANDROID_HOME
ENV PATH=$PATH:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools

ARG CMDLINE_TOOLS_BUILD=11076708
ARG CMDLINE_TOOLS_SHA256=2d2d9764bb04c7cd4a6f932ac5953f365e6c288518b5a99ae6e09118a6c4f859
RUN --mount=type=cache,target=/var/cache/apt \
    apt-get update && apt-get install -y --no-install-recommends \
    wget unzip git && \
    rm -rf /var/lib/apt/lists/*

RUN wget -q https://dl.google.com/android/repository/commandlinetools-linux-${CMDLINE_TOOLS_BUILD}_latest.zip -O /tmp/cmdline-tools.zip && \
    echo "${CMDLINE_TOOLS_SHA256} /tmp/cmdline-tools.zip" | sha256sum -c - && \
    mkdir -p $ANDROID_HOME/cmdline-tools && \
    unzip -q /tmp/cmdline-tools.zip -d $ANDROID_HOME/cmdline-tools && \
    mv $ANDROID_HOME/cmdline-tools/cmdline-tools $ANDROID_HOME/cmdline-tools/latest && \
    rm /tmp/cmdline-tools.zip

ARG BUILD_TOOLS_VERSION=34.0.0
ARG COMPILE_SDK_VERSION=34
ARG NDK_VERSION=26.1.10909125

RUN mkdir -p $ANDROID_HOME/.android && touch $ANDROID_HOME/.android/repositories.cfg
RUN sdkmanager --install "platform-tools;35.0.1" "build-tools;${BUILD_TOOLS_VERSION}" "platforms;android-${COMPILE_SDK_VERSION}" "ndk;${NDK_VERSION}"

几个关键细节:


--mount=type=cache,target=/var/cache/apt 是 BuildKit 的实验性语法,需要 syntax=docker/dockerfile:1 声明。它让 apt 的缓存持久化在 BuildKit 的缓存空间中,而不是镜像层内。这意味着 apt-get update 的索引文件不会被打包进最终镜像,且多次构建时可以复用。


mkdir -p $ANDROID_HOME/.android && touch $ANDROID_HOME/.android/repositories.cfg 这行是 workaround。sdkmanager 在某些版本会尝试写入这个文件,如果目录不存在会报错。这个行为没有稳定复现规律,但在 CI 的干净环境中出现频率更高。


sdkmanager--install 参数支持多个组件,比多次调用 sdkmanager 更高效,因为后者每次都要重新初始化仓库索引。


局限与未解决的问题


这个方案并非完美。有几个我明确知道但尚未优雅解决的问题。


NDK 的体积。即使只安装一个版本,NDK 也占用约 1.2GB 镜像空间。如果项目使用多个 NDK 版本(比如依赖的第三方库用旧版 NDK 编译),镜像体积会线性增长。我考虑过用 volume 挂载 NDK,但 CI 环境的 volume 管理增加了复杂度。目前接受这个体积代价。


模拟器系统镜像。sdkmanager 可以安装 system-images;android-34;google_apis;x86_64,但这些镜像用于运行模拟器,在 CI 的 headless 环境中通常不需要。如果误安装,镜像体积增加 2GB 以上。我通过严格的组件白名单避免,但团队成员偶尔会在本地测试时手动安装,然后忘记清理。


Gradle 的 daemon。Docker 容器默认不会运行 Gradle daemon,因为容器生命周期短。但某些场景下(比如交互式调试容器),启动 daemon 可以加速后续构建。我尝试过在容器内运行 gradle --daemon,但遇到 daemon 进程在容器退出时成为僵尸进程的问题。最终方案是:CI 环境禁用 daemon(org.gradle.daemon=false),本地开发容器启用但手动管理生命周期。


macOS 构建的缺失。这个 Dockerfile 基于 Linux 镜像,但团队有成员使用 Apple Silicon Mac。Docker Desktop for Mac 的 Rosetta 模拟可以运行 x86_64 镜像,但 Android 模拟器的系统镜像需要 arm64 版本。我们目前维护两个变体的 Dockerfile,通过 docker buildx --platform 构建多架构镜像,但 sdkmanager 的系统镜像架构选择需要额外的 ARG 控制,增加了维护负担。


替代方案评估:为什么不用 Bazel 或 Nix


文章快结束时,值得提一下我评估过但没有采用的方案。


Bazel 的 Android 规则(rules_android)在 Google 内部使用,但开源版本的成熟度和社区支持远不如 Gradle。2024 年初我测试时,rules_android 的 NDK 集成还有未解决的 issue,且 Bazel 的远程缓存配置对小型团队来说运维负担过重。Bazel 的确定性构建确实吸引人,但迁移成本对于已有 Gradle 项目来说不划算。


Nix 的 androidenv 包集合在 NixOS 社区维护,理论上可以提供完全可复现的 Android 环境。我实际用 nix-shell 搭建过,但遇到两个阻碍:一是 Nix 的 Android SDK 包版本滞后于 Google 官方发布,二是 Nix 的学习曲线对团队其他成员来说太陡。Nix 的封闭性设计(pure evaluation)和 Android 工具链的 impure 假设(下载许可、动态生成的配置文件)之间存在根本张力。


GitHub Actions 的 setup-android action(由 Android 社区维护,非官方)提供了另一种路径:完全不在 Docker 中管理 Android SDK,而是每次 CI 运行时动态安装。这个 action 的源码在 android-actions/setup-android 仓库,它内部也是调用 sdkmanager,但把缓存委托给 actions/cache。对于不需要自定义 Docker 镜像的团队,这是更简单的选择。但我们的场景需要自定义镜像(包含内部 Maven 仓库的证书、特定的网络代理配置),所以 Docker 方案仍是必要的。


最后的工作流整合


把 Dockerfile 整合到 CI 工作流时,我采用了"基础镜像预构建 + 项目镜像快速构建"的两层策略。


基础镜像(包含 JDK、Android SDK、通用工具)每周构建一次,推送到团队的 Harbor 私有仓库。项目镜像(基于基础镜像,只复制项目代码和特定配置)在每次 CI 时构建,利用 FROM harbor.internal/android-base:2024-week-03 的快速拉取和几乎确定的缓存命中。


Harbor 的镜像清理策略需要配合:保留最近 4 周的基础镜像标签,自动删除旧版本。这个策略在 Harbor 的"垃圾回收"配置中设置,但默认的 GC 不会清理未引用的 blob,需要手动启用"允许删除有标签的镜像"选项,配合标签保留规则的仔细设计。


GitHub Actions 的工作流片段:


jobs:
  build:
    runs-on: self-hosted
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - uses: docker/build-push-action@v5
        with:
          context: .
          push: false
          load: true
          tags: android-build:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
      - run: docker run --rm -v ${{ github.workspace }}:/project android-build:${{ github.sha }} ./gradlew assembleRelease

cache-to: type=gha,mode=max 会把所有中间层都导出到 GitHub Actions 的缓存,包括 RUN 指令产生的层。但 mode=max 的缓存体积很大,对于超过 5GB 的镜像,可能超出 GitHub 的 10GB 缓存配额。我们的基础镜像压缩后约 3.5GB,加上项目层的增量,目前还在配额内,但接近边界。


如果配额成为瓶颈,下一步会是迁移到自托管的 S3 兼容存储作为 BuildKit 缓存后端,使用 type=s3,region=us-east-1,bucket=my-bucket。这增加了基础设施复杂度,但提供了理论上无限的缓存空间。


写在最后


Docker 构建 Android 环境这件事,技术细节琐碎,但核心原则清晰:理解每一层缓存的失效条件,把变更频率不同的组件物理隔离,接受 Android 工具链的历史包袱并寻找 workaround。没有完美的方案,只有对特定团队场景足够好的权衡。


我至今仍在调整那个 Dockerfile,上周刚把 build-tools 升级到 34.0.0,下周可能要测试 JDK 21 的兼容性。这种持续维护是基础设施工作的常态,不值得抱怨,但值得记录。

Security 库的 EncryptedSharedPreferences,性能损耗有多少 2026-06-30
Paging 3 的 RemoteMediator,缓存策略怎么设计 2026-06-30

评论区