引言: 一种加速镜像构建的方式

Dockerfile的构建优化我平时见过不少, 我之前也介绍过一篇Docker系列(七)-Dockerfile进阶-多阶段构建与构建优化, 但是除了这些方法外, 还有一种另类的方案. 能更加极致的优化镜像构建时间并将层的概念进行扩展.

代码仓库过大带来的问题

我们的代码使用的是Python. 对于这类解释型语言, 使用容器运行意味着需要将代码拷贝进去到镜像中, 这是一种常见的情景:

1
2
3
4
5
6
7
8
9
10
11
12
# 这是我们准备好的代码结构
.
├── Dockerfile
├── etc
│   ├── docker-entrypoint.sh
│   └── app_uwsgi.yaml
├── src
│   └── sample # 用户的代码目录
└── var
├── log
├── run
└── scripts
1
2
3
4
5
6
7
8
# 安装依赖
COPY src/sample/requirements.txt requirements.txt
RUN pip install -r requirements.txt -i https://mirrors.aliyun.com/pypi/simple/

# ===== 以上内容可以缓存

# 拷贝代码
COPY --chown=xxx:xxx src/$PROJ_GIT_REPO $PROJ_SRC/$PROJ_GIT_REPO

这是一种比较常见的方案, 应该也是读者预期的, 我们将代码的依赖做了缓存, 但是没有缓存代码本身. 这就会引出一个问题, 假如src/sample这个仓库本身很大, 那么我们一次次构建, 上传镜像, 甚至到运行时pull镜像, 这一层都会很大, 构建中变慢的时间在这个过程中, 其实被放大了3倍.

两阶段clone代码

基于此种问题, 我们引入了一种新的方案. 直接在Dockerfile构建时clone代码, 但是分为两个阶段, 第一阶段仅有基础的 clone语句, 该层会被直接缓存. 而后的fetch语句中增加了对于git checkout的使用, 强制改变当前HEAD为需要的 commit hash, 改造后的代码如下:

1
2
3
4
5
6
7
8
9
10
COPY src/sample/requirements.txt requirements.txt
RUN pip install -r requirements.txt -i https://mirrors.aliyun.com/pypi/simple/
RUN su - xxx -c "git clone $REPO_URL $PROJ_SRC/$PROJ_GIT_REPO && cd $PROJ_SRC/$PROJ_GIT_REPO && git submodule update --init --recursive"

# ===== 以上内容可以缓存

RUN su - xxx -c "cd $PROJ_SRC/$PROJ_GIT_REPO &&\
git reset --hard && git clean -ffdx &&\
git fetch --tags --progress -- $REPO_URL +refs/heads/*:refs/remotes/origin/* &&\
git checkout -f 03ca73c32eb8a7485548f7cc75cc9ba6f708cd33 && git submodule update --init --recursive"

上面给出的代码我们已经在正式环境中运行超过一年以上, 未收到任何异常.

具体效果

第一clone的层约有180M, 第二次再fetch已经只有90M了. 这个改造下来, 镜像的push和pull也省了至少90M, 即使时10M/s的网速, 我们把整体的时间大概压缩了20s.

方案总结

针对某一类拥有历史记录比较多, 而每次改动却又不是特别大的项目, 节省的时间和空间会更加明显, 但是也有一个问题就是Dockerfile中, 需要我们放入可以直接clone的地址, 所以仅推荐私有仓库或是开源代码使用, 否则还是会有代码泄漏的风险.

使用ssh协议clone代码

上面的两阶段clone代码主要有两个问题:

  1. 我们的用户都是使用GitLab的, 可以clone代码的地址必须用户手动拼接好, 使用体验不佳
  2. 该地址直接放入了Dockerfile中, 并不是特别安全

基于此问题, 我们打算引入一种新的方案, 将git clone的地址改为ssh协议, Dockerfile中仅有ssh地址, 最后的成品中也没有私钥, 就不用担心代码泄漏的风险.

stackoverflow上面有很多关于这个问题的讨论, 例如:

  1. 两阶段法构建, 虽然最终的容器中没有ssh文件, 但是我们依然全量的拷贝了用户代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# https://stackoverflow.com/a/66648529/5563477
FROM alpine as MY_TMP_GIT_IMAGE

RUN apk add --no-cache git
RUN mkdir -p /root/.ssh && chmod 700 /root/.ssh
COPY /.ssh/id_ed25519 /root/.ssh/id_ed25519
RUN chmod 600 /root/.ssh/id_ed25519

RUN apk -yqq add --no-cache openssh-client && ssh-keyscan -t ed25519 -H gitlab.com >> /root/.ssh/known_hosts
RUN git clone git@gitlab.com:GITLAB_USERNAME/test.git
RUN rm -r /root/.ssh

# Start of the second image
FROM MY_BASE_IMAGE
COPY --from=MY_TMP_GIT_IMAGE /MY_GIT_REPO ./MY_GIT_REPO
  1. 构建时使用--squash, 这样删除私钥了之后, squash能将层压缩为一层, 从而抹去key在先前层存在的问题, 虽然也能实现功能, 但是仅压缩为一层意味着完全没有缓存
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# docker build -t example --build-arg ssh_prv_key="$(cat ~/.ssh/id_rsa)" --build-arg ssh_pub_key="$(cat ~/.ssh/id_rsa.pub)" --squash .

# Authorize SSH Host
RUN mkdir -p /root/.ssh && \
chmod 0700 /root/.ssh && \
ssh-keyscan github.com > /root/.ssh/known_hosts

# Add the keys and set permissions
RUN echo "$ssh_prv_key" > /root/.ssh/id_rsa && \
echo "$ssh_pub_key" > /root/.ssh/id_rsa.pub && \
chmod 600 /root/.ssh/id_rsa && \
chmod 600 /root/.ssh/id_rsa.pub

# Avoid cache purge by adding requirements first
ADD ./requirements.txt /app/requirements.txt

WORKDIR /app/

RUN pip install -r requirements.txt

# Remove SSH keys
RUN rm -rf /root/.ssh/
  1. 使用Docker buildkit, 这是Docker支持的一个新特性, 是目前为止比较优雅的一种方案了

来自: https://stackoverflow.com/a/58883743/5563477

1
2
export DOCKER_BUILDKIT=1
docker build --ssh default=~/.ssh/id_rsa .
1
2
3
4
5
6
7
8
9
10
11
# syntax=docker/dockerfile:experimental
FROM alpine

# Install ssh client and git
RUN apk add --no-cache openssh-client git

# Download public key for github.com
RUN mkdir -p -m 0600 ~/.ssh && ssh-keyscan github.com >> ~/.ssh/known_hosts

# Clone private repository
RUN --mount=type=ssh git clone git@github.com:myorg/myproject.git myproject

未来的镜像构建方式

目前市面上已经有不止一款docker镜像打包工具了. 我这里只是简单介绍一下它们, 并且看看对于ssh协议clone代码的支持, 我们的生产环境还未使用, 当然读者也不应该仅仅为了这个特性就更换目前稳定的构建打包工具.

kaniko

1
2
3
4
5
6
7
8
9
10
docker run \
-v `pwd`:/workspace \
-v /home/corvo/.docker:/kaniko/.docker \
gcr.io/kaniko-project/executor:latest \
--context=dir:///workspace \
--cache=true \
--cache-copy-layers \
--cache-repo="registry.cn-hangzhou.aliyuncs.com/corvofeng/develop-cache" \
--dockerfile=/workspace/Dockerfile \
--destination="registry.cn-hangzhou.aliyuncs.com/corvofeng/develop:v1"

cache-dir的形式我没跑通, 本地的/cache目录总是不写入数据, 所以只测试了cache-repo的形式, 可以让kaniko把cache放到了仓库中, 每次构建镜像时检查仓库的缓存

简单测试了一下kaniko的缓存方案, 对于corvofeng:develop这个仓库, 会默认使用/corvofeng/develop/cache地址来缓存, 你也可以自己指定一个缓存仓库.

在GitLab runner中, 使用这种方式构建镜像再合适不过了, https://docs.gitlab.cn/jh/ci/docker/using_kaniko.html

kaniko也支持ssh协议clone代码, 挂载ssh-agent对应的unix socket到容器中即可

1
2
3
4
5
6
7
8
9
10
11
12
13
docker run \
-v `pwd`:/workspace \
-v "$SSH_AUTH_SOCK":"$SSH_AUTH_SOCK"\
-v /home/corvo/.docker:/kaniko/.docker \
-e SSH_AUTH_SOCK=$SSH_AUTH_SOCK \
gcr.io/kaniko-project/executor:latest \
--context=dir:///workspace \
--cache=true \
--cache-copy-layers \
--build-arg="SSH_AUTH_SOCK=$SSH_AUTH_SOCK" \
--cache-repo="registry.cn-hangzhou.aliyuncs.com/corvofeng/develop-cache" \
--dockerfile=/workspace/Dockerfile \
--destination="registry.cn-hangzhou.aliyuncs.com/corvofeng/develop:v1"
1
2
3
4
FROM python:3.8-alpine
ARG SSH_AUTH_SOCK
RUN apk add --no-cache openssh-client git
RUN ssh-add -l

对应的效果如下

buildah

buildah的使用比较接近docker buildkit, 可以在镜像构建时挂载unixsocket, 下面是一个简单的例子

1
sudo buildah build --build-arg=SSH_AUTH_SOCK=$SSH_AUTH_SOCK --volume $SSH_AUTH_SOCK:$SSH_AUTH_SOCK .
1
2
3
4
5
FROM alpine

RUN apk add --no-cache openssh-client git
ARG SSH_AUTH_SOCK
RUN ssh-add -l

效果如下:

总结

首先, 这篇博客介绍的优化措施并不适合所有的项目, 主要针对大型项目镜像的构建, 如果你的项目比较小, 就没必要考虑这种手段.

另外, 我借用SSH_AUTH_SOCk是想说明, 现有的工具已经支持我们在构建时挂载文件或是unix socket, 对于仅在构建时需要的密码或是私钥, 完全可以使用文件挂载的方式来实现.

虽然我这篇博客都是在围绕私有项目的构建和部署来讲解优化措施的, 但是对于kankio以及buildah, 它们完全可以应用到开源项目之中. 我在创建一些命令行工具的容器时, 也会首选这两种工具.

我没有针对arm64之类的镜像构建做过深入测试, 如果有这类需求, 建议还是自己确认下是否可行.