Nginx vs Envoy vs MOSN 平滑升級原理解析

rootsongjc發表於2020-02-26

前言

本文是對 Nginx、Envoy 及 MOSN 的平滑升級原理區別的分析,適合對 Nginx 實現原理比較感興趣的同學閱讀,需要具備一定的網路程式設計知識。

平滑升級的本質就是 listener fd 的遷移,雖然 Nginx、Envoy、MOSN 都提供了平滑升級支援,但是鑑於它們程式模型的差異,反映在實現上還是有些區別的。這裡來探討下它們其中的區別,並著重介紹 Nginx 的實現。

Nginx

相信有很多人認為 Nginx 的 reload 操作就能完成平滑升級,其實這是個典型的理解錯誤。實際上 reload 操作僅僅是平滑重啟,並沒有真正的升級新的二進位制檔案,也就是說其執行的依然是老的二進位制檔案。

Nginx 自身也並沒有提供平滑升級的命令選項,其只能靠手動觸發訊號來完成。具體正確的操作步驟可以參考這裡:Upgrading Executable on the Fly,這裡只分析下其實現原理。

Nginx 的平滑升級是通過 fork + execve 這種經典的處理方式來實現的。準備升級時,Old Master 程式收到訊號然後 fork 出一個子程式,注意此時這個子程式執行的依然是老的映象檔案。緊接著這個子程式會通過 execve 呼叫執行新的二進位制檔案來替換掉自己,成為 New Master。

那麼問題來了:New Master 啟動時按理說會執行 bind + listen 等操作來初始化監聽,而這時候 Old Master 還沒有退出,埠未釋放,執行 execve 時理論上應該會報:Address already in use 錯誤,但是實際上這裡卻沒有任何問題,這是為什麼?

因為 Nginx 在 execve 的時候壓根就沒有重新 bind + listen,而是直接把 listener fd 新增到 epoll 的事件表。因為這個 New Master 本來就是從 Old Master 繼承而來,自然就繼承了 Old Master 的 listener fd,但是這裡依然有一個問題:該怎麼通知 New Master 呢?

環境變數execve 在執行的時候可以傳入環境變數。實際上 Old Master 在 fork 之前會將所有 listener fd 新增到 NGINX 環境變數:

ngx_pid_t
ngx_exec_new_binary(ngx_cycle_t *cycle, char *const *argv)
{
...
    ctx.path = argv[0];
    ctx.name = "new binary process";
    ctx.argv = argv;

    n = 2;
    env = ngx_set_environment(cycle, &n);
...
    env[n++] = var;
    env[n] = NULL;
...
    ctx.envp = (char *const *) env;

    ccf = (ngx_core_conf_t *) ngx_get_conf(cycle->conf_ctx, ngx_core_module);

    if (ngx_rename_file(ccf->pid.data, ccf->oldpid.data) == NGX_FILE_ERROR) {
       ...
        return NGX_INVALID_PID;
    }

    pid = ngx_execute(cycle, &ctx);

    return pid;
}

Nginx 在啟動的時候,會解析 NGINX 環境變數:

static ngx_int_t
ngx_add_inherited_sockets(ngx_cycle_t *cycle)
{
...
    inherited = (u_char *) getenv(NGINX_VAR);
    if (inherited == NULL) {
        return NGX_OK;
    }
    if (ngx_array_init(&cycle->listening, cycle->pool, 10,
                       sizeof(ngx_listening_t))
        != NGX_OK)
    {
        return NGX_ERROR;
    }

    for (p = inherited, v = p; *p; p++) {
        if (*p == ':' || *p == ';') {
            s = ngx_atoi(v, p - v);
            ...
            v = p + 1;

            ls = ngx_array_push(&cycle->listening);
            if (ls == NULL) {
                return NGX_ERROR;
            }

            ngx_memzero(ls, sizeof(ngx_listening_t));

            ls->fd = (ngx_socket_t) s;
        }
    }
    ...
    ngx_inherited = 1;

    return ngx_set_inherited_sockets(cycle);
}

一旦檢測到是繼承而來的 socket,那就說明已經開啟了,不會再繼續 bind + listen 了:

ngx_int_t
ngx_open_listening_sockets(ngx_cycle_t *cycle)
{
    ...
    /* TODO: configurable try number */

    for (tries = 5; tries; tries--) {
        failed = 0;

        /* for each listening socket */

        ls = cycle->listening.elts;
        for (i = 0; i < cycle->listening.nelts; i++) {
        ...
            if (ls[i].inherited) {

                /* TODO: close on exit */
                /* TODO: nonblocking */
                /* TODO: deferred accept */

                continue;
            }
            ...

            ngx_log_debug2(NGX_LOG_DEBUG_CORE, log, 0,
                           "bind() %V #%d ", &ls[i].addr_text, s);

            if (bind(s, ls[i].sockaddr, ls[i].socklen) == -1) {
                ...
            }
            ...
        }
    }

    if (failed) {
        ngx_log_error(NGX_LOG_EMERG, log, 0, "still could not bind()");
        return NGX_ERROR;
    }

    return NGX_OK;
}

Envoy

Envoy 使用的是單程式多執行緒模型,其侷限就是無法通過環境變數來傳遞 listener fd。因此 Envoy 採用的是 UDS(unix domain sockets)方案。當 New Envoy 啟動完成後,會通過 UDS 向 Old Envoy 請求 listener fd 副本,拿到 listener fd 之後開始接管新來的連線,並通知 Old Envoy 終止執行。

file descriptor 是可以通過 sendmsg/recvmsg 來傳遞的。

MOSN

MOSN 開源地址:https://github.com/mosn/mosn

MOSN 的方案和 Envoy 類似,都是通過 UDS 來傳遞 listener fd。但是其比 Envoy 更厲害的地方在於它可以把老的連線從 Old MOSN 上遷移到 New MOSN 上。也就是說把一個連線從程式 A 遷移到程式 B,而保持連線不斷!!!厲不厲害?聽起來很簡單,但是實現起來卻沒那麼容易,比如資料已經被拷貝到了應用層,但是還沒有被處理,怎麼辦?這裡面有很多細節需要處理。它子所以能做到這種層面,靠的也是核心的 sendmsg/recvmsg 技術。

SCM_RIGHTS - Send or receive a set of open file descriptors from another process. The data portion contains an integer array of the file descriptors. The passed file descriptors behave as though they have been created with dup(2). http://linux.die.net/man/7/unix

這裡有一個 Go 實現的小 Demo: tcp 連結遷移

對比

Nginx 的實現是相容性最強的,因為 Envoy 和 MOSN 都依賴 sendmsg/recvmsg 系統呼叫,需要核心 3.5+ 支援。MOSN 的難度最高,算得上是真正的無損升級,而 Nginx 和 Envoy 對於老的連線,僅僅是實現 graceful shutdown,嚴格來說是有損的。這對於 HTTP(通過 Connection: close) 和 gRPC(GoAway Frame) 協議支援很友好,但是遇到自定義的 TCP 協議就抓瞎了。如果遇到客戶端沒有處理 close 異常,很容易發生 socket fd 洩露問題。

本文作者 ms2008,轉載自Nginx vs Envoy vs Mosn 平滑升級原理解析

更多原創文章乾貨分享,請關注公眾號
  • Nginx vs Envoy vs MOSN 平滑升級原理解析
  • 加微信實戰群請加微信(註明:實戰群):gocnio

相關文章