原文地址
前言
本流程的设计目的是针对服务器资源并不是非常充分的个人开发小伙伴,设计一套完全自动编译打包发布的流程。部署方面肯定是使用容器进行部署,比较方便管理,容器管理服务使用portainer进行管理,之前使用的是rancher,但是后来rancher主做k8s的管理对于独立容器的管理没有早先版本那么易用就放弃了,故而选择portainer。其他的产品也调研过,但是整体来说portainer是目前阶段最好的选择。
编译位置选择gitlab直接编译的选项,不使用在服务器拉取代码编译的原因是希望服务器配置尽可能少。因为本着能省就省的原则小伙伴使用的基本都是轻量级服务器,那么服务器迁移就是很有可能会出现的事情,我们需要最快速最简单的搭建起一个可用服务器,所以希望服务器本身的配置越少越好。而不使用容器编译的原因是为了减少容器体积,容器编译需要使用maven+jdk或者gradle+jdk的容器,这种容器的的体积相比于jre可多太多了(顺带说一句JAVA17没有官方JRE的镜像,只能使用精简版本的JDK代替,但是也远远比mavne+jdk这种容器小的多)。使用gitlab是个人习惯,也可用使用其他仓库操作,步骤大同小异
所以我们的自动编译打包发布流程就是首先gitlab上传代码,走CI流程直接编译生成制品,然后使用webhook调用portainer接口,portainer收到调用使用gitlab的密钥拉去gitlab制品发布
portainer构建
portainer有两个版本,社区版本和商业版本。社区版本虽然有功能阉割,但是目前来说阉割的功能不影响使用。商业版本可以在官网申请,可以免费试用5个节点,下发一个3年的证书,需要填写一些信息和同意一些协议,由于是个人开发使用没太注意协议,如果是企业使用建议看看。我这里使用的是商业版本,主要是商业版帅,而且没有社区版的升级提示页面比较简洁,大家按需选择即可
这里给出的官方文档和我这里的docker-compose,如果有细节问题可以查阅官方文档
#这里新建了portainer_portainer_data的数据卷,当然也可以使用指定文件夹作为数据卷,但是如果不是设置官方的portainer_data作为数据卷的,那么在一些官方的文档中的某些命令可能会有问题(比如重置密码)需要稍作修改,所以尽量使用portainer_data作为数据卷,我这里也不标准
version: "3"
services:
portainer:
image: 'portainer/portainer-ee:latest'
restart: always
container_name: 'portainer'
hostname: 'portainer'
privileged: true
ports:
- '8000:8000'
- '9443:9443'
- '9000:9000'
privileged: true
volumes:
- portainer_data:/data
- /var/run/docker.sock:/var/run/docker.sock
volumes:
portainer_data:
构建完成设置密码添加栈、容器、用户一类的基本多点点基本就会了,如果是商业版本的将他发到你邮箱的密码在进入时输入激活即可。关于portainer端口的nginx代理,包括在此之前的域名解析,和portainer子域名的申请以及https证书的申请等等和本篇没有关系就不赘言了
GitLab构建
我们这里就不在自己服务器上搭建gitlab了,gitlab的资源消耗太大,而且公共的gitlab的官网的也挺好的,如果是自己玩的话完全不需要自己搭建gitlab。我们这里就是主要介绍项目完成自动构建发布需要的文件应该怎么写,我们需要gitlab-ci.yml来控制ci的打包命令以及docker-compose.yml作为portainer的构建方法以及Dockerfile让portainer做实际构建,我们从后往前推导首先是Dockerfile
Dockerfile
在执行到Dockerfile的时候,我们在gitlab的jar包应该已经打包完成了,所以我们需要使用请求gitlab来获取已经打好的jar包,翻阅gitlab文档可知我们需要3个参数可以从公网上拿到jar包,分别是访问令牌和项目id以及作业id,故而如果我们能在参数中拿到这三个值,我们只需要在容器中通过文档的url拿到制品压缩包,解压缩获得jar包,然后直接部署就可以了
最终Dockerfile文件如下,
FROM astercass/jdk17-simple:latest
LABEL author="astercass@qq.com"
WORKDIR /usr/src/apps
ARG READ_TOKEN
ARG CI_PROJECT_ID
ARG CI_JOB_ID
RUN curl --location --output artifacts.zip \
--header PRIVATE-TOKEN:$READ_TOKEN \
"https://gitlab.com/api/v4/projects/$CI_PROJECT_ID/jobs/$CI_JOB_ID/artifacts" \
&& unzip artifacts.zip && mv ./target/*.jar /usr/src/apps/applicationA.jar
ENTRYPOINT ["java","-jar","/usr/src/apps/applicationA.jar","--logging.path=/usr/src/apps/log"]
astercass/jdk17-simple:latest这个镜像是bellsoft/liberica-openjdk-alpine:17添加curl命令(apk add curl)得来到的,由于我们Dockerfile文件中必须使用curl来获取公网包,但是该镜像并没有安装,为了快速构建镜像所以我补充了curl命令并上传了astercass/jdk17-simple:latest。使用原镜像并添加apk add curl效果一样,另外提一句liberica-openjdk-alpine是spring推荐【We recommend BellSoft Liberica JDK version 17.】可以放心使用
docker-compose.yml
docker-compose和Dockerfile一样,实际的执行机器是我们自己的服务器,是由portainer执行的。由于是从docker-compose执行的Dockerfile,那么所需要的3个参数自然也需要我们这边传入了,查阅portainer的文档可知我们可以在网络钩子webhook中携带参数,那么gitlab作为调用方只需要要传入这三个参数,那么就可以让我们的容器拿到gitlab打好的包了
最终docker-compose.yml文件如下
version: "3.9"
services:
applicationA:
build:
context: ./
dockerfile: Dockerfile
args:
READ_TOKEN: ${READ_TOKEN}
CI_PROJECT_ID: ${CI_PROJECT_ID}
CI_JOB_ID: ${CI_JOB_ID}
pull_policy: build
hostname: 'application_a'
container_name: 'application_a'
restart: always
docker-compose.yml中pull_policy这个属性设置为build非常重要,这个是portainer可以更新的关键
gitlab-ci.yml
从上文可知,我们这里需要提供访问令牌和项目id以及作业id三个信息给到portainer,当然还需要portainer的webhook地址。访问令牌是固定的,我们先在gitlab的个人设置中生成令牌,等到之后我们在portainer建立stack的时候直接作为环境变量写入即可。项目id和作业id在ci的时候可以通过gitlab的预设环境变量CI_PROJECT_ID和CI_JOB_ID获取。portainer的webhook的地址,我们之后在stack的步骤会配置在自定义的环境变量中,我们首先假定配置的环境变量为PORTAINER_APPLICATION_A_WEB_HOOK
此时在ci文件中我们可以通过CI_PROJECT_ID和$CI_JOB_ID作为参数即可
我们将ci分为两个阶段,打包阶段和发布阶段,在打包阶段直接使用mvn命令打包生成制品,同时将本阶段的作业id暂且存下。在发布阶段,将本项目id和上一个阶段的作业id作为参数调用钩子地址完成发布调用,这里给出我的文件代码
image: maven:3.8.5-openjdk-17
#image: alpine:latest #use for test
stages:
- build
- release
#print variable and version
before_script:
- export APPLICATION_VERSION=`mvn spring-boot:build-info | grep Building | awk '{print $4}'`
- echo ${APPLICATION_VERSION}
- export TAG=${CI_COMMIT_BRANCH}_${CI_COMMIT_SHA:0:8}_${APPLICATION_VERSION}
- echo ${TAG}
- echo ${CI_PROJECT_ID}
- echo ${CI_JOB_ID}
#gitlab current variables
- echo ${PORTAINER_APPLICATION_A_WEB_HOOK}
#build
build:
stage: build
script:
- mvn clean package
- export PARAM_VAR="CI_PROJECT_ID=${CI_PROJECT_ID}&CI_JOB_ID=${CI_JOB_ID}"
- echo "PARAM_VAR=${PARAM_VAR}" >> build.env
- cat build.env
except:
- tags
allow_failure: false
artifacts:
reports:
dotenv: build.env
paths:
- target/*.jar
expire_in: 1 week
#deploy
release:
stage: release
script:
- echo ${PORTAINER_APPLICATION_A_WEB_HOOK}?${PARAM_VAR}
- curl -X POST "${PORTAINER_APPLICATION_A_WEB_HOOK}?${PARAM_VAR}"
dependencies:
- build
Stack构建
进入portainer,选择环境后,进入Stacks栏,点击Add stack,使用Repository的构建方法,Name随意,gitlab需要权限验证所以需要开启Authentication,Username随意,Personal Access Token需要在gitlab的个人设置中生成令牌,注意这里的令牌可以通过查询Docker的构建历史获得,如果需要的话注意权限问题,Repository URL和Repository reference以及Compose path按具体的项目要求填写,下面两个就比较重要了
首先开启Automatic updates,选择webhook,复制值,在gitlab的项目或者组的【设置】【CI/CD】中作为环境变量的VALUE,变量的KEY即为我们上面提到的PORTAINER_APPLICATION_A_WEB_HOOK。当然你这里也可以选择轮询的方式去获取最新的代码,但是既然网络钩子的方式轮询去拉多多少少看起来不那么优雅
然后是Environment variables,这里我们需要手动添加一个环境变量,就是刚才填在Personal Access Token栏目中的令牌,按照上文中Dockerfile和docker-compose.yml中的命名我们将变量的KEY命名为READ_TOKEN,而VALUE则是该令牌的值,还是那句话,有需要的话注意权限问题。还有另外两个参数CI_PROJECT_ID和CI_JOB_ID不用填写,会从webhook中带过来,按下deploy the stack如果成功的话这里就可以看到三个参数了
补充
portainer版本,社区版升级到商业版本,商业版降级到社区版本
本文档细节之处大家自行优化,比如在Dockerfile文件中没有指定yml配置环境,在ci文件中没有区别分支等
-
gitlab的个人的令牌可以在服务器中使用docker history的方式查看构建历史,譬如
docker history --format "{{.CreatedBy}}" d632af1b1136 --no-trunc
所以个人令牌以参数传入的方式是可以被服务器其他用户看到的,故而如果需要注意令牌的等级,或者换低等级的用户创建令牌
-
为什么CI_PROJECT_ID和CI_JOB_ID直接从webhook中作为参数带过来,而READ_TOKEN需要我们在portainer手动创建呢,也直接在gitlab-ci调用webhook时作为参数传进来不就行了吗?
这是因为在gitlab-ci中,我们需要将生成制品和调用webhook分为两个阶段,否则制品还没生成,直接调用的话拿不到制品就很傻了,而且分为build和release两个阶段看起来也更优雅一些
那么既然是两个阶段它们的JOB_ID就是不同的,也就是说其实在release阶段,需要向portainer传递的JOB_ID是build阶段的JOB_ID,所以就涉及到不同阶段参数传递的问题,这里我们使用官方推荐的方式通过传递环境变量完成参数传递
由于个人令牌在gitlab-ci中自动会被隐藏,所有的打印都会变成glpat-[MASKED],只是在打印的时候是这样实际使用还是原有值,但是
echo "$PARAM" >> build.env
这种命令就会将glpat-[MASKED]一并打入build.env,所以portainer收到的个人令牌就真的变成了"glpat-[MASKED]",其实这个问题也好解决无论是在设置中将这个变量设置为不隐藏,还是只使用glpat-后面的部分作为变量之后再拼接都可以绕过校验,但总觉一方面不太优雅另一方面有违gitlab的基本规则,故而将个人令牌使用在portainer中直接写入,其他变量由gitlab通过webhook传递的方式完成功能
如果使用每次都拉去代码重构镜像的方式,会导致冗余无用镜像过多,尤其是在频繁更新的时候,所以我们需要使用cron去定期清除无用镜像核心命令是docker image prune -a --filter="label=need_filtter_label" -f具体的脚本文件和cron配置不属于本篇内容,不再赘述
一些坑
如果在构建portainer时候不是设置官方的portainer_data作为数据卷的,那么在一些官方的文档中的某些命令可能会有问题(比如重置密码)需要稍作修改,所以尽量使用portainer_data作为数据卷
构建portainer使用docker-compose的方式一定要使用volumes:这个属性创建卷,如果直接使用本地目录作为对应数据卷,portainer将无法连接到本地服务,可能还会其他问题,所以不要直接使用本地目录作为对应数据卷
docker-compose.yml中pull_policy这个属性设置为build非常重要,这个是portainer可以更新的关键。如果没有这个参数,那么portainer每次都只会拉本地镜像,完全不会重新使用Dockerfile构建,只有在删除本地镜像后才会重新构建,非常鸡肋。我一度都想换个方式,portainer不使用gitRepository的方式构建stack而是使用镜像的方式,通过gitlab上传已经带有jar包的镜像到dockerhub私有仓库,然后portainer直接拉镜像构建。终于我发现pull_policy这个属性,可以看到在22年4月初的时候,portainer的贡献者还在说We would also very much like to use this feature. 【这个功能是 stack "full image rebuild" feature implemented on portainer UI as well.】幸运的是在4月末有人提出可以使用pull_policy属性解决,这里列出pull_policy具体的相关的官方文档
-
我这边调研的时候是最后再细看的portainer的webhook文档,在此之前只是大概浏览了一些知道方案可行,所以一开始我并不知道portainer的webhook可以带参数,所以花了大量的时间在如何让portainer可以获得项目id和作业id上,姑且记录下以防之后用到
我这里原本的考虑是,如果我们把项目id和作业id放在公网的某个地方,通过某个密钥访问,在ci的时候每次去更新这个id,然后portainer收到调用后用密钥去访问获取项目id和作业id(其实项目id原理上可以写死在portainer的stack中,因为反正也不会变,但是考虑到如果是利用脚手架开新项目的话,脚手架名称可以提醒开发更换为本项目名称,但是如果放一串数字在那里会增加开发的理解成本,故而我们需要将项目id也做同样处理)
考虑到既然portainer可以拿到gitlab的个人令牌,那么不妨直接使用gitlab的个人令牌作为密钥,将项目id和作业id配成gitlab的环境变量就可以完成这个功能了,调研后发现可行,故而我们需要在gitlab-ci中执行插入/更新参数的逻辑。如果是首次ci,没有该变量执行插入逻辑,那么它的返回为
{"variable_type":"env_var","key":"KEY_XXX","value":"VALUE_YYY","protected":false,"masked":false,"raw":false,"environment_scope":"*"}
如果已有变量则插入逻辑后它的返回为
{"message":{"key":["(KEY_XXX)已被使用"]}}
我们则需要再执行更新逻辑,可以根据是否含有"(KEY_XXX)"字符串来判断是否需要调用更新逻辑,添加的部分代码如下
before_script: #这里我们需要使用【项目名称】作为参数去定义需要传递的【项目id】和【作业id】 - export PROJECT_WEB_ID=${CI_PROJECT_NAME}_PROJECT_WEB_ID - echo ${PROJECT_WEB_ID} - export PROJECT_BRANCH_LAST_JOB_ID=${CI_PROJECT_NAME}_${CI_COMMIT_BRANCH}_LAST_JOB_ID - echo ${PROJECT_BRANCH_LAST_JOB_ID} #这里由于我们是使用的是【组环境变量】所以需要将组id定义到组环境变量中,当然还有个人令牌,这个个人令牌和上文的不同,需要有写权限的 - echo ${ACCESS_ADMIN_TOKEN} - echo ${CI_GROUP_ID} build: stage: build script: - mvn clean package #project id in web - export INSERT_PROJECT_ID_RESULT=`curl --request POST --header "PRIVATE-TOKEN:${ACCESS_ADMIN_TOKEN}" "https://gitlab.com/api/v4/groups/${CI_GROUP_ID}/variables" --form "key=${PROJECT_WEB_ID}" --form "value=${CI_PROJECT_ID}"` - echo $INSERT_PROJECT_ID_RESULT # update project id - > if [[ "$INSERT_PROJECT_ID_RESULT" =~ "($PROJECT_WEB_ID)" ]]; then curl --request PUT --header "PRIVATE-TOKEN:${ACCESS_ADMIN_TOKEN}" \ "https://gitlab.com/api/v4/groups/${CI_GROUP_ID}/variables/${PROJECT_WEB_ID}" \ --form "value=${CI_PROJECT_ID}" fi #job id in web - export INSERT_JOB_ID_RESULT=`curl --request POST --header "PRIVATE-TOKEN:${ACCESS_ADMIN_TOKEN}" "https://gitlab.com/api/v4/groups/${CI_GROUP_ID}/variables" --form "key=${PROJECT_BRANCH_LAST_JOB_ID}" --form "value=${CI_JOB_ID}"` - echo $INSERT_JOB_ID_RESULT # update job id - > if [[ "$INSERT_JOB_ID_RESULT" =~ "($PROJECT_BRANCH_LAST_JOB_ID)" ]]; then curl --request PUT --header "PRIVATE-TOKEN:${ACCESS_ADMIN_TOKEN}" \ "https://gitlab.com/api/v4/groups/${CI_GROUP_ID}/variables/${PROJECT_BRANCH_LAST_JOB_ID}" \ --form "value=${CI_JOB_ID}" fi
作为接收方portainer的Dockerfile文件当然也需要做更改,核心代码如下
ARG ACCESS_ADMIN_TOKEN ARG CI_GROUP_ID ARG DEP_ENV_NAME RUN THIS_PROJECT_ID=`curl --header PRIVATE-TOKEN:$ACCESS_ADMIN_TOKEN \ "https://gitlab.com/api/v4/groups/$CI_GROUP_ID/variables/application_a_PROJECT_WEB_ID" \ | sed -e 's/.*value\":\"\([0-9]\+\).*/\1/g'` && \ THIS_CHANNEL_ID=`curl --header PRIVATE-TOKEN:$ACCESS_ADMIN_TOKEN \ "https://gitlab.com/api/v4/groups/$CI_GROUP_ID/variables/application_a_$DEP_ENV_NAME_LAST_JOB_ID" \ | sed -e 's/.*value\":\"\([0-9]\+\).*/\1/g'` && \ curl --location --output artifacts.zip \ --header PRIVATE-TOKEN:$ACCESS_ADMIN_TOKEN \ "https://gitlab.com/api/v4/projects/$THIS_PROJECT_ID/jobs/$THIS_CHANNEL_ID/artifacts" \ && unzip artifacts.zip && mv ./target/*.jar /usr/src/appw/application_a.jar
ACCESS_ADMIN_TOKEN和CI_GROUP_ID都是不变量可以写死在protainer的stack中,选用group环境变量的原因就是group基本作为一套项目的CICD配置是不会变的,如果有其他项目组需要则需要取用新的groupId