Go实现ping的3种方式

版权声明:本文为博主原创文章,博客地址:
https://blog.csdn.net/zxy_666/article/details/79958948,未经博主允许不得转载。


背景

公司容器云项目需在平台界面上提供一个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)
    }
}

以上代码摘录自:https://studygolang.com/articles/6733

方案二 :远程登录到目标机器,再发出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
}

以上代码摘录自:
http://www.jb51.net/article/90153.htm

扩展1

实现:
用go实现模拟的shell交互终端

以下代码摘录自:
https://www.studygolang.com/articles/5004

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")
}

程序运行效果:

Go实现ping的3种方式_第1张图片

扩展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的需求基本落定了。

你可能感兴趣的:(Go)