19. 從零開始編寫一個類nginx工具, 配置資料的熱更新原理及實現

問蒙服務框架發表於2023-10-27

wmproxy

wmproxy是由Rust編寫,已實現http/https代理,socks5代理, 反向代理,靜態檔案伺服器,內網穿透,配置熱更新等, 後續將實現websocket代理等,同時會將實現過程分享出來, 感興趣的可以一起造個輪子法

專案地址

gite: https://gitee.com/tickbh/wmproxy

github: https://github.com/tickbh/wmproxy

配置資料

資料通常配置在配置檔案中,如果需要變更配置,我們通常將配置檔案進行更新,並通知程式重新載入配置以便生效。

nginx的變更方式

在nginx中,我們通常用

nginx -s reload

進行資料的安全無縫的過載。在nginx中,是多程式的模式,也就是在nginx -s reload訊號發出後master程式通知之前的work程式停止接收新的流,也就是accpet暫停,但是會服務完當前的資料請求,並同時會啟用新的work程式來接受新的請求

缺點:nginx只能整體的配置做全部重置,且無法檢視當前的配置(除非看配置檔案,配置可能被重新修改過和記憶體中的值可能不匹配)

當前選取的方式

當前選擇的是用HTTP請求的方式,也就是對本地的埠進行監聽(http://127.0.0.1:8837),對本地埠監聽也不會造成對外暴露埠帶來的安全問題,這樣子可以高度的自定義。具有比較高的活躍性,也可以實時查詢記憶體中的資料。

例如訪問:

  • http://127.0.0.1:8837/reload即可通知目標程式過載當前的配置
  • http://127.0.0.1:8837/now即可以知道當前的所有的配置列表
  • http://127.0.0.1:8837/stop即可以關閉當前的程式,停止服務,類似於nginx中的nginx -s stop
  • http://127.0.0.1:8837/adapt載入當前配置,看是否錯誤,但是不進行應用。
    等功能。

功能實現的原理

  • 單程式
    單程式模式的缺點:如果存在記憶體洩漏之類的情況,無論如何過載程式都無法將記憶體恢復,會始終保持較高的記憶體值直到最終不可用的階段。如果發生未正確處理的異常,可能會使該程式崩潰的風險,處於無服務狀態。
    單程式模式的優點:在當前程式儲存的一些有利於加速服務的將會很好的被保留下來(如健康檢查的資料),非同步程式里正在處理的資料等。無需進行程式間通訊,配合tokio的非同步處理可以將單程式的優勢完美髮揮出來。

  • 埠複用
    無論哪種模式,都需要處理資料過載時,繫結物件的轉移TcpListener或者重新繫結TcpListener,在Rust中轉移繫結物件相對來說較麻煩後續如果擴充成多程式模式也無法進行轉移,所以不考慮用轉移所有權的問題。那麼此時我們的解決方法就是set_reuse_addressset_reuse_port,不同平臺該方法上有不同的表現,我們用的是socket2的封裝,用該方法的注意事項:

  • 在windows平臺上,不存在set_reuse_port方法,僅呼叫set_reuse_address即可實現一個地址多次繫結

  • 在linux上,不同的版本上,有些只需呼叫set_reuse_address即可埠複用,有些需要同時呼叫set_reuse_port

  • 在macos上,需要呼叫set_reuse_addressset_reuse_port函式才可實現埠複用

所以這裡涉及一個分平臺的編碼,我們在此使用的是,這和C/C++中的#ifdef WINDOWS類似,但是隻能在函式級的做調整,所以此處額外在封裝了兩個函式來做呼叫。

/// 非windows平臺
#[cfg(not(target_os = "windows"))]
fn set_reuse_port(socket: &Socket, reuse: bool) -> io::Result<()> {
    socket.set_reuse_port(true)?;
    Ok(())
}

/// windows平臺,空實現
#[cfg(target_os = "windows")]
fn set_reuse_port(_socket: &Socket, _sreuse: bool) -> io::Result<()> {
    Ok(())
}

然後將原來的TcpListener::bind(addr)函式改成Helper::bind即可無縫切換到支援埠複用的功能,針對代理端及反向代理端:

/// 可埠複用的繫結方式,該埠可能被多個程式同時使用
pub async fn bind<A: ToSocketAddrs>(addr: A) -> io::Result<TcpListener> {
    let addrs = addr.to_socket_addrs()?;
    let mut last_err = None;
    for addr in addrs {
        let socket = Socket::new(Domain::IPV4, Type::STREAM, None)?;
        socket.set_nonblocking(true)?;
        let _ = socket.set_only_v6(false);
        socket.set_reuse_address(true)?;
        Self::set_reuse_port(&socket, true)?;
        socket.bind(&addr.into())?;
        match socket.listen(128) {
            Ok(_) => {
                let listener: std::net::TcpListener = socket.into();
                return TcpListener::from_std(listener);
            }
            Err(e) => {
                log::info!("繫結埠地址失敗,原因: {:?}", addr);
                last_err = Some(e);
            }
        }
    }

    Err(last_err.unwrap_or_else(|| {
        io::Error::new(
            io::ErrorKind::InvalidInput,
            "could not resolve to any address",
        )
    }))
}

測試功能

測試配置載入reload,一開始我們繫結81的埠

程式啟動後改為繫結82的埠,然後呼叫reload(curl.exe http://127.0.0.1:8837/reload)

此時,再呼叫stop(curl.exe http://127.0.0.1:8837/stop),正確的預期應該顯示關閉,且82埠不可再訪問

符合功能預期,初步測試完畢

相關原始碼實現

以下是啟動及傳送過載配置的流程示意圖

flowchart TD A[載入配置] B[繫結埠] C[控制端] D[服務1] E[服務2] F[控制窗戶端] A -->|載入資料後繫結| B B -->|"(1)繫結埠後啟動"| C B -->|"(1)非同步的方式啟動"| D F -->|傳送過載入命令| C C -->|"(3)傳送關閉服務命令"| D C -->|"(2)啟動新的服務後關閉原服務"| E

以下是中控的定義,訊息的通知主要透過Sender/Receiver來進行資料的通知。

/// 控制端,可以對配置進行熱更新
pub struct ControlServer {
    /// 控制端當前的配置檔案,如果部分修改將直接修改資料進行重啟
    option: ConfigOption,
    /// 通知服務進行關閉的Sender,服務相關如果收到該訊息則停止Accept
    server_sender_close: Option<Sender<()>>,
    /// 通知中心服務的Sender,每個服務擁有一個該Sender,可反向通知中控關閉
    control_sender_close: Sender<()>,
    /// 通知中心服務的Receiver,收到一次則將當前的引用計數-1,如果為0則表示需要關閉伺服器
    control_receiver_close: Option<Receiver<()>>,
    /// 服務的引用計數
    count: i32,
}

啟動控制終端,接收HTTP的指令和關閉的指令,此時control已經變成了Arc<Mutex<ControlServer>>,方便在各各執行緒間傳播,同步修改資料。

pub async fn start_control(control: Arc<Mutex<ControlServer>>) -> ProxyResult<()> {
    let listener = {
        let value = &control.lock().await.option;
        TcpListener::bind(format!("127.0.0.1:{}", value.control)).await?
    };

    loop {
        let mut receiver = {
            let value = &mut control.lock().await;
            value.control_receiver_close.take()
        };
        
        tokio::select! {
            Ok((conn, addr)) = listener.accept() => {
                let cc = control.clone();
                tokio::spawn(async move {
                    let mut server = Server::new_data(conn, Some(addr), cc);
                    if let Err(e) = server.incoming(Self::operate).await {
                        log::info!("反向代理:處理資訊時發生錯誤:{:?}", e);
                    }
                });
                let value = &mut control.lock().await;
                value.control_receiver_close = receiver;
            }
            _ = Self::receiver_await(&mut receiver) => {
                let value = &mut control.lock().await;
                value.count -= 1;
                log::info!("反向代理:控制端收到關閉訊號,當前:{}", value.count);
                if value.count <= 0 {
                    break;
                }
                value.control_receiver_close = receiver;
            }
        }
    }
    Ok(())
}

處理相關訊息:

if req.path() == "/reload" {
    // 將重新啟動伺服器
    let _ = value.do_restart_serve().await;
    return Ok(Response::text()
    .body("重新載入配置成功")
    .unwrap()
    .into_type());
}

if req.path() == "/stop" {
    // 通知控制端關閉,控制端阻塞主執行緒,如果控制端退出後程式退出
    if let Some(sender) = &value.server_sender_close {
        let _ = sender.send(()).await;
    }
    return Ok(Response::text()
    .body("關閉程式成功")
    .unwrap()
    .into_type());
}

以下是主要的啟動程式碼:

async fn inner_start_server(&mut self, option: ConfigOption) -> ProxyResult<()>  {
    let sender = self.control_sender_close.clone();
    let (sender_no_listen, receiver_no_listen) = channel::<()>(1);
    let sender_close = self.server_sender_close.take();
    // 每次啟動的時候將讓控制計數+1
    self.count += 1;
    tokio::spawn(async move {
        let mut proxy = Proxy::new(option);
        // 將上一個程式的關閉許可權交由下一個服務,只有等下一個服務準備完畢的時候才能關閉上一個服務
        if let Err(e) = proxy.start_serve(receiver_no_listen, sender_close).await {
            log::info!("處理失敗服務程式失敗: {:?}", e);
        }
        // 每次退出的時候將讓控制計數-1,減到0則退出
        let _ = sender.send(()).await;
    });
    self.server_sender_close = Some(sender_no_listen);
    Ok(())
}

結語

此時以不同於nginx的另一種配置的載入已經開發完畢,配置的熱載入可以讓您更從容的保護好您的系統。

點選 [關注][在看][點贊] 是對作者最大的支援

相關文章