實戰:如何優雅的從 Skywalking 切換到 OpenTelemetry

crossoverJie發表於2024-04-09

背景

最近公司將我們之前使用的鏈路工具切換為了 OpenTelemetry.

我們的技術棧是:

        OTLP                               
Client──────────►Collect────────►StartRocks
(Agent)                               ▲    
                                      │    
                                      │    
                                   Jaeger                                       

其中客戶端使用 OpenTelemetry 提供的 Java Agent 進行埋點收集資料,再由 Agent 透過 OTLP(OpenTelemetry Protocol) 協議將資料發往 Collector,在 Collector 中我們可以自行任意處理資料,並決定將這些資料如何儲存(這點在以往的 SkyWalking 體系中是很難自定義的)

這裡我們將資料寫入 StartRocks 中,供之後的 UI 層進行檢視。

OpenTelemetry 是可觀測系統的新標準,基於它可以相容以前使用的 Prometheus、 victoriametrics、skywalking 等系統,同時還可以靈活擴充套件,不用與任何但一生態或技術棧進行繫結。
更多關於 OTel 的內容會在今後介紹。

難點

其中有一個關鍵問題就是:如何線上上進行無縫切換

雖然我們內部的釋出系統已經支援重新發布後就會切換到新的鏈路,也可以讓業務自行釋出然後逐步的切換到新的系統,這樣也是最保險的方式。

但這樣會有幾個問題:

  • 當存在呼叫依賴的系統沒有全部切換為新鏈路時,再查詢的時候就會出現斷層,整個鏈路無法全部串聯起來。
  • 業務團隊沒有足夠的動力去推動釋出,可能切換的週期較長。

所以最好的方式還是由我們在後臺統一發布,對外沒有任何感知就可以一鍵全部切換為 OpenTelemetry。

仔細一看貌似也沒什麼難的,無非就是模擬使用者點選發布按鈕而已。

但這事由我們自動來做就不一樣了,使用者點選發布的時候會選擇他們認為可以釋出的分支進行釋出,我們不能自作主張的比如選擇 main 分支,有可能只是合併了但還不具備釋出條件。

所以保險的方式還是得用當前專案上一次釋出時所使用的 git hash 值重新打包釋出。

但這也有幾個問題:

  • 重複打包釋出太慢了,線上幾十上百個專案,每打包釋出一次就得幾分鐘,雖然可以併發,但考慮到 kubernetes 的壓力也不能調的太高。
  • 保不準業務映象中有單獨加入一些環境變數,這樣打包可能會漏。

切換方案

所以思來想去最保險的方法還是將業務映象拉取下來,然後手動刪除映象中的 skywalking 包以及 JVM 引數,全部替換為 OpenTelemetry 的包和 JVM 引數。

整體的方案如下:

  1. 遍歷 namespace 的 pod >0 的 deployment
  2. 遍歷 deployment 中的所有 container,獲得業務映象
    1. 跳過 istio 和日誌採集 container,獲取到業務容器
    2. 判斷該容器是否需要替換,其實就是判斷環境變數中是否有 skywalking ,如果有就需要替換。
    3. 獲取業務容器的映象
  3. 基於該 Image 重新構建一個 OpenTelemetry 的映象
    3.1 新的映象包含新的啟動指令碼.
    3.1.1 新的啟動指令碼中會刪除原有的 skywalking agent
    3.2 新映象會包含 OpenTelemetry 的 jar 包以及我們自定義的 OTel 擴充套件包
    3.3 替換啟動命令為新的啟動指令碼
  4. 修改 deployment 中的 JVM 啟動引數
  5. 修改 deployment 的映象後滾動更新
  6. 開啟一個 goroutine 定時檢測更新之後是否啟動成功
    1. 如果長時間 (比如五分鐘) 都沒有啟動成功,則執行回滾流程

具體程式碼

因為需要涉及到操作 kubernetes,所以整體就使用 Golang 實現了。

遍歷 deployment 得到需要替換的容器映象

func ProcessDeployment(ctx context.Context, finish []string, deployment v1.Deployment, clientSet kubernetes.Interface) error {
	deploymentName := deployment.Name
	for _, s := range finish {
		if s == deploymentName {
			klog.Infof("Skip finish deployment:%s", deploymentName)
			return nil
		}
	}
	// Write finish deployment name to a file
	defer writeDeploymentName2File(deploymentName, fmt.Sprintf("finish-%s.log", deployment.Namespace))

	appName := deployment.GetObjectMeta().GetLabels()["appName"]
	klog.Infof("Begin to process deployment:%s, appName:%s", deploymentName, appName)

	upgrade, err := checkContainIstio(ctx, deployment, clientSet)
	if err != nil {
		return err
	}
	if upgrade == false {
		klog.Infof("Don't have istio, No need to upgrade deployment:%s appName:%s", deploymentName, appName)
		return nil
	}

	for i, container := range deployment.Spec.Template.Spec.Containers {
		if strings.HasPrefix(deploymentName, container.Name) {

			// Check if container has sw jvm
			for _, envVar := range container.Env {
				if envVar.Name == "CATALINA_OPTS" {
					if !strings.Contains(envVar.Value, "skywalking") {
						klog.Infof("Skip upgrade don't have sw jvm deployment:%s container:%s", deploymentName, container.Name)
						return nil
					}
				}
			}
			upgrade(container)

			// Check newDeployment status
			go checkNewDeploymentStatus(ctx, clientSet, newDeployment)

			// delete from image
			deleteImage(container.Image)

		}
	}

	return nil
}

這個函式需要傳入一個 deployment ,同時還有一個已經完成了的列表進來。

已完成列表用於多次執行的時候可以快速跳過已經執行的 deployment。

checkContainIstio() 函式很簡單,判斷是否包含了 Istio 容器,如果沒有包含說明不是後端應用(可能是前端、大資料之類的任務),就可以直接跳過了。



而判斷是否需要替換的前提這事判斷環境變數 CATALINA_OPTS 中是否包含了 skywalking 的內容,如果包含則說明需要進行替換。

Upgrade 核心函式

func upgrade(container Container){
	klog.Infof("Begin to upgrade deployment:%s container:%s", deploymentName, container.Name)
	newImageName := fmt.Sprintf("%s-otel-%s", container.Image, generateRandomString(4))
	err := BuildNewOtelImage(container.Image, newImageName)
	if err != nil {
		return err
	}

	// Update deployment jvm ENV
	for e, envVar := range container.Env {
		if envVar.Name == "CATALINA_OPTS" {
			otelJVM := replaceSWAgent2OTel(envVar.Value, appName)
			deployment.Spec.Template.Spec.Containers[i].Env[e].Value = otelJVM
		}
	}
	// Update deployment image
	deployment.Spec.Template.Spec.Containers[i].Image = newImageName

	newDeployment, err := clientSet.AppsV1().Deployments(deployment.Namespace).Update(ctx, &deployment, metav1.UpdateOptions{})
	if err != nil {
		return err
	}
	klog.Infof("Finish upgrade deployment:%s container:%s", deploymentName, container.Name)
}

這裡一共分為以下幾部:

  • 基於老映象構建新映象
  • 更新原有的 CATALINA_OPTS 環境變數,也就是替換 skywalking 的引數
  • 更新 deployment 映象,觸發滾動更新

構建新映象

	dockerfile = fmt.Sprintf(`FROM %s
COPY %s /home/admin/%s
COPY otel.tar.gz /home/admin/otel.tar.gz
RUN tar -zxvf /home/admin/otel.tar.gz -C /home/admin
RUN rm -rf /home/admin/skywalking-agent
ENTRYPOINT ["/bin/sh", "/home/admin/start.sh"]
`, fromImage, script, script)

	idx := strings.LastIndex(newImageName, "/") + 1
	dockerFileName := newImageName[idx:]
	create, err := os.Create(fmt.Sprintf("Dockerfile-%s", dockerFileName))
	if err != nil {
		return err
	}
	defer func() {
		create.Close()
		os.Remove(create.Name())
	}()
	_, err = create.WriteString(dockerfile)
	if err != nil {
		return err
	}

	cmd := exec.Command("docker", "build", ".", "-f", create.Name(), "-t", newImageName)
	cmd.Stdin = strings.NewReader(dockerfile)
	if err := cmd.Run(); err != nil {
		return err
	}

其實這裡的重點就是構建這個新映象,從這個 dockerfile 中也能看出具體的邏輯,也就是上文提到的刪除原有的 skywalking 資源同時將新的 OpenTelemetry 資源打包進去。

最後再將這個映象上傳到私服。


其中的替換 JVM 引數也比較簡單,直接刪除 skywalking 的內容,然後再追加上 OpenTelemetry 需要的引數即可。

定時檢測替換是否成功

func checkNewDeploymentStatus(ctx context.Context, clientSet kubernetes.Interface, newDeployment *v1.Deployment) error {
	ready := true
	tick := time.Tick(10 * time.Second)
	for i := 0; i < 30; i++ {
		<-tick
		originPodList, err := clientSet.CoreV1().Pods(newDeployment.Namespace).List(ctx, metav1.ListOptions{
			LabelSelector: metav1.FormatLabelSelector(&metav1.LabelSelector{
				MatchLabels: newDeployment.Spec.Selector.MatchLabels,
			}),
		})
		if err != nil {
			return err
		}

		// Check if there are any Pods
		if len(originPodList.Items) == 0 {
			klog.Infof("No Pod in deployment:%s, Skip", newDeployment.Name)
		}
		for _, item := range originPodList.Items {
			// Check Pod running
			for _, status := range item.Status.ContainerStatuses {
				if status.RestartCount > 0 {
					ready = false
					break
				}
			}
		}
		klog.Infof("Check deployment:%s namespace:%s status:%t", newDeployment.Name, newDeployment.Namespace, ready)
		if ready == false {
			break
		}
	}

	if ready == false {
		// rollback
		klog.Infof("=======Rollback deployment:%s namespace:%s", newDeployment.Name, newDeployment.Namespace)
		writeDeploymentName2File(newDeployment.Name, fmt.Sprintf("rollback-%s.log", newDeployment.Namespace))
	}

	return nil
}

這裡會啟動一個 10s 執行一次的定時任務,每次都會檢測是否有容器發生了重啟(正常情況下是不會出現重啟的)

如果檢測了 30 次都沒有重啟的容器,那就說明本次替換成功了,不然就記錄一個日誌檔案,然後人工處理。

這種通常是原有的映象與 OpenTelemetry 不相容,比如裡面寫死了一些 skywalking 的 API,導致啟動失敗。

所以替換任務跑完之後我還會檢測這個 rollback-$namespace 的日誌檔案,人工處理這些失敗的應用。

分批處理 deployment

最後講講如何單個呼叫剛才的 ProcessDeployment() 函式。

考慮到不能對 kubernetes 產生影響,所以我們需要限制併發處理 deployment 的數量(我這裡的限制是 10 個)。

所以就得分批進行替換,每次替換 10 個,而且其中有一個執行失敗就得暫停後續任務,由人工檢測失敗原因再決定是否繼續處理。

畢竟處理的是線上應用,需要小心謹慎。

所以觸發的程式碼如下:

func ProcessDeploymentList(ctx context.Context, data []v1.Deployment, clientSet kubernetes.Interface) error {
	file, err := os.ReadFile(fmt.Sprintf("finish-%s.log", data[0].Namespace))
	if err != nil {
		return err
	}
	split := strings.Split(string(file), "\n")

	batchSize := 10
	start := 0

	for start < len(data) {

		end := start + batchSize
		if end > len(data) {
			end = len(data)
		}

		batch := data[start:end]

		//等待goroutine結束
		var wg sync.WaitGroup
		klog.Infof("Start process batch size %d", len(batch))

		errs := make(chan error, len(batch))

		wg.Add(len(batch))
		for _, item := range batch {
			d := item
			go func() {
				defer wg.Done()
				if err := ProcessDeployment(ctx, split, d, clientSet); err != nil {
					klog.Errorf("!!!Process deployment name:%s error: %v", d.Name, err)
					errs <- err
					return
				}
			}()
		}

		go func() {
			wg.Wait()
			close(errs)
		}()

		//任何一個失敗就返回
		for err := range errs {
			if err != nil {
				return err
			}
		}

		start = end
		klog.Infof("Deal next batch")
	}

	return nil

}

使用 WaitGroup 來控制一組任務,使用一個 chan 來傳遞異常;這類分批處理的程式碼在一些批處理框架中還蠻常見的。

總結

最後只需要查詢某個 namespace 下的所有 deployment 列表傳入這個批處理函式即可。

不過整個過程中還是有幾個點需要注意:

  • 因為需要替換映象的前提是要把現有的映象拉取到本地,所以跑這個任務的客戶端需要有充足的磁碟,同時和映象伺服器的網路條件較好。
  • 不然執行的過程會比較慢,同時磁碟佔用滿了也會影響任務。

其實這個功能依然有提升空間,考慮到後續會升級 OpenTelemetry agent 的版本,甚至也需要增減一些 JVM 引數。

所以最後有一個統一的工具,可以直接升級 Agent,而不是每次我都需要修改這裡的程式碼。

後來在網上看到了得物的相關分享,他們可以遠端載入配置來解決這個問題。

這也是一種解決方案,直到我們看到了 OpenTelemetry 社群提供了 Operator,其中也包含了注入 agent 的功能。

apiVersion: opentelemetry.io/v1alpha1  
kind: Instrumentation  
metadata:  
  name: my-instrumentation  
spec:  
  exporter:  
    endpoint: http://otel-collector:4317  
  propagators:  
    - tracecontext  
    - baggage  
    - b3  
  sampler:  
    type: parentbased_traceidratio  
    argument: "0.25"  
  java:  
    image: private/autoinstrumentation-java:1.32.0-1

我們可以使用他提供的 CRD 來配置我們 agent,只要維護好自己的映象就好了。

使用起來也很簡單,只要安裝好了 OpenTelemetry-operator ,然後再需要注入 Java Agent 的 Pod 中使用註解:

instrumentation.opentelemetry.io/inject-java: "true"

operator 就會自動從剛才我們配置的映象中讀取 agent,然後複製到我們的業務容器。

再配置上環境變數 $JAVA_TOOL_OPTIONS=/otel/javaagent.java, 這是一個 Java 內建的環境變數,應用啟動的時候會自動識別,這樣就可以自動注入 agent 了。

envJavaToolsOptions   = "JAVA_TOOL_OPTIONS"

// set env value
idx := getIndexOfEnv(container.Env, envJavaToolsOptions)  
if idx == -1 {  
    container.Env = append(container.Env, corev1.EnvVar{  
       Name:  envJavaToolsOptions,  
       Value: javaJVMArgument,  
    })} else {  
    container.Env[idx].Value = container.Env[idx].Value + javaJVMArgument  
}

// copy javaagent.jar
pod.Spec.InitContainers = append(pod.Spec.InitContainers, corev1.Container{  
    Name:      javaInitContainerName,  
    Image:     javaSpec.Image,  
    Command:   []string{"cp", "/javaagent.jar", javaInstrMountPath + "/javaagent.jar"},  
    Resources: javaSpec.Resources,  
    VolumeMounts: []corev1.VolumeMount{{  
       Name:      javaVolumeName,  
       MountPath: javaInstrMountPath,  
    }},})

大致的執行原理是當有 Pod 的事件發生了變化(重啟、重新部署等),operator 就會檢測到變化,此時會判斷是否開啟了剛才的註解:

instrumentation.opentelemetry.io/inject-java: "true"

接著會寫入環境變數 JAVA_TOOL_OPTIONS,同時將 jar 包從 InitContainers 中複製到業務容器中。

這裡使用到了 kubernetes 的初始化容器,該容器是用於做一些準備工作的,比如依賴安裝、配置檢測或者是等待其他一些元件啟動成功後再啟動業務容器。

目前這個 operator 還處於使用階段,同時部分功能還不滿足(比如支援自定義擴充套件),今後有時間也可以分析下它的執行原理。

參考連結:

  • https://xie.infoq.cn/article/e6def1e245e9d67735bd00dd5
  • https://github.com/open-telemetry/opentelemetry-operator/#opentelemetry-auto-instrumentation-injection

相關文章