基于 GitLab、Docker-Compose 和 Harbor 的 CI/CD 实现

在红岩网校工作站运维安全部工作已有一年之久,在维护各种服务的同时,也了解到了各种先进的技术架构。而这一年来使我印象尤其深刻的是网校先进的 Kubernetes 集群架构和 CI/CD 流程。
于是在这个暑假,我决定将自己的基础架构推上 CI/CD 流程。关于 CI,大约一年前我已经基于 Docker 部署了 GitLab,并使用 Docker in Docker 实现了 GitLab Runner。部署过程当时由于时间因素并未写入博客,可以类比 以 Docker in Docker 的方式部署 JupyterHub 进行配置。

实现效果:
CI/CD Pipeline

注意:考虑到 CI/CD 方案中包含较多我的基础架构的部署细节,CI/CD 完整方案并不会完全开源,这里只提供操作思路和部分源代码。

部署 Harbor

这里部署 Harbor 仅仅是因为我们需要一个本地 registry,不使用 Harbor 而使用其它 registry 也是可以的。

首先前往 https://github.com/goharbor/harbor/releases/ 下载 Harbor 的安装器,在线或离线版本均可。
解压后,会发现存在一个 harbor.yml.tmpl,这是 Harbor 配置文件的模板。
我们首先需要修改的是 hostname 或 external_url,这里我的 Harbor 是部署在反向代理后面的,因此我只需要设置完整的 external_url 即可。然后如果你的 Harbor 也像我的一样在反代后面,则不需要启用 https。

然后找到 database,记得将它的默认密码改掉。
同时 data_volume 的数据存储的位置 /data 可能会和你的其它服务冲突,可以酌情修改。
这一切完成后,在确保安装了 docker-compose 和 docker 的情况下运行 ./install.sh,即可启动 Harbor。端口可以从生成的 docker-compose.yml 中查看得到。
如果出现容器名称冲突,可以修改 docker-compose.yml 后对容器名进行修改等等。

启动 Harbor 后,使用默认用户名 admin 和默认密码 Harbor12345 登录后,请立即修改密码。

然后我们最好先修改机器人帐户的前缀,默认情况下,Harbor 机器人账户的前缀是 robot$,而 $ 符号在 shell 脚本中有特别的含义,这对于 GitLab CI 并不友好。因此可以修改为 robot- 等。然后我们为 CI 创建一个帐户,并配置好项目和权限。

应用服务器配置

这里为了方便地进行部署,我们选择使用 Docker Compose 来进行配置,虽然 Docker Compose 并没有 Kubernetes 的编排能力强大,但也可以比较方便地进行容器的编排和部署。
同时,我希望能够一定程度上模拟 Kubernetes 中 IngressRoute 的能力。

我们首先创建一个部署帐户,并为这个帐户配置 ssh 公钥。这个帐户应当位于 docker 组中,并且有 shell 访问权限。
然后我们可以配置这个帐户具有写入 nginx 配置文件目录的权限,并且使它能够测试和重载 nginx,这里可以使用 sudoers 配置实现:

【用户名】 ALL=(root) NOPASSWD: /usr/sbin/nginx -t, /usr/sbin/nginx -s reload

CI/CD 实现思路

我们的 CI/CD 主要分为两个部分,一个是负责打包服务镜像的 imagebuilder,另一个是负责将服务部署到服务器上的 deployer。

这里为了安全性,我们选择将访问密钥等验证信息作为受信任的 organization 中的环境变量。因此我们先定义一组 CI 用到的环境变量:
REG_USERNAME:代表 CI 机器人用户的名称
REG_SERVER:registry 的 URL
REG_PASSWORD:CI 用户的访问 token
DEPLOY_SSH_USER:部署时使用的 ssh 用户
DEPLOY_SSH_KEY:部署时使用的 ssh 私钥
DEPLOY_SSH_HOST:部署时连接的应用服务器 hostname

imagebuilder

在设计 imagebuilder 的时候,我尽可能的希望它能够变得更加具有扩展性,能够支持更多的镜像构建参数。它的 Dockerfile 比较简单:

FROM docker:dind

ENV DOCKER_BUILDKIT=1

COPY build.sh /

ENTRYPOINT ["/bin/sh", "/build.sh"]

这里使用 ENTRYPOINT 而不是 CMD,主要是希望限制用户无法通过 CI 配置文件在 shell 中直接执行命令,以更大程度上保障安全。

build.sh 内容如下:

#!/bin/sh
if [ -z "${IMAGE_NAME}" ]; then
    echo "no image name" 1>&2
    exit 1
fi
if [ -z "${IMAGE_TAG}" ]; then
    if [ -n "${CI_COMMIT_TAG}" ]; then
        IMAGE_TAG="${CI_COMMIT_TAG}"
    elif [ -n "${CI_COMMIT_SHORT_SHA}" ]; then
        IMAGE_TAG="${CI_COMMIT_SHORT_SHA}"
    fi
fi
if [ -z "${IMAGE_BUILD_DIR}" ]; then
    IMAGE_BUILD_DIR="."
fi
if [ -z "${IMAGE_DOCKERFILE}" ]; then
    IMAGE_DOCKERFILE="Dockerfile"
fi

build_args="$(env | grep -e '^IMAGE_BUILD_ARG_' | sed "s/IMAGE_BUILD_ARG_//g;s/\'//g;s/\=\(.*\)/='\1'/g;s/^/--build-arg /")"

cd "${CI_PROJECT_DIR}" || (echo "FATAL! unable to reach project dir." 1>&2 && return 1)
docker login -u "${REG_USERNAME}" -p "${REG_PASSWORD}" "https://${REG_SERVER}" && \
docker build -t "${REG_SERVER}/${IMAGE_NAME}:${IMAGE_TAG}" -f "${IMAGE_BUILD_DIR}/${IMAGE_DOCKERFILE}" $build_args "${IMAGE_BUILD_DIR}" && \
docker push "${REG_SERVER}/${IMAGE_NAME}:${IMAGE_TAG}"

if [ -z "${IMAGE_NO_LATEST_WITHOUT_COMMIT_TAG}" ] \
     || ( [ -n "${IMAGE_NO_LATEST_WITHOUT_COMMIT_TAG}" ] && [ -n "${CI_COMMIT_TAG}" ] ); then
    docker tag "${REG_SERVER}/${IMAGE_NAME}:${IMAGE_TAG}" "${REG_SERVER}/${IMAGE_NAME}:latest" && \
    docker push "${REG_SERVER}/${IMAGE_NAME}:latest"
fi

这里目前检测 commit tag 还有一定的问题,之后会尝试修复。
可以看到,这个脚本能够指定镜像名称、tag,也可以指定构建目录和 Dockerfile 文件名。尤其是,它还支持构建参数,只需要传入 IMAGE_BUILD_ARG_ 为前缀的环境变量即可。

deployer

部署器相对而言要稍稍复杂一点,这里我自行实现了一套一定程度上可拔插的类似 IngressRoute 的方案,总的来说是通过一些 shell 脚本向 nginx 配置中添加站点。

Dockerfile 如下:

FROM alpine:latest

RUN apk add --no-cache openssh-client docker-compose docker-cli gawk

RUN mkdir -p "${HOME}/.ssh" && \
    echo "StrictHostKeyChecking no" > "${HOME}/.ssh/config" && \
    echo "UserKnownHostsFile /dev/null" >> "${HOME}/.ssh/config"

COPY deploy.sh /

ENTRYPOINT ["/bin/sh", "/deploy.sh"]

实际上,这里我们也可以选择传入指定的 ssh 主机指纹来预防欺诈,但我的环境本身受信度足够,故并没有这样配置。
deploy.sh 内容如下:

#!/bin/sh

prepare_ssh_key() {
    if [ -z "${DEPLOY_SSH_KEY}" ]; then
        echo 'no deploy ssh key' 1>&2
        return 1
    fi
    mkdir -p "${HOME}/.ssh/" && \
    printf "%s\n%s\n%s\n" "-----BEGIN OPENSSH PRIVATE KEY-----" "$(printf "%s" "${DEPLOY_SSH_KEY}" | awk 'BEGIN{FS=OFS=""}{for(i=1;i<=NF;i++){if(i%70==0&&i!=NF){printf $i"\n"}else{printf $i}}}')" "-----END OPENSSH PRIVATE KEY-----" > "${HOME}/.ssh/id_rsa" && \
    chmod 700 "${HOME}/.ssh/id_rsa"
    if [ "${DEPLOY_SSH_PORT}" -gt 0 ] && [ "${DEPLOY_SSH_PORT}" -lt 65536 ]; then
        printf "\nHost %s\n    Port %d\n" "${DEPLOY_SSH_HOST}" "${DEPLOY_SSH_PORT}" >> "${HOME}/.ssh/config"
    fi
}

patch_ondeploy() {
    if [ -z "${ONDEPLOY_PATCH}" ]; then
        return 0
    fi
    printf "%s" "${ONDEPLOY_PATCH}" > /tmp/ondeploy.patch
    patch -p1 -i /tmp/ondeploy.patch
}

deploy_docker_compose() {
    if [ -z "${DOCKER_COMPOSE_FILE}" ]; then
        DOCKER_COMPOSE_FILE="docker-compose.yml"
    fi
    if [ -z "${DEPLOY_SSH_USER}" ]; then
        DEPLOY_SSH_USER="$(whoami)"
    fi
    if [ -z "${DEPLOY_SSH_HOST}" ]; then
        echo 'no deploy host' 1>&2
    fi
    docker login -u "${REG_USERNAME}" -p "${REG_PASSWORD}" "https://${REG_SERVER}" && \
    docker-compose -H "ssh://${DEPLOY_SSH_USER}@${DEPLOY_SSH_HOST}" -f "${DOCKER_COMPOSE_FILE}" pull && \
    docker-compose -H "ssh://${DEPLOY_SSH_USER}@${DEPLOY_SSH_HOST}" -f "${DOCKER_COMPOSE_FILE}" up -d
}

apply_ingress_route() {
    if [ -n "${INGRESS_TYPE}" ]; then
        if [ -z "${SITENAME}" ]; then
            SITENAME="${CI_PROJECT_NAME}"
        fi
        if [ -z "${BACKEND}" ]; then
            echo 'not specified backend for ingress!' 1>&2
            return 1
        fi
        case "${INGRESS_TYPE}" in
            "site")
                if [ -z "${SERVERNAME}" ]; then
                    echo 'not specified servername for ingress!' 1>&2
                    return 1
                fi
                ssh "${DEPLOY_SSH_USER}@${DEPLOY_SSH_HOST}" /opt/deploy/addsite.sh --non-interactive "${SITENAME}" "${SERVERNAME}" "${BACKEND}"
            ;;
            "api")
                if [ -z "${WEBPATH}" ]; then
                    echo 'not specified webpath for ingress!' 1>&2
                    return 1
                fi
                ssh "${DEPLOY_SSH_USER}@${DEPLOY_SSH_HOST}" /opt/deploy/addapi.sh --non-interactive "${SITENAME}" "${WEBPATH}" "${BACKEND}"
            ;;
            *)
                echo 'wrong ingress type' 1>&2
                return 1
            ;;
        esac
    else
        echo 'no ingress type, skipping.' 1>&2
    fi
}

cd "${CI_PROJECT_DIR}"
prepare_ssh_key && \
patch_ondeploy && \
deploy_docker_compose && \
apply_ingress_route

这里有一部分技术细节,首先,GitLab 的环境变量不支持多行,因此我们需要将 RSA 私钥的 base64 编码内容压缩成一行。因此我们需要将它还原成标准的 OpenSSH 私钥。这里我选择使用 awk 脚本来实现。
然后,我们在使用第三方编写的开源项目时,可能需要对它的 docker-compose 等等进行一定程度上的修改。因此,脚本支持通过环境变量来在部署时 patch 需要的文件,从而保持 Git 仓库的整洁。
这里我的仿 IngressRoute 是在 /opt/deploy 下放置了两个脚本,可以通过阅读 IngressRoute 部分的部署脚本来仿照实现。

GitLab 配置

首先我们需要配置 Runner,如果你也希望使用 Docker in Docker,可以参考 ridi/gitlab-dind-runner 项目。

Runner 配置:

concurrent = 1
check_interval = 0

[session_server]
  session_timeout = 1800

[[runners]]
  name = "runner_1"
  url = "【GitLab 可供访问的地址】"
  token = "【TOKEN】"
  executor = "docker"
  builds_dir = "/builds"
  cache_dir = "/cache"
  [runners.custom_build_dir]
  [runners.cache]
    [runners.cache.s3]
    [runners.cache.gcs]
    [runners.cache.azure]
  [runners.docker]
    host = "unix:///var/run/docker-run/docker.sock"
    tls_verify = false
    image = "ubuntu:latest"
    privileged = true
    disable_entrypoint_overwrite = false
    oom_kill_disable = false
    disable_cache = false
    dns = ["【本地自建 DNS 服务】"]
    volumes = ["/var/run/docker-run/docker.sock:/var/run/docker.sock", "/builds:/builds", "/cache:/cache"]
    shm_size = 0
    pull_policy = "always"

对于项目,这里我选择将所有需要进行 CI/CD 的项目放入一个 Group 中。在 Group 设置中可以对组的 CI/CD 变量进行修改。将上一步中提到的三个 REG 相关的变量和三个 DEPLOY 相关的变量填入即可。
然后这里我晒一下当前我的公式生成 API 的 CI/CD 配置文件 .gitlab-ci.yml

stages:
  - imagebuild
  - deploy

image_prod:
  image: reg.imvictor.tech/base/imagebuilder:latest
  stage: imagebuild
  variables:
      IMAGE_NAME: service/equation
  script: 'true'
  only:
    - master

deploy_prod:
  image: reg.imvictor.tech/base/deployer:latest
  stage: deploy
  script: 'true'
  variables:
    INGRESS_TYPE: api
    SITENAME: equation
    BACKEND: "20203"
    WEBPATH: equation
  when: manual
  only:
    - master

这样,每次我们提交修改时,便可以自动地打包出镜像,并部署到服务器上,实现自动化的持续集成和持续部署。

发表评论

您的电子邮箱地址不会被公开。 必填项已用*标注