首发于平等的黑
利用"元编程"实现基础镜像的集中管理和动态构建 (Drone CI 0.8)

利用"元编程"实现基础镜像的集中管理和动态构建 (Drone CI 0.8)

作为重度使用容器技术的中小企业,大量基础镜像的维护是一个很头疼的事情。这里的基础镜像可以是常用的操作系统、语言、开源工具等。对于这些镜像,官方的 image 并不能总是开箱既用,往往要加入一些定制操作。例如对于 ubuntu-image 要配置中国时区,更换为国内 163 apt源,加入一些常用的 shell 调试工具(vim, dnsutils),对于 python-image 需要更换为国内豆瓣源等等操作。

这里的痛点是,为每一个这样的镜像配置 git 仓库和自动构建都是很折腾的耗时操作,这些基础镜像的散乱分布也不便于集中管理。本文结合 drone ci 0.8版本,利用它支持的 bash string substitution 特性完成一个可以同时维护多个基础镜像并通过 git commit message 触发基于模板的自动构建。


首先先从一个很 naive 的场景说起,场景是基于官方镜像,封装一个用于腾讯云服务,更换为python豆瓣源的 CI 例子(Dockerfile 和 .drone.yml 文件):

FROM python:2.7.15

RUN rm -f /etc/apt/sources.list \
    && echo "deb http://mirrors.tencentyun.com/debian/ jessie main non-free contrib" >> /etc/apt/sources.list \
    && echo "deb http://mirrors.tencentyun.com/debian/ jessie-updates main non-free contrib" >> /etc/apt/sources.list \
    && echo "deb http://mirrors.tencentyun.com/debian/ jessie-backports main non-free contrib" >> /etc/apt/sources.list \
    && echo "deb-src http://mirrors.tencentyun.com/debian/ jessie main non-free contrib" >> /etc/apt/sources.list \
    && echo "deb-src http://mirrors.tencentyun.com/debian/ jessie-updates main non-free contrib" >> /etc/apt/sources.list \
    && echo "deb-src http://mirrors.tencentyun.com/debian/ jessie-backports main non-free contrib" >> /etc/apt/sources.list
    
RUN mkdir ~/.pip \
    && echo "[global]" >> ~/.pip/pip.conf \
    && echo "index-url = https://pypi.douban.com/simple" >> ~/.pip/pip.conf


pipeline:
  docker:
    image: plugins/docker
    repo: docker.example.com/base/python
    dockerfile: Dockerfile
    when:
      branch: master


这里我们期望做到:

  • 上游的官方镜像的版本号,可以变成一个环境变量被注入(像 Node.js 这种三天两头跳版本号的官方仓库,可以注入版本号进行基础镜像更新就很有必要了)
  • 可以通过指令决定基于哪个模板来构建镜像
  • 可以通过指令决定构建产物的镜像名(docker tag)


于是我们的 .drone.yml 可以设计成这个样子:

pipeline:
  image_build:
    image: plugins/docker
    repo: ${my_custom_image_name}
    dockerfile: ${my_custom_dockerfile_path}
    when:
      branch: master
      status: [success]


然后在仓库里放入模板文件,比如约定为 .tmpl 结尾,这样这种 tmpl 文件可以在一个 git 仓库里放置任意多个:

FROM python:${upstream_version}

RUN rm -f /etc/apt/sources.list \
    && echo "deb http://mirrors.tencentyun.com/debian/ jessie main non-free contrib" >> /etc/apt/sources.list \
    && echo "deb http://mirrors.tencentyun.com/debian/ jessie-updates main non-free contrib" >> /etc/apt/sources.list \
    && echo "deb http://mirrors.tencentyun.com/debian/ jessie-backports main non-free contrib" >> /etc/apt/sources.list \
    && echo "deb-src http://mirrors.tencentyun.com/debian/ jessie main non-free contrib" >> /etc/apt/sources.list \
    && echo "deb-src http://mirrors.tencentyun.com/debian/ jessie-updates main non-free contrib" >> /etc/apt/sources.list \
    && echo "deb-src http://mirrors.tencentyun.com/debian/ jessie-backports main non-free contrib" >> /etc/apt/sources.list
    
RUN mkdir ~/.pip \
    && echo "[global]" >> ~/.pip/pip.conf \
    && echo "index-url = https://pypi.douban.com/simple" >> ~/.pip/pip.conf


接下来就是环境变量的注入过程了。相对容易解决的是 tmpl 文件的环境变量注入,这里只要用bash脚本或者脚本语言实现一个字符串替换,在 drone pipeline 的 build 步骤,将 ${upstream_version} 替换为真实的版本号,并生成 Dockerfile 文件即可。所以 .drone.yml 文件可以进化为这样子:

pipeline:
  gen_dockerfile_by_tmpl:
    image: python:2
    pull: true
    commands:
      - python gen.py # 执行完这个step, 在当前工作目录生成了 Dockerfile 
  image_build:
    image: plugins/docker
    repo: ${my_custom_image_name}
    dockerfile: Dockerfile
    when:
      branch: master
      status: [success]


还差两个问题需要解决。1. 如何将 upstream_version, tmpl_filename 等信息带给脚本文件 gen.py 用于动态生成 Dockerfile 2. 如何将 image-name 信息注入替换掉 .drone.yml 的 ${my_custom_image_name}

翻遍文档,有如下两个机制可以利用一下:

  • DRONE_COMMIT_MESSAGE 环境变量,这里可以通过 git commit message 将任意文本带到 drone 的执行环境。我们可以利用 git commit message 模拟一个 command
  • bash string substitution 文档里列举的比较有限,其实 drone 里可以利用所有的标准 string substitution 语法

既然第一点可以携带任意信息进入 drone, 那么 gen.py 的实现就相对简单了,暂时跳过。先明确一下 gen.py 的职责就是读取 DRONE_COMMIT_MESSAGE 环境变量,从这里面 parse 出准备使用的模板文件和准备替换的环境变量,生成一个能真实执行的 Dockerfile.

对于 .drone.yml 文件里的 ${my_custom_imagename} 注入,这里还是略有些限制。在 drone 的构建执行过程中,.drone.yml 只有有限的 env 的注入能力。这是因为 .drone.yml 文件是在 drone 触发构建过程时,被传入 drone-server, 进行一些替换和 parsing 之后,再控制 drone-agent 完成相应的构建步骤。由于 parsing 部分执行在 drone-server, 所以 yml 里的 environment/build_args 等关键字都是不影响 .drone.yml 模板文件本身的。需要寻找一个能够在构建过程前,替换 .drone.yml 环境变量的机制。

阅读 官方文档, drone 在解析 .drone.yml 文件的过程中,只有两个地方可以 hack,一个是 drone 自己暴露出的环境变量会被值替换 (可供选择的变量在这个页面有罗列 environment-reference) 另一处是 matrix build 功能,并不适用。

经过简单实验,在 .drone.yml里面,对于

repo: ${DRONE_COMMIT_MESSAGE} # DRONE_COMMIT_MESSAGE="my git commit msg"

会被替换为:

repo: "my git commit msg
"

似乎 drone 解析时会自作主张在git msg后面插入一个换行符,前后还会加上双引号。这样的话会破坏掉 yml 语法格式。利用 bash string substitution 可以勉强绕开它,变成这样子:

repo: ${DRONE_COMMIT_MESSAGE%%|*}" # DRONE_COMMIT_MESSAGE="docker.example.com/base/python|blahblah"

上个代码块的 bash 语法含义是,从后向前匹配,remove 匹配到的pattern(这个 pattern 匹配了竖线和后面所有内容,包括了恶心的换行符) 还要留意要在最后面补上一个双引号,这样才符合 yaml 语法格式。替换后:

repo: "docker.example.com/base/python"

完美!


既然已经做到了通过 git commit message 动态的指定所要构建的镜像名称(tag),索性一鼓作气把 git commit message 设计成一个 Command DSL

比如 <image_name> | <image_version> | <tmplfile> 或者夸张一点的 <image_name> with version xxx by xx_tmpl. 这里的形式只取决于 gen.py 如何解析这段文本。


剩下的次要细节就不展开说明了,把文中所列的几个主要文件罗列一下:

项目结构:

/template/
-- python.tmpl
-- node.tmpl
-- node-build.tmpl
-- ubuntu.tmpl
.drone.yml
gen.py
readme.md


gen.py 脚本

#!/usr/bin/python
# -*- coding: UTF-8 -*-

import os, sys, re
from string import Template


class Command:
    def __init__(self, **kwds):
        self.__dict__.update(kwds)


def get_build_command():
    command = os.environ['DRONE_COMMIT_MESSAGE']
    if command is None:
        print('git commit msg 为 null')
        sys.exit(1)

    print('[DEBUG] ' + command)

    if not re.match(r'[-a-z0-9/.]+\|[.0-9]+\|[-a-z]+\.tmpl', command, flags=0):
        print('command(git commit msg) 格式错误,忽略镜像构建')
        sys.exit(1)

    piece = command.split("|")
    tmplpath = piece[2].rstrip('\n\t')
    print('命令解析成功: 使用模板:' + tmplpath + " 构建镜像: " + piece[0] + ":" + piece[1] )
    return Command(tmpl=tmplpath, version=piece[1])


def gen_docker_file(cmd):
    with open("./template/" + cmd.tmpl) as f:
        with open("Dockerfile", "w") as f1:
            for line in f:
                if "$" in line:
                    d = dict(upstream_version=cmd.version)
                    line = Template(line).safe_substitute(d)
                f1.write(line)


def gen_tag_file(cmd):
    with open(".tags", "w") as f:
        tag_str = cmd.version
        f.write(tag_str)
        while "." in tag_str:
            tag_str = tag_str.rsplit('.', 1)[0]
            f.write("," + tag_str)


cmd = get_build_command()

gen_docker_file(cmd)
gen_tag_file(cmd)

这里利用 drone-docker-plugin 支持的 .tags 特性,比如对于 node: 8.11.3 可以递归处理 dot 符号,生成内容为 "8.11.3,8.11,8,latest" 的 .tags 文件,起到一个 auto-tag 的效果


最后是 .drone.yml 文件

pipeline:
  gen_dockerfile_by_tmpl:
    image: python:2
    pull: true
    commands:
      - python gen.py 
  image_build:
    image: plugins/docker
    repo: ${DRONE_COMMIT_MESSAGE%%|*}"
    dockerfile: Dockerfile
    when:
      branch: master
      status: [success]


最终效果:仓库有 git 提交时触发构建,当提交信息为:

docker.example.com/base/python|2.7.11|python.tmpl

drone 以 python.tmpl 为模板,生成并 push 三个镜像到私有仓库

docker.example.com/base/python:2.7.11
docker.example.com/base/python:2.7
docker.example.com/base/python:2

编辑于 2018-06-19

文章被以下专栏收录