1、前言

​ 通过我们的标题就可以看出,我们已经不止一次优化过此流程了,从我们优化之后,一直较为稳定的运行了很长一段时间了,为什么要再次优化呢?其根本原因是由于客户有新的需求了,旧的CI/CD流程在新需求面前确实存在bug。

2、出现问题

​ 问题出现的很简单:跑CI/CD的过程中,异常退出了,如图1所示:

image-20240111112105935

说实话我刚拿到这个报错的时候,一脸懵逼……报错很简单,执行pushimage的时候,找不到命令。很简单的报错,但是这个流程已经跑了好几年了,几乎是没碰到过这个报错。这个命令其实是通过/tmp/gitci-v3.sh这个脚本source进来的,只要这个脚本存在,不可能会出现没有这个命令的问题。根据上面的截图,可以得出两个疑点:1、可能是在source之前的 ossutil cp 命令存在问题,导致最终此脚本没有执行source操作;2、在执行source的过程中,此脚本的内容发生了变化,可能是空的或者是残缺的,导致最终没有注册pushimage,阿里云年前的重大故障也是类似的,生成的RAM白名单格式是残缺的,又没有校验。导致最终代码在执行的过程中出现故障。

3、尝试伪代码验证修复bug

​ 我们猜测归猜测,还是要考虑客户的实际情况结合起来做判断。客户的新需求其实也简单,在执行CI/CD的过程中,存在大量的同时间并发执行CI/CD。既然是并发执行,跟我们之前的猜测结合起来看:ossutil cp在并发执行的时候,可能存在目标文件被置为空的情况。我在之前接触到这块代码的时候,就怀疑ossutil cp在并发的情况下,可能存在此问题,结果之前并发的量不高,估计是出现的次数太少了。之前应该是开发手动再触发一下就好了。我们根据上下文以及经验判断出了疑点,那就要考虑怎么解决此bug。既然ossutil cp不是并发安全的,我们就再包一层,使用代码达到并发安全的目的。我们计划先ossutil cp到临时文件后mv回gitci-v3.sh脚本来修复此bug,我们首先通过伪代码进行简单的修复验证:通过脚本test-mv.sh进行循环mv替换指定脚本test.sh,然后通过脚本test-source.sh并发source test.sh脚本,测试看生成临时文件后再mv回test.sh脚本,并发source的时候是否正常。

test-mv.sh

#!/bin/bash

# testing mult mv file and source

while true
do
    str=$(openssl rand -hex 4)
    echo $str
    testfile=test.sh-$str
    
    echo 'function test() {' >> $testfile
    echo 'echo 'test-$str'' >> $testfile
    echo '}' >> $testfile

    sleep 0.1
    mv $testfile test.sh

done

test-source.sh

#!/bin/bash

# testing mult mv file and source

while true
do
    source test.sh
    test

    sleep 1
done

我们通过伪代码的执行结果可以得出结论:通过保存临时文件再执行mv替换的思路是并发安全的,不会出现异常退出。结果如图2:

4、编写代码进行修复

先编写传参-f 和 -l,分别对应OSS上的路径和本地路径,然后通过checkLog 配置日志对象。

func main() {
	ossFile := flag.String("f", "oss://fc-devops/git-sh/gitci-v3.sh", "check oss file oss://fc-devops/git-sh/gitci-v3.sh")
	localFile := flag.String("l", "/tmp/gitci-v3.sh", "sync to local file /tmp/gitci-v3.sh")
	flag.Parse()

	checkLog := tools.LogProcessing(cfgInfo["checkLog"])
}

cfgInfo是一个全局变量,里面存放了连接Redis的配置文件、日志文件路径、ossutil的路径

var cfgInfo = map[string]string{
	"configFile":      "./config/config.ini", // Configuration file.
	"checkLog": "./logs/check.log", // check log file.
	"ossutilCommand":  "./ossutil64",   // Ossutil command.
}

通过ini模块加载配置文件中的Redis连接配置,并进行测试连接Redis

// 在工作目录下加载配置文件
	cfg, err := ini.Load(cfgInfo["configFile"])
	if err != nil {
		checkLog.Errorf("Fail to read config file: %v", err)
		os.Exit(2)
	}

	redisHost := cfg.Section("").Key("redisHost").String()
	redisPassword := cfg.Section("").Key("redisPassword").String()
	redisPort := cfg.Section("").Key("redisPort").String()

	redisAddr := fmt.Sprintf("%v:%v", redisHost, redisPort)
	client := redis.NewClient(&redis.Options{
		Addr: redisAddr,
		Password: redisPassword,
		DB: 0,
	})

	ctx := context.Background()
	_, err = client.Ping(ctx).Result()
	if err != nil {
		checkLog.Errorf("Fail to connect redis: %v", err)
		os.Exit(3)
	}

我们以每台ECS为一个唯一全局锁,所以我们要获取ECS的IP地址:

func GetInterfaceIpv4Addr(interfaceName string) (addr string, err error) {
	var (
		ief      *net.Interface
		addrs    []net.Addr
		ipv4Addr net.IP
	)
	if ief, err = net.InterfaceByName(interfaceName); err != nil { // get interface
		return
	}
	if addrs, err = ief.Addrs(); err != nil { // get addresses
		return
	}
	for _, addr := range addrs { // get ipv4 address
		if ipv4Addr = addr.(*net.IPNet).IP.To4(); ipv4Addr != nil {
			break
		}
	}
	if ipv4Addr == nil {
		return "", errors.New(fmt.Sprintf("interface %s don't have an ipv4 address\n", interfaceName))
	}
	return ipv4Addr.String(), nil
}

然后把IP地址和其他字符串做拼接,把拼接后的key写入到Redis中:

redisKey := fmt.Sprintf("check-%v", addrs)

	// fmt.Println(redisKey)

	res, err := client.SetNX(ctx, redisKey, 1, 30*time.Second).Result()
	if err != nil {
		checkLog.Errorf("set key ttl failed! ERROR:%v\n", err)
		os.Exit(5)
	}

我们这里使用Redis全局锁,只是为了在并发的时候,只有一个进程会去更新gitci.sh这个脚本,就算是并发去更新也无碍,但是除了拿到锁的其他的进程去更新都是在做重复的无用功(因为本身gitci.sh这个脚本更新频率就非常的低),所以这里采用Redis去控制。

最后我们根据是否获取到锁,来进行下一步操作,获取到锁则尝试更新 gitci.sh脚本:

// 获取到redis全局锁之后,执行shell更新操作
	if res {
		// 先生成临时文件的随机后缀字符串
		str := RandStringRunes(10)
		tempFile := fmt.Sprintf("%v-%v", *localFile, str)
		ossCmd := cfgInfo["ossutilCommand"]
		// 把执行的ossutil命令拼接出来
		arg := fmt.Sprintf("%v cp -f %v %v", ossCmd, *ossFile, tempFile)
	    // 	fmt.Println(arg)
		cmd := exec.Command("bash", "-c", arg)
		err := cmd.Run()
		if err != nil {
			checkLog.Errorf("starting exec cmd oss cp failed! ERROR:%v\n", err)
		}

获取到更新之后,先把脚本保存在临时文件中,然后比对临时文件和原文件的MD5值,如果不相等,代表脚本有更新,则进行替换,否则删除临时文件,并退出:

// 生成MD5值的函数
func fileToMd5(filePath string) string {
	file, err := os.Open(filePath)
	if err != nil {
		log.Fatal(err)
	}
	defer file.Close()

	hash := md5.New()
	if _, err := io.Copy(hash, file); err != nil {
		log.Fatal(err)
	}
	return hex.EncodeToString(hash.Sum(nil))
}

// 对比从OSS拉取下来的shell脚本的MD5值跟服务器上面保存的是否相同,如果不同则进行替换,如果相同则不替换并删除临时脚本
		if fileToMd5(*localFile) != fileToMd5(tempFile) {
			err := os.Rename(tempFile, *localFile)
			if err != nil {
				checkLog.Errorf("ERROR: tempfile: %v mv to localfile: %v failed!", tempFile, localFile)
				os.Exit(6)
			}
			os.Exit(0)
		}
		// md5值相同,清理临时文件
		err = os.Remove(tempFile)
		if err != nil {
			checkLog.Errorf("ERROR: delete tempfile: %v failed!", tempFile)
			os.Exit(7)
		}
		os.Exit(0)
	}
		fmt.Println("未获取到锁,无需做其他处理,直接退出。")
		os.Exit(0)

5、尝试变更进行修复

当我们自信满满的进行bug修复的时候,结果是啪啪打脸……换上我们的go脚本之后,整个CI/CD流程都出现了异常,异常结果如图所示:

image-20240115110552366

看起来是一模一样的错误,但是之前是偶发这个错误,但是现在是必现,为什么我们加了并发控制,还不如不加呢?我们开始在线debug。最终我们发现通过全路径执行 check_gitsh_update 退出码为1, 而在脚本的存放目录下面,使用相对路径执行就没有这个问题,如图所示:

image-20240115111038477

image-20240115111200681

这里其实很简单,因为绝对路径下,我们的代码找不到相对路径的配置文件。基于以上问题,我们进行了代码修复:

// 获取程序的存放路径,并将其设置为工作目录
	ex, err := os.Executable()
	if err != nil {
		checkLog.Errorf("Fail read work dir, ERROR: %v", err)
		os.Exit(1)
	}
	exPath := filepath.Dir(ex)
	err = os.Chdir(exPath)
	if err != nil {
		checkLog.Errorf("Fail change work dir, ERROR: %v", err)
		os.Exit(2)
	}

6、修复并发执行ossutil cp之后,又出现新的问题

我们修复了上面的并发执行bug后,安稳运行了一段时间,结果又出现新的问题,其实这个问题是在ossutil bug之前的,我们尝试修复过,但是 失败了,问题如图所示:

image-20240115111720851

此问题其实是并发执行 helm repo update 的时候,由于helm 本身的bug,无法并发执行。我们之前以为是helm v2的bug,但是升级到helm v3之后,虽然缓解了,但是并没有彻底解决,为此我们使用Redis全局锁进行控制,但是 我们加上执行Redis的步骤,整个CI/CD流程就会异常退出。会出现类似我们修复ossutil的时候,修了还不如不修的窘况。

7、再次尝试修复 helm 并发更新repo bug

我们把代码片段拿出来,使用这段代码进行测试:

repo=xcx-uat
flag=$(redis-cli -h 127.0.0.1 -a test123 set $repo-$HOSTNAME 1 NX EX 30 | grep OK | wc -l)
echo "$repo-$HOSTNAME"
if [ "$flag" == "1" ]
then
  helm repo update $repo
  else
    echo "helm repo $repo Have been updated!"
fi

不管我们手动执行多少次,结果都是符合预期的,这就非常的奇怪,明明是符合预期的代码,为什么放到gitlab 的CI/CD中,就会失败报错呢?结果如图所示:

image-20240115113041078

我们再次查看修复代码导致的报错信息:

image-20240115113509015

这里的报错说明代码应该是在执行 redis-cli的时候,出现的返回码为1(非0,gitlab就会报错退出),那我们单独把这段命令拿出来执行,再观察命令的返回码,但是从结果来看,返回码一直都是0,而非报错的时候的1,如图所示:

image-20240115114008613

这个时候说实话,我也有点懵了,之前我们也是这样排查的,但是没有发现问题,而且我们还把管道符都去掉,一一排查返回值,发现都是0,但是 gitlab的报错是不会骗人的,说明这里确实是出现了非0的返回码。我们和gitlab肯定有一个是错的,这里大概率是我们。所以我们不死心,再次进行测试,结果如图所示:

image-20240115114722439

最终结果让我们大吃一惊,原来如此!我们之前一直没有关注 grep 在未搜索到指定的值的时候的返回值是否为0,甚至心里面一直默认是为0的,而之前测试之所以没有测试出来,也是因为心里默认是0,只进行了一次测试,也就是获取到锁的时候,返回OK,grep能正常的过滤到值。定位到问题解决起来就很简单了,我们把管道符全部去掉或者把grep换成其他的,这里我们选择去掉,直接判断返回结果是否为OK。

8、总结

​ 我们根据gitlab CI/CD并发运行的报错信息,进行了2次修复,虽然期间有这样那样由于修复导致的问题,最终还是顺利的修复了CI/CD的并发运行bug。你一定会有点奇怪,为什么两次修复不是都使用shell,而是使用go和shell?这或许就是运维独有的快乐吧!我们可以使用shell/python/go甚至C或者其他语言进行工作,主要看个人心情。哈哈,不知道你能否体会到其中的快乐?!