include不要包含关键字

1
2
3
4
5
6
7
8
stages:
- build
- deploy
- publish

# 设置一个全局 before_script,其他 job 会继承它
include:
- local: 'ssh-setup.yml'

包含了ssh关键字会报错

1
jobs setup ssh config should implement the script:, run:, or trigger: keyword

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:容器运行时导入密钥对

在 GitLab CI/CD 中使用 SSH 密钥

在admin > > setting > CICD > Variables位置导入私钥文件

导入后在执行脚本前,将私钥的环境变量导入到容器中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
deploy:
stage: deploy
image: docker:latest
before_script:
## Install ssh-agent if not already installed
- 'command -v ssh-agent >/dev/null || ( apt-get update -y && apt-get install openssh-client -y )'
## Run ssh-agent
- eval $(ssh-agent -s)
##
## Give the right permissions, otherwise ssh-add will refuse to add files
## Add the SSH key stored in SSH_PRIVATE_KEY file type CI/CD variable to the agent store
##
- chmod 400 "$SSH_PRIVATE_KEY"
- ssh-add "$SSH_PRIVATE_KEY"
## Create the SSH directory and give it the right permissions
- mkdir -p ~/.ssh
- chmod 700 ~/.ssh

这里和传统的将密钥对放到.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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 每个任务执行前都会执行下面这段代码
default:
before_script:
## Install ssh-agent if not already installed
- 'command -v ssh-agent >/dev/null || ( apt-get update -y && apt-get install openssh-client -y )'
## Run ssh-agent
- eval $(ssh-agent -s)
##
## Give the right permissions, otherwise ssh-add will refuse to add files
## Add the SSH key stored in SSH_PRIVATE_KEY file type CI/CD variable to the agent store
##
- chmod 400 "$SSH_PRIVATE_KEY"
- ssh-add "$SSH_PRIVATE_KEY"
## Create the SSH directory and give it the right permissions
- mkdir -p ~/.ssh
- chmod 700 ~/.ssh
## Verify the SSH host key
- ssh-keyscan xx.xx.xx.xx >> ~/.ssh/known_hosts

新建Dockerfile,镜像用于构建自己的服务并包含有连接生产环境的权限,在Dockerfile中包含上面setup.yml

1
2
3
4
5
6
7
FROM docker:latest

# 复制 setup.yml 到容器的 /config/ci 目录
COPY setup.yml /config/ci/setup.yml

# 创建生产环境的docker context
RUN docker context create production --docker "host=ssh://root@xx.xx.xx.xx"

然后在各个需要发布到生产服务器的项目引入公共逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
stages:
- build
- deploy
- publish

# 引入公共逻辑
include:
- local: '/config/ci/setup.yml'

deploy:
stage: deploy
## 包含setup.yml的镜像
image: docker-executor:latest
script:
## Execute your deployment commands
- ssh root@xx.xx.xx.xx "cd /app/hexo && git pull && docker-compose pull && docker-compose up -d --force-recreate"
rules:
- changes:
- Dockerfile
- docker-compose.yml
- entrypoint.sh
- config/*
- blog/**/*
- .gitlab-ci.yml

但是发现include总是找不到/config/ci/setup.yml,原来.gitlab-ci.yml无法找到所在目录之外的文件。

可行的方式

  • 将setup.yml文件放到各个项目中,比较冗余
  • 在每个任务中使用before_script代替include,在before_script执行初始化脚本,执行的脚本就没有在项目目录中的限制
  • 构建包含密钥对的容器,而不是从环境变量中加载

使用脚本获取ssh

编写脚本setup.sh

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#!/bin/bash
## Install ssh-agent if not already installed
command -v ssh-agent >/dev/null || ( apt-get update -y && apt-get install openssh-client -y )
## Run ssh-agent
eval $(ssh-agent -s)
##
## Give the right permissions, otherwise ssh-add will refuse to add files
## Add the SSH key stored in SSH_PRIVATE_KEY file type CI/CD variable to the agent store
##
chmod 400 "$SSH_PRIVATE_KEY"
ssh-add "$SSH_PRIVATE_KEY"
## Create the SSH directory and give it the right permissions
mkdir -p ~/.ssh
chmod 700 ~/.ssh
## Verify the SSH host key
ssh-keyscan xx.xx.xx.xx >> ~/.ssh/known_hosts

构建包含该脚本的镜像的Dockerfile并构建为docker-executor

1
2
3
4
5
6
7
8
FROM docker:latest

# 复制 setup.yml 到容器的 /config/ci 目录
COPY ./setup.sh /app/
RUN chmod +x /app/setup.sh

# docker context
RUN docker context create production --docker "host=ssh://root@10.0.24.9"

构建镜像

1
docker build -t docker-executor:latest .

修改runner的配置文件config.toml,修改拉取策略为if-not-present,
否则每次都会从dockerhub拉取,然而该镜像并没有上传dockerhub

1
2
3
4
5
6
7
8
9
10
11
12
[runners.docker]
tls_verify = false
image = "docker-executor:latest"
pull_policy = ["if-not-present"]
allowed_pull_policies = ["always", "if-not-present"]
privileged = true
disable_entrypoint_overwrite = false
oom_kill_disable = false
disable_cache = false
volumes = ["/cache", "/var/run/docker.sock:/var/run/docker.sock"]
shm_size = 0
network_mtu = 0

在项目的.gitlab-ci.yml添加before_script,这里可以不设镜像会默认使用runner配置的镜像,也可以显式指定。

1
2
3
4
5
6
7
deploy:
stage: deploy
image:
name: docker-executor:latest
pull_policy: if-not-present
before_script:
- source /app/setup.sh

构建代理镜像供runner使用

定义Dockerfile

1
2
3
4
FROM laoyutang/clash-and-dashboard:latest

RUN wget -O /root/.config/clash/config.yaml [订阅地址]
RUN sed -i 's/^mixed-port:.*$/mixed-port: 7890/' /root/.config/clash/config.yaml

构建镜像

1
docker build -t myclash .

在cicd脚本中使用代理镜像

通过service的方式启动myclash容器

1
2
3
4
5
6
7
8
9
10
11
12
13
build:
stage: build
services:
- name: myclash:latest
alias: clash
before_script:
# 设置代理环境变量
- export http_proxy="http://clash:7890"
- export https_proxy="http://clash:7890"
- export HTTP_PROXY="http://clash:7890"
- export HTTPS_PROXY="http://clash:7890"
script:
- xxx

拓展:script中docker build使用代理

1
2
3
4
5
6
7
8
9
10
11
build:
stage: build
services:
- name: myclash:latest
alias: clash
before_script:
# 获取clash ip
- export CLASH_IP=$(grep 'clash' /etc/hosts | awk '{print $1}' | head -n 1)
script:
# 通过build-arg将clash的IP传入
- docker build --network host --no-cache --build-arg CLASH_IP=$CLASH_IP -t 10.0.24.9:5000/recite:latest .

在使用Dockerfile使用传入的CLASH_IP参数

1
2
3
4
5
6
7
8
9
10
FROM haskell:9.6.5 as build

ARG CLASH_IP

ENV http_proxy "http://${CLASH_IP}:7890"
ENV HTTP_PROXY "http://${CLASH_IP}:7890"
ENV https_proxy "http://${CLASH_IP}:7890"
ENV HTTPS_PROXY "http://${CLASH_IP}:7890"

xxxx

在定义环境变量的时候,Protect variable会限制“受保护的分支”和“受保护的标签才能访问”。受保护的分支在项目设置的repository菜单下可以设置,默认是master,这意味着如果提交develop分支是无法访问protect variable的。

以下是我碰到的场景

1
2
3
4
5
6
7
8
9
10
11
12
13
deploy_prod:
stage: deploy_prod
image:
name: docker-executor:latest
pull_policy: if-not-present
before_script:
- source /app/setup.sh
script:
- docker-compose --context production pull
- docker-compose --context production --env-file .env.prod up -d --force-recreate
rules:
- if: '$CI_COMMIT_REF_NAME == "master"' # 仅在主分支上触发生产环境部署
- when: manual # 手动触发,确保只有在测试通过后才进行正式环境部署

这种配置非master分支都会触发manual,if和when是或者的关系,当在非master分支提交后再手动触发该任务是无法访问到受保护的环境变量的。

官网

在构建过程中如果需要测试或者使用代理等等就需要访问到其他服务,引入服务后在构建前就会将服务构建好,供构建使用。

例如有个访问mysql的java代码,编译完成后需要进行一些测试,测试需要修改mysql数据,这时候就可以添加一个mysql服务。

service对比直接run的优势

其实也可以直接在脚本中通过docker run启动需要的服务,但是service使用起来更加方便。

  • 同一个网络
  • 自动启动和停止,这在shell模式的runner很有用,job执行完就将服务停止掉。

join

join 函数通常用于处理嵌套的 Monad 结构,例如 Maybe (Maybe a) 或 IO (IO a)。它将一个嵌套的 Monad “拉平” 成一个单层 Monad,类型签名如下:

1
join :: Monad m => m (m a) -> m a

在 Maybe 中使用 join

1
2
3
4
5
6
7
import Control.Monad (join)

example1 :: Maybe Int
example1 = join (Just (Just 5)) -- 结果为 Just 5

example2 :: Maybe Int
example2 = join (Just Nothing) -- 结果为 Nothing

在 List 中使用 join

1
2
example3 :: [Int]
example3 = join [[1, 2], [3, 4], [5]] -- 结果为 [1, 2, 3, 4, 5]

在 IO 中使用 join

1
2
example4 :: IO ()
example4 = join (putStrLn <$> getLine) -- 用户输入内容后将被打印出来

这里 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
2
3
instance Traversable [] where
traverse :: Applicative f => (a -> f b) -> [a] -> f [b]
traverse f = foldr (\x ys -> (:) <$> f x <*> ys) (pure [])

(:) <$> 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 xx放到ys等同于f [b]中的[b]。其实当你看到<$>和<*>同时使用的时候就可以想到lift提升了,(:)的签名是a -> [a] -> [a],提升后的签名是f a -> f [a] -> f [a]

例子 1: 把偶数转换为字符串

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import Data.Traversable (traverse)

-- 定义一个函数,只转换偶数,奇数会返回 Nothing
evenToString :: Int -> Maybe String
evenToString x = if even x then Just (show x) else Nothing

-- 用 traverse 把这个函数应用到一个列表里
result :: Maybe [String]
result = traverse evenToString [2, 4, 6] -- 每个元素都是偶数
-- result 的值是 Just ["2", "4", "6"]

result2 :: Maybe [String]
result2 = traverse evenToString [2, 3, 6] -- 包含奇数,返回 Nothing
-- result2 的值是 Nothing

例子 2: 和 IO 一起使用

1
2
3
4
5
6
7
8
9
import System.IO (readFile)

-- 用 traverse 读取多个文件的内容
readFiles :: [FilePath] -> IO [String]
readFiles paths = traverse readFile paths

-- 使用 readFiles 读取多个文件
result3 :: IO [String]
result3 <- readFiles ["file1.txt", "file2.txt", "file3.txt"]

总结

traverse 的应用场景通常是:

你有一个数据结构(比如列表、树、Maybe 等)。
你想对每个元素执行一个带有副作用的操作,比如返回 Maybe、IO 或者其他 Applicative 类型。
traverse 帮助你保持原数据结构的形状,同时把每个操作的结果组合成一个整体的结果。

1
2
3
4
-- "您好"
putStrLn "您好"
-- "\24744\22909"
print "您好"

其中print函数打印中文会乱码而putStrLn不会,这是因为print内部会调用show函数,show函数会将中文转为unicode编码。

所以如果需要打印中文字符串可以使用putStrLn,如果需要打印一个对象,而对象的属性值包含中文,可以重写show函数,再使用print输出。

在Haskell中,默认情况下,抛出异常并不会像其他语言(例如Java或Python)那样自动打印堆栈跟踪。

惰性求值意味着表达式不会立即计算,而是等到需要它时才会计算,这对于异常的传播有影响。

1
2
3
4
5
6
7
8
9
10
11
12
13
import Debug.Trace (trace)

someFunc :: IO ()
someFunc = do
let a = f1
b = f2 a
print b

f1 :: Int
f1 = trace "run f1" 10

f2 :: Int -> Int
f2 val = trace "run f2" val + 10

执行结果为

1
2
3
run f2
run f1
20

正常的栈应该是

1
2
3
4
5
someFunc 入 : [someFunc]
f1 入 : [someFunc, f1]
f1 出 : [someFunc]
f2 入 : [someFunc, f2]
f2 出 : [someFunc]

但是在haskell可能是下面这样,跟代码的表现完全不一样,这时候会疑惑f2方法哪里调用了f1,这在实际的代码中会更复杂。

1
2
3
4
5
someFunc 入 : [someFunc]
f2 入 : [someFunc, f2]
f1 入 : [someFunc, f2, f1]
f1 出 : [someFunc, f2]
f2 出 : [someFunc]

另外一个原因是因为ghc优化也会导致堆栈难以跟踪,如上例子,f1可能直接优化到f2中形成组合函数。

  • 顺序
  • 判断
  • 循环
  • 异常

上述四个操作时流程控制的常见操作,然而在haskell中是没有循环for这个关键字的,那么haskell时如何实现循环的呢?

递归

循环的平替最常见莫过于递归了,如下是一个为数组的每个数字+1

1
2
3
addOne :: [Int] -> [Int]
addOne [] = []
addOne (x:xs) = (x + 1) : addOne xs

高阶函数

所谓高阶函数就是函数的抽象,函数既可以作为参数,也可作为返回值。

高阶函数的底层也是通过递归实现,关于遍历的高阶函数通常都会附加一些通用操作,如

  • 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
2
3
4
5
6
7
-- 冗余的写法如下
addOne :: [Int] -> [Int]
addOne array = (+1) <$> array

-- 省略参数的写法
addOne :: [Int] -> [Int]
addOne = ((+1) <$>)

代数结构就是普通的data,组合各个类型形成新的类型

1
data MyData = IntData Int | TextData Text

广义代数结构是一个语言拓展,开启广义代数数据类型

1
{-# LANGUAGE GADTs #-}

多参数构造子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{-# LANGUAGE GADTs #-}

module Lib
( someFunc
) where

data Expr a where
I :: Int -> Expr Int
B :: Bool -> Expr Bool
Add :: Expr Int -> Expr Int -> Expr Int

eval :: Expr a -> a
eval (I x) = x
eval (B x) = x
eval (Add x y) = eval x + eval y

someFunc :: IO ()
someFunc = do
let expr = Add (I 3) (I 4)
print (eval expr) -- 输出 7

能使用类型没有定义的变量

1
2
3
4
5
6
7
8
9
10
data DynamicSql where
ConstSql :: Text -> DynamicSql
-- 变量a并没有在DynamicSql类型中定义,也就是DynamicSql a
ValSql :: PersistField a => a -> DynamicSql
MaybeSql :: (a -> DynamicSql) -> Maybe a -> DynamicSql

parseDynamicSql :: DynamicSql -> (Text, [PersistValue])
parseDynamicSql (ConstSql txt) = (txt <> " ", [])
parseDynamicSql (ValSql val) = (" ? ", [toPersistValue val])
parseDynamicSql (MaybeSql dynamicSql' mVal) = maybe (" ", []) (parseDynamicSql . dynamicSql') mVal

应用

广义代数结构有些函数的性质,每一个值构造器都是一个逻辑的抽象,具体的实现由其他函数通过模式匹配的方式处理,如此可以针对一个类型提供多种实现。

以上方DynamicSql为例,DynamicSql是父接口,多个值构造器是子接口,parseDynamicSql为子接口提供实现。

0%