目录
在红岩网校工作站运维安全部工作已有一年之久,在维护各种服务的同时,也了解到了各种先进的技术架构。而这一年来使我印象尤其深刻的是网校先进的 Kubernetes 集群架构和 CI/CD 流程。
于是在这个暑假,我决定将自己的基础架构推上 CI/CD 流程。关于 CI,大约一年前我已经基于 Docker 部署了 GitLab,并使用 Docker in Docker 实现了 GitLab Runner。部署过程当时由于时间因素并未写入博客,可以类比 以 Docker in Docker 的方式部署 JupyterHub 进行配置。
实现效果:
注意:考虑到 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
chmod 700 "${HOME}/.ssh/id_rsa"
if [ "${DEPLOY_SSH_PORT}" -gt 0 ] && [ "${DEPLOY_SSH_PORT}" -lt 65536 ]; then
printf "\nHost
fi
}
patch_ondeploy() {
if [ -z "${ONDEPLOY_PATCH}" ]; then
return 0
fi
printf
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
这样,每次我们提交修改时,便可以自动地打包出镜像,并部署到服务器上,实现自动化的持续集成和持续部署。
我的评价是 屌!
好