0%

为什么写单元测试?

关于测试,有一张很经典的图,如下:

说明:

测试类型 成本 速度 频率
E2E 测试
集成测试
单元测试

也就是说,单元测试是最快、最便宜的测试方式。这不难理解,单元测试往往用来验证代码的最小单元,比如一个函数、一个方法,这样的测试我们一个命令就能跑完整个项目的单元测试,而且速度还很快,所以单元测试是我们最常用的测试方式。 而 E2E 测试和集成测试,往往需要启动整个项目,然后需要真实用户进行手动操作,这样的测试成本高,速度慢,所以我们往往不会频繁地运行这样的测试。只有在项目的最后阶段,我们才会运行这样的测试。而单元测试,我们可以在开发的过程中,随时随地地运行,这样我们就能及时发现问题,及时解决问题。

一个基本的 Go 单元测试

Go 从一开始就支持单元测试,Go 的测试代码和普通代码一般是放在同一个包下的,只是测试代码的文件名是 _test.go 结尾的。比如我们有一个 add.go 文件,那么我们的测试文件就是 add_test.go

1
2
3
4
5
6
// add.go
package main

func Add(a int, b int) int {
return a + b
}
1
2
3
4
5
6
7
8
9
10
// add_test.go
package main

import "testing"

func TestAdd(t *testing.T) {
if Add(1, 2) != 3 {
t.Error("1 + 2 did not equal 3")
}
}

我们可以通过 go test 命令来运行测试:

1
go test

输出:

1
2
PASS
ok go-test 0.004s

注意:

  1. 测试函数的命名必须以 Test 开头,后面的名字必须以大写字母开头,比如 TestAdd
  2. 测试函数的参数是 *testing.T 类型。
  3. go test 加上 -v 参数可以输出详细的测试信息,加上 -cover 参数可以输出测试覆盖率。

go test 命令的参数详解

基本参数

  • -v:输出详细的测试信息。比如输出每个测试用例的名称。
  • -run regexp:只运行匹配正则表达式的测试用例。如 -run TestAdd
  • -bench regexp:运行匹配正则表达式的基准测试用例。
  • -benchtime t:设置基准测试的时间,默认是 1s,也就是让基准测试运行 1s。也可以指定基准测试的执行次数,格式如 -benchtime 100x,表示基准测试执行 100 次。
  • -count n:运行每个测试函数的次数,默认是 1 次。如果指定了 -cpu 参数,那么每个测试函数会运行 n * GOMAXPROCS 次。但是示例测试只会运行一次,该参数对模糊测试无效。
  • -cover:输出测试覆盖率。
  • -covermode set,count,atomic:设置测试覆盖率的模式。默认是 set,也就是记录哪些语句被执行过。
  • -coverpkg pkg1,pkg2,pkg3:用于指定哪些包应该生成覆盖率信息。这个参数允许你指定一个或多个包的模式,以便在运行测试时生成这些包的覆盖率信息。
  • -cpu 1,2,4:设置并行测试的 CPU 数量。默认是 GOMAXPROCS。这个参数对模糊测试无效。
  • -failfast:一旦某个测试函数失败,就停止运行其他的测试函数了。默认情况下,一个测试函数失败了,其他的测试函数还会继续运行。
  • -fullpath:测试失败的时候,输出完整的文件路径。
  • -fuzz regexp:运行模糊测试。
  • -fuzztime t:设置模糊测试的时间,默认是 1s。又或者我们可以指定模糊测试的执行次数,格式如 -fuzztime 100x,表示模糊测试执行 100 次。
  • -fuzzminimizetime t:设置模糊测试的最小化时间,默认是 1s。又或者我们可以指定模糊测试的最小化执行次数,格式如 -fuzzminimizetime 100x,表示模糊测试最小化执行 100 次。在模糊测试中,当发现一个失败的案例后,系统会尝试最小化这个失败案例,以找到导致失败的最小输入。
  • -json:以 json 格式输出
  • -list regexp:列出所有匹配正则表达式的测试用例名称。
  • -parallel n:设置并行测试的数量。默认是 GOMAXPROCS。
  • -run regexp:只运行匹配正则表达式的测试用例。
  • -short:缩短长时间运行的测试的测试时间。默认关闭。
  • -shuffle off,on,N:打乱测试用例的执行顺序。默认是 off,也就是不打乱,这会由上到下执行测试函数。
  • -skip regexp:跳过匹配正则表达式的测试用例。
  • -timeout t:设置测试的超时时间,默认是 10m,也就是 10 分钟。如果测试函数在超时时间内没有执行完,那么测试会 panic
  • -vet list:设置 go vet 的检查列表。默认是 all,也就是检查所有的。

性能相关

  • -benchmem:输出基准测试的内存分配情况(也就是 go test -bench . 的时候可以显示每次基准测试分配的内存)。
  • -blockprofile block.out:输出阻塞事件的分析数据。
  • -blockprofilerate n:设置阻塞事件的采样频率。默认是 1(单位纳秒)。如果没有设置采样频率,那么就会记录所有的阻塞事件。
  • -coverprofile coverage.out:输出测试覆盖率到文件 coverage.out
  • -cpuprofile cpu.out:输出 CPU 性能分析信息到文件 cpu.out
  • -memprofile mem.out:输出内存分析信息到文件 mem.out
  • -memprofilerate n:设置内存分析的采样频率。
  • -mutexprofile mutex.out:输出互斥锁事件的分析数据。
  • -mutexprofilefraction n:设置互斥锁事件的采样频率。
  • -outputdir directory:设置输出文件的目录。
  • -trace trace.out:输出跟踪信息到文件 trace.out

子测试

使用场景:当我们有多个测试用例的时候,我们可以使用子测试来组织测试代码,使得测试代码更具组织性和可读性。

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
package main

import (
"testing"
)

func TestAdd2(t *testing.T) {
cases := []struct {
name string
a, b, sum int
}{
{"case1", 1, 2, 3},
{"case2", 2, 3, 5},
{"case3", 3, 4, 7},
}

for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
sum := Add(c.a, c.b)
if sum != c.sum {
t.Errorf("Sum was incorrect, got: %d, want: %d.", sum, c.sum)
}
})
}
}

输出:

1
2
3
4
5
6
7
8
9
10
11
➜  go-test go test   
--- FAIL: TestAdd2 (0.00s)
--- FAIL: TestAdd2/case1 (0.00s)
add_test.go:21: Sum was incorrect, got: 4, want: 3.
--- FAIL: TestAdd2/case2 (0.00s)
add_test.go:21: Sum was incorrect, got: 6, want: 5.
--- FAIL: TestAdd2/case3 (0.00s)
add_test.go:21: Sum was incorrect, got: 8, want: 7.
FAIL
exit status 1
FAIL go-test 0.004s

我们可以看到,上面的输出中,失败的单元测试带有每个子测试的名称,这样我们就能很方便地知道是哪个测试用例失败了。

setup 和 teardown

在一般的单元测试框架中,都会提供 setupteardown 的功能,setup 用来初始化测试环境,teardown 用来清理测试环境。

方法一:通过 Go 的 TestMain 方法

很遗憾的是,Go 的测试框架并没有直接提供这样的功能,但是我们可以通过 Go 的特性来实现这样的功能。

在 Go 的测试文件中,如果有 TestMain 函数,那么执行 go test 的时候会执行这个函数,而不会执行其他测试函数了,其他的测试函数需要通过 m.Run 来执行,如下面这样:

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
package main

import (
"fmt"
"os"
"testing"
)

func setup() {
fmt.Println("setup")
}

func teardown() {
fmt.Println("teardown")
}

func TestAdd(t *testing.T) {
if Add(1, 2) != 3 {
t.Error("1 + 2 != 3")
}
}

func TestMain(m *testing.M) {
setup()
code := m.Run()
teardown()
os.Exit(code)
}

在这个例子中,我们在 TestMain 函数中调用了 setupteardown 函数,这样我们就实现了 setupteardown 的功能。

方法二:使用 testify 框架

我们也可以使用 Go 中的第三方测试框架 testify 来实现 setupteardown 的功能(使用 testify 中的 suite 功能)。

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
package main

import (
"fmt"
"github.com/stretchr/testify/suite"
"testing"
)

type AddSuite struct {
suite.Suite
}

func (suite *AddSuite) SetupTest() {
fmt.Println("Before test")
}

func (suite *AddSuite) TearDownTest() {
fmt.Println("After test")
}

func (suite *AddSuite) TestAdd() {
suite.Equal(Add(1, 2), 3)
}

func TestAddSuite(t *testing.T) {
suite.Run(t, new(AddSuite))
}

go test 输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
➜  go-test go test
Before test
After test
--- FAIL: TestAddSuite (0.00s)
--- FAIL: TestAddSuite/TestAdd (0.00s)
add_test.go:22:
Error Trace: /Users/ruby/GolandProjects/go-test/add_test.go:22
Error: Not equal:
expected: 4
actual : 3
Test: TestAddSuite/TestAdd
FAIL
exit status 1
FAIL go-test 0.006s

我们可以看到,这里也同样执行了 SetupTestTearDownTest 函数。

testing.T 可用的方法

最后,我们可以直接从 testing.T 提供的 API 来学习如何编写测试代码。

基本日志输出

  • t.Log(args ...any):打印信息,不会标记测试函数为失败。
  • t.Logf(format string, args ...any):打印格式化的信息,不会标记测试函数为失败。

可能有读者会有疑问,输出不用 fmt 而用 t.Log,这是因为:

  • t.Logt.Logf 打印的信息默认不会显示,只有在测试函数失败的时候才会显示。又或者我们使用 -v 参数的时候才显示,这让我们的测试输出更加清晰,只有必要的时候日志才会显示。
  • t.Logt.Logf 打印的时候,还会显示是哪一行代码打印的信息,这样我们就能很方便地定位问题。
  • fmt.Println 打印的信息一定会显示在控制台上,就算我们的测试函数通过了,也会显示,这样会让控制台的输出很乱。

例子:

1
2
3
4
5
6
// add.go
package main

func Add(a int, b int) int {
return a + b
}
1
2
3
4
5
6
7
8
9
10
11
12
13
// add_test.go
package main

import (
"testing"
)

func TestAdd(t *testing.T) {
t.Log("TestAdd is running")
if Add(1, 2) != 3 {
t.Error("Expected 3")
}
}

输出:

1
2
3
➜  go-test go test
PASS
ok go-test 0.004s

我们修改一下 Add 函数,让测试失败,再次运行,输出如下:

1
2
3
4
5
6
7
➜  go-test go test
--- FAIL: TestAdd (0.00s)
add_test.go:8: TestAdd is running
add_test.go:10: Expected 3
FAIL
exit status 1
FAIL go-test 0.004s

我们可以发现,在测试成功的时候,t.Log 打印的日志并没有显示,只有在测试失败的时候才会显示。

如果我们想要在测试成功的时候也显示日志,可以使用 -v 参数:go test -v

标记测试函数为失败

  • t.Fail():标记测试函数为失败,但是测试函数后续代码会继续执行。(让你在测试函数中标记失败情况,并收集所有失败的情况,而不是在遇到第一个失败时就立即停止测试函数的执行。)
  • t.FailNow():标记测试函数为失败,并立即返回,后续代码不会执行(通过调用 runtime.Goexit,但是 defer 语句还是会被执行)。
  • t.Failed():返回测试函数是否失败。
  • t.Fatal(args ...any):标记测试函数为失败,并输出信息,然后立即返回。等价于 t.Log + t.FailNow
  • t.Fatalf(format string, args ...any):标记测试函数为失败,并输出格式化的信息,然后立即返回。等价于 t.Logf + t.FailNow

如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package main

import (
"testing"
)

func TestAdd(t *testing.T) {
if Add(1, 2) != 3 {
t.Fatal("Expected 3")
}

if Add(2, 3) != 5 {
t.Fatal("Expected 4")
}
}

这里只会输出第一个失败的测试用例,因为 t.Fatal 会立即返回。

标记测试函数为失败并输出信息

  • t.Error(args ...any):标记测试函数为失败,并打印错误信息。等价于 t.Log + t.Fail
  • t.Errorf(format string, args ...any):标记测试函数为失败,并打印格式化的错误信息。等价于 t.Logf + t.Fail

这两个方法会让测试函数立即返回,不会继续执行后面的代码。

测试超时控制

  • t.Deadline():返回测试函数的截止时间(这是通过 go test -timeout 60s 这种形式指定的超时时间)。

注意:如果我们通过 -timeout 指定了超时时间,当测试函数超时的时候,测试会 panic

跳过测试函数中后续代码

作用:可以帮助测试代码在特定条件下灵活地跳过测试,避免不必要的测试执行,同时提供清晰的信息说明为什么跳过测试。

  • t.Skip(args ...any):跳过测试函数中后续代码,标记测试函数为跳过。等同于 t.Log + t.SkipNow
  • t.Skipf(format string, args ...any):跳过测试函数中后续代码,并打印格式化的跳过信息。等同于 t.Logf + t.SkipNow
  • t.SkipNow():跳过测试函数中后续代码,标记测试函数为跳过。这个方法不会输出内容,前面两个会输出一些信息
  • t.Skipped():返回测试函数是否被跳过。

测试清理函数

  • t.Cleanup(f func()):注册一个函数,这个函数会在测试函数结束后执行。这个函数会在测试函数结束后执行,不管测试函数是否失败,都会执行。(可以注册多个,执行顺序类似 defer,后注册的先执行)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package main

import (
"fmt"
"testing"
)

func TestAdd(t *testing.T) {
t.Cleanup(func() {
fmt.Println("cleanup 0")
})
t.Cleanup(func() {
fmt.Println("cleanup 1")
})
}

输出:

1
2
3
4
5
➜  go-test go test
cleanup 1
cleanup 0
PASS
ok go-test 0.004s

使用临时文件夹

  • t.TempDir():返回一个临时文件夹,这个文件夹会在测试函数结束后被删除。可以调用多次,每次都是不同的文件夹。
1
2
3
4
5
6
7
8
9
10
11
package main

import (
"fmt"
"testing"
)

func TestAdd(t *testing.T) {
fmt.Println(t.TempDir())
fmt.Println(t.TempDir())
}

输出:

1
2
3
4
5
➜  go-test go test
/var/folders/dm/r_hly4w5557b000jh31_43gh0000gp/T/TestAdd4259799402/001
/var/folders/dm/r_hly4w5557b000jh31_43gh0000gp/T/TestAdd4259799402/002
PASS
ok go-test 0.004s

临时的环境变量

  • t.Setenv(key, value string):设置一个临时的环境变量,这个环境变量会在测试函数结束后被还原。

在单元测试中,使用 Setenv 函数可以模拟不同的环境变量设置,从而测试代码在不同环境下的行为。例如,你可以在测试中设置特定的环境变量值,然后运行被测试的代码,以验证代码在这些环境变量设置下的正确性。

子测试

可以将一个大的测试函数拆分成多个子测试,使得测试代码更具组织性和可读性。

  • t.Run(name string, f func(t *testing.T)):创建一个子测试,这个子测试会在父测试中执行。子测试可以有自己的测试函数,也可以有自己的子测试。

获取当前测试的名称

  • t.Name():返回当前测试的名称(也就是测试函数名)。

t.Helper()

  • t.Helper():标记当前测试函数是一个辅助函数,这样会让测试输出更加清晰,只有真正的测试函数会被标记为失败。

例子:

1
2
3
4
5
6
// add.go
package main

func Add(a int, b int) int {
return a + b + 1
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// add_test.go
package main

import (
"testing"
)

func test(a, b, sum int, t *testing.T) {
result := Add(a, b)
if result != sum {
t.Errorf("Add(%d, %d) = %d; want %d", a, b, result, sum)
}
}

func TestAdd(t *testing.T) {
test(1, 2, 3, t)
test(2, 3, 5, t)
}

输出如下:

1
2
3
4
5
6
7
8
➜  go-test go test -v
=== RUN TestAdd
add_test.go:10: Add(1, 2) = 4; want 3
add_test.go:10: Add(2, 3) = 6; want 5
--- FAIL: TestAdd (0.00s)
FAIL
exit status 1
FAIL go-test 0.004s

我们可以看到,两个测试失败输出的报错行都是 test 函数里面的 t.Errorf,而不是 test 函数的调用者 TestAdd,也就是说,在这种情况下我们不好知道是 test(1, 2, 3, t) 还是 test(2, 3, 5, t) 失败了(当然我们这里还是挺明显的,只是举个例子),这时我们可以使用 t.Helper()

1
2
3
4
5
6
7
func test(a, b, sum int, t *testing.T) {
t.Helper() // 在助手函数中加上这一行
result := Add(a, b)
if result != sum {
t.Errorf("Add(%d, %d) = %d; want %d", a, b, result, sum)
}
}

输出如下:

1
2
3
4
5
6
7
8
➜  go-test go test -v
=== RUN TestAdd
add_test.go:16: Add(1, 2) = 4; want 3
add_test.go:17: Add(2, 3) = 6; want 5
--- FAIL: TestAdd (0.00s)
FAIL
exit status 1
FAIL go-test 0.004s

这个时候,我们就很容易知道是哪一个测试用例失败了,这对于我们需要封装 helper 函数的时候很有用。

如果我们想使用 pm2 来管理多个应用,可以通过配置文件来实现。

初始化配置文件

1
pm2 init simple

这个命令会在当前目录下生成一个 ecosystem.config.js 文件,这个文件就是 pm2 的配置文件。

文件内容大概如下:

1
2
3
4
5
6
module.exports = {
apps : [{
name : "app1",
script : "./app.js"
}]
}

根据配置文件来管理应用

  • pm2 reload 重新加载应用程序,但不会停止正在运行的应用程序实例。它会重新启动应用程序,但在新实例准备就绪之前,旧实例将继续提供服务。这意味着在应用程序重新加载期间,可能会有短暂的服务中断。
  • pm2 restart 停止当前运行的应用程序实例,并启动一个新的实例来替代它。这意味着在重启过程中会有一个短暂的服务中断,因为在新实例启动之前,旧实例将停止提供服务。

启动所有应用

1
pm2 start ecosystem.config.js

停止所有应用

1
pm2 stop ecosystem.config.js

重启所有应用

1
pm2 restart ecosystem.config.js

重载所有应用

1
pm2 reload ecosystem.config.js

删除所有应用

1
pm2 delete ecosystem.config.js

启动特定应用

1
pm2 start ecosystem.config.js --only app1

指定多个:

1
pm2 start ecosystem.config.js --only app1,app2

不同的环境(env)

你可以通过 env_* 来指定不同的环境,比如:

1
2
3
4
5
6
7
8
9
10
11
12
module.exports = {
apps : [{
name : "app1",
script : "./app.js",
env_production: {
NODE_ENV: "production"
},
env_development: {
NODE_ENV: "development"
}
}]
}

然后启动时指定环境:

1
2
pm2 start ecosystem.config.js --env production
pm2 start ecosystem.config.js --env development

配置项详解

配置项 类型 示例 说明
name string "app1" 应用名称
script string "./app.js" 启动脚本
cwd string "/path/to/app" 启动应用程序的目录
args string "-a 13 -b 12" 传递给脚本的参数
interpreter string "/usr/bin/python" 解释器
interpreter_args string "-u" 传递给解释器的参数

高级配置项

  • exec_mode 说明:
    • 在 fork 模式下,每个应用程序实例都在单独的进程中运行。这意味着每个应用程序实例都有自己的内存空间和资源。
    • 在 cluster 模式下,应用程序会以集群的方式运行,使用 Node.js 的 cluster 模块来创建多个子进程。这些子进程共享同一个端口,可以充分利用多核处理器的优势。
配置项 类型 示例 说明
instances number 4 启动多少个实例
exec_mode string "cluster" 执行模式,默认 fork
watch boolean true 是否监视文件变化,文件变动的时候重启进程
ignore_watch array ["node_modules", "logs"] 忽略监视的文件或目录
max_memory_restart string "1G" 当内存使用超过多少时重启进程
env object { NODE_ENV: "development" } 环境变量
env_production object { NODE_ENV: "production" } 生产环境的环境变量,当指定 --env 参数的时候生效
appendEnvToName boolean true 是否将环境变量追加到应用名称后面

日志配置

配置项 类型 示例 说明
log_date_format string "YYYY-MM-DD HH:mm Z" 日志日期格式
error_file string "/path/to/error.log" 错误日志文件,默认 $HOME/.pm2/logs/<app name>-error-<pid>.log
out_file string "/path/to/out.log" 标准输出日志文件,默认 $HOME/.pm2/logs/<app name>-out-<pid>.log
log_file string "/path/to/combined.log" 组合日志文件(标准输出+错误输出)
pid_file string "/path/to/pid" pid 文件,默认 $HOME/.pm2/pids/<app name>-<pid>.pid
combine_logs boolean true 日志文件名不添加 pid 后缀
time boolean true 在日志中添加时间戳

控制流程

配置项 类型 示例 说明
min_uptime number 1000 应用程序在多少毫秒内被认为是启动成功的
listen_timeout number 8000 应用程序启动后多少毫秒没有监听端口就认为启动失败
kill_timeout number 1600 pm2 发送 kill 信号给应用程序后多少毫秒后强制杀死进程
shutdown_with_message boolean false 是否在关闭进程时发送消息给进程
wait_ready boolean false
max_restarts number 10 启动失败之后,最大重试次数
autorestart boolean true 是否自动重启
cron_restart string "0 0 * * *" 定时重启,参考 cron
vizion boolean false 是否启用版本控制
post_update list ["npm install"] 更新后执行的命令
force boolean false 强制启动应用程序

部署

配置项 类型 示例 说明
key string "/path/to/key" ssh 私钥
user string ssh 用户名
host string ssh 主机
ssh_options object { "StrictHostKeyChecking": "no" } ssh 选项
ref string "origin/master" git 分支
repo string git 仓库
path string "/path/to/deploy" 部署路径
pre-setup string "echo 'commands or script to run before setup on target host'" 部署前执行的命令
post-setup string "echo 'commands or script to run after setup on target host'" 部署后执行的命令
pre-deploy-local string "echo 'commands or script to run on local machine before the setup process starts'" 本地部署前执行的命令
post-deploy string "echo 'commands or script to run on target host after the deploy process finishes'" 部署后执行的命令

在之前的《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 的输入、输出和模块插件,以及如何使用条件语句和脚本处理器来编辑日志事件。