Gitlab Runner发布服务
gitlab runner是gitlab cicd的执行器,对于提交的代码通常都会进行打包然后发布,而runner通常和生产的机器不在同一个机器上,所以本文将讨论runner如何将构建好的服务发布到生产的服务器上。
runner的执行器有多种类型,常用的有shell和docker
- shell executor: 在runner所在的环境执行任务,也就是说,如果runner运行在linux上,就在linux中运行任务;如果运行在docker容器中,那么就在容器中执行任务。
- docker executor: 启动一个新docker容器执行任务,使得构建任务的环境隔离。
获取访问生产服务器的权限
以docker executor为例,runner收到任务后启动了一个新的容器,那么在这个新的容器默认是没有ssh密钥对的,所以我们要做的是让这个容器拥有访问生产服务器的ssh密钥对。
方式1:拥有密钥对的镜像
我们可以制作一个拥有访问生产服务器密钥对的镜像,runner收到任务的时候就通过该镜像启动容器。
方式2:容器运行时导入密钥对
在admin > > setting > CICD > Variables位置导入私钥文件

导入后在执行脚本前,将私钥的环境变量导入到容器中
1 | deploy: |
这里和传统的将密钥对放到.ssh目录不一样,这里使用了一个软件ssh-agent,然后通过ssh-add将密钥导入到内存中,这种方式是为了避免私钥持久化,因为当前使用的是runner的docker executor所以任务执行完成容器就销毁了,所以这种方式和持久化的方式都可以。
发布服务
有了访问生产服务器的权限后,就可以开始发布服务了
方式1:ssh直连发布
这种方式没什么需要特别注意的地方
方式2:通过docker context远程启动docker compose发布
通过docker context,可以在一台机器上控制多台机器的docker。
创建生产的docker context
1 | docker context create production --docker "host=ssh://root@xx.xx.xx.xx" |
启动生产环境的docker compose
1 | docker-compose --context production up -d |
包含权限的容器
我想runner在执行的时候启动一个包含连接生产环境权限的容器,也就是这个镜像默认包含ssh生产服务器的权限。
错误的方式
我的想法是写一个setup.yml,该文件配置了读取环境变量并加载到ssh-agent中的逻辑,然后各项目的.gitlab-ci.yml可以include这个setup.yml,这样就可以实现各个项目能够公用一份连接生产环境的setup.yml。
编写setup.yml
1 | # 每个任务执行前都会执行下面这段代码 |
新建Dockerfile,镜像用于构建自己的服务并包含有连接生产环境的权限,在Dockerfile中包含上面setup.yml
1 | FROM docker:latest |
然后在各个需要发布到生产服务器的项目引入公共逻辑
1 | stages: |
但是发现include总是找不到/config/ci/setup.yml,原来.gitlab-ci.yml无法找到所在目录之外的文件。
可行的方式
- 将setup.yml文件放到各个项目中,比较冗余
- 在每个任务中使用before_script代替include,在before_script执行初始化脚本,执行的脚本就没有在项目目录中的限制
- 构建包含密钥对的容器,而不是从环境变量中加载
使用脚本获取ssh
编写脚本setup.sh
1 | #!/bin/bash |
构建包含该脚本的镜像的Dockerfile并构建为docker-executor
1 | FROM docker:latest |
构建镜像
1 | docker build -t docker-executor:latest . |
修改runner的配置文件config.toml,修改拉取策略为if-not-present,
否则每次都会从dockerhub拉取,然而该镜像并没有上传dockerhub
1 | [runners.docker] |
在项目的.gitlab-ci.yml添加before_script,这里可以不设镜像会默认使用runner配置的镜像,也可以显式指定。
1 | deploy: |
Gitlab Runner Docker模式使用代理
构建代理镜像供runner使用
定义Dockerfile
1 | FROM laoyutang/clash-and-dashboard:latest |
构建镜像
1 | docker build -t myclash . |
在cicd脚本中使用代理镜像
通过service的方式启动myclash容器
1 | build: |
拓展:script中docker build使用代理
1 | build: |
在使用Dockerfile使用传入的CLASH_IP参数
1 | FROM haskell:9.6.5 as build |
Gitlab Runner 环境变量找不到
在定义环境变量的时候,Protect variable会限制“受保护的分支”和“受保护的标签才能访问”。受保护的分支在项目设置的repository菜单下可以设置,默认是master,这意味着如果提交develop分支是无法访问protect variable的。
以下是我碰到的场景
1 | deploy_prod: |
这种配置非master分支都会触发manual,if和when是或者的关系,当在非master分支提交后再手动触发该任务是无法访问到受保护的环境变量的。
Gitlab Service
常用函数
join
join 函数通常用于处理嵌套的 Monad 结构,例如 Maybe (Maybe a) 或 IO (IO a)。它将一个嵌套的 Monad “拉平” 成一个单层 Monad,类型签名如下:
1 | join :: Monad m => m (m a) -> m a |
在 Maybe 中使用 join
1 | import Control.Monad (join) |
在 List 中使用 join
1 | example3 :: [Int] |
在 IO 中使用 join
1 | example4 :: IO () |
这里 getLine 的类型为 IO String,putStrLn <$> getLine 的类型为 IO (IO ()),使用 join 可以得到 IO () 类型,从而直接执行打印操作。
traverse
traverse 函数的作用是将一个函数应用于数据结构中的每个元素,并返回一个包含应用结果的新的数据结构。该函数的签名如下:
1 | traverse :: Traversable t => (a -> f b) -> t a -> f (t b) |
以下是列表的Traversable的实现
1 | instance Traversable [] where |
(:) <$> f x <*> ys的解释如下
- f参数:
(a -> f b) - f x:x是
[a]的一个元素,所以f x生成f b - ys:是
f [b] - (:) <$> f x:表示(:)应用于f内部的x
- (:) <$> f x <*> ys:的意思就是(f (x :))里面的
x :应用于ys的内部也就是[b]
容易误解的地方有两个
- 一个是函数声明中的f和函数体的参数f不是同一个f,容易弄混了,函数声明中的f是一个Applicative,而函数体的参数的f是一个(a -> f b)的函数。
- 另外一个是
(:) <$> f x <*> ys容易误解为f x : ys也可以,后面的写法是将f x直接放入ys中,而第一种写法是将f x的x放到ys等同于f [b]中的[b]。其实当你看到<$>和<*>同时使用的时候就可以想到lift提升了,(:)的签名是a -> [a] -> [a],提升后的签名是f a -> f [a] -> f [a]。
例子 1: 把偶数转换为字符串
1 | import Data.Traversable (traverse) |
例子 2: 和 IO 一起使用
1 | import System.IO (readFile) |
总结
traverse 的应用场景通常是:
你有一个数据结构(比如列表、树、Maybe 等)。
你想对每个元素执行一个带有副作用的操作,比如返回 Maybe、IO 或者其他 Applicative 类型。
traverse 帮助你保持原数据结构的形状,同时把每个操作的结果组合成一个整体的结果。
haskell在控制台输出中文
1 | -- "您好" |
其中print函数打印中文会乱码而putStrLn不会,这是因为print内部会调用show函数,show函数会将中文转为unicode编码。
所以如果需要打印中文字符串可以使用putStrLn,如果需要打印一个对象,而对象的属性值包含中文,可以重写show函数,再使用print输出。
为何haskell没有异常堆栈
在Haskell中,默认情况下,抛出异常并不会像其他语言(例如Java或Python)那样自动打印堆栈跟踪。
惰性求值意味着表达式不会立即计算,而是等到需要它时才会计算,这对于异常的传播有影响。
1 | import Debug.Trace (trace) |
执行结果为
1 | run f2 |
正常的栈应该是
1 | someFunc 入 : [someFunc] |
但是在haskell可能是下面这样,跟代码的表现完全不一样,这时候会疑惑f2方法哪里调用了f1,这在实际的代码中会更复杂。
1 | someFunc 入 : [someFunc] |
另外一个原因是因为ghc优化也会导致堆栈难以跟踪,如上例子,f1可能直接优化到f2中形成组合函数。
haskell如何遍历
- 顺序
- 判断
- 循环
- 异常
上述四个操作时流程控制的常见操作,然而在haskell中是没有循环for这个关键字的,那么haskell时如何实现循环的呢?
递归
循环的平替最常见莫过于递归了,如下是一个为数组的每个数字+1
1 | addOne :: [Int] -> [Int] |
高阶函数
所谓高阶函数就是函数的抽象,函数既可以作为参数,也可作为返回值。
高阶函数的底层也是通过递归实现,关于遍历的高阶函数通常都会附加一些通用操作,如
- filter过滤元素: for + if
- map转换元素: for + return
- traverse碰到异常停止操作: for + throw
map
map函数接收两个参数
- 操作,本例中就是
+1 - 被操作对象,本例就是数组
1
2
3
4
5
6
7-- 冗余的写法如下
addOne :: [Int] -> [Int]
addOne array = map (+1) array
-- 省略参数的写法
addOne :: [Int] -> [Int]
addOne = map (+1)
函子Functor
Functor的作用就是将一个操作作用于容器内的元素,这个容器不局限于数组,写法是操作<$>容器
1 | -- 冗余的写法如下 |
广义代数结构
代数结构就是普通的data,组合各个类型形成新的类型
1 | data MyData = IntData Int | TextData Text |
广义代数结构是一个语言拓展,开启广义代数数据类型
1 | {-# LANGUAGE GADTs #-} |
多参数构造子
1 | {-# LANGUAGE GADTs #-} |
能使用类型没有定义的变量
1 | data DynamicSql where |
应用
广义代数结构有些函数的性质,每一个值构造器都是一个逻辑的抽象,具体的实现由其他函数通过模式匹配的方式处理,如此可以针对一个类型提供多种实现。
以上方DynamicSql为例,DynamicSql是父接口,多个值构造器是子接口,parseDynamicSql为子接口提供实现。