圖解 kubernetes 命令執行核心實現

baxiaoshi發表於2020-04-08

K8s 中的命令執行由 apiserver、kubelet、cri、docker 等元件共同完成, 其中最複雜的就是協議切換以及各種流拷貝相關,讓我們一起來看下關鍵實現,雖然程式碼比較多,但是不會開發應該也能看懂,祝你好運

1. 基礎概念

K8s 中的命令執行中有很多協議相關的處理, 我們先一起看下這些協議處理相關的基礎概念

1.1 Http 協議中的 Connection 與 Upgrade

image.png

HTTP/1.1 中允許在同一個連結上通過 Header 頭中的 Connection 配合 Upgrade 來實現協議的轉換,簡單來說就是允許在通過 HTTP 建立的連結之上使用其他的協議來進行通訊,這也是 k8s 中命令中實現協議升級的關鍵

1.2 Http 協議中的 101 狀態碼

image.png

在 HTTP 協議中除了我們常見的 HTTP1.1,還支援 websocket/spdy 等協議,那服務端和客戶端如何在 http 之上完成不同協議的切換呢,首先第一個要素就是這裡的 101(Switching Protocal) 狀態碼, 即服務端告知客戶端我們切換到 Uprage 定義的協議上來進行通訊 (複用當前連結)

1.3 SPDY 協議中的 stream

image.png

SPDY 協議是 google 開發的 TCP 會話層協議, SPDY 協議中將 Http 的 Request/Response 稱為 Stream,並支援 TCP 的連結複用,同時多個 stream 之間通過 Stream-id 來進行標記,簡單來說就是支援在單個連結同時進行多個請求響應的處理,並且互不影響,k8s 中的命令執行主要也就是通過 stream 來進行訊息傳遞的

1.4 檔案描述符重定向

image.png

在 Linux 中程式執行通常都會包含三個 FD:標準輸入、標準輸出、標準錯誤, k8s 中的命令執行會將對應的 FD 進行重定向,從而獲取容器的命令的輸出,重定向到哪呢?當然是我們上面提到過的 stream 了 (因為對 docker 並不熟悉,所以這個地方並不保證 Docker 部分的準確性)

1.5 http 中的 Hijacker

image.png

在 client 與 server 之間通過 101 狀態碼、connection、upragde 等完成基於當前連結的轉換之後, 當前連結上傳輸的資料就不在是之前的 http1.1 協議了,此時就要將對應的 http 連結轉成對應的協議進行轉換,在 k8s 命令執行的過程中,會獲取將對應的 request 和 response,都通過 http 的 Hijacker 介面獲取底層的 tcp 連結,從而繼續完成請求的轉發

1.6 基於 tcp 的流對拷的轉發

在通過 Hijacker 獲取到兩個底層的 tcp 的 readerwriter 之後,就可以直接通過 io.copy 在兩個流上完成對應資料的拷貝,這樣就不需要在 apiserver 這個地方進行協議的轉換,而是直接通過 tcp 的流對拷就可以實現請求和結果的轉發

基礎大概就介紹這些,接下來我們一起去看看其底層的具體實現,我們從 kubectl 部分開始來逐層分析

2.kubectl

Kubectl 執行命令主要分為兩部分 Pod 合法性檢測和命令執行, Pod 合法性檢測主要是獲取對應 Pod 的狀態,檢測是否在執行, 這裡我們重點關注下命令執行部分

2.1 命令執行核心流程

image.png

命令執行的核心分為兩個步驟:1.通過 SPDY 協議建立連結 2)構建 Stream 建立連結

func (*DefaultRemoteExecutor) Execute(method string, url *url.URL, config *restclient.Config, stdin io.Reader, stdout, stderr io.Writer, tty bool, terminalSizeQueue remotecommand.TerminalSizeQueue) error {
    exec, err := remotecommand.NewSPDYExecutor(config, method, url)
    if err != nil {
        return err
    }
    return exec.Stream(remotecommand.StreamOptions{
        Stdin:             stdin,
        Stdout:            stdout,
        Stderr:            stderr,
        Tty:               tty,
        TerminalSizeQueue: terminalSizeQueue,
    })
}

2.2 exec 請求構建

我們可以看到這個地方拼接的 Url /pods/{namespace}/{podName}/exec 其實就是對應 apiserver 上面 pod 的 subresource 介面,然後我們就可以去看 apiserver 端的請求處理了

    // 建立一個exec
        req := restClient.Post().
            Resource("pods").
            Name(pod.Name).
            Namespace(pod.Namespace).
            SubResource("exec")
        req.VersionedParams(&corev1.PodExecOptions{
            Container: containerName,
            Command:   p.Command,
            Stdin:     p.Stdin,
            Stdout:    p.Out != nil,
            Stderr:    p.ErrOut != nil,
            TTY:       t.Raw,
        }, scheme.ParameterCodec)
return p.Executor.Execute("POST", req.URL(), p.Config, p.In, p.Out, p.ErrOut, t.Raw, sizeQueue)

2.3 建立 Stream

image.png

在 exec.Stream 主要是通過 Headers 傳遞要建立的 Stream 的型別,與 server 端進行協商

// set up stdin stream
if p.Stdin != nil {
    headers.Set(v1.StreamType, v1.StreamTypeStdin)
    p.remoteStdin, err = conn.CreateStream(headers)
    if err != nil {
        return err
    }
}

// set up stdout stream
if p.Stdout != nil {
    headers.Set(v1.StreamType, v1.StreamTypeStdout)
    p.remoteStdout, err = conn.CreateStream(headers)
    if err != nil {
        return err
    }
}

// set up stderr stream
if p.Stderr != nil && !p.Tty {
    headers.Set(v1.StreamType, v1.StreamTypeStderr)
    p.remoteStderr, err = conn.CreateStream(headers)
    if err != nil {
        return err
    }
}

3.APIServer

APIServer 在命令執行的過程中扮演了代理的角色,其負責將 Kubectl 和 kubelet 之間的請求來進行轉發,注意這個轉發主要是基於 tcp 的流對拷完成的,因為 kubectl 和 kubelet 之間的通訊,實際上是 spdy 協議,讓我們一起看下關鍵實現吧

3.1 Connection

image.png

Exec 的 SPDY 請求會首先傳送到 Connect 介面, Connection 介面負責跟後端的 kubelet 進行連結的建立,並且進行響應結果的返回,在 Connection 介面中,首先會通過 Pod 獲取到對應的 Node 資訊,並且構建 Location 即後端的 Kubelet 的連結地址和 transport

func (r *ExecREST) Connect(ctx context.Context, name string, opts runtime.Object, responder rest.Responder) (http.Handler, error) {
    execOpts, ok := opts.(*api.PodExecOptions)
    if !ok {
        return nil, fmt.Errorf("invalid options object: %#v", opts)
    }
    // 返回對應的地址,以及建立連結
    location, transport, err := pod.ExecLocation(r.Store, r.KubeletConn, ctx, name, execOpts)
    if err != nil {
        return nil, err
    }
    return newThrottledUpgradeAwareProxyHandler(location, transport, false, true, true, responder), nil
}

3.2 獲取後端服務地址

在獲取地址主要是構建後端的 location 資訊,這裡會通過 kubelet 上報來的資訊獲取到對應的 node 的 host 和 Port 資訊,並且拼裝出 pod 的最終指向路徑即這裡的 Path 欄位/exec/{namespace}/{podName}/{containerName}

loc := &url.URL{
    Scheme:   nodeInfo.Scheme,
    Host:     net.JoinHostPort(nodeInfo.Hostname, nodeInfo.Port),   // node的埠
    Path:     fmt.Sprintf("/%s/%s/%s/%s", path, pod.Namespace, pod.Name, container),    // 路徑
    RawQuery: params.Encode(),
}

3.3 協議提升 handler 初始化

協議提升主要是通過 UpgradeAwareHandler 控制器進行實現, 該 handler 接收到請求之後會首先嚐試進行協議提升,其主要是檢測 http 頭裡面的 Connection 的值是不是 Upragde 來實現, 從之前 kubelet 的分析中可以知道這裡肯定是 true

func newThrottledUpgradeAwareProxyHandler(location *url.URL, transport http.RoundTripper, wrapTransport, upgradeRequired, interceptRedirects bool, responder rest.Responder) *proxy.UpgradeAwareHandler {

    handler := proxy.NewUpgradeAwareHandler(location, transport, wrapTransport, upgradeRequired, proxy.NewErrorResponder(responder))
    handler.InterceptRedirects = interceptRedirects && utilfeature.DefaultFeatureGate.Enabled(genericfeatures.StreamingProxyRedirects)
    handler.RequireSameHostRedirects = utilfeature.DefaultFeatureGate.Enabled(genericfeatures.ValidateProxyRedirects)
    handler.MaxBytesPerSec = capabilities.Get().PerConnectionBandwidthLimitBytesPerSec
    return handler
}

func (h *UpgradeAwareHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
    // 如果協議提升成功,則由該協議完成
    if h.tryUpgrade(w, req) {
        return
    }
    // 省略N多程式碼
}

3.4 協議提升處理

image.png

協議提升處理的邏輯比較多,這裡分為幾個小節來進行依次說明, 主要是先從 HTTP 連結中獲取請求,並進行轉發,然後同時持有兩個連結,並且在連結上進行 TCP 流的拷貝

3.4.1 與 kubelet 建立連結

協議提升的第一步就是與後端的 kubelet 建立連結了,這裡會將 kubelet 發過來的請求進行拷貝,並且傳送給後端的 kubelet, 並且這裡也會獲取到一個與 kubelet 建立的 http 的連結,後面進行流對拷的時候需要用到, 注意實際上這個 http 請求響應的狀態碼,是 101,即 kubelet 上實際上是構建了一個 spdy 協議的 handler 來進行通訊的

// 構建http請求
req, err := http.NewRequest(method, location.String(), body)
if err != nil {
    return nil, nil, err
}

req.Header = header

// 傳送請求建立連結
intermediateConn, err = dialer.Dial(req)
if err != nil {
    return nil, nil, err
}

// Peek at the backend response.
rawResponse.Reset()
respReader := bufio.NewReader(io.TeeReader(
    io.LimitReader(intermediateConn, maxResponseSize), // Don't read more than maxResponseSize bytes.
    rawResponse)) // Save the raw response.
    // 讀取響應資訊
resp, err := http.ReadResponse(respReader, nil)

3.4.2 Request 請求的 Hijack

這個請求實際上是 spdy 協議的,在通過 Hijack 獲取到底層的連結之後,需要先將上面的請求轉發給 kubelet 從而觸發 kubelet 傳送後面的 Stream 請求建立連結,就是這裡的 Write 將 kubelet 的結果轉發

requestHijackedConn, _, err := requestHijacker.Hijack()
// Forward raw response bytes back to client.
if len(rawResponse) > 0 {
    klog.V(6).Infof("Writing %d bytes to hijacked connection", len(rawResponse))
    if _, err = requestHijackedConn.Write(rawResponse); err != nil {
        utilruntime.HandleError(fmt.Errorf("Error proxying response from backend to client: %v", err))
    }
}

3.4.3 雙向流對拷

經過上面的兩步操作,apiserver 上就擁有來了兩個 http 連結,因為協議不是 http 的所以 apiserver 不能直接進行操作,而只能採用流對拷的方式來進行請求和響應的轉發

// 雙向拷貝連結
go func() {
    var writer io.WriteCloser
    if h.MaxBytesPerSec > 0 {
        writer = flowrate.NewWriter(backendConn, h.MaxBytesPerSec)
    } else {
        writer = backendConn
    }
    _, err := io.Copy(writer, requestHijackedConn)
    if err != nil && !strings.Contains(err.Error(), "use of closed network connection") {
        klog.Errorf("Error proxying data from client to backend: %v", err)
    }
    close(writerComplete)
}()

go func() {
    var reader io.ReadCloser
    if h.MaxBytesPerSec > 0 {
        reader = flowrate.NewReader(backendConn, h.MaxBytesPerSec)
    } else {
        reader = backendConn
    }
    _, err := io.Copy(requestHijackedConn, reader)
    if err != nil && !strings.Contains(err.Error(), "use of closed network connection") {
        klog.Errorf("Error proxying data from backend to client: %v", err)
    }
    close(readerComplete)
}()

4.kubelet

Kubelet 上的命令執行主要是依賴於 CRI.RuntimeService 來執行的,kubelet 只負責對應請求的轉發,並最終構建一個轉發後續請求的 Stream 代理,就完成了他的使命

4.1 執行命令主流程

主流程主要是獲取要執行的命令,然後檢測對應的 Pod 新,並呼叫 host.GetExec 返回一個對應的 URL,然後後續的請求就由 proxyStream 來完成, 我們一步步開始深入

func (s *Server) getExec(request *restful.Request, response *restful.Response) {
    // 獲取執行命令
    params := getExecRequestParams(request)
    streamOpts, err := remotecommandserver.NewOptions(request.Request)
    // 獲取pod的資訊
    pod, ok := s.host.GetPodByName(params.podNamespace, params.podName)
    podFullName := kubecontainer.GetPodFullName(pod)
    url, err := s.host.GetExec(podFullName, params.podUID, params.containerName, params.cmd, *streamOpts)
    proxyStream(response.ResponseWriter, request.Request, url)
}

4.2 Exec 返回執行結果

host.GetExec 最終會呼叫到 runtimeService 即 cri.RuntimeService 的 Exec 介面來進行請求的執行,該介面會返回一個地址即/exec/{token},此時並沒有執行真正的命令只是建立了一個命令執行請求而已

func (m *kubeGenericRuntimeManager) GetExec(id kubecontainer.ContainerID, cmd []string, stdin, stdout, stderr, tty bool) (*url.URL, error) {
    // 省略請求構造
    // 執行命令
    resp, err := m.runtimeService.Exec(req)
    return url.Parse(resp.Url)
}

最終其實就是呼叫 cri 的的 exec 介面, 我們先忽略該介面具體返回的啥,將 kubelet 剩餘的邏輯看完

func (c *runtimeServiceClient) Exec(ctx context.Context, in *ExecRequest, opts ...grpc.CallOption) (*ExecResponse, error) {
    err := c.cc.Invoke(ctx, "/runtime.v1alpha2.RuntimeService/Exec", in, out, opts...)
}

4.3 proxyStream

image.png

這裡我們可以發現,又是我們之前見過的 UpgradeAwareHandler,不過這次的 url 是後端 exec 執行返回的 url 了,然後剩下部分就跟 apiserver 裡面的差不多,在兩個 http 連結之間進行流對拷

我們想一下這個地方 Request 和 Response,其實是對應的 apiserver 與 kubelet 建立的連結,這個連結上是 spdy 的頭,記住這個地方, 則此時又跟後端繼續建立連結,後端其實也是一個 spdy 協議的 server, 至此我們還差最後一個部分就是返回的那個連結到底是啥,對應的控制器又是誰,進行下一節 cri 部分

// proxyStream proxies stream to url.
func proxyStream(w http.ResponseWriter, r *http.Request, url *url.URL) {
    // TODO(random-liu): Set MaxBytesPerSec to throttle the stream.
    handler := proxy.NewUpgradeAwareHandler(url, nil /*transport*/, false /*wrapTransport*/, true /*upgradeRequired*/, &responder{})
    handler.ServeHTTP(w, r)
}

5.CRI

CRI.RuntimeService 負責最終的命令執行,也是命令執行真正執行的位置,其中也涉及到很多的協議處理相關的操作,讓我們一起來看下關鍵實現吧

5.1 DockerRuntime 的註冊

在上面我們呼叫了 RuntimeService 的 Exec 介面,在 kubelet 中最終發現如下程式碼,建立了一個 DockerServer 並啟動

ds, err := dockershim.NewDockerService(kubeDeps.DockerClientConfig, crOptions.PodSandboxImage, streamingConfig,
dockerServer := dockerremote.NewDockerServer(remoteRuntimeEndpoint, ds)
        if err := dockerServer.Start(); err != nil {
            return err
        }

其中在 Start 函式裡面,註冊了下面兩個 RuntimeService,寫過 grpc 的朋友都知道,這個其實就是註冊對應 rpc 介面的實現,其實最終我們呼叫的是 DockerService 的介面

runtimeapi.RegisterRuntimeServiceServer(s.server, s.service)
runtimeapi.RegisterImageServiceServer(s.server, s.service)

5.2 DockerService 的 Exec 實現

Exec 最終的實現可以發現實際上是呼叫 streamingServer 的 GetExec 介面,返回了一個/exec/{token}的介面

func (ds *dockerService) Exec(_ context.Context, req *runtimeapi.ExecRequest) (*runtimeapi.ExecResponse, error) {
    // 執行Exec請求
    return ds.streamingServer.GetExec(req)
}

我們繼續追蹤 streamingServer 可以看到 GetExec 介面實現如下, 最終 build 了一個 url=/exec/{token},注意這裡實際上儲存了當前的 Request 請求在 cache 中

func (s *server) GetExec(req *runtimeapi.ExecRequest) (*runtimeapi.ExecResponse, error) {
    // 生成token
    token, err := s.cache.Insert(req)
    return &runtimeapi.ExecResponse{
        Url: s.buildURL("exec", token),
    }, nil
}

5.3 構建命令引數執行 Exec

首先通過 token 來獲取之前快取的 Request,然後通過 exec 請求命令,構建 StreamOpts,並最終呼叫 ServeExec 進行執行,接下來就是最不容易看懂的部分了,前方高能

func (s *server) serveExec(req *restful.Request, resp *restful.Response) {
    // 獲取token
    token := req.PathParameter("token")
    // 快取請求
    cachedRequest, ok := s.cache.Consume(token)
    // 構建exec引數s
    exec, ok := cachedRequest.(*runtimeapi.ExecRequest)

    streamOpts := &remotecommandserver.Options{
        Stdin:  exec.Stdin,
        Stdout: exec.Stdout,
        Stderr: exec.Stderr,
        TTY:    exec.Tty,
    }

    // 構建ServerExec執行請求
    remotecommandserver.ServeExec(
        resp.ResponseWriter,
        req.Request,
        s.runtime,
        "", // unused: podName
        "", // unusued: podUID
        exec.ContainerId,
        exec.Cmd,
        streamOpts,
        s.config.StreamIdleTimeout,
        s.config.StreamCreationTimeout,
        s.config.SupportedRemoteCommandProtocols)
}

5.4 ServerExec

ServerExec 關鍵步驟就兩個:1)建立 stream 2) 執行請求, 比較複雜的主要是集中在建立 stream 部分,我們注意下 ExecInContainer 的引數部分,傳入了通過建立流獲取的 ctx 的相關檔案描述符的 Stream, createStreams 裡面的實現有兩種協議 websocket 和 https,這裡我們主要分析 https(我們使用 kubectl 使用的就是 https 協議)

func ServeExec(w http.ResponseWriter, req *http.Request, executor Executor, podName string, uid types.UID, container string, cmd []string, streamOpts *Options, idleTimeout, streamCreationTimeout time.Duration, supportedProtocols []string) {
    // 建立serveExec
    ctx, ok := createStreams(req, w, streamOpts, supportedProtocols, idleTimeout, streamCreationTimeout)

    defer ctx.conn.Close()

    // 獲取執行,這是一個阻塞的過程,err會獲取當前的執行是否成功, 這裡將ctx裡面的資訊,都傳入進去,對應的其實就是各種流
    err := executor.ExecInContainer(podName, uid, container, cmd, ctx.stdinStream, ctx.stdoutStream, ctx.stderrStream, ctx.tty, ctx.resizeChan, 0)

}

5.5 建立 HTTPS Stream

Stream 的建立我將其概括成下面幾個步驟:1)進行 https 的握手 2) 協議升級為 spdy 3) 等待 stream 的建立,我們依次來看

1.完成 https 的握手

protocol, err := httpstream.Handshake(req, w, supportedStreamProtocols)

2.協議提升

// 流管道
streamCh := make(chan streamAndReply)

upgrader := spdy.NewResponseUpgrader()
// 構建spdy連結
conn := upgrader.UpgradeResponse(w, req, func(stream httpstream.Stream, replySent <-chan struct{}) error {
    // 當新請求建立之後,會追加到streamch
    streamCh <- streamAndReply{Stream: stream, replySent: replySent}
    return nil
})

這裡有一個關鍵機制就是後面 func 回撥函式的傳遞和 streamch 的傳遞,這裡建立一個連結之後會建立一個 Server,並且傳入了一個控制器就是 func 回撥函式,該函式每當建立一個連結之後,如果獲取到對應的 stream 就追加到 StreamCh 中,下面就是最複雜的網路處理部分了,因為太複雜,所以還是單獨開一節吧

5.6 spdy stream 的建立

image.png

總體流程上看起來簡單,主要是先根據請求來進行協議切換,然後返回 101,並且基於當前的連結構建 SPDY 的請求處理,然後等待 kubectl 通過 apiserver 傳送的需要建立的 Stream,就完成了彼此通訊流 stream 的建立

5.6.1 進行協議提升響應

首先第一步會先進行協議提升的響應,這裡我們注意幾個關鍵部分,spdy 協議,以及 101 狀態碼

// 協議
hijacker, ok := w.(http.Hijacker)
if !ok {
    errorMsg := fmt.Sprintf("unable to upgrade: unable to hijack response")
    http.Error(w, errorMsg, http.StatusInternalServerError)
    return nil
}

w.Header().Add(httpstream.HeaderConnection, httpstream.HeaderUpgrade)
// sydy協議
w.Header().Add(httpstream.HeaderUpgrade, HeaderSpdy31)
w.WriteHeader(http.StatusSwitchingProtocols)

5.6.2 建立 spdyServer

spdyConn, err := NewServerConnection(connWithBuf, newStreamHandler)

最終會通過 newConnection 負責新連結的建立

func NewServerConnection(conn net.Conn, newStreamHandler httpstream.NewStreamHandler) (httpstream.Connection, error) {
    // 建立一個新的連結, 通過一個已經存在的網路連結
    spdyConn, err := spdystream.NewConnection(conn, true)

    return newConnection(spdyConn, newStreamHandler), nil
}

這裡我們可以看到是啟動一個後臺的 server 來進行連結請求的處理

func newConnection(conn *spdystream.Connection, newStreamHandler httpstream.NewStreamHandler) httpstream.Connection {
    c := &connection{conn: conn, newStreamHandler: newStreamHandler}
    // 當建立連結後,進行syn請求建立流的時候,會呼叫newSpdyStream
    go conn.Serve(c.newSpdyStream)
    return c
}

5.6.3 Serve

1.首先會啟動多個 goroutine 來負責請求的處理,這裡的 worker 數量是 5 個,佇列大小是 20,

frameQueues := make([]*PriorityFrameQueue, FRAME_WORKERS)
    for i := 0; i < FRAME_WORKERS; i++ {
        frameQueues[i] = NewPriorityFrameQueue(QUEUE_SIZE)

        // Ensure frame queue is drained when connection is closed
        go func(frameQueue *PriorityFrameQueue) {
            <-s.closeChan
            frameQueue.Drain()
        }(frameQueues[i])

        wg.Add(1)
        go func(frameQueue *PriorityFrameQueue) {
            // let the WaitGroup know this worker is done
            defer wg.Done()

            s.frameHandler(frameQueue, newHandler)
        }(frameQueues[i])
    }

2.監聽 synStreamFrame,分流 frame,會按照 frame 的 streamID 來進行 hash 選擇對應的 frameQueues 佇列

case *spdy.SynStreamFrame:
    if s.checkStreamFrame(frame) {
        priority = frame.Priority
        partition = int(frame.StreamId % FRAME_WORKERS)
        debugMessage("(%p) Add stream frame: %d ", s, frame.StreamId)
        // 新增到對應的StreamId對應的frame裡面
        s.addStreamFrame(frame)
    } else {
        debugMessage("(%p) Rejected stream frame: %d ", s, frame.StreamId)
        continue

        // 最終會講frame push到上面的優先順序佇列裡面
        frameQueues[partition].Push(readFrame, priority)

3.讀取 frame 進行並把讀取到的 stream 通過 newHandler 傳遞給上面的 StreamCH

func (s *Connection) frameHandler(frameQueue *PriorityFrameQueue, newHandler StreamHandler) {
    for {
        popFrame := frameQueue.Pop()
        if popFrame == nil {
            return
        }

        var frameErr error
        switch frame := popFrame.(type) {
        case *spdy.SynStreamFrame:
            frameErr = s.handleStreamFrame(frame, newHandler)
    }
}

消費的流到下一節

5.7 等待建立 Stream

Stream 的等待建立主要是通過 Headers 裡面的 StreamType 來實現,這裡面會講對應的 stdinStream 和對應的 spdy 裡面的 stream 繫結,其他型別也是這樣


func (*v3ProtocolHandler) waitForStreams(streams <-chan streamAndReply, expectedStreams int, expired <-chan time.Time) (*context, error) {
    ctx := &context{}
    receivedStreams := 0
    replyChan := make(chan struct{})
    stop := make(chan struct{})
    defer close(stop)
WaitForStreams:
    for {
        select {
        case stream := <-streams:
            streamType := stream.Headers().Get(api.StreamType)
            switch streamType {
            case api.StreamTypeError:
                ctx.writeStatus = v1WriteStatusFunc(stream)
                go waitStreamReply(stream.replySent, replyChan, stop)
            case api.StreamTypeStdin:
                ctx.stdinStream = stream
                go waitStreamReply(stream.replySent, replyChan, stop)
            case api.StreamTypeStdout:
                ctx.stdoutStream = stream
                go waitStreamReply(stream.replySent, replyChan, stop)
            case api.StreamTypeStderr:
                ctx.stderrStream = stream
                go waitStreamReply(stream.replySent, replyChan, stop)
            case api.StreamTypeResize:
                ctx.resizeStream = stream
                go waitStreamReply(stream.replySent, replyChan, stop)
            default:
                runtime.HandleError(fmt.Errorf("unexpected stream type: %q", streamType))
            }
        case <-replyChan:
            receivedStreams++
            if receivedStreams == expectedStreams {
                break WaitForStreams
            }
        case <-expired:
            // TODO find a way to return the error to the user. Maybe use a separate
            // stream to report errors?
            return nil, errors.New("timed out waiting for client to create streams")
        }
    }

    return ctx, nil
}

5.8 CRI 介面卡執行命令

跟蹤呼叫鏈最終可以看到如下的呼叫,最終指向了 execHandler.ExecInContainer 介面用於在容器中執行命令

func (a *criAdapter) ExecInContainer(podName string, podUID types.UID, container string, cmd []string, in io.Reader, out, err io.WriteCloser, tty bool, resize <-chan remotecommand.TerminalSize, timeout time.Duration) error {
    // 執行command
    return a.Runtime.Exec(container, cmd, in, out, err, tty, resize)
}
func (r *streamingRuntime) Exec(containerID string, cmd []string, in io.Reader, out, err io.WriteCloser, tty bool, resize <-chan remotecommand.TerminalSize) error {
    //  執行容器
    return r.exec(containerID, cmd, in, out, err, tty, resize, 0)
}

// Internal version of Exec adds a timeout.
func (r *streamingRuntime) exec(containerID string, cmd []string, in io.Reader, out, errw io.WriteCloser, tty bool, resize <-chan remotecommand.TerminalSize, timeout time.Duration) error {
    // exechandler
    return r.execHandler.ExecInContainer(r.client, container, cmd, in, out, errw, tty, resize, timeout)
}

5.9 命令執行主流程

命令的指向的主流程主要分為兩個部分:1)建立 exec 執行任務 2) 啟動 exec 執行任務

func (*NativeExecHandler) ExecInContainer(client libdocker.Interface, container *dockertypes.ContainerJSON, cmd []string, stdin io.Reader, stdout, stderr io.WriteCloser, tty bool, resize <-chan remotecommand.TerminalSize, timeout time.Duration) error {
    // 在容器中執行命令
    done := make(chan struct{})
    defer close(done)

    // 執行命令
    createOpts := dockertypes.ExecConfig{
        Cmd:          cmd,
        AttachStdin:  stdin != nil,
        AttachStdout: stdout != nil,
        AttachStderr: stderr != nil,
        Tty:          tty,
    }
    // 建立執行命令任務
    execObj, err := client.CreateExec(container.ID, createOpts)

    startOpts := dockertypes.ExecStartCheck{Detach: false, Tty: tty}
    // 這裡我們可以看到我們將前面獲取到的stream的封裝,都作為FD傳入到容器的執行命令裡面去了
    streamOpts := libdocker.StreamOptions{
        InputStream:  stdin,
        OutputStream: stdout,
        ErrorStream:  stderr,
        RawTerminal:  tty,
        ExecStarted:  execStarted,
    }
    // 執行命令
    err = client.StartExec(execObj.ID, startOpts, streamOpts)

    ticker := time.NewTicker(2 * time.Second)
    defer ticker.Stop()
    count := 0
    for {
        //  獲取執行結果
        inspect, err2 := client.InspectExec(execObj.ID)
        if !inspect.Running {
            if inspect.ExitCode != 0 {
                err = &dockerExitError{inspect}
            }
            break
        }
        <-ticker.C
    }

    return err
}

Docker 的命令執行介面呼叫

func (cli *Client) ContainerExecCreate(ctx context.Context, container string, config types.ExecConfig) (types.IDResponse, error) {
    resp, err := cli.post(ctx, "/containers/"+container+"/exec", nil, config, nil)
    return response, err
}

5.10 命令執行核心實現

image.png

命令執行的核心實現主要是兩個步驟:1)首先傳送 exec 執行請求 2)啟動對應的 exec 並獲取結果, 複雜的還是 SPDY 相關的 Stream 的邏輯

func (d *kubeDockerClient) StartExec(startExec string, opts dockertypes.ExecStartCheck, sopts StreamOptions) error {
    // 啟動執行命令, 獲取結果
    resp, err := d.client.ContainerExecAttach(ctx, startExec, dockertypes.ExecStartCheck{
        Detach: opts.Detach,
        Tty:    opts.Tty,
    })
    // 將輸入流拷貝到輸出流, 這裡會講resp裡面的結果拷貝到outputSTream裡面
    return d.holdHijackedConnection(sopts.RawTerminal || opts.Tty, sopts.InputStream, sopts.OutputStream, sopts.ErrorStream, resp)
}

5.10.1 命令執行請求

cli.postHijacked(ctx, "/exec/"+execID+"/start", nil, config, headers)

5.10.2 傳送請求獲取連線

這裡的 HiHijackConn 功能跟之前介紹的類似,其核心也是通過建立 http 連結,然後進行協議提升,其 conn 就是底層的 tcp 連結,同時還給對應的連結設定了 Keepliave 當前是 30s, 到此我們就又有了一個基於 spdy 雙向通訊的連結

func (cli *Client) postHijacked(ctx context.Context, path string, query url.Values, body interface{}, headers map[string][]string) (types.HijackedResponse, error) {
    conn, err := cli.setupHijackConn(ctx, req, "tcp")
    return types.HijackedResponse{Conn: conn, Reader: bufio.NewReader(conn)}, err
}

5.10.3 建立流對拷

至此在 kubelet 上面我們獲取到了與後端執行命令的 Stream 還有與 apiserver 建立的 Stream, 此時就只需要將兩個流直接進行拷貝,就可以實現資料的傳輸了

func (d *kubeDockerClient) holdHijackedConnection(tty bool, inputStream io.Reader, outputStream, errorStream io.Writer, resp dockertypes.HijackedResponse) error {
    receiveStdout := make(chan error)
    if outputStream != nil || errorStream != nil {
        // 將響應結果拷貝到outputstream裡面
        go func() {
            receiveStdout <- d.redirectResponseToOutputStream(tty, outputStream, errorStream, resp.Reader)
        }()
    }

    stdinDone := make(chan struct{})
    go func() {
        if inputStream != nil {
            io.Copy(resp.Conn, inputStream)
        }
        resp.CloseWrite()
        close(stdinDone)
    }()

    return nil
}

5.10.4 檢測執行狀態

image.png 在發生完成執行命令以後,會每隔 2s 鍾進行一次執行狀態的檢查,如果發現執行完成,則就直接退出

func (*NativeExecHandler) ExecInContainer(client libdocker.Interface, container *dockertypes.ContainerJSON, cmd []string, stdin io.Reader, stdout, stderr io.WriteCloser, tty bool, resize <-chan remotecommand.TerminalSize, timeout time.Duration) error {
    ticker := time.NewTicker(2 * time.Second)
    defer ticker.Stop()
    count := 0
    for {
        //  獲取執行結果
        inspect, err2 := client.InspectExec(execObj.ID)
        if err2 != nil {
            return err2
        }
        if !inspect.Running {
            if inspect.ExitCode != 0 {
                err = &dockerExitError{inspect}
            }
            break
        }
        <-ticker.C
    }

    return err
}

func (cli *Client) ContainerExecInspect(ctx context.Context, execID string) (types.ContainerExecInspect, error) {
    resp, err := cli.get(ctx, "/exec/"+execID+"/json", nil, nil)
    return response, err
}

6.總結

image.png

整個命令執行的過程其實還是蠻複雜的,主要是在於網路協議切換那部分,我們可以看到其實在整個過程中,都是基於 SPDY 協議來進行的,並且在 CRI.RuntimeService 那部分我們也可以看到 Stream 的請求處理其實也是多 goroutine 併發的,仰慕一下大牛的設計,有什麼寫的不對的地方,歡迎一起討論,謝謝大佬們能看到這裡

kubernetes 學習筆記地址: https://www.yuque.com/baxiaoshi/tyado3

微訊號:baxiaoshi2020 歡迎一起交流學習分享

更多原創文章乾貨分享,請關注公眾號
  • 圖解 kubernetes 命令執行核心實現
  • 加微信實戰群請加微信(註明:實戰群):gocnio

相關文章