1. 程式人生 > >Go實現ping的3種方式

Go實現ping的3種方式

背景

公司容器雲專案需在平臺介面上提供一個ping工具,實現從任何pod內ping指定IP.

背景說明:

· 容器雲專案
容器雲專案是基於kubernetes(簡稱k8s)叢集搭建的應用容器管理平臺。叢集中的節點是虛擬機器或物理機,節點分為master節點和node節點(worker節點)。node節點上執行著pod(k8s叢集的最小工作單元),pod中執行著容器,數量隨意,但公司的專案裡一個pod只執行一個容器。其中,容器是應用提供服務的載體,應用打成映象後用於建立相應的容器。
每個節點都有各自的IP,每個pod也有他們的IP,通常在叢集搭建時,給pod分配指定網段內的IP,由flannel實現叢集間網路的通訊,flannel保證叢集中的每個pod都被分配不同的IP,避免了網路衝突。

· ping需求分析
叢集外可以ping通叢集的節點IP,卻ping不通叢集內的ip,如pod的ip。但是應用方pod提供的服務會出現訪問不了的情況,運維人員需登入到叢集上檢視響應pod的網路情況。為了解放運維人員的部分勞動力,故有此需求,希望在容器雲平臺介面直接提供一個ping工具,讓使用者可以直接從每個pod例項發出ping命令,ping目標的IP由使用者提供,發出ping請求的源pod的ip就是該pod在叢集中建立時被分配的ip,前端可獲取得到。

實現歷程:

依次嘗試了3種方案,直到第三種才實現了需求。

  • 用go實現ping原理
  • (使用go提供的終端登入,在目標終端直接發出ping命令)/(在本機直接發出ping命令)
  • 在每個pod內引入故障診斷容器

方案一 :實現ping的原理

方案:
用go實現ping的原理,引數是ping的目標IP和ping請求次數。限制是:該方案ping的源IP無法更改,預設就是發出ping操作的機器IP

實現:

注:本程式(ICMP協議)需要root許可權才可執行,啟動需要sudo許可權

package main

import (
    "net"
    "time"
    "fmt"
    "strconv"
    "os"
)

type PingOption struct{
    Count int
    Size int
Timeout int64 Nerverstop bool } func NewPingOption()*PingOption{ return &PingOption{ Count:4, Size:32, Timeout:1000, Nerverstop:false, } } func main(){ //argsmap:=map[string]interface{}{} //ping3("www.yeepay.com",argsmap)//10.151.30.227 不存在:67.4.3.2(現在又存在了) 公網IP:63.142.250.4(通) argsmap:=map[string]interface{}{} p:=NewPingOption() p.ping3("www.baidu.com",argsmap) } //ping連線用的協議是ICMP,原理: //Ping的基本原理是傳送和接受ICMP請求回顯報文。接收方將報文原封不動的返回傳送方,傳送方校驗報文,校驗成功則表示ping通。 //一臺主機向一個節點發送一個型別欄位值為8的ICMP報文,如果途中沒有異常(如果沒有被路由丟棄,目標不迴應ICMP或者傳輸失敗), //則目標返回型別欄位值為0的ICMP報文,說明這臺主機可達 func (p *PingOption)ping3(host string, args map[string]interface{}) { //要傳送的回顯請求數 var count int = 4 //要傳送緩衝區大小,單位:位元組 var size int = 32 //等待每次回覆的超時時間(毫秒) var timeout int64 = 1000 //Ping 指定的主機,直到停止 var neverstop bool = false fmt.Println(args,"args") if len(args)!=0{ count = args["n"].(int) size = args["l"].(int) timeout = args["w"].(int64) neverstop = args["t"].(bool) } //查詢規範的dns主機名字 eg.www.baidu.com->www.a.shifen.com cname, _ := net.LookupCNAME(host) starttime := time.Now() //此處的連結conn只是為了獲得ip := conn.RemoteAddr(),顯示出來,因為後面每次連線都會重新獲取conn,todo 但是每次重新獲取的conn,其連線的ip保證一致麼? conn, err := net.DialTimeout("ip4:icmp", host, time.Duration(timeout*1000*1000)) //每個域名可能對應多個ip,但實際連線時,請求只會轉發到某一個上,故需要獲取實際連線的遠端ip,才能知道實際ping的機器是哪臺 // ip := conn.RemoteAddr() // fmt.Println("正在 Ping " + cname + " [" + ip.String() + "] 具有 32 位元組的資料:") var seq int16 = 1 id0, id1 := genidentifier3(host) //ICMP報頭的長度至少8位元組,如果報文包含資料部分則大於8位元組。 //ping命令包含"請求"(Echo Request,報頭型別是8)和"應答"(Echo Reply,型別是0)2個部分,由ICMP報頭的型別決定 const ECHO_REQUEST_HEAD_LEN = 8 //記錄傳送次數 sendN := 0 //成功應答次數 recvN := 0 //記錄失敗請求數 lostN := 0 //所有請求中應答時間最短的一個 shortT := -1 //所有請求中應答時間最長的一個 longT := -1 //所有請求的應答時間和 sumT := 0 for count > 0 || neverstop { sendN++ //ICMP報文長度,報頭8位元組,資料部分32位元組 var msg []byte = make([]byte, size+ECHO_REQUEST_HEAD_LEN) //第一個位元組表示報文型別,8表示回顯請求 msg[0] = 8 // echo //ping的請求和應答,該code都為0 msg[1] = 0 // code 0 //校驗碼佔2位元組 msg[2] = 0 // checksum msg[3] = 0 // checksum //ID識別符號 佔2位元組 msg[4], msg[5] = id0, id1 //identifier[0] identifier[1] //序號佔2位元組 msg[6], msg[7] = gensequence3(seq) //sequence[0], sequence[1] length := size + ECHO_REQUEST_HEAD_LEN //計算檢驗和。 check := checkSum3(msg[0:length]) //左乘右除,把二進位制位向右移動位 msg[2] = byte(check >> 8) msg[3] = byte(check & 255) conn, err = net.DialTimeout("ip:icmp", host, time.Duration(timeout*1000*1000)) //todo test //ip := conn.RemoteAddr() fmt.Println("remote ip:",host) checkError3(err) starttime = time.Now() //conn.SetReadDeadline可以在未收到資料的指定時間內停止Read等待,並返回錯誤err,然後判定請求超時 conn.SetDeadline(starttime.Add(time.Duration(timeout * 1000 * 1000))) //onn.Write方法執行之後也就傳送了一條ICMP請求,同時進行計時和計次 _, err = conn.Write(msg[0:length]) //在使用Go語言的net.Dial函式時,傳送echo request報文時,不用考慮i前20個位元組的ip頭; // 但是在接收到echo response訊息時,前20位元組是ip頭。後面的內容才是icmp的內容,應該與echo request的內容一致 const ECHO_REPLY_HEAD_LEN = 20 var receive []byte = make([]byte, ECHO_REPLY_HEAD_LEN+length) n, err := conn.Read(receive) _ = n var endduration int = int(int64(time.Since(starttime)) / (1000 * 1000)) sumT += endduration time.Sleep(1000 * 1000 * 1000) //除了判斷err!=nil,還有判斷請求和應答的ID識別符號,sequence序列碼是否一致,以及ICMP是否超時(receive[ECHO_REPLY_HEAD_LEN] == 11,即ICMP報頭的型別為11時表示ICMP超時) if err != nil || receive[ECHO_REPLY_HEAD_LEN+4] != msg[4] || receive[ECHO_REPLY_HEAD_LEN+5] != msg[5] || receive[ECHO_REPLY_HEAD_LEN+6] != msg[6] || receive[ECHO_REPLY_HEAD_LEN+7] != msg[7] || endduration >= int(timeout) || receive[ECHO_REPLY_HEAD_LEN] == 11 { lostN++ //todo //fmt.Println("對 " + cname + "[" + ip.String() + "]" + " 的請求超時。") fmt.Println("對 " + cname + "[" + host + "]" + " 的請求超時。") } else { if shortT == -1 { shortT = endduration } else if shortT > endduration { shortT = endduration } if longT == -1 { longT = endduration } else if longT < endduration { longT = endduration } recvN++ ttl := int(receive[8]) // fmt.Println(ttl) //todo //fmt.Println("來自 " + cname + "[" + ip.String() + "]" + " 的回覆: 位元組=32 時間=" + strconv.Itoa(endduration) + "ms TTL=" + strconv.Itoa(ttl)) fmt.Println("來自 " + cname + "[" + host + "]" + " 的回覆: 位元組=32 時間=" + strconv.Itoa(endduration) + "ms TTL=" + strconv.Itoa(ttl)) } seq++ count-- } //todo 先註釋,用下一行測試 //stat3(ip.String(), sendN, lostN, recvN, shortT, longT, sumT) stat3(host, sendN, lostN, recvN, shortT, longT, sumT) } func checkSum3(msg []byte) uint16 { sum := 0 length := len(msg) for i := 0; i < length-1; i += 2 { sum += int(msg[i])*256 + int(msg[i+1]) } if length%2 == 1 { sum += int(msg[length-1]) * 256 // notice here, why *256? } sum = (sum >> 16) + (sum & 0xffff) sum += (sum >> 16) var answer uint16 = uint16(^sum) return answer } func checkError3(err error) { if err != nil { fmt.Fprintf(os.Stderr, "Fatal error: %s", err.Error()) os.Exit(1) } } func gensequence3(v int16) (byte, byte) { ret1 := byte(v >> 8) ret2 := byte(v & 255) return ret1, ret2 } func genidentifier3(host string) (byte, byte) { return host[0], host[1] } func stat3(ip string, sendN int, lostN int, recvN int, shortT int, longT int, sumT int) { fmt.Println() fmt.Println(ip, " 的 Ping 統計資訊:") fmt.Printf(" 資料包: 已傳送 = %d,已接收 = %d,丟失 = %d (%d%% 丟失),\n", sendN, recvN, lostN, int(lostN*100/sendN)) fmt.Println("往返行程的估計時間(以毫秒為單位):") if recvN != 0 { fmt.Printf(" 最短 = %dms,最長 = %dms,平均 = %dms\n", shortT, longT, sumT/sendN) } }

方案二 :遠端登入到目標機器,再發出ping

前情回顧:

因為容器雲平臺本身也是以pod的形式執行在kubernetes叢集中,只不過提供了一個操作平臺,其他應用的上線都通過該平臺釋出,實際上就是該平臺拿到應用釋出所需的所有資訊,調kubernetes的client-go去建立響應的應用pod。而ping工具是由容器雲平臺提供的,也就是在容器雲的專案程式碼裡,如果使用方案一,ping的源IP就是容器雲應用的pod的IP,這顯然與需求不符。需求是任意pod均可作為發出ping命令的源(容器雲平臺介面有每個pod例項相關的各種資訊,包括podIP)。
鑑於需求的特殊性,衍生了一種思路,即通過go的遠端登入工具實現。基本思路是:
· 從容器雲應用的pod裡登入到發出ping的pod中
· 然後在該pod中發出ping命令

實現:
1. 從yce的pod裡登入到master節點(master節點可以對叢集中所有資源進行操作,包括pod )
2. 從master節點上登入到發出ping的pod中(登入命令使用的是k8s的叢集的操作工具kubectl)
3. 在該pod中發出ping命令

關鍵程式碼:

1. session, err := connect(user, pass, srcIP, 22)  
2. err=session.Run("pwd; ls; kubectl exec -it "+podName+" -n configcenter bash; ls; ping -c 3 "+dstIP)
package main

import (
    "net/http"
    //第三方依賴包,需下載
    "golang.org/x/crypto/ssh"
    "time"
    "net"
    "fmt"
    "os"
)

func main(){
    //叢集外訪問k8s叢集pod通過:http://nodeIP:nodePort/xxx訪問
    //nodeip:18.11.55.44 nodePort:32099
    http.HandleFunc("/pingWithSrc",PingWithSrc)
    http.ListenAndServe(":8080",nil)
}

func PingWithSrc(w http.ResponseWriter, r *http.Request){
    //預設登入資訊
    user:="master節點的登入密碼"
    pass:="master節點的登入密碼"
    //解析引數
    if r.Header.Get("user")!=""{
        user=r.Header.Get("user")
    }
    if r.Header.Get("pass")!=""{
        pass=r.Header.Get("pass")
    }

    srcIP:=r.Header.Get("srcIP")
    dstIP:=r.Header.Get("dstIP")
    count:=r.Header.Get("count")
    podName:=r.Header.Get("podName")
    fmt.Println("header:",srcIP,dstIP,count,user,pass,podName)

    //!!!
    //登入到指定機器(這裡是master節點) 
    session, err := connect(user, pass, srcIP, 22)
    if err != nil {
        fmt.Println(err)
    }
    defer session.Close()
    fmt.Println("enter node:"+srcIP)

    //輸出重定向(這裡把session.Run()的執行結果輸出到w中,請求時會列印到瀏覽器頁面上)
    session.Stdout = w
    session.Stderr = os.Stderr

    //!!!
    //session.Run在一個程序裡只能執行一次,若執行多條將報“Run"已經開啟還是執行之類的錯誤
    //若想執行多條命令,命令間用" ; "分號隔開即可
    //"kubectl exec -it "+podName+" -n configcenter bash"是k8s的命令,表示登入到指定namespace下的指定pod中。
    //接著在該pod中執行ping命令,指定ping時一定要指定請求次數(-c 次數),否則程式會不斷髮出請求,就無法返回了
    err=session.Run("pwd; ls; kubectl exec -it "+podName+" -n configcenter bash; ls; ping -c 3 "+dstIP)
    if err!=nil{
        fmt.Println("err:",err)
        w.Write([]byte("失敗:"+srcIP+" ping "+dstIP+" 不通"))
    }else{
        w.Write([]byte("成功:"+srcIP+" ping "+dstIP+" 通"))
    }
}

func connect(user, password, host string, port int) (*ssh.Session, error) {
    var (
        auth         []ssh.AuthMethod
        addr         string
        clientConfig *ssh.ClientConfig
        client       *ssh.Client
        session      *ssh.Session
        err          error
    )
    // get auth method
    auth = make([]ssh.AuthMethod, 0)
    auth = append(auth, ssh.Password(password))

    clientConfig = &ssh.ClientConfig{
        User:    user,
        Auth:    auth,
        Timeout: 30 * time.Second,
        //需要驗證服務端,不做驗證返回nil就可以,點選HostKeyCallback看原始碼就知道了
        HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error {
            return nil
        },
    }

    // connet to ssh
    addr = fmt.Sprintf("%s:%d", host, port)

    if client, err = ssh.Dial("tcp", addr, clientConfig); err != nil {
        return nil, err
    }

    // create session
    if session, err = client.NewSession(); err != nil {
        return nil, err
    }

    return session, nil
}

擴充套件1

實現:
用go實現模擬的shell互動終端

package main

import (
    "net/http"
    "golang.org/x/crypto/ssh"
    "time"
    "net"
    "os"
    "log"
)

func main(){
    PingShell()
}

func PingShell(){
    check := func(err error, msg string) {
        if err != nil {
            log.Fatalf("%s error: %v", msg, err)
        }
    }

    //!!!
    client, err := ssh.Dial("tcp", "目標機器的IP:22", &ssh.ClientConfig{
        User: "目標機器的登入賬號",
        Auth: []ssh.AuthMethod{ssh.Password("目標機器的登入密碼")},
        Timeout: 30 * time.Second,
        //需要驗證服務端,不做驗證返回nil就可以,點選HostKeyCallback看原始碼就知道了
        HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error {
            return nil
        },
    })
    check(err, "dial")

    session, err := client.NewSession()
    check(err, "new session")
    defer session.Close()

    //輸出重定向到標準輸出流
    session.Stdout = os.Stdout
    session.Stderr = os.Stderr
    session.Stdin = os.Stdin

    modes := ssh.TerminalModes{
        ssh.ECHO:          0,
        ssh.TTY_OP_ISPEED: 14400,
        ssh.TTY_OP_OSPEED: 14400,
    }
    err = session.RequestPty("xterm", 25, 100, modes)
    check(err, "request pty")

    err = session.Shell()
    check(err, "start shell")

    err = session.Wait()
    check(err, "return")
}

程式執行效果:

這裡寫圖片描述

擴充套件2

若在本地發出ping命令,即發出ping的ip預設是本機,可以不用實現ping的原理,go提供的工具,一句話就能搞定。

實現:
從本機直接ping指定IP

package main

import (
    "net/http"
    "os/exec"
)

func main(){
    http.HandleFunc("/ping",Ping)
    http.ListenAndServe(":8080",nil)
}

func Ping(w http.ResponseWriter, r *http.Request){
    dstIP:=r.Header.Get("dstIP")
    //!!!
    cmd := exec.Command("ping","-c","3", dstIP)
    cmd.Stdout = w
    err:=cmd.Run()
    if err!=nil{
        w.Write([]byte(err.Error()))
    }
}

結果: 現在需求是能實現了,但該方案存在不安全隱患,因為登入到了叢集的節點上。 於是,又衍生了第三種方案。

方案三 :藉助go直接發出ping

前情回顧:

方案二被否定後,引出了以下2種思路。
* 參照kubectl exec命令調apiserver
(k8s叢集master節點上的元件,叢集所有操作都通過api呼叫它實現)登入進pod的方式,從程式碼裡調apiserver登入到pod內相關的api,實現程式碼直接登入到pod內,不經過節點。
* 在每個pod內引入故障排查容器。

  • 第一種思路可以實現,但可能稍微費勁點,因為需檢視client-go(官方提供的對apiserver api封裝的客戶端包,通過它訪問apiserver,操作k8s叢集)相關原始碼,且過低版本的client-go併為提供該api,設計client-go升級問題。

  • 第二思路非常棒。因為pod內可以有多個容器,且pod內的每個容器共享網路,也就是說pod被分配的IP就是其中每個容器的IP。所以,在每個pod裡新增一個故障排除容器(均使用同一個映象生成),該容器提供一個api,請求提供ping的目標IP(當然加上請求次數也可),然後該容器直接ping目標IP,並返回ping結果。這樣一來,在yce的pod裡呼叫欲發出ping的源容器的上述api,yce的pod拿到結果後再返回前端展示。

第2種思路很好,是一種旁路控制的思維,一個pod裡有2個容器,一個提供業務邏輯,而另一個可以專注故障排除(無論是網路,系統還是系統元件方面的故障),擴充套件性很好,不只是ping,以後可以新增其他功能,如telnet等。

實現:

新建一個服務,直接發出ping(發出ping的源IP預設是服務所在的機器IP)

package main

import (
    "net/http"
    "os/exec"
    "github.com/julienschmidt/httprouter"
    "github.com/maxwell92/gokits/log"
    "time"
    "strconv"
    "bytes"
    "fmt"
)

var logger = log.Log

func main() {
    router:=httprouter.New()
    router.GET("/ping/:dstIP/:count",Ping)

    //一個pod內的容器監聽埠不能衝突,由於業務容器監聽8080,這就取8090吧
    logger.Infoln("troubleshooting listen at port: 8090")
    http.ListenAndServe(":8090", router)
}

func Ping(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
    fmt.Println("---------enter ping")
    dstIP := params.ByName("dstIP")
    count := params.ByName("count")

    countInt, _ := strconv.ParseInt(count, 10, 64)
    if countInt < 1 || countInt > 10 {
        w.Write([]byte("請求次數只能在1-10之間"))
        return
    }

    var buf bytes.Buffer
    //關鍵程式碼
    cmd := exec.Command("ping", "-c", count, dstIP)
    cmd.Stdout = &buf
    go run(cmd, dstIP)

    time.Sleep(time.Second * time.Duration(countInt))
    if buf.String() != "" {
        w.Write([]byte(buf.String()))
    } else {
        w.Write([]byte("ping " + dstIP + " failed"))
    }
}

func run(cmd *exec.Cmd, dstIP string) {
    //關鍵程式碼
    err := cmd.Run()
    if err != nil {
        logger.Errorf("ping %s failed. err=", dstIP, err)
    }
}

故障診斷容器:
將上面的程式碼打成docker映象,並使用該映象建立故障容器。

並在容器雲專案程式碼裡新增一個api,供雲平臺前端呼叫。形如:
“/api/v2/ping/srcIP/:srcIP/dstIP/:dstIP/count/:count”

package debugContainer

import (
    "app/backend/upgrade/controller"
    "github.com/julienschmidt/httprouter"
    "net/http"
    "strconv"
    "github.com/maxwell92/gokits/log"
    myerror "app/backend/common/yce/error"
    "io/ioutil"
    "net"
)

var logger = log.Log

type PingController struct {
    controller.YceController
}

func (p *PingController) Get(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
    //發出ping的容器IP
    srcIP := params.ByName("srcIP")
    dstIP := params.ByName("dstIP")
    //ping的請求次數
    count := params.ByName("count")

    countInt, _ := strconv.ParseInt(count, 10, 64)
    if countInt < 1 || countInt > 10 {
        logger.Errorf("PingController failed : count=%s. 請求次數只能在1-10之間")
        p.Ye = myerror.NewYceError(myerror.EARGS, "")
        p.Ye.Message = p.Ye.Message + ":請求次數count只能在1-10之間"
        return
    }

    //校驗IP格式格式正確
    if checkIP(srcIP) == nil || checkIP(dstIP) == nil {
        logger.Errorf("PingController failed. IP地址格式錯誤:srcIP=%s,dstIP=%s", srcIP, dstIP)
        p.Ye = myerror.NewYceError(myerror.EARGS, "")
        p.Ye.Message = p.Ye.Message + ":IP地址格式錯誤"
        return
    }

    targetURL := "http://" + srcIP + ":8090/ping/" + dstIP + "/" + count
    res, err := http.Get(targetURL)
    if err != nil {
        logger.Errorf("PingController request targeURL failed. err=%s", err)
        p.Ye = myerror.NewYceError(myerror.DEBUGCONT_PING_REQ_ERR, "")
        return
    }
    defer res.Body.Close()

    body, err := ioutil.ReadAll(res.Body)
    if err != nil {
        logger.Errorf("PingController read from res.body error. err=%s", err)
        p.Ye = myerror.NewYceError(myerror.EGDK_IOUTIL, "")
        return
    }
    //返回前端,需根據自己實際情況修改
    p.WriteOk(w, string(body))
}

func checkIP(ip string) []byte {
    return net.ParseIP(ip)
}

結果:至此,ping的需求基本落定了。