上个月受 DockOne 社区邀请,做了一次 CI 实践方面的线上分享,在此记录下。 本文讲述 GitLab CI 的架构及其能力特性,分析它在 DevOps 实践中的作用。 通过分析 Docker In Docker 的技术细节,详细讲述 CI 实践以及在生产环境中的所做的优化,包括但不限于镜像仓库等,以达到数倍的性能提升。 本次分享内容以 GitLab Community Edition 11.0.4 edb037c 为例。

为何选择 GitLab CI

认识 GitLab CI

什么是 GitLab CI

GitLab CI 是 GitLab 为了提升其在软件开发工程中作用,完善 DevOPS 理念所加入的 CI/CD 基础功能。可以便捷的融入软件开发环节中。通过 GitLab CI 可以定义完善的 CI/CD Pipeline。

优势

  • GitLab CI 是默认包含在 GitLab 中的,我们的代码使用 GitLab 进行托管,这样可以很容易的进行集成
  • GitLab CI 的前端界面比较美观,容易被人接受
  • 包含实时构建日志,容易追踪
  • 采用 C/S 的架构,可方面的进行横向扩展,性能上不会有影响
  • 使用 YAML 进行配置,任何人都可以很方便的使用。

重点概念

Pipeline

Pipeline 相当于一个构建任务,里面可以包含多个流程,如依赖安装、编译、测试、部署等。 任何提交或者 Merge Request 的合并都可以触发 Pipeline

Stages

Stage 表示构建的阶段,即上面提到的流程.

  • 所有 Stages 按顺序执行,即当一个 Stage 完成后,下一个 Stage 才会开始
  • 任一 Stage 失败,后面的 Stages 将永不会执行,Pipeline 失败
  • 只有当所有 Stages 完成后,Pipeline 才会成功

Jobs

Job 是 Stage 中的任务.

  • 相同 Stage 中的 Jobs 会并行执行
  • 任一 Job 失败,那么 Stage 失败,Pipeline 失败
  • 相同 Stage 中的 Jobs 都执行成功时,该 Stage 成功

好的,基本的概念已经和大家介绍了, 大家可以发现,上面说的概念,没有提到任务的实际执行者. 那任务在哪里执行呢?

GitLab runner

Runner 是任务的实际执行者, 可以在 MacOS/Linux/Windows 等系统上运行。使用 golang 进行开发。 同时也可部署在 k8s 上

注册

docker run --rm -t -i -v /path/to/config:/etc/gitlab-runner --name gitlab-runner gitlab/gitlab-runner register \
  --executor "docker" \
  --docker-image alpine:3 \
  --url "https://gitlab.com/" \
  --registration-token "PROJECT_REGISTRATION_TOKEN" \
  --description "docker-runner" \
  --tag-list "dev" \
  --run-untagged \
  --locked="true"

上面的示例为将 runner 注册为一个容器, 当然 大家也可以直接在物理机上执行。 在物理机上的注册方式与注册为容器大致相同

sudo gitlab-runner register \
  --non-interactive \
  --url "https://gitlab.com/" \
  --registration-token "PROJECT_REGISTRATION_TOKEN" \
  --executor "docker" \
  --docker-image alpine:3 \
  --description "docker-runner" \
  --tag-list "docker,aws" \
  --run-untagged \
  --locked="false" \

# (这段代码来自官方文档)

接下来,我们来看下 runner 的类型, 以便在使用时进行区分。

类型

  • Shared - Runner runs jobs from all unassigned projects
  • Group - Runner runs jobs from all unassigned projects in its group
  • Specific - Runner runs jobs from assigned projects
  • Locked - Runner cannot be assigned to other projects
  • Paused - Runner will not receive any new jobs

配置

首先最外层的是全局配置, 默认会有

concurrent = 1
check_interval = 0

这两个。 比较需要关注的是下面几个

全局配置
  • concurrent: 并发数, 0 为无限制
  • sentry_dsn:与 Sentry 联动,可以将异常等收集至 Sentry 中。
  • listen_address: 暴露出 metrics 供 Prometheus 监控

Executor

  • Shell
  • Docker (本次的分享内容)
  • Docker Machine and Docker Machine SSH (autoscaling)
  • Parallels
  • VirtualBox
  • SSH
  • Kubernetes (推荐)

详解 Docker In Docker

概述

Docker In Docker 简称 dind,在 GitLab CI 的使用中,可能会常被用于 service 的部分。 dind 表示在 Docker 中实际运行了一个 Docker 容器, 或 Docker daemon。

其实如果只是在 Docker 中执行 docker 命令, 那装个二进制文件即可。 但是如果想要运行 Docker daemon (比如需要执行 docker info)或者访问任意的设备都是不允许的。

Docker 在 run 命令中提供了两个很重要的选项 --privileged--device , 另外的选项比如 --cap-add--cap-drop 跟权限也很相关,不过不是今天的重点,按下不表。

--device 选项可以供我们在不使用 --privileged 选项时,访问到指定设备, 比如 docker run --device=/dev/sda:/dev/xvdc --rm -it ubuntu fdisk /dev/xvdc 但是这也只是有限的权限, 我们知道 docker 的技术实现其实是基于 cgroup 的资源隔离,而 --device 却不足于让我们在容器内有足够的权限来完成 docker daemon 的启动。

在 2013年 左右, –privileged 选项被加入 docker, 这让我们在容器内启动容器变成了可能。 虽然 –privileged 的初始想法是为了能让容器开发更加便利,不过有些人在使用的时候,其实可能有些误解。

有时候,我们可能只是想要能够在容器内正常的build 镜像,或者是与 Docker daemon 进行交互,例如 docker images 等命令。 那么,我们其实不需要 dind, 我们需要的是 Docker Out Of Docker,即 dood,在使用的时候,其实是将 docker.sock 挂载入容器内

例如, 使用如下命令: sudo docker run --rm -ti -v /var/run/docker.sock:/var/run/docker.sock taobeier/docker /bin/sh 在容器内可进行正常的docker images 等操作, 同时需要注意,在容器内的动作,将影响到宿主机上的 docker daemon。

如何实现

  • 创建组和用户,并将用户加入该组。 使用 groupadd 和 useradd 命令
  • 更新 subuid 和 subgid 文件, 将新用户和组配置到 /etc/subgid 和 /etc/subuid 文件中。 subuid 和 subgid 规定了允许用户使用的从属id
  • 接下来需要挂载 /sys/kernel/security 为 securityfs 类型可以使用 mountpoint 命令进行测试 mountpoint /sys/kernel/security 如果不是一个挂载点, 那么使用 mount -t securityfs none /sys/kernel/security 进行挂载。如果没有挂载成功的话, 可以检查是否是 SELinux 或者 AppArmor 阻止了这个行为。这里详细的安全问题,可以参考 Linux Security Modules (LSM)
  • 接下来允许 dockerd 命令启动 daemon 即可, dockerd --host=unix:///var/run/docker.sock --host=tcp://0.0.0.0:2375 即可将docker daemon 监听至 2375 端口

简单做法

可以直接使用 Docker 官方镜像仓库中的 docker:dind 镜像, 但是在运行时, 需要指定 --privileged 选项

CI 实践

runner 实践

看 runner 部分的配置

[[runners]]
  name = "docker"
  url = "https://gitlab.example.com/"
  token = "TOKEN"
  limit = 0
  executor = "docker"
  builds_dir = ""
  shell = ""
  environment = ["ENV=value", "LC_ALL=en_US.UTF-8"]
  clone_url = "http://172.17.0.4"

由于网络原因, clone_url 可以配置为可访问的地址,这样代码 clone 的时候,将会使用配置的这个地址。实际请求为 http://gitlab-ci-token:TOKEN@172.17.0.4/namespace/project.git

  • 再看一下 runners.docker 的配置,这部分将影响 docker 的实际运行
[runners.docker]
  host = ""
  hostname = ""
  tls_cert_path = "/home/tao/certs"
  image = "docker"
  dns = ["8.8.8.8"]
  privileged = false
  userns_mode = "host"
  devices = ["/dev/net/tun"]
  disable_cache = false
  wait_for_services_timeout = 30
  cache_dir = ""
  volumes = ["/data", "/home/project/cache"]
  extra_hosts = ["other-host:127.0.0.1"]
  services = ["mongo", "redis:3"]
  allowed_images = ["go:*", "python:*", "java:*"]

dns, privileged, extra_hosts, services 比较关键, 尤其是在生产中网络情况多种多样, 需要格外关注。 至于 devices 配置 ,在今儿分享的一开始已经讲过了, allowed_images 的话, 是做了个限制。

上面几个配置项, 用过 docker 的同学,应该很容易理解。 我们来看下 services 这个配置项

image: registry.docker-cn.com/taobeier/docker

variables:
  DOCKER_DRIVER: overlay2    # overlay2 is best bug need kernel >= 4.2

services:
    - name: registry.docker-cn.com/taobeier/docker:stable-dind
      alias: docker

stages:
  - build
  - deploy


build_and_test:
  stage: build
  tags:
    - build
  script:
    # change repo
    #- sed -i 's/dl-cdn.alpinelinux.org/mirrors.ustc.edu.cn/g' /etc/apk/repositories
    # 使用默认官方源 apk 耗时 7min 30s.  修改后 耗时 18s
    - ping -c 1 docker
    - ping -c 1 registry.docker-cn.com__taobeier__docker
    - ipaddr
    - apk add --no-cache py-pip 
    # 使用默认耗时 1 min 15s.  修改后耗时 43s
    - pip install -i https://mirrors.ustc.edu.cn/pypi/web/simple docker-compose
    - docker-compose up -d
    - docker-compose run --rm web pytest -s -v tests/test_session.py

deploy:
  image: "registry.docker-cn.com/library/centos"
  stage: deploy
  tags:
    - deploy
  script:
    # install ssh client
    - 'ssh-agent || (yum install -y openssh-clients)'
    # run ssh-agent
    - eval $(ssh-agent -s)
    - echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add - > /dev/null
    # create ssh dir
    - mkdir -p ~/.ssh
    - chmod 700 ~/.ssh
    # use ssh-keyscan to get key
    - ssh-keyscan -p $SSH_PORT $DEPLOY_HOST >> ~/.ssh/known_hosts
    - chmod 644 ~/.ssh/known_hosts

    # - ssh -p $SSH_PORT $DEPLOY_USER@$DEPLOY_HOST ls
    - rm -rf .git
    - scp -r -P $SSH_PORT . $DEPLOY_USER@$DEPLOY_HOST:~/we/

services 的本质其实是使用了 docker 的 –link ,我们来看下它如何工作

Docker Executor 如何工作

  • 创建 service 容器 (已经配置在 service 中的镜像)
  • 创建 cache 容器 (存储已经配置在 config.toml 的卷和构建镜像的 Dockerfile)
  • 创建 build 容器 并且 link 所有的 service 容器.
  • 启动 build 容器 并且发送 job 脚本到该容器中.
  • 执行 job 的脚本.
  • 检出代码: /builds/group-name/project-name/.
  • 执行 .gitlab-ci.yml 中定义的步骤.
  • 检查脚本执行后的状态码,如果非 0 则构建失败.
  • 移除 build 和 service 容器.

私有镜像源

用户认证需要 GitLab Runner 1.8 或更高版本,在 0.6 ~ 1.8 版本之间的 Runner 需要自行去 Runner 的机器上手动执行。

默认情况下,如果访问的镜像仓库需要认真的话, GitLab Runner 会使用 DOCKER_AUTH_CONFIG 变量的作为认证的凭证。

注意:DOCKER_AUTH_CONFIG 是完成的 docker auth 凭证,也就是说,它应该和我们 ~/.docker/config.json 中的内容一致,例如:

{
    "auths": {
        "registry.example.com": {
            "auth": "5oiR5piv5byg5pmL5rab"
        }
    }
}

简单的做法就是,我们在本地/服务器上执行 docker login 私有镜像源 登录成功后,将 ~/.docker/config.json 的文件内容直接复制,作为我们的变量的值, 或者是 echo -n '用户名:密码' | base64 以这样的方式来获得 auth 的内容,组装成对应的格式,写入 GitLab 的 value 配置中。

生产环境中的 CI 性能优化

  1. 使用国内源对容器镜像进行加速 例如:使用 Docker 中国官方镜像加速服务 https://registry.docker-cn.com 当然各家公司其实也有提供镜像加速的服务。

  2. 使用私有镜像仓库。例如 Docker Registry, 或者 Harbor, 我们是在使用 Harbor 作为私有镜像仓库的。

因为网络的原因, 如果默认使用官方镜像, 1. 官方镜像拉不下来;2. 在官方镜像中安装包耗时长;3. 如果换源,需要每个 Dockerfile 都要做相同的事情。 这我们当然是不能同意的。 所以,我们构建了自己的私有镜像。 从 BusyBox 开始, 构建 Alpine Linux 使用私有源, 以此为基础 构建我们所需要的其他镜像。 用户不再需要自行换源。

这个操作完成后, 原先我们需要在 CI 执行的过程中安装 py-pip(为了安装 docker-compose 和我们的服务依赖)耗时从 3min30s 减少到了 18s。

这里,需要说下为何我们是从头开始构建镜像,而不是基于官方镜像。 主要是为了减少镜像体积 以及为了更快的适用于我们的需求。

同样的,我们构建了基础的 Docker 镜像,Python/Maven 等镜像,都是默认使用了我们的私有源,并且,用户在使用时, 并不需要关注换源的事情, 减少用户的心智负担。

  1. 规范 Dockerfile, 减少不必要的依赖安装, 减少镜像体积。其实结合上面的部分,我们做的事情是直接构建了我们的基础镜像 docker/alpine/maven之类的基础镜像,默认直接都换了源。这样既方便使用,还可以减少镜像层数。

  2. 拆分 job, 通过 tag 的方式可指定runner, 由不同的 runner 来并行执行无强依赖的一些动作。 便于分摊压力。

  3. 使用 Cache, CI 的构建中,大多数的镜像,其实变化不大,所以使用cache 可以成倍的提升 CI 的速度。

  4. 可能遇到的坑

前面提到了 service 中可以使用各种各样的服务, 无论是 dind 还是 mysql redis 等。 但是 如果我们全部做到了优化,都使用我们的私有源, 那便会发现问题。

因为 gitlab ci 默认对于 docker:dind 的 service 其实会选择连名为 docker 的 host ,以及 2375 端口。 当使用私有镜像源的时候, 比如

services:
    - name: registry.docker-cn.com/taobeier/docker:stable-dind

那这个 service 的 host 是什么呢?

这个 service 的 host 其实是会变成 registry.docker-cn.com__taobeier__docker ,然后 gitlab runner 便会找不到, job 就会执行失败

有两种解决办法。 一种是加一个变量

variables:
    DOCKER_HOST: "tcp://registry.docker-cn.com__taobeier__docker:2375"

但是 这种方式 很麻烦, 没有人能完全记住 遇到 / 会转换为 _ 难免会有问题。 那么就有了第二种办法:

services:
    - name: registry.docker-cn.com/taobeier/docker:stable-dind
    -       alias: docker

加一个 alias 。 这个方法目前很少人在用, 毕竟网络上查到的都是第一种 ,但是这个方式却是最简单的。

Q&A

Q:您提到把各种依赖都以 Service 的提供,请问是以哪种方式呢? 比如Python的依赖,怎么做成Service呢?

A:Service 化的依赖,主要是指类似 DB / MySQL/ Reids 之类的。 或者是 dind 其实它提供的是 2375 端口的TCP服务。 Python 的依赖,我推荐的做法是, 构建一个换了源的 Python 镜像。 安装依赖的时候,耗时会少很多。 或者说, 可以在定义 Pipeline 的时候, 将虚拟环境的 venv 文件夹作为 cache ,之后的安装也会检查这个,避免不必要的安装。

Q:请问,你们为什么不用Jenkins Pipeline,而使用GitLab CI?

A:主要原因是我提到的那几个方面。 集成较好, 界面美观优雅, 使用简单(所有有仓库写权限的人 都可以使用, 只要创建 .gitlab-ci.yml 并且配置了 Runner 即可使用) 。换个角度,我们来看下使用Jenkins 的问题, Jenkins 对于项目的配置其实和 GitLab 的代码是分离的, 两部分的, 用户(或者说我们的开发者)在使用的时候, 需要有两个平台, 并且,大多数时候, Jenkins 的权限是不放开的。 对用户来讲, 那相当于是个黑盒。 那可能的问题是什么呢? 遇到构建失败了, 但是只有运维知道发生了什么,但是研发无能为力,因为没有权限。 使用GItLab的好处,这个时候就更加突出了, 配置就在代码仓库里面,并且使用 YAML 的配置,很简单。 有啥问题,直接查,直接改。

Q:关于 Runner 的清理的问题,在长时间使用后,Runner 机器上回产生很多的 Cache 容器,如何清理呢。能够在任务中自动清除吗?

A:这个就相对简单了,首先, 如果你的 Cache 容器确认没用了, 每个 Cache 容器其实都有名字的, 直接按 Cache 的名字过略, 批量删掉。 如果你不确定它是否有用,那你直接删掉也是不影响的, 因为 Docker Excutor 的执行机制是创建完 Service 容器后, 创建 Cache 容器。 要是删掉了,它只是会再创建一次。 如果你想在任务中清除, 目前还没做相关的实践,待我实践后,看看有没有很优雅的方式。

Q:请问下Maven的settings.xml怎么处理?本地Maven仓库呢?

A:我们构建了私有的 Maven 镜像, 私有镜像中是默认使用了我们的私有源。 对于项目中用户无需关注 settings.xml 中是否配置repo。

Q:在GitLab的CD方案中,在部署的时候,需要在变量中配置跳板机的私钥,如果这个项目是对公司整部门开发,那么如何保护这个私钥呢?

A:可以使用 secret variable 将私钥写入其中, (但是项目的管理员,具备查看该 variable 的权限)开发一个 web server (其实只要暴露 IP 端口之类的就可以) 在 CI 执行的过程中去请求, server 对来源做判断 (比如 执行CI 的时候,会有一些特定的变量,以此来判断,是否真的是 CI 在请求)然后返回私钥。

Q:GitLab CI适合什么类型的项目呢?国内目前还比较小众吧?

A:国内目前还较为小众(相比 Jenkins 来说)其实只要需要 CI 的项目,它都适合。


可以通过下面二维码订阅我的文章公众号【MoeLove】

TheMoeLove