镜像构建工具 Habitus

Posted by Dylan Chen on July 30, 2018

Habitus 是一个独立的 Docker 镜像构建工具,它基于 Dockerfile 和 build.yml 来构建 Docker 镜像。Habitus 可以将工作流添加到镜像的构建过程中,允许使用者创建一个构建链,根据工作流生成最终的镜像。这个和 Openshift 的 source-to-image 以及 Docker 17.05 开始支持的 multi-stage builds 有些类似。

2018 年 7 月,Habitus 被正式提交给 CNCF TOC (# Technical Oversight Committee),TOC 将于 8 月对 Habitus 进行讨论。(Present Habitus to TOC)

关键特性

  • 通过 uid 参数支持多租户
  • 支持在构建的容器里执行任意的命令
  • 允许衔接不同步骤中构建的镜像
  • 构建完成后可以指定清理命令,清理完后会对镜像进行压缩 (squash),从而彻底清除被删除文件的痕迹
  • 管理构建过程中需要用到的各种敏感信息 secrets
  • 可以在各个构建步骤中指定 artifacts,它们能够在后续构建步骤中被使用
  • 支持非 TLS 连接 Docker Daemon

解决的问题

私有 GIT 仓库的 SSH Key 问题

如果在构建镜像过程中,需要从私有 GIT 仓库中拉取源代码,不可避免的我们需要将私有的 SSH Key 添加进构建过程中,这样可能存在将 key 打进最终 image 中的风险。例如,

FROM golang:1.9.2  
WORKDIR /go/src  
# install git  
...  
COPY ./id_rsa /root/.ssh/id_rsa  
RUN git clone git@github.com:private/helloworld.git  
RUN go build -v -o app ./helloworld  
RUN rm /root/.ssh/id_rsa  

在上述 Dockerfile 中,虽然在最后一步我们删除了 id_rsa 文件,但是在镜像文件中,我们依然可以从删除前的其他 layer 入手找到它。我们以一个简单的例子为例,

FROM busybox  
WORKDIR /app  
COPY ./id_rsa /app  
RUN rm /app/id_rsa  
$ docker build -t test .  
$ docker history test  
IMAGE               CREATED                  CREATED BY                                      SIZE                COMMENT  
e9a6d81aa4c2        Less than a second ago   /bin/sh -c rm /app/id_rsa                       0B  
70f4e5bfe7a6        Less than a second ago   /bin/sh -c #(nop) COPY file:41cb0e247db8209d…   24B  
69032d96e32b        Less than a second ago   /bin/sh -c #(nop) WORKDIR /app                  0B  
8ac48589692a        3 months ago             /bin/sh -c #(nop)  CMD ["sh"]                   0B  
<missing>           3 months ago             /bin/sh -c #(nop) ADD file:c94ab8f861446c74e…   1.15MB$ docker run -it --rm 70f4e5bfe7a6 cat /app/id_rsa  
This is private ssh key content  

这里,我们直接运行了删除 id_rsa 之前的 layer 70f4e5bfe7a6

如果使用运行容器的方式,我们可以将 SSH Key mount 进容器中,这样可以解决 key 的安全隐患问题,例如:

$ docker run -it --rm -v ${HOME}/.ssh:/root/.ssh -v $(pwd):/git alpine/git clone git@github.com:foobar/private-repo.git

但是通过运行容器的方式,我们需要配合 shell 脚本来完成复杂的构建过程。

Habitus 在构建的过程中会启动一个 web 服务,来将构建需要的 secrets 暴露给内部的 Docker 网络,并且只有在构建过程中才可用。我们可以从这个 web 服务中获取这些 secrets 并且在同一个 layer 中删除它们即可。

# Optional steps to take care of git and Github protocol and server fingerprint issues  
RUN git config --global url."git@github.com:".insteadOf "https://github.com/"  
RUN ssh-keyscan -H github.com >> ~/.ssh/known_hosts  
  
# using Secrets  
ARG host  
RUN wget -O ~/.ssh/id_rsa http://$host:8080/v1/secrets/file/id_rsa && chmod 0600 ~/.ssh/id_rsa && ssh -T git@github.com && rm ~/.ssh/id_rsa  

避免不必要的依赖

镜像越庞大,越容易出现安全问题,镜像中应该尽可能的将不需要的依赖去掉。像 Java 和 Go 这种编译语言,在编译时会用到各种编译时的依赖,但是运行时并不需要它们,如果将它们打进镜像中,不但使得镜像变得臃肿,还会引入新的安全隐患。因此我们希望这些编译时依赖能够从最终的镜像中排除。Habitus 允许我们在一个构建镜像中构建项目,然后将构建好的 artifacts 方便地放进后续的的镜像中。

node.js 项目同样存在这样的问题,在 package.json 中我们可能会通过 devDependencies 定义大量的构建时依赖,如 babel,这些依赖仅在构建项目的时候才需要。

复杂的构建过程

对于复杂的构建过程,可能需要使用多个不同的 Dockerfile,并且需要多个构建步骤有序地配合,才能完成最终的镜像构建。这其中可能就需要使用 shell 脚本来完成整个构建过程。

而使用 Habitus,我们可以在一个 build.yml 文件中描述所有的构建步骤,并且不同的构建步骤建能够良好地衔接。

多租户支持

使用 Habitus 构建镜像时可以通过命令行参数 uid (Unique ID) 来支持多租户,这个参数值将会以后缀的形式添加进构建的镜像名中。例如,

build:
  version: 2016-03-14
  steps:
    builder:
      name: app:v1.0
      dockerfile: Dockerfile
$ habitus --uid=devops

我们将得到镜像 app-devops:v1.0

在生产环境中使用镜像

Habitus 能够为生产环境构建出轻巧、安全、高性能以及稳定的镜像,从而使得运行的应用更加健康稳定。

关键特性分析

Secret 服务

在 Habitus 构建镜像的过程中,会启动一个 secret 服务来提供定义在 build.yml 文件中的各种 secrets。在 Dockerfile 中,可以通过 wget 或者 curl 来从 secret 服务中获得指定的 secret。如果需要使用 secrets,需要在执行 habitus 命令的时候指定 --secrets=true 参数。

$ habitus --secrets=true

当开启了 secrets 时,Habitus 会启动一个 Restful 服务来提供 secrets,服务的地址由参数 bindingport 指定。

if config.SecretService {
  // start the API
  secret_service := &api.Server{Builder: b}
  err = secret_service.StartServer(VERSION)
  if err != nil {
    log.Fatalf("Cannot start API server due to %s", err.Error())
    os.Exit(2)
  }
}

该服务主要提供获取 secret 的 API: /v1/secrets/:type/:name。目前支持两种 secret 类型(即 type):

  • file 文件,如 key 文件
  • env 环境变量

Secret 服务中通过 provider 来管理不同类型的 secret,例如,对于文件类型,FileProvider 会收集 build.yml 中定义的所以文件类型的 secret,并以 map 形式保存。当一个文件类型的 secret 请求到来时,FileProvider 会将对应文件的内容返回。

type FileProvider struct {
  registry map[string]string
}

func (f *FileProvider) GetSecret(name string) (string, error) {
  fl := f.registry[name]
  dat, err := ioutil.ReadFile(fl)
  if err != nil {
    return "", err
  }

  return string(dat), nil
}

func (f *FileProvider) RegisterSecret(name string, value string) error {
  if f.registry == nil {
    f.registry = make(map[string]string)
  }

  // TODO: check for duplicates and invalid names and values
  f.registry[name] = value

  return nil
}

Secret 服务也支持用户名密码方式的身份验证,只需要在运行 habitus 的时候指定对应的参数:

$ habitus --secrets=true --authentication-secret-server=true --user-secret-server=habitus --password-secret-server=123456

在 Dockerfile 中,我们需要传入用户名密码来获取 secrets,如:

RUN curl -s -u ${user}:${password} http://$host:8080/v1/secrets/env/my_secret

镜像清理和压缩(Squash)

如果在 build.yml 中指定了 cleanup 命令,Habitus 会在镜像构建好之后使用该镜像启动一个容器,并以 exec 的方式在该容器中依次执行指定的清理命令。

在执行完清理后,Habitus 会将这个容器 commit 成一个镜像(docker commit),并将这个镜像保存到一个临时的 tar 文件中(docker save)。接下来 Habitus 会对这个保存的镜像文件进行压缩,最后将压缩后的镜像加载进 docker daemon。

镜像文件的每一个 layer 都是只读的,在某个 layer 中如果删除了之前的某个文件,该文件只会在新的 layer 中标记为删除,而不会真正地从镜像中删除,例如,

FROM busybox

COPY ./big.tar /tmp
RUN rm /tmp/big.tar

通过这个 Dockerfile 构建得到的镜像,依然会包含 big.tar 文件,即使我们显式地执行了删除命令。这种时候我们就需要对镜像进行压缩,压缩的好处是能够彻底去掉不需要的文件,使得镜像不再臃肿,对于敏感的数据(例如私有的 key 文件),镜像压缩也可以避免将它们暴露进最终的镜像中。

镜像压缩 Docker 很早就已经有官方支持(相关 PR),但是默认并没有开启,如果像要开启镜像压缩,可以在启动 Docker Daemon 的时候添加 --experimental 参数。

$ /usr/bin/dockerd --experimental

然后,在构建镜像的时候,我们可以通过 --squash 参数指定压缩,同样使用上述 Dockerfile,我们可以得到压缩后的镜像,同时保留构建历史不变。

$ docker build -t test-squash --squash .

Artifactes

Habitus 会在每一步构建完成后,启动一个容器,将 artifacts 中指定的文件拷贝到工作目录下。后续的构建步骤中可以直接在 Dockerfile 中使用这些文件。

安装与使用

Habitus 作为一个命令行工具,安装起来非常简单,在 macOS 和 Linux 中,使用下面的命令来进行安装:

curl -sSL https://raw.githubusercontent.com/cloud66/habitus/master/habitus_install.sh | bash  

Habitus 使用一个 yml 文件来描述镜像的构建,它可以包含多个步骤(step),每个步骤都是相互独立的,但是可以通过 depends_on 来定义依赖关系,没有依赖关系的步骤在构建时会并行地执行。下游步骤可以使用上游步骤生成的 artifacts 和镜像。

build:
  version: 2016-03-14 # version of the build schema.
  steps:
    builder:
      name: builder
      dockerfile: Dockerfile.builder
      secrets:
        id_rsa:
          type: file
          value: _env(HOME)/.ssh/my_private_key
      artifacts:
        - /go/src/github.com/cloud66/iron-mountain/iron-mountain
        - /go/src/github.com/cloud66/iron-mountain/config.json
        - /go/src/github.com/cloud66/iron-mountain/localhost.crt
        - /go/src/github.com/cloud66/iron-mountain/localhost.key
      cleanup:
        commands:
          - rm -rf /root/.ssh/
    deployment:
      name: ironmountain
      dockerfile: Dockerfile.deployment
      depends_on:
        - builder
    uploader:
      name: uploader
      context: ./uploader
      dockerfile: Dockerfile
      depends_on:
        - ironmountain
      command: s3cmd --access_key=_env(ACCESS_KEY) --secret_key=_env(SECRET_KEY) put /app/iron-mountain s3://uploads.aws.com

一个步骤包含以下的元素:

  • name 生成的镜像的名字
  • dockerfile 该步骤使用的 Dockerfile
  • context 构建镜像时的 Docker build context
  • artifacts 该步骤生成的 artifacts,这些文件会在该步骤镜像构建完后被拷贝到工作目录中,供之后得步骤使用(通过 ADD 或 COPY 命令)
  • secrets 构建过程中使用的 secrets,这些 secrets 由 Habitus 内部的 web 服务提供,可以通过 wgetcurl 来获取它们。
  • cleanup 该步骤镜像构建完成后执行的清理命令,清理完成后会对镜像进行压缩(squash),从而彻底清除被删除文件的痕迹。
  • depends_on 定义步骤间的依赖关系,每个步骤将在它依赖的步骤完成之后开始构建
  • command 该步骤镜像构建完成后,启动一个容器运行指定的命令,例如将生成的 artifacts 上传到 S3。

参考

  • https://github.com/cloud66-oss/habitus
  • http://www.habitus.io/
  • https://github.com/cncf/toc/issues/134
  • https://blog.cloud66.com/how-to-create-the-smallest-possible-docker-image-for-your-golang-application/
  • https://blog.cloud66.com/create-construct-and-weld-the-perfect-production-ready-secure-java-container-with-habitus-io/