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