背景
最近公司將我們之前使用的鏈路工具切換爲了 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 參數。
整體的方案如下:
- 遍歷 namespace 的
pod >0
的 deployment - 遍歷 deployment 中的所有 container,獲得業務鏡像
- 跳過 istio 和日誌採集 container,獲取到業務容器
- 判斷該容器是否需要替換,其實就是判斷環境變量中是否有 skywalking ,如果有就需要替換。
- 獲取業務容器的鏡像
- 基於該 Image 重新構建一個 OpenTelemetry 的鏡像 3.1 新的鏡像包含新的啓動腳本. 3.1.1 新的啓動腳本中會刪除原有的 skywalking agent 3.2 新鏡像會包含 OpenTelemetry 的 jar 包以及我們自定義的 OTel 擴展包 3.3 替換啓動命令爲新的啓動腳本
- 修改 deployment 中的 JVM 啓動參數
- 修改 deployment 的鏡像後滾動更新
- 開啓一個 goroutine 定時檢測更新之後是否啓動成功
- 如果長時間 (比如五分鐘) 都沒有啓動成功,則執行回滾流程
具體代碼
因爲需要涉及到操作 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 還處於使用階段,同時部分功能還不滿足(比如支持自定義擴展),今後有時間也可以分析下它的運行原理。
參考鏈接: