0%

在之前的《gitlab ci cd 不完全指南》一文中,我们讲了 gitlab ci 中的一些基本用法。 本文会继续介绍一些在使用 gitlab ci 过程中的优化方法,帮助大家减少在 gitlab ci 上的等待时间。

本文会从以下几个方面来介绍 gitlab ci 的优化方法:

  1. gitlab runner 的配置优化:包括 executor 的选择、concurrent 的设置等
  2. 依赖缓存:如何使用 cache 来加速构建
  3. 依赖使用国内的源:如何使用国内的源来加速依赖的下载
  4. 多个 job 同时执行
  5. job 配置优化:cache policy、GIT_STRATEGY、dependencies
  6. 网络优化:减少同步代码的时间
  7. 升级 gitlab 及 runner 版本

gitlab runner 的配置优化

concurrent 配置

gitlab runner 中有一个很重要的配置是 concurrent,它表示 gitlab runner 同时运行的 job 数量。默认情况下,concurrent 的值是 1,也就是说 gitlab runner 同时只能运行一个 job。如果你的 gitlab runner 有多个 executor,那么可以将 concurrent 设置为大于 1 的值,这样可以让 gitlab runner 同时运行多个 job,从而减少等待时间。

具体可参考:https://docs.gitlab.com/runner/configuration/advanced-configuration.html

executor 的选择

比较常见的是 docker 和 shell 类型的 executor,docker executor 的优势是可以在不同的环境中运行 job,比如在不同的镜像中运行 job,这样可以避免环境的不一致性。 而 shell executor 的优势是可以直接在 gitlab runner 的机器上运行 job,不需要额外的环境,在较老的 gitlab 版本中,shell executor 是很快的,但 shell executor 的可靠性较低,依赖于 gitlab runner 的机器,如果机器出现问题,那么 job 就会失败。

在 16.10 版本的实际使用中,docker executor 的速度有了明显提升,所以不必为了速度而选择 shell executor。

具体可参考:https://docs.gitlab.com/runner/executors/

docker executor 的 pull policy

docker executor 在运行 job 时,会拉取 docker 镜像,这个过程会耗费一些时间。我们可以通过设置 pull_policy 来控制是否每次都拉取镜像。默认情况下,pull_policy 的值是 always,也就是每次都会拉取镜像。 如果我们的镜像不经常更新(比如那种用来 build 项目的 job 所依赖的镜像),那么可以将 pull_policy 设置为 if-not-present,这样只有在本地没有镜像的时候才会拉取镜像。

可选值:

  • always: 每次都拉取镜像
  • if-not-present: 本地没有镜像的时候才会拉取镜像
  • never: 从不拉取镜像

具体可参考:https://docs.gitlab.com/runner/executors/docker.html#configure-how-runners-pull-images

依赖缓存

如果我们的项目需要下载一些第三方依赖,比如 npm、composer、go mod 等,那么我们可以使用 cache 来加速构建。cache 会将我们下载的依赖缓存到 gitlab runner 中,下次构建时就不需要重新下载依赖了。

下面是一个前端项目的例子:

1
2
3
4
5
6
7
8
9
build:
stage: build
cache:
key:
files:
- package.json
- package-lock.json
paths:
- node_modules/

上面这个例子的含义是:

  • 当 package.json 或 package-lock.json 文件发生变化时,就会重新下载依赖(不使用缓存)
  • 将 node_modules 目录缓存到 gitlab runner 中

具体可参考:https://docs.gitlab.com/ee/ci/caching/

需要注意的是:通过监测多个文件的变动来决定是否使用缓存的这个配置,在较新版本的 gitlab 中才有,具体忘记什么版本开始支持

依赖使用国内的源

还是拿部署前端项目作为例子,我们在下载依赖时,可以使用国内的源来加速下载。比如使用淘宝的 npm 镜像:

1
2
3
4
5
6
7
build:
stage: build
script:
- npm config set sass_binary_site https://npmmirror.com/mirrors/node-sass
- npm config set registry https://registry.npmmirror.com
- npm install
- npm run build

实际上其实就是我们在 npm install 之前设置了一下 registry,这样就会使用国内的源来下载依赖。

类似的,其他常用语言的包管理工具一般都有国内源,比如 go mod 有七牛云、composer 有阿里云的源等。

多个 job 同时执行:一个 stage 的多个 job

这里说的是那种没有相互依赖的 job,可以同时执行。比如在我们的后端项目中,build 这个 stage 中有两个 job,一个是用来生成 api 文档的,另一个是用来安装依赖的。 因为生成文档这个操作只是依赖于源码本身,不需要等到依赖安装完成,所以可以同时执行。

这种情况实际上就是把多个 job 放到一个 stage 中,这样 gitlab ci 就会同时执行这些 job:

1
2
3
4
5
6
7
8
9
10
11
12
stages:
- build

composer:
stage: build
script:
- composer install

apidoc:
stage: build
script:
- php artisan apidoc

注意:如果 job 之间有依赖,或者可能会读写相同的文件,那么可能会有异常。

job 配置优化

cache policy

这在之前那篇文章有说过,这里再重复一下。默认是 pull-push。意思是在 job 开始时拉取缓存,push 是在 job 结束时推送缓存,这样会保留我们在 job 执行过程中对缓存目录的变更。

但是实际上有时候我们是不需要在 job 结束的时候更新缓存的,比如我们的 job 不会更新缓存目录,那么我们可以设置为 pull,这样在 job 结束的时候就不会推送缓存了。

1
2
3
4
5
6
7
8
9
10
# 只是拉取缓存,然后同步到服务器的 job,不会更新缓存
sync:
cache:
key:
files:
- composer.json
- composer.lock
paths:
- "vendor/"
policy: pull

GIT_STRATEGY: none

跟上面这一小点类似,如果我们的 job 并不需要拉取代码,那么可以设置 GIT_STRATEGYnone,这样就不会拉取代码了。

1
2
3
4
deploy:
stage: deploy
variables:
GIT_STRATEGY: none

在实际中的应用场景是:部署跟发布分离的时候,发布的 job 并不需要拉取代码,只需要通过远程 ssh 命令执行发布的脚本即可。如果我们的 git 仓库比较大,那么这样可以减少一些时间。

dependencies: []

我们知道,gitlab ci 中的 job 可以产出一些构建的产物,比如前端项目 build 出来的静态文件、go 项目编译出来的二进制文件等,这些产物可以被其他 job 使用,只要我们通过 artifacts 配置即可。

但并不是所有的 job 都需要这些 artifacts 的,这个时候我们可以通过 dependencies: [] 来告诉 gitlab ci 这个 job 不需要依赖其他 job 的产物。

这样就可以节省下下载 artifacts 的时间。

网络优化

网络状况的好坏直接影响了同步代码的时间,这也是最容易做到的优化方式了。如果我们需要同步的机器比较多,而且同步的文件比较大的时候,网络优化带来的效果就更加明显了。

升级 gitlab 及 runner 版本

最近将 gitlab 从 15.9 升级到 16.10 后,发现使用 docker 的 executor 的时候,初始化容器的速度相比旧版本有明显了提升。这也说明了 gitlab 在不断的优化中,所以及时升级 gitlab 及 runner 版本也是一个不错的选择。

原来可能要 5~10s,如果 job 的数量多,这点提升就会比较明显了。

当然我们可以选择一个 stage 多个 job,但是有很多时候一些 job 是没有办法并行的,因为会相互影响。

原因

在 15.9 版本中,gitlab ci 中的 job 无法在失败之后进行重试,表现为失败之后进入 pending 状态,一直持续。从而导致了在有时候 job 偶尔的失败需要手动去重试,非常不方便。有时候还会因为来不及手动重试会直接影响线上服务。

gitlab 15.9 使用的 docker 配置

gitlab 使用的是 docker 部署,docker-compose 配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
gitlab:
container_name: gitlab
image: 'gitlab/gitlab-ce:15.9.3-ce.0'
restart: always
hostname: '192.168.2.168'
environment:
GITLAB_OMNIBUS_CONFIG: |
external_url 'http://192.168.2.168:8200'
pages_external_url 'http://192.168.2.168:8300'
gitlab_rails['gitlab_shell_ssh_port'] = 2222
ports:
- '8200:8200'
- '8300:8300'
- '2222:22'
volumes:
- '/usr/lnmp/gitlab/config:/etc/gitlab'
- '/usr/lnmp/gitlab/logs:/var/log/gitlab'
- '/usr/lnmp/gitlab/data:/var/opt/gitlab'
shm_size: '256m'

要解决的几个关键问题

因为是 docker 配置,所以本来打算直接用旧的文件夹来启动一个新版本的容器,不过在启动新版本的容器的时候起不来。 因为版本跨度过大,可能中间太多不兼容,最后决定起一个新的容器,然后把旧的数据手动迁移过去。

在本次迁移中,为了保证旧的用户密码在迁移后依然可用,直接使用了旧的配置文件来启动新的 gitlab 容器,也就是上面 docker-compose 配置中的 /user/lnmp/gitlab/config 这个文件夹。

gitlab 没有什么工具可以用来迁移数据的,所以比较麻烦。

在迁移过程中,有几个很关键的问题需要解决:

  1. 用户和组迁移
  2. 用户的 ssh key 迁移
  3. 项目迁移
  4. 用户权限迁移

用户权限这个没有什么好的办法,只能手动去设置。如果用户太多的话,可以看看 gitlab 有没有提供 API,使用它的 API 可能会方便一些。

其他的几个问题可以稍微简单一点,本文会详细介绍。

gitlab 获取 token 以及调用 API 的方法

我们需要使用管理员账号来创建 token,其他账号是没有权限的。

gitlab 提供了很多 REST API,可以通过这些 API 来获取到 gitlab 的数据,以及对 gitlab 进行操作。 我们的一些迁移操作就调用了它的 API 来简化操作,同时也可以避免人为操作导致的一些错误。

但是调用这些 API 之前,我们需要获取到一个 token,从 gitlab 个人设置中获取到 token,然后使用这个 token 来调用 API(Preferences -> Access Tokens,添加 token 的时候把所有权限勾上就好)。

通过 REST API 导出分组的示例:

1
curl --request POST --header "PRIVATE-TOKEN: glpat-L1efQKvKeWu" "http://192.168.2.168:8200/api/v4/groups/1/export"

说明:

  • PRIVATE-TOKEN header:就是我们在 gitlab 个人设置中获取到的 token。
  • /api/v4/groups/1/export:这个是 gitlab 的 API,可以通过这个 API 导出分组,这里的 1 是分组的 ID。

我们可以将这个 curl 命令通过使用自己熟悉的编程语言来调用,这样可以很方便地对获取到的数据进行后续操作。比如我在这个过程中就是使用 python 来调用 gitlab 的 API。

启动一个新的 gitlab 16.10 版本的容器

在开始迁移之前,需要先启动一个新的 gitlab 16.10 版本的容器,这个容器是全新的,没有任何数据。但配置文件是复制了一份旧的配置文件。新的 docker-compose.yml 配置文件如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
gitlab:
container_name: gitlab
image: 'gitlab/gitlab-ce:15.9.3-ce.0'
restart: always
hostname: '192.168.2.168'
environment:
GITLAB_OMNIBUS_CONFIG: |
external_url 'http://192.168.2.168:8200'
pages_external_url 'http://192.168.2.168:8300'
gitlab_rails['gitlab_shell_ssh_port'] = 2222
ports:
- '8200:8200'
- '8300:8300'
- '2222:22'
volumes:
- '/usr/lnmp/gitlab/config:/etc/gitlab'
- '/usr/lnmp/gitlab/logs:/var/log/gitlab'
- '/usr/lnmp/gitlab/data:/var/opt/gitlab'
shm_size: '256m'
networks:
- elasticsearch

gitlab-new:
container_name: "gitlab-new"
image: 'gitlab/gitlab-ce:16.10.0-ce.0'
restart: always
hostname: '192.168.2.168'
environment:
GITLAB_OMNIBUS_CONFIG: |
external_url 'http://192.168.2.168:8211'
pages_external_url 'http://192.168.2.168:8311'
gitlab_rails['gitlab_shell_ssh_port'] = 2233
ports:
- '8211:8211'
- '8311:8311'
- '2233:22'
volumes:
- '/usr/lnmp/gitlab-new/config:/etc/gitlab'
- '/usr/lnmp/gitlab-new/logs:/var/log/gitlab'
- '/usr/lnmp/gitlab-new/data:/var/opt/gitlab'
shm_size: '256m'
networks:
- elasticsearch

在个配置文件中:

  1. 旧的配置保持不变
  2. 旧的配置目录复制了一份 /usr/lnmp/gitlab/config => /usr/lnmp/gitlab-new/config
  3. /usr/lnmp/gitlab-new 目录下只有 config 文件夹,没有 logsdata 文件夹,这两个文件夹会在容器启动过程中生成。

这样我们就可以在旧的 gitlab 运行过程中,先进行用户、组等数据的迁移。

用户迁移

Gitlab 提供了获取用户信息的 API,可以通过这个 API 获取到用户的信息(从旧的 gitlab),然后再通过 API 创建用户(在新的 gitlab)。

我们可以在 https://docs.gitlab.com/16.10/ee/api/users.html 查看 gitlab 的用户 API 详细文档。

我们会用到它的几个 API:

  1. 获取用户信息:GET /users:通过这个 API 我们可以获取到所有用户的信息(从旧的 gitlab)。
  2. 获取用户的 ssh key:GET /users/:id/keys:通过这个 API 我们可以获取到用户的 ssh key(从旧的 gitlab)。
  3. 创建用户:POST /users:通过这个 API 我们可以创建用户(到新的 gitlab)。
  4. 创建用户的 ssh key:POST /users/:id/keys:通过这个 API 我们可以创建用户的 ssh key(到新的 gitlab)。

从旧的 gitlab 获取用户信息

完整代码太长了,这里只放出关键代码:

1
2
3
# http://192.168.2.168:8200 是旧的 gitlab 地址
response = requests.get("http://192.168.2.168:8200/api/v4/users?per_page=100", headers={'Private-Token': tk})
users = response.json()

这里的 tk 是我们在 gitlab 个人设置中获取到的 token。

它返回的数据格式如下(users):

1
2
3
4
5
6
7
8
9
10
11
[
{
"id": 33,
"username": "x",
"name": "x",
"state": "deactivated",
"avatar_url": null,
"web_url": "http://192.168.2.168:8200/x"
// 其他字段....
}
]

从旧的 gitlab 获取用户的 ssh key

在上一步获取到所有的用户信息之后,我们可以通过用户的 ID 来获取用户的 ssh key。

1
2
3
4
# http://192.168.2.168:8200 是旧的 gitlab 地址
for user in users:
ssh_keys_response = requests.get(f"http://192.168.2.168:8200/api/v4/users/{user['id']}/keys", headers={'Private-Token': tk})
user['keys'] = ssh_keys_response.json()

这里的 user['keys'] 就是用户的 ssh key 信息,格式如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
[
{
"user_id": 6,
"username": "ming",
"name": "ming",
"keys": [
{
"id": 24,
"title": "win7",
"created_at": "2023-04-10T03:15:54.428Z",
"expires_at": null,
"key": "ssh-rsa AAAAB3Nza...",
"usage_type": "auth_and_signing"
}
]
}
]

这一步,我们获取了用户的 ssh key 并关联到了 user 中了。

在新的 gitlab 上创建用户

在获取到了用户信息之后,我们就可以在新的 gitlab 上创建用户了。

1
2
3
4
5
6
7
8
9
10
11
12
# http://192.168.2.168:8211 是新的 gitlab 的地址
create_user_response = requests.post("http://192.168.2.168:8211/api/v4/users", headers={'Private-Token': tk_new}, json={
'username': user['username'],
"email": user['email'],
"name": user['name'],
"password": '12345678Git*',

'note': user['note'],
'bio': user['bio'],
'commit_email': user['commit_email'],
})
user['new_user'] = create_user_response.json()

这个接口会返回新创建的用户信息,格式如下,我们可以通过这个信息来获取到新创建的用户的 ID。

因为新旧 gitlab 的用户 ID 会不一样,所以这里需要获取新的 ID 来创建 ssh key。

1
2
3
4
5
6
7
8
9
10
{
"id": 2,
"username": "xx",
"name": "xx",
"state": "active",
"locked": false,
"avatar_url": "",
"web_url": "http://192.168.2.168:8211/",
// 其他字段
}

在新的 gitlab 上创建用户的 ssh key

创建了用户之后,我们就可以创建用户的 ssh key 了。

1
2
3
4
5
6
7
8
# http://192.168.2.168:8211 是新的 gitlab 的地址
for ssh_key in user['keys']:
create_keys_response = requests.post(
f"http://192.168.2.168:8211/api/v4/users/{user['new_user']['id']}/keys",
headers={'Private-Token': tk_new},
json={"title": ssh_key['title'],"key": ssh_key['key']}
)
print(create_keys_response.json())

到这里,我们就把用户迁移过来了。

新旧 gitlab 用户密码不一样的问题

在现代的应用中,密码一般会使用诸如 APP_KEY 这种应用独立的 key 来做一些加密,如果是不同的 key,那么加密出来的密码就会不一样。 出于这个考虑,我们在迁移的过程中,直接保留了旧的配置,里面包含了应用的一些 key,这样我们就可以直接从旧系统的数据库中提取出加密后的密码来使用, 因为加密的时候使用的 key 是一样的,所以放到新的系统中是可以直接使用的。

从旧的 gitlab 数据库中获取用户密码

  1. 进入 gitlab 容器内部:docker exec -it gitlab bash
  2. 进入 gitlab 数据库:gitlab-rails dbconsole
  3. 获取用户密码:select username, encrypted_password from users;

这会输出所有用户的用户名和加密后的密码,我们可以把这个密码直接放到新的 gitlab 中:

1
2
3
4
  username  |                      encrypted_password
------------+--------------------------------------------------------------
a | $2a$10$k1/Cj2qUCyWgwalCmzTFo.iqYgnqIoFmQVuT2S6mBuQF0Nql0CRGm
b | $2a$10$iWWXEcPJpyZVxfmIU6nv6e46JRj2dYnwGP6GXclryEAeEXJvOZ5aC

因为 username 是唯一的,所以我们可以通过 username 来找到新的 gitlab 中对应的用户(肉眼查找法),最后更新这个用户的 encrypted_password 字段即可。

当然,也可以复制出来做一些简单的文本处理:

1
2
3
4
5
6
7
8
9
10
11
12
text = """
a | $2a$10$k1/Cj2qUCyWgwalCmzTFo.iqYgnqIoFmQVuT2S6mBuQF0Nql0CRGm
b | $2a$10$iWWXEcPJpyZVxfmIU6nv6e46JRj2dYnwGP6GXclryEAeEXJvOZ5aC
"""

lines = text.strip().split("\n")
result = []
for line in lines:
kvs = [v.strip() for v in line.strip().split('|')]
result.append({kvs[0]: kvs[1]})

print(result)

这样我们就可以得到一个字典列表:

1
2
[{'a': '$2a$10$k1/Cj2qUCyWgwalCmzTFo.iqYgnqIoFmQVuT2S6mBuQF0Nql0CRGm'},
{'b': '$2a$10$iWWXEcPJpyZVxfmIU6nv6e46JRj2dYnwGP6GXclryEAeEXJvOZ5aC'}]

在新的 gitlab 中更新用户密码

同样地,需要进入新的 gitlab 容器内部,然后进入数据库,然后更新用户密码:

  1. 进入 gitlab 容器内部:docker exec -it gitlab-new bash
  2. 进入 gitlab 数据库:gitlab-rails dbconsole
  3. 获取用户密码:select id, username from users;

拿到上一步的字典后,我们可以通过 username 找到新的 gitlab 中对应的用户 ID,然后更新密码:

  1. 通过新的 gitlab 的用户 API 获取用户列表
  2. 循环这个用户列表,如果 username 在上一步的字典中,那么就更新这个用户的密码
1
2
3
4
# http://192.168.2.168:821 是新的 gitlab 地址
# tk_new 是新的 gitlab 上的 token
response = requests.get("http://192.168.2.168:8211/api/v4/users?per_page=100", headers={'Private-Token': tk_new})
users = response.json()

因为连接到 gitlab 的数据库又比较麻烦,所以这里只是生成了的 update 语句,然后手动在新的 gitlab 数据库中执行:

1
2
3
4
5
6
7
8
9
# users 是新 gitlab 中的用户
for user in users:
if user['username'] in result:
# 通过 username 找到旧的密码
encrypted_password = result[user['username']]
# 生成 update 语句
# 这个 update 语句会将新系统中的用户密码更新为旧系统中的密码
# 因为是使用相同的 key 加密的,所以迁移后也依然可以使用旧的密码来登录
print(f"update users set encrypted_password='{encrypted_password}', reset_password_token=null, reset_password_sent_at = null,confirmation_token=null,confirmation_sent_at=null where id={user['id']};")

这会生成一些 update 语句,我们可以复制出来在新的 gitlab 中执行(也就是本小节的 gitlab-rails dbconsole)。 到这一步,用户的迁移就算完成了。

Group 的迁移

Group 的迁移和用户的迁移类似,只是 Group 的 API 不同,我们可以在 https://docs.gitlab.com/ee/api/group_import_export.html 查看 gitlab 的 Group API 文档。

注意:如果我们的 Group 中除了项目,没什么东西的话,直接自己手动在 gitlab 上创建 Group,然后把项目迁移过去就好了。 使用它的 API 是因为它可以同时迁移:milestone、label、wiki、子 Group 等信息。

  1. 我们首先需要知道在旧的 gitlab 中的 Group ID,然后通过 API 导出 Group(旧的 gitlab)。
  2. 调用了导出的 API 之后,需要等待系统导出完成,然后下载导出的文件(旧的 gitlab)。
  3. 调用下载导出文件的 API,获取到导出的分组(旧的 gitlab)。
  4. 最后,调用导入分组的 API,将分组导入到新的 gitlab 中(新的 gitlab)。

下面是一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 从旧的 gitlab 导出 Group,1 是 Group ID
curl --request POST --header "PRIVATE-TOKEN: glpat-abc" "http://192.168.2.168:8200/api/v4/groups/1/export"

# 下载导出的分组
# --output 指定下载的文件名
# `api/v4/groups/1/export/download` 中的 1 是 Group ID
curl --request GET \
--header "PRIVATE-TOKEN: glpat-abc" \
--output download_group_42.tar.gz \
"http://192.168.2.168:8200/api/v4/groups/1/export/download"

# 导入分组(在新的 gitlab)
# --form 指定 form 表单参数
# `name` 是新的 Group 名称(给人看的名称)
# `path` 是新的 Group 路径(体现在 git 仓库的路径上)
# `file` 是下载的文件(上一步导出的 Group 文件)
curl --request POST --header "PRIVATE-TOKEN: glpat-def" \
--form "name=g1" --form "path=g2" \
--form "file=@/Users/ruby/Code/devops/download_group_42.tar.gz" "http://192.168.2.168:8211/api/v4/groups/import"

这样就可以把 Group 迁移过来了。

项目迁移

项目的迁移没有找到什么好的方法,只能手动迁移了。

gitlab 的项目因为有很多分支、release、issue,所以不能只是简单地把 git 仓库拷贝过去就好了,还需要把这些信息也迁移过去。 这就需要将项目导出,然后导入到新的 gitlab 中。

具体操作步骤如下:

  1. 在项目主页的 Settings -> General -> Advanced -> Export project 中导出项目
  2. 点击导出之后,需要等待导出完成,然后下载导出的文件,还是在 Settings -> General -> Advanced -> Export project 中,点击下载
  3. 在新的 gitlab 中,点击 New Project,然后选择 Import project,选择上一步下载的文件,导入项目(导入的类型选择 Gitlab export
  4. 填写项目名,命名空间选择跟旧的 gitlab 一样的 Group,选择上一步下载的文件,然后点击 Import project,等待导入完成即可

权限迁移

这个也是得手动设置,没有什么好的办法。 也许有 API,但是用户少的时候,还是手动设置更快。

gitlab runner 迁移

这个也是看着旧的 gitlab runner 配置,手动配置一下就完了,没几个。

需要注意的是,gitlab runner 配置的 docker 类型的 runner 的时候,需要加上 pull_policy = ["if-not-present"],这样会在执行 job 的时候快很多,不然每次都会去拉取镜像。

1
2
3
4
[[runners]]
name = "docker-runner"
[runners.docker]
pull_policy = ["if-not-present"]

总结

最后,再回顾一下迁移过程的一些关键操作:

  1. 用户迁移:通过 API 从旧的 gitlab 获取用户信息、ssh key,然后在新的 gitlab 中通过 API 创建用户、创建 ssh key。
  2. 用户密码可以进入容器中使用 gitlab-rails dbconsole 来获取用户密码,然后在新的 gitlab 中更新用户密码。
  3. Group 迁移:通过 API 导出 Group,然后下载导出的文件,最后导入到新的 gitlab 中。
  4. 项目迁移:通过 gitlab 的项目导出、导入功能来迁移项目。这种迁移方式会保留项目的 issues、分支 等信息。

Filebeat 是一款功能强大的日志传送器,旨在简化从不同来源收集、处理和转发日志到不同目的地的过程。Filebeat 在开发时充分考虑了效率,可确保日志管理无缝且可靠。它的轻量级特性和处理大量数据的能力使其成为开发人员和系统管理员的首选。

在本综合指南中,您将深入探索 Filebeat 的功能。从基础知识开始,您将设置 Filebeat 以从各种来源收集日志。然后,您将深入研究高效处理这些日志的复杂性,从 Docker 容器收集日志并将其转发到不同的目标进行分析和监视。

前言

在上一篇文章《Filebeat vs Logstash:日志采集工具对比》中,我们对比了 Filebeat 和 Logstash 的一些优缺点,下面是一份简介版的总结:

我们可以看到,我们选择 Filebeat 一方面是因为它占用资源少,另外一方面是我们不需要对日志做复杂的处理,同时也不需要将日志发送到多个目的地。

环境准备

创建用以测试的目录:

1
2
mkdir log-processing-stack
cd log-processing-stack

为演示应用程序创建一个子目录并移动到该目录中:

1
mkdir logify && cd logify

完成这些步骤后,您可以在下一节中创建演示日志记录应用程序。

开发演示日志记录应用程序

在本节中,你将使用 Bash 脚本语言构建一个基本的日志记录应用程序。应用程序将定期生成日志,模拟应用程序生成日志数据的真实场景。

logify 目录中,创建一个名为 logify.sh 的文件,并将以下内容添加到文件中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#!/bin/bash
filepath="/var/log/logify/app.log"

create_log_entry() {
local info_messages=("Connected to database" "Task completed successfully" "Operation finished" "Initialized application")
local random_message=${info_messages[$RANDOM % ${#info_messages[@]}]}
local http_status_code=200
local ip_address="127.0.0.1"
local emailAddress="user@mail.com"
local level=30
local pid=$$
local time=$(date +%s)
local log='{"status": '$http_status_code', "ip": "'$ip_address'", "level": '$level', "emailAddress": "'$emailAddress'", "msg": "'$random_message'", "pid": '$pid', "timestamp": '$time'}'
echo "$log"
}

while true; do
log_record=$(create_log_entry)
echo "${log_record}" >> "${filepath}"
sleep 3
done

create_log_entry() 函数以 JSON 格式生成日志记录,包含严重性级别、消息、HTTP 状态代码和其他关键字段等基本详细信息。此外,它还包括敏感字段,例如电子邮件地址、和 IP 地址,这些字段是特意包含的,以展示 Filebeat 屏蔽字段中敏感数据的能力。

接下来,程序进入无限循环,重复调用 create_log_entry() 函数并将日志写入 /var/log/logify 目录中的指定文件。

添加完代码后,保存更改并使脚本可执行:

1
chmod +x logify.sh

然后,创建 /var/log/logify 用于存储应用程序日志的目录:

1
sudo mkdir /var/log/logify

接下来,使用 $USER 环境变量将 /var/log/logify 目录的所有权分配给当前登录的用户:

1
sudo chown -R $USER:$USER /var/log/logify/

在后台运行 logify.sh 脚本:

1
./logify.sh &

命令末尾的 & 符号指示脚本在后台运行,允许您在日志记录应用程序独立运行时继续使用终端执行其他任务。

当程序启动时,它将显示如下所示的输出:

1
[1] 91773

此处表示 91773 进程 ID,如果需要,该 ID 可用于稍后终止脚本。

若要查看 app.log 文件的内容,可以使用以下 tail 命令:

1
tail -n 4 /var/log/logify/app.log

此命令以 JSON 格式显示 app.log 文件中的最后 4 个日志条目:

1
2
3
4
{"status": 200, "ip": "127.0.0.1", "level": 30, "emailAddress": "user@mail.com", "msg": "Connected to database", "pid": 6512, "timestamp": 1709286422}
{"status": 200, "ip": "127.0.0.1", "level": 30, "emailAddress": "user@mail.com", "msg": "Initialized application", "pid": 6512, "timestamp": 1709286425}
{"status": 200, "ip": "127.0.0.1", "level": 30, "emailAddress": "user@mail.com", "msg": "Initialized application", "pid": 6512, "timestamp": 1709286428}
{"status": 200, "ip": "127.0.0.1", "level": 30, "emailAddress": "user@mail.com", "msg": "Operation finished", "pid": 6512, "timestamp": 1709286431}

现在,您已成功创建用于生成示例日志条目的日志记录应用程序。

安装 Filebeat

我的系统是 MacOS,所以执行下面的命令即可:

1
2
curl -L -O https://artifacts.elastic.co/downloads/beats/filebeat/filebeat-8.12.2-darwin-x86_64.tar.gz
tar xzvf filebeat-8.12.2-darwin-x86_64.tar.gz

最后,进入 filebeat 目录下去执行 filebeat 命令:

1
2
cd filebeat-8.12.2-darwin-x86_64
./filebeat version

输出:

1
filebeat version 8.12.2 (amd64), libbeat 8.12.2 [0b71acf2d6b4cb6617bff980ed6caf0477905efa built 2024-02-15 13:39:16 +0000 UTC]

Filebeat 的工作原理

在开始使用 Filebeat 之前,了解其工作原理至关重要。在本节中,我们将探讨其基本组件和流程,确保您在深入研究实际使用之前有一个坚实的基础:

要了解 Filebeat 的工作原理,主要需要熟悉以下组件:

  • 收割机(Harvesters),收割机负责逐行读取文件的内容。当 Filebeat 配置为监控特定日志文件时,会为每个文件启动一个收集器。这些收割机不仅可以读取日志数据,还可以管理打开和关闭文件。通过逐行增量读取文件,收集器可确保有效地收集新附加的日志数据并转发以进行处理。
  • 输入(Inputs):输入充当收割机和数据源之间的桥梁。他们负责管理收割机并找到 Filebeat 需要从中读取日志数据的所有来源。可以为各种源(例如日志文件、容器或系统日志)配置输入。用户可以通过定义输入来指定 Filebeat 应监控的文件或位置。

Filebeat 读取日志数据后,日志事件将进行转换或使用数据进行扩充。最后发送到指定的目的地。

可以在 filebeat.yml 配置文件中指定以下行为:

我们可以在下载的目录中看到有一个 filebeat.yml 的配置文件。

1
2
3
4
5
6
filebeat.inputs:
. . .
processors:
. . .
output.plugin_name:
. . .

现在让我们详细研究每个部分:

  • filebeat.inputs:Filebeat 实例应监控的输入源。
  • processors:在将数据发送到输出之前对其进行扩充、修改或筛选。
  • output.plugin_name:Filebeat 应转发日志数据的输出目标。

这些指令中的每一个都要求您指定一个执行其相应任务的插件。

现在,让我们来探讨一些可以与 Filebeat 一起使用的输入、处理器和输出。

Filebeat 输入插件

Filebeat 提供了一系列输入插件,每个插件都经过定制,用于从特定来源收集日志数据:

  • container:收集容器日志。
  • filestream:主动从日志文件中读取行。
  • syslog:从 Syslog 中获取日志条目。
  • httpjson:从 RESTful API 读取日志消息。

Filebeat 输出插件

Filebeat 提供了多种输出插件,使您能够将收集的日志数据发送到不同的目的地:

  • File:将日志事件写入文件。
  • Elasticsearch:使 Filebeat 能够使用其 HTTP API 将日志转发到 Elasticsearch。
  • Kafka:将日志记录下发给 Apache Kafka。
  • Logstash:直接向 Logstash 发送日志。

Filebeat 模块插件

Filebeat 通过其模块简化日志处理,提供专为特定日志格式设计的预配置设置。这些模块使您能够毫不费力地引入、解析和丰富日志数据,而无需进行大量手动配置。以下是一些可以显著简化日志处理工作流程的可用模块:

  • Logstash
  • AWS
  • PostgreSQL
  • Nginx
  • RabbitMQ
  • HAproxy

Filebeat 入门

现在您已经了解了 Filebeat 的工作原理,让我们将其配置为从文件中读取日志条目并将其显示在控制台上。

首先,打开位于解压目录的 Filebeat 配置文件 filebeat.yml

1
vim filebeat.yml

接下来,清除文件的现有内容,并将其替换为以下代码:

1
2
3
4
5
6
7
filebeat.inputs:
- type: log
paths:
- /var/log/logify/app.log

output.console:
pretty: true

在本节 filebeat.inputs 中,您指定 Filebeat 应使用 logs 插件从文件中读取日志。paths 参数指示 Filebeat 将监控的日志文件的路径,此处设置为 /var/log/logify/app.log

output.console 部分将收集到的日志数据发送到控制台。pretty: true 参数可确保日志条目在控制台上显示时以可读且结构良好的格式显示。

添加这些配置后,保存文件。

在执行 Filebeat 之前,必须验证配置文件语法以识别和纠正任何错误:

1
./filebeat -c ./filebeat.yml test config

如果配置文件正确,则应看到以下输出:

1
Config OK

现在,继续运行 Filebeat:

1
./filebeat -c ./filebeat.yml

当 Filebeat 开始运行时,它将显示类似于以下内容的日志条目:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
{
"@timestamp": "2024-03-02T01:35:34.696Z",
"@metadata": {
"beat": "filebeat",
"type": "_doc",
"version": "8.12.2"
},
"log": {
"offset": 2875279,
"file": {
"path": "/var/log/logify/app.log"
}
},
"message": "{\"status\": 200, \"ip\": \"127.0.0.1\", \"level\": 30, \"emailAddress\": \"user@mail.com\", \"msg\": \"Task completed successfully\", \"pid\": 6512, \"timestamp\": 1709343333}",
"input": {
"type": "log"
},
"ecs": {
"version": "8.0.0"
},
"host": {
"name": "rubys-iMac.local"
},
"agent": {
"version": "8.12.2",
"ephemeral_id": "310a7b92-f2fb-42ca-b3d8-e32e348c7a57",
"id": "f177dd40-1249-487c-b9da-aaab03cfd05c",
"name": "rubys-iMac.local",
"type": "filebeat"
}
}

Filebeat 现在在控制台中显示日志消息。Bash 脚本中的日志事件位于 message 字段下,Filebeat 添加了其他字段以提供上下文。您现在可以通过按 CTRL + C 停止 Filebeat。

成功配置 Filebeat 以读取日志并将其转发到控制台后,下一节将重点介绍数据转换。

使用 Filebeat 转换日志

当 Filebeat 收集数据时,您可以在将其发送到输出之前对其进行处理。您可以使用新字段来丰富它,解析数据,以及删除或编辑敏感字段以确保数据隐私。

在本部分中,你将通过以下方式转换日志:

  • 解析 JSON 日志。
  • 删除不需要的字段。
  • 添加新字段。
  • 屏蔽敏感数据。

使用 Filebeat 解析 JSON 日志

由于演示日志记录应用程序以 JSON 格式生成日志,因此必须正确解析它们以进行结构化分析。

让我们检查上一节中的示例日志事件:

1
2
3
4
5
6
...
"message": "{\"status\": 200, \"ip\": \"127.0.0.1\", \"level\": 30, \"emailAddress\": \"user@mail.com\", \"msg\": \"Task completed successfully\", \"pid\": 6512, \"timestamp\": 1709343333}",
"input": {
"type": "log"
},
...

要将日志事件解析为有效的 JSON,请打开 Filebeat 配置文件:

1
vim filebeat.yml

然后,使用以下代码行更新文件:

1
2
3
4
5
6
7
8
9
10
11
12
filebeat.inputs:
- type: log
paths:
- /var/log/logify/app.log

processors:
- decode_json_fields:
fields: ["message"]
target: ""

output.console:
pretty: true

在上面的代码片段中,您将处理器配置为 decode_json_fields 解码每个日志条目 message 字段中的 JSON 编码数据,并将其附加到日志事件。

保存并退出文件。使用以下命令重新运行 Filebeat:

1
./filebeat -c ./filebeat.yml

如果配置文件正确,则应看到以下输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
{
"@timestamp": "2024-03-02T01:43:07.367Z",
"@metadata": {
"beat": "filebeat",
"type": "_doc",
"version": "8.12.2"
},
"status": 200,
"ip": "127.0.0.1",
"level": 30,
"message": "{\"status\": 200, \"ip\": \"127.0.0.1\", \"level\": 30, \"emailAddress\": \"user@mail.com\", \"msg\": \"Initialized application\", \"pid\": 6512, \"timestamp\": 1709343785}",
"ecs": {
"version": "8.0.0"
},
"host": {
"name": "rubys-iMac.local"
},
"agent": {
"type": "filebeat",
"version": "8.12.2",
"ephemeral_id": "f33a1fc6-e8f1-4dde-8740-5f85a1e8bcfd",
"id": "f177dd40-1249-487c-b9da-aaab03cfd05c",
"name": "rubys-iMac.local"
},
"pid": 6512,
"timestamp": 1709343785,
"log": {
"offset": 2898143,
"file": {
"path": "/var/log/logify/app.log"
}
},
"input": {
"type": "log"
},
"emailAddress": "user@mail.com",
"msg": "Initialized application"
}

在输出中,您将看到 message 字段中的所有属性(如 msgip 等)都已添加到日志事件中。

现在,您可以解析 JSON 日志,您将修改日志事件的属性。

使用 Filebeat 添加和删除字段

日志事件包含需要保护的敏感 emailAddress 字段。在本部分中,你将删除该 emailAddress 字段,并向日志事件添加一个新字段,以提供更多上下文。

打开 Filebeat 配置文件:

1
vim filebeat.yml

添加以下行以修改日志事件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
filebeat.inputs:
- type: log
paths:
- /var/log/logify/app.log

processors:
- decode_json_fields:
fields: ["message"]
target: ""
- drop_fields:
fields: ["emailAddress", "message"]

- add_fields:
fields:
env: "environment" # Add a new 'env' field set to "development"
output.console:
pretty: true

若要修改日志事件,请添加 drop_fields 处理器,该处理器具有一个 field 选项,用于获取要删除的字段列表,包括敏感 EmailAddress 字段和 message 字段。删除 message 字段是因为在分析数据后,message 字段的属性已合并到日志事件中,从而使原始 message 字段过时。

编写代码后,保存并退出文件。然后,重新启动 Filebeat:

1
./filebeat -c ./filebeat.yml

运行 Filebeat 时,您会注意到该 emailAddress 字段已被成功删除,并且已将一个新 env 字段添加到日志事件中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
{
"@timestamp": "2024-03-02T01:47:33.907Z",
"@metadata": {
"beat": "filebeat",
"type": "_doc",
"version": "8.12.2"
},
"ip": "127.0.0.1",
"level": 30,
"log": {
"offset": 2911714,
"file": {
"path": "/var/log/logify/app.log"
}
},
"ecs": {
"version": "8.0.0"
},
"msg": "Operation finished",
"pid": 6512,
"timestamp": 1709344053,
"status": 200,
"fields": {
"env": "environment"
},
"input": {
"type": "log"
},
"host": {
"name": "rubys-iMac.local"
},
"agent": {
"version": "8.12.2",
"ephemeral_id": "607c49c8-9339-4851-b8e6-caab3bf6138b",
"id": "f177dd40-1249-487c-b9da-aaab03cfd05c",
"name": "rubys-iMac.local",
"type": "filebeat"
}
}

现在,您可以扩充和删除不需要的字段,接下来将编写条件语句。

在 Filebeat 中使用条件语句

Filebeat 允许您检查条件,并在条件计算结果为 true 时添加字段。在本节中,您将检查该 status 值是否等于 true ,如果满足条件,您将向日志事件添加一个 is_successful 字段。

为此,请打开配置文件:

1
vim filebeat.yml

之后,添加突出显示的行以根据指定条件添加 is_successful 字段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
...
processors:
- decode_json_fields:
fields: ["message"]
target: ""
- drop_fields:
fields: ["emailAddress"] # Remove the 'emailAddress' field

- add_fields:
fields:
env: "environment" # Add a new 'env' field set to "development"
# 如果 status 字段的值为 200,则添加 is_successful 字段
- add_fields:
when:
equals:
status: 200
target: ""
fields:
is_successful: true
...

when 选项检查 status 字段值是否等于 200。如果为 true,则将该 is_successful 字段添加到日志事件中。

保存新更改后,启动 Filebeat:

1
./filebeat -c ./filebeat.yml

Filebeat 将生成与此内容密切相关的输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
{
"@timestamp": "2024-03-02T01:50:31.697Z",
"@metadata": {
"beat": "filebeat",
"type": "_doc",
"version": "8.12.2"
},
"status": 200,
"ip": "127.0.0.1",
"level": 30,
"ecs": {
"version": "8.0.0"
},
"fields": {
"env": "environment"
},
"input": {
"type": "log"
},
"host": {
"name": "rubys-iMac.local"
},
"agent": {
"version": "8.12.2",
"ephemeral_id": "20cca3c1-6ba3-4a78-8e0e-cb07bdb885f1",
"id": "f177dd40-1249-487c-b9da-aaab03cfd05c",
"name": "rubys-iMac.local",
"type": "filebeat"
},
"is_successful": true,
"msg": "Task completed successfully",
"pid": 6512,
"log": {
"file": {
"path": "/var/log/logify/app.log"
},
"offset": 2920724
},
"timestamp": 1709344231
}

在输出中,该 is_successful 字段已添加到日志条目中,HTTP 状态代码为 200

这负责根据条件添加新字段。

使用 Filebeat 编辑敏感数据

在本文前面,您删除了 emailAddress 字段以确保数据隐私。但是,IP 地址敏感字段仍保留在日志事件中。此外,组织内的其他开发人员可能会无意中将敏感数据添加到日志事件中。通过编辑与特定模式匹配的数据,您可以屏蔽任何敏感信息,而无需删除整个字段,从而确保保留消息的重要性。

在文本编辑器中,打开 Filebeat 配置文件:

1
vim filebeat.yml

添加以下代码以编辑 IP 地址:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
...
processors:
- script:
lang: javascript
id: redact-sensitive-info
source: |
function process(event) {
// Redact IP addresses (e.g., 192.168.1.1) from the "message" field
event.Put("message", event.Get("message").replace(/\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/g, "[REDACTED-IP]"));
}
- decode_json_fields:
fields: ["message"]
target: ""
- drop_fields:
fields: ["emailAddress"] # Remove the 'emailAddress' field

- add_fields:
fields:
env: "environment" # Add a new 'env' field set to "development"
- add_fields:
when:
equals:
status: 200
target: ""
fields:
is_successful
...

在添加的代码中,您将定义一个用 JavaScript 编写的脚本,用于编辑日志事件中的敏感信息。该脚本使用正则表达式来标识 IP 地址,并分别将它替换为 [REDACTED-IP]

添加代码后,运行 Filebeat:

1
./filebeat -c ./filebeat.yml

输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
{
"@timestamp": "2024-03-02T01:53:50.792Z",
"@metadata": {
"beat": "filebeat",
"type": "_doc",
"version": "8.12.2"
},
"log": {
"offset": 2930777,
"file": {
"path": "/var/log/logify/app.log"
}
},
"pid": 6512,
"status": 200,
"fields": {
"env": "environment"
},
"msg": "Initialized application",
"is_successful": true,
"level": 30,
"input": {
"type": "log"
},
"ecs": {
"version": "8.0.0"
},
"host": {
"name": "rubys-iMac.local"
},
"agent": {
"id": "f177dd40-1249-487c-b9da-aaab03cfd05c",
"name": "rubys-iMac.local",
"type": "filebeat",
"version": "8.12.2",
"ephemeral_id": "8bf595e9-0376-42ec-be51-42a104527449"
},
"timestamp": 1709344429,
"ip": "[REDACTED-IP]"
}

输出中的日志事件现在将 IP 地址替换为 [REDACTED-IP]

注意:上面的脚本会将 message 中的所有 IP 地址都替换。

您现在可以停止 Filebeat 和 logify.sh 程序。

若要停止 bash 脚本,请获取进程 ID:

1
jobs -l | grep "logify"

输出:

1
[1]  + 6512 running    ./logify.sh

替换 kill 命令中的进程 ID:

1
kill -9 <6512>

成功编辑敏感字段后,您现在可以使用 Filebeat 从 Docker 容器收集日志,并将它们集中起来以进行进一步的分析和监控。

总结

在本文中,我们介绍了 Filebeat 的基本概念和工作原理。我们还演示了如何配置 Filebeat 以收集日志,并使用处理器对日志事件进行转换。我们还讨论了 Filebeat 的输入、输出和模块插件,以及如何使用条件语句和脚本处理器来编辑日志事件。

Filebeat 和 Logstash 均由 Elastic 开发,是 Elastic Stack 不可或缺的组件,它们都充当日志收集器,具有不同的特性和功能。Logstash 是 ELK Stack(Elasticsearch、Logstash、Kibana)的原始组件,旨在高效地从多个来源收集大量日志并将其分发到不同的目的地。

虽然 Logstash 越来越受欢迎,但由于资源密集型操作,尤其是在资源有限的系统上,它面临着挑战。为了解决这个问题,Elastic 推出了 Filebeat 作为 Beats 系列的一部分,为 Logstash 提供了轻量级的替代品。这一新增导致 ELK Stack 更名为 Elastic Stack,以更好地涵盖包括 Beats 在内的不断增长的工具套件。Filebeat 旨在补充 Logstash,但随着时间的推移演变成一个独立的日志收集器。

因此,鉴于这两个日志收集器的独特优势和功能,在这两个日志收集器之间做出决定可能是一项艰巨的任务。本文将比较 Filebeat 和 Logstash,探讨它们的优缺点,并提供有关何时选择其中一种的见解。

什么是 Filebeat?

Filebeat 是由 Elastic 开发的免费开源日志收集器,是 Elastic Stack 中 beats 系列的一部分。这套工具对于收集和传送各种类型的数据(如日志、指标和网络信息)至关重要。Filebeat 最初主要设计用于 Logstash,但随着时间的推移,随着 Elastic 对其日志处理能力的不断更新,Filebeat 已经超越了这一点。

此外,Filebeat 具有各种内置输入和输出,可满足不同的来源和目标。如果这些内置选项不符合特定要求,它还允许创建自定义插件。Filebeat 还包括内部模块,用于从广泛使用的工具(如 NGINX、Apache、系统日志和 MySQL)收集和解析日志。

Filebeat 因其轻量级设计、可靠性和稳健性而受到重视。它支持加密数据传输,并使用背压敏感协议,这在处理大量数据时很有用。此功能使其能够调整其数据传输速率,防止目的地过载。

什么是 Logstash?

Logstash 是由 Elastic 创建的免费开源数据管道工具。它旨在有效地收集、处理日志并将其发送到各种目标。Logstash 以其灵活性而闻名,提供多个输入、过滤器和输出,使其能够适应不同的日志处理需求。它擅长过滤、解析和转换日志,提供高级日志处理能力。

作为 Elastic Stack 的关键组件,Logstash 可与 Beats、Elasticsearch 和 Kibana 等其他工具无缝协作。它从各种来源提取数据并将其推送到 Elasticsearch,然后转发到 Kibana 进行分析和可视化。

Logstash 拥有超过 200 个插件,并提供了一个 API,用于创建满足特定用户需求的自定义插件。Logstash 的一个显著特点是其可靠性,它由一个持久队列支撑,该队列在传输日志事件时保存日志事件。在事件发送失败的情况下,Logstash 可以将其重新路由到另一个队列进行进一步检查和重新处理。

现在我们已经对 Filebeat 和 Logstash 有了基本的了解,让我们来比较一下这两个工具。我们的比较将重点关注以下关键标准:

特征 Filebeat Logstash
支持的平台 跨平台 跨平台
内存使用率/性能 轻量级 利用大量内存
生态系统和插件 少于 60 个插件 超过 200 个插件
日志解析 具有内置解析器和模块 具有更强大的解析器
事件路由 不支持 使用条件语句路由日志
传输 缓冲日志事件并具有持久性队列 缓冲日志事件并具有持久性队列
UI & UX 没有 UI,但可以与 Kibana 集成 没有 UI,但可以与 Kibana 集成

1. 支持的平台

Filebeat 是使用 Go 开发的,Go 是一种以创建高性能网络和基础设施程序而闻名的现代语言。其设计允许 Filebeat 以较低的内存占用量收集、处理和转发日志。此外,它的兼容性跨越各种平台,包括 Linux、Windows、MacOS,甚至容器化环境。

另一方面,Logstash 是使用 JRuby 构建的,JRuby 是 Java 中 Ruby 编程语言的高性能实现。要运行 Logstash,需要 Java 虚拟机 (JVM)。JVM 的跨平台兼容性确保 Logstash 可以在各种系统上运行,包括 Linux、Windows 和 MacOS。这使得 Logstash 在平台支持方面同样通用。

考虑到支持的平台,Filebeat 和 Logstash 都表现出很强的适应性,使其成为跨平台支持的绝佳选择。

2. 内存使用率/性能:Filebeat 获胜

Filebeat 旨在专注于轻量级效率,使其能够处理大量数据,同时保持最小的内存消耗。Filebeat 的单个实例通常使用不到 2MB 的内存,并使用不到 30% 的 CPU。如前所述,这种非凡的效率主要归功于它在 Go 中的发展。

此外,Filebeat 还具有负载平衡和故障转移功能,这对于确保一致的日志数据检索和转发至关重要,尤其是在高流量场景中。

相比之下,Logstash 需要的内存占用要高得多。根据官方文档,为了有效运行,它需要一个至少具有 2GB 内存的主机,建议的内存分配约为 4GB。这种需求的增加源于 Logstash 对 Java 虚拟机 (JVM) 及其复杂的日志处理功能的依赖,这自然会消耗更多的资源。虽然 Logstash 确实包含负载平衡和故障转移功能以实现可靠的日志处理,但其较高的资源要求使其不太适合内存和 CPU 使用率是关键限制的环境。

总之,对于性能和可扩展性至关重要且内存使用率至关重要的环境,Filebeat 是更有利的选择,因为它具有更低的内存占用和高效的资源利用率。

3. 生态系统和插件:Logstash 获胜

Filebeat 专注于轻量级和高效,仍然提供包含 60 多个插件的大量库。这些插件涵盖了各种输入和输出,包括 AWS S3、Kafka、Redis 和 File。这些插件的全面详细信息可以在文档中的详细输入和输出页面上找到。对于那些熟练使用 Go 的人来说,Filebeat 还允许创建自定义插件,为特定用例提供灵活性。此外,Filebeat 的模块简化了从 MySQL 或 Nginx 等流行工具读取日志的过程,增强了其易用性。

另一方面,Logstash 拥有丰富的插件生态系统,数量超过 200 个,分为输入、输出、过滤器和编解码器。这些插件大多是内置的,构成了 Logstash 高级日志处理能力的基础。广泛使用的插件包括 Grok 过滤器插件,它使用正则表达式解析日志,以及 GeoIP 过滤器,它为 IP 地址生成地理信息。

为 Logstash 创建自定义插件非常简单,官方文档提供了全面的指导。

由于其广泛的插件生态系统,Logstash 在需要高级数据处理的场景中成为明显的赢家。

4. 日志解析:Logstash 获胜

在日志解析方面,Filebeat 和 Logstash 都展示了高级日志解析功能,集成了能够处理结构化和非结构化日志的内置插件。

Filebeat 通过使用可用的处理器增强了其解析功能,能够解析 JSON 和 CSV 等标准格式。此外,内部模块使 Filebeat 能够解析来自 Nginx、MySQL 或 Apache 等来源的流行格式。

同样,Logstash 提供了强大的解析器和内置功能。Logstash 的与众不同之处在于包含 Grok,它使用正则表达式将日志事件与特定模式进行匹配。Grok 具有 200 多种预定义模式,能够解析来自不同来源(如 MongoDB、Postgres 和 AWS)的结构化和非结构化日志,并灵活地定义用于解析自定义日志格式的模式。

在日志解析方面,Filebeat 适合处理标准日志格式,而 Logstash 则凭借强大的解析功能脱颖而出。

5. 事件路由:Logstash 获胜

事件路由是一个过程,其中日志事件根据条件或每个日志事件中的内容定向到特定目标。这意味着您可以设置规则,根据每个日志包含的数据确定应将每个日志发送到何处。例如,您可以将日志收集器配置为将 HTTP 状态代码为 200 的所有日志事件发送到远程位置,而将状态代码为 400 的日志事件写入特定文件。

Filebeat 虽然在日志收集方面很高效,但并非设计为事件路由。它通常会将所有收集的日志转发到单个端点(通常是 Logstash),这与其原始设计目的一致。因此,在需要将日志从多个来源分发到不同目标的场景中,Filebeat 的功能会受到限制。

另一方面,Logstash 在事件路由方面表现出卓越的熟练程度。它支持使用条件语句(如 if-then-else )将日志事件路由到多个目标。这些条件允许 Logstash 根据特定条件评估日志事件并相应地指导它们。

鉴于事件路由在根据特定需求定制日志分发方面的重要性,Logstash 在这方面的表现优于 Filebeat。

6. 传输

在传输数据方面,Filebeat 和 Logstash 不相上下,它们都提供有效的输出插件,用于从各种来源收集日志并将其传送到多个目的地,包括云存储、Kafka、AWS 和本地文件。

这两种工具的一个关键特性是它们使用内存中队列来缓冲日志事件。此功能对于管理日志数据中的峰值和临时存储日志以防止数据输出过载至关重要。内存中队列在重新发送可能由于输出目标问题而无法传输的日志事件方面也发挥着作用。

在意外中断或过早退出时,存在丢失存储在这些内存中队列中的日志事件的风险。但是,Filebeat 和 Logstash 通过提供为持久性数据存储配置磁盘队列的选项来降低这种风险。这种额外的弹性层确保了工具可以无缝地恢复其操作,在突然关闭时从中断的地方继续运行。

需要注意的是,尽管 Logstash 和 Filebeat 具有持久队列,但偶尔会发生故障,尤其是在处理大量数据时。为了缓解这种情况,建议使用专用工具(如 Kafka)从日志收集器中卸载负载。该工具充当临时数据持久层,为日志管理基础结构增加了额外的弹性和稳定性。

考虑到它们在数据传输方面的能力,Filebeat 和 Logstash 都比对方具有明显的优势。两者都可以可靠地传输和持久化数据。

什么时候应该使用 Filebeat 或 Logstash ?

在 Filebeat 和 Logstash 之间做出决定取决于您的特定日志管理需求。

当您的主要需求是从各种来源收集日志并将其定向到 Elasticsearch 或云存储等单一目标时,Filebeat 是一个理想的选择。其他理想的情况是,您的日志需要基本解析,例如处理 Nginx 日志或管理不需要复杂处理的 syslog 输入。

另一方面,如果您正在处理需要复杂操作、扩充或过滤的半结构化或非结构化日志,那么 Logstash 更合适。当您的目标是将日志事件路由到多个目标时,它也是首选工具。

更有效的策略是结合使用 Filebeat 和 Logstash,特别是用于管理来自不同来源的日志。通常,由于 Filebeat 的效率,您可以使用 Filebeat 来收集日志。然后,收集的日志将转发到 Logstash 进行更复杂的处理。Logstash 可以配置为将日志分发到不同的目的地,或者将它们转发到 Elasticsearch 进行索引,然后转发到 Kibana 进行可视化。这种组合方法有效地利用了 Filebeat 和 Logstash 的优势。

有关更具可伸缩性的设置,请参阅下图:

实例

让我们看一个使用 Elastic Stack 的实际示例,并考虑 Filebeat 和 Logstash 如何协同工作来管理和处理日志。

假设您有以下格式的 Nginx 日志:

1
203.0.113.1 - - [14/Jan/2022:08:30:45 +0000] "GET /example-page HTTP/1.1" 200 1024 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.110 Safari/537.36"

以下是使用 Filebeat 和 Logstash 处理这些日志的方法:

  1. 使用 Filebeat:首先,使用 Filebeat 从 Nginx 收集日志。Filebeat 可以配置 Nginx 模块,该模块旨在解析 Nginx 日志。
  2. 转发到 Logstash:收集和初始解析后,配置 Filebeat 将这些日志发送到 Logstash。
  3. 在 Logstash 中处理:在 Logstash 中,您可以对日志进行高级操作。这包括提取特定字段、设置日期格式、编辑 IP 地址、重命名字段和其他所需的转换。
  4. 发送到 Elasticsearch 和 Kibana:处理完成后,Logstash 会将日志转发到 Elasticsearch 进行索引。索引后,可以在 Kibana 中对数据进行可视化和分析。

此工作流程演示了 Filebeat 和 Logstash 如何无缝协作。此设置对于处理需要简单和复杂处理和可视化过程的日志非常方便。

结论

在本文中,我们比较了 Filebeat 和 Logstash,展示了每种工具如何满足不同的需求。

Filebeat 是一个轻量级选项,非常适合资源有限且需要基本日志解析的环境。相反,Logstash 是为需要高级日志处理的场景量身定制的。

同时使用这两种工具也可以具有战略意义,以获得两全其美的优势。使用 Filebeat 收集日志并将其转发到 Logstash 进行高级转换提供了一种平衡的方法。

什么是分布式锁?

分布式锁是一种在分布式系统中用于控制并发访问的机制。在分布式系统中,多个客户端可能会同时对同一个资源进行访问,这可能导致数据不一致的问题。分布式锁的作用是确保同一时刻只有一个客户端能够对某个资源进行访问,从而避免数据不一致的问题。

分布式锁的实现通常依赖于一些具有分布式特性的技术,如 ZooKeeperRedis、数据库等。这些技术提供了在分布式环境中实现互斥访问的机制,使得多个客户端在竞争同一个资源时能够有序地进行访问。

通过使用分布式锁,可以确保分布式系统中的数据一致性和并发访问的有序性,从而提高系统的可靠性和稳定性。

Zookeeper 与 Redis 的分布式锁对比

ZooKeeperRedis 都是常用的实现分布式锁的工具,但它们在实现方式、特性、适用场景等方面有一些区别。以下是 ZooKeeper 分布式锁与 Redis 分布式锁的比较:

实现方式

  • ZooKeeper 分布式锁主要依赖于其临时节点和顺序节点的特性。客户端在 ZooKeeper 中创建临时顺序节点,并通过监听机制来实现锁的获取和释放。
  • Redis 分布式锁通常使用 SETNX(set if not exists) 命令来尝试设置一个 key,如果设置成功则获取到锁。也可以通过设置过期时间和轮询机制来防止死锁和提高锁的可靠性。

特性

  • ZooKeeper 分布式锁具有严格的顺序性和公平性,保证了锁的获取顺序与请求顺序一致,避免了饥饿问题。
  • Redis 分布式锁的性能通常更高,因为它是一个内存数据库,读写速度非常快。然而,它可能存在不公平性和死锁的风险,需要额外的机制来避免这些问题。

适用场景

  • ZooKeeper 分布式锁适用于对顺序性和公平性要求较高的场景,如分布式调度系统、分布式事务等。
  • Redis 分布式锁适用于对性能要求较高的场景,如缓存系统、高并发访问的系统等。Redis 的高性能使得它在处理大量并发请求时具有优势。

可靠性

  • ZooKeeper 分布式锁具有较高的可靠性,因为它依赖于 ZooKeeper 的高可用性和强一致性保证。即使部分节点宕机,ZooKeeper 也能保证锁的正确性和一致性。
  • Redis 分布式锁的可靠性取决于其实现方式和配置。在某些情况下,如 Redis 节点宕机或网络故障,可能会导致锁失效或死锁。因此,需要合理配置 Redis 和采取额外的措施来提高锁的可靠性。

综上所述,ZooKeeper 分布式锁和 Redis 分布式锁各有优缺点,具体选择哪种方式取决于实际业务场景和需求。在需要保证顺序性和公平性的场景下,ZooKeeper 分布式锁可能更适合;而在需要高性能和快速响应的场景下,Redis 分布式锁可能更合适。

为什么 Zookeeper 可以实现分布式锁

ZooKeeper 可以实现分布式锁,主要得益于其以下几个特性:

  1. 临时节点:ZooKeeper 支持创建临时节点,这些节点在创建它们的客户端会话结束时会被自动删除。这种特性使得 ZooKeeper 的节点具有生命周期,可以随着客户端的存活而存在,客户端断开连接后自动消失,非常适合作为锁的标识。
  2. 顺序节点:ZooKeeper 的另一个重要特性是支持创建顺序节点。在创建节点时,ZooKeeper 会在节点名称后自动添加一个自增的数字,确保节点在 ZNode 中的顺序性。这个特性使得 ZooKeeper 可以实现分布式锁中的公平锁,按照请求的顺序分配锁。
  3. Watcher 机制:ZooKeeper 还提供了 Watcher 机制,允许客户端在指定的节点上注册监听事件。当这些事件触发时,ZooKeeper 服务端会将事件通知到感兴趣的客户端,从而允许客户端做出相应的措施。这种机制使得 ZooKeeper 的分布式锁可以实现阻塞锁,即当客户端尝试获取已经被其他客户端持有的锁时,它可以等待锁被释放。

基于以上特性,ZooKeeper 可以实现分布式锁。具体实现流程如下:

  1. 客户端需要获取锁时,在 ZooKeeper 中创建一个临时顺序节点作为锁标识。
  2. 客户端判断自己创建的节点是否是所有临时顺序节点中序号最小的。如果是,则客户端获得锁;如果不是,则客户端监听序号比它小的那个节点。
  3. 当被监听的节点被删除时(即持有锁的客户端释放锁),监听者会收到通知,然后重新判断自己是否获得锁。
  4. 当客户端释放锁时,只需要将会话关闭,临时节点就会被自动删除,从而释放了锁。

因此,ZooKeeper 通过其临时节点、顺序节点和 Watcher 机制等特性,实现了分布式锁的功能。

使用 Golang 实现 Zookeeper 分布式锁

下面我们通过一个简单的例子来演示如何使用 Golang 实现 ZooKeeper 分布式锁。

创建 zookeeper 客户端连接

1
2
3
4
5
6
7
8
9
10
import "github.com/go-zookeeper/zk"

func client() *zk.Conn {
// 默认端口 2181
c, _, err := zk.Connect([]string{"192.168.2.168"}, time.Second)
if err != nil {
panic(err)
}
return c
}

创建父节点 - /lock

我们可以在获取锁之前,先创建一个父节点,用于存放锁节点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
type Lock struct {
c *zk.Conn
}

// 父节点 /lock 不存在的时候进行创建
func NewLock() *Lock {
c := client()
e, _, err := c.Exists("/lock")
if err != nil {
panic(err)
}
if !e {
_, err := c.Create("/lock", []byte(""), 0, zk.WorldACL(zk.PermAll))
if err != nil {
panic(err)
}
}

return &Lock{c: c}
}

获取锁

在 Zookeeper 分布式锁实现中,获取锁的过程实际上就是创建一个临时顺序节点,并判断自己是否是所有临时顺序节点中序号最小的。

获取锁的关键是:

  1. 创建的需要是临时节点
  2. 创建的需要是顺序节点

具体创建代码如下:

1
p, err := l.c.Create("/lock/lock", []byte(""), zk.FlagEphemeral|zk.FlagSequence, zk.WorldACL(zk.PermAll))

其中 zk.FlagEphemeral 表示创建的是临时节点,zk.FlagSequence 表示创建的是顺序节点。

判断当前创建的节点是否是最小节点

具体步骤如下:

  1. 通过 l.c.Children("/lock") 获取 /lock 下的所有子节点
  2. 对所有子节点进行排序
  3. 判断当前创建的节点是否是最小节点
  4. 如果是最小节点,则获取到锁,函数调用返回;如果不是,则监听前一个节点(这会导致函数调用阻塞)
1
2
3
4
5
6
7
8
9
10
11
12
13
childs, _, err := l.c.Children("/lock")
if err != nil {
return "", err
}

// childs 是无序的,所以需要排序,以便找到当前节点的前一个节点,然后监听前一个节点
sort.Strings(childs)

// 成功获取到锁
p1 := strings.Replace(p, "/lock/", "", 1)
if childs[0] == p1 {
return p, nil
}

不是最小节点,监听前一个节点

具体步骤如下:

  1. 通过 sort.SearchStrings 找到当前节点在所有子节点中的位置
  2. 调用 l.c.ExistsW 判断前一个节点是否依然存在(锁有可能在调用 ExistsW 之前已经被释放了),如果不存在则获取到锁
  3. 如果前一个节点依然存在,则阻塞等待前一个节点被删除
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 监听锁,等待锁释放
// 也就是说,如果当前节点不是最小的节点,那么就监听前一个节点
// 一旦前一个节点被删除,那么就可以获取到锁
index := sort.SearchStrings(childs, p1)
b, _, ev, err := l.c.ExistsW("/lock/" + childs[index-1])
if err != nil {
return "", err
}

// 在调用 ExistsW 之后,前一个节点已经被删除
if !b {
return p, nil
}

// 等待前一个节点被删除
<-ev

return p, nil

在调用 ExistsW 的时候,如果前一个节点已经被删除,那么 ExistsW 会立即返回 false,否则我们可以通过 ExistsW 返回的第三个参数 ev 来等待前一个节点被删除。

<-ev 处,我们通过 <-ev 来等待前一个节点被删除,一旦前一个节点被删除,ev 会收到一个事件,这个时候我们就可以获取到锁了。

释放锁

如果调用 Lock 可以成功获取到锁,我们会返回当前创建的节点的路径,我们可以通过这个路径来释放锁。

1
2
3
func (l *Lock) Unlock(p string) error {
return l.c.Delete(p, -1)
}

完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
package main

import (
"github.com/go-zookeeper/zk"
"sort"
"strings"
"time"
)

func client() *zk.Conn {
c, _, err := zk.Connect([]string{"192.168.2.168"}, time.Second) //*10)
if err != nil {
panic(err)
}
return c
}

type Lock struct {
c *zk.Conn
}

func NewLock() *Lock {
c := client()
e, _, err := c.Exists("/lock")
if err != nil {
panic(err)
}
if !e {
_, err := c.Create("/lock", []byte(""), 0, zk.WorldACL(zk.PermAll))
if err != nil {
panic(err)
}
}

return &Lock{c: c}
}

func (l *Lock) Lock() (string, error) {
p, err := l.c.Create("/lock/lock", []byte(""), zk.FlagEphemeral|zk.FlagSequence, zk.WorldACL(zk.PermAll))
if err != nil {
return "", err
}
childs, _, err := l.c.Children("/lock")
if err != nil {
return "", err
}

// childs 是无序的,所以需要排序,以便找到当前节点的前一个节点,然后监听前一个节点
sort.Strings(childs)

// 成功获取到锁
p1 := strings.Replace(p, "/lock/", "", 1)
if childs[0] == p1 {
return p, nil
}

// 监听锁,等待锁释放
// 也就是说,如果当前节点不是最小的节点,那么就监听前一个节点
// 一旦前一个节点被删除,那么就可以获取到锁
index := sort.SearchStrings(childs, p1)
b, _, ev, err := l.c.ExistsW("/lock/" + childs[index-1])
if err != nil {
return "", err
}

// 在调用 ExistsW 之后,前一个节点已经被删除
if !b {
return p, nil
}

// 等待前一个节点被删除
<-ev

return p, nil
}

func (l *Lock) Unlock(p string) error {
return l.c.Delete(p, -1)
}

测试代码

下面这个例子模拟了分布式的 counter 操作,我们通过 ZooKeeper 分布式锁来保证 counter 的原子性。

当然这个例子只是为了说明 ZooKeeper 分布式锁的使用,实际上下面的功能通过 redis 自身提供的 incr 就可以实现,不需要这么复杂。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
package main

import (
"context"
"fmt"
"github.com/redis/go-redis/v9"
"sync"
)

func main() {
var count = 1000
var wg sync.WaitGroup
wg.Add(count)

l := NewLock()
// 创建 redis 客户端连接
redisClient = redis.NewClient(&redis.Options{
Addr: "192.168.2.168:6379",
Password: "", // no password set
DB: 0, // use default DB
})

for i := 0; i < count; i++ {
go func(i1 int) {
defer wg.Done()

// 获取 Zookeeper 分布式锁
p, err := l.Lock()
if err != nil {
return
}
// 成功获取到了分布式锁:
// 1. 从 redis 获取 zk_counter 的值
// 2. 然后对 zk_counter 进行 +1 操作
// 3. 最后将 zk_counter 的值写回 redis
cmd := redisClient.Get(context.Background(), "zk_counter")
i2, _ := cmd.Int()
i2++
redisClient.Set(context.Background(), "zk_counter", i2, 0)
// 释放分布式锁
err = l.Unlock(p)
if err != nil {
println(fmt.Errorf("unlock error: %v", err))
return
}
}(i)
}

wg.Wait()

l.c.Close()
}

我们需要将测试程序放到不同的机器上运行,这样才能模拟分布式环境。

总结

最后,再来回顾一下本文内容:

  1. sync.Mutex 这种锁只能保证单进程内的并发安全,无法保证分布式环境下的并发安全。
  2. 使用 ZookeeperRedis 都能实现分布式锁,但是 Zookeeper 可以保证顺序性和公平性,而 Redis 可以保证高性能。
  3. Zookeeper 通过其临时节点、顺序节点和 Watcher 机制等特性,实现了分布式锁的功能。