WebSocket協議 + nginx 動態負載均衡 (史上最全)

文章很長,建議收藏起來慢慢讀!瘋狂創客圈總目錄 語雀版 | 總目錄 碼雲版| 總目錄 博客園版 爲您奉上珍貴的學習資源 :


推薦:入大廠 、做架構、大力提升Java 內功 的 精彩博文

入大廠 、做架構、大力提升Java 內功 必備的精彩博文 2021 秋招漲薪1W + 必備的精彩博文
1:Redis 分佈式鎖 (圖解-秒懂-史上最全) 2:Zookeeper 分佈式鎖 (圖解-秒懂-史上最全)
3: Redis與MySQL雙寫一致性如何保證? (面試必備) 4: 面試必備:秒殺超賣 解決方案 (史上最全)
5:面試必備之:Reactor模式 6: 10分鐘看懂, Java NIO 底層原理
7:TCP/IP(圖解+秒懂+史上最全) 8:Feign原理 (圖解)
9:DNS圖解(秒懂 + 史上最全 + 高薪必備) 10:CDN圖解(秒懂 + 史上最全 + 高薪必備)
11: 分佈式事務( 圖解 + 史上最全 + 吐血推薦 ) 12:限流:計數器、漏桶、令牌桶
三大算法的原理與實戰(圖解+史上最全)
13:架構必看:12306搶票系統億級流量架構
(圖解+秒懂+史上最全)
14:seata AT模式實戰(圖解+秒懂+史上最全)
15:seata 源碼解讀(圖解+秒懂+史上最全) 16:seata TCC模式實戰(圖解+秒懂+史上最全)

Java 面試題 30個專題 , 史上最全 , 面試必刷 阿里、京東、美團... 隨意挑、橫着走!!!
1: JVM面試題(史上最強、持續更新、吐血推薦) 2:Java基礎面試題(史上最全、持續更新、吐血推薦
3:架構設計面試題 (史上最全、持續更新、吐血推薦) 4:設計模式面試題 (史上最全、持續更新、吐血推薦)
17、分佈式事務面試題 (史上最全、持續更新、吐血推薦) 一致性協議 (史上最全)
29、多線程面試題(史上最全) 30、HR面經,過五關斬六將後,小心陰溝翻船!
9.網絡協議面試題(史上最全、持續更新、吐血推薦) 更多專題, 請參見【 瘋狂創客圈 高併發 總目錄

SpringCloud 微服務 精彩博文
nacos 實戰(史上最全) sentinel (史上最全+入門教程)
SpringCloud gateway (史上最全) 分庫分表sharding-jdbc底層原理與實操(史上最全,5W字長文,吐血推薦)

WebSocket協議+Nginx動態負載均衡(史上最全)

HTML5 擁有衆多引人注目的新特性,如 Canvas、本地存儲、多媒體編程接口、WebSocket 等等。

其中,WebSocket 的出現使得瀏覽器提供對 Socket 的支持成爲可能,從而在瀏覽器和服務器之間提供了一個基於 TCP 連接的雙向通道。

使用 WebSocket,web開發人員可以很方便地構建實時 web 應用。

說明:

本文含三大Java高階知識:

  • WebSocket協議原理,
  • Netty+Websocket 安全驗證技巧
  • Nginx動態負載均衡

背景

以前,很多網站使用輪詢實現推送技術。輪詢是在特定的的時間間隔(比如1秒),由瀏覽器對服務器發出HTTP request,然後由服務器返回最新的數據給瀏覽器。輪詢的缺點很明顯,瀏覽器需要不斷的向服務器發出請求,然而HTTP請求的header是非常長的,而實際傳輸的數據可能很小,這就造成了帶寬和服務器資源的浪費。

Comet使用了AJAX改進了輪詢,可以實現雙向通信。但是Comet依然需要發出請求,而且在Comet中,普遍採用了長鏈接,這也會大量消耗服務器帶寬和資源。

於是,WebSocket協議應運而生。

WebSocket協議

瀏覽器通過 JavaScript 向服務器發出建立 WebSocket 連接的請求,連接建立以後,客戶端和服務器通過 TCP 連接直接交換數據。WebSocket 連接本質上是一個 TCP 連接。

WebSocket在數據傳輸的穩定性和數據傳輸量的大小方面,具有很大的性能優勢。Websocket.org 比較了輪詢和WebSocket的性能優勢:

websocket vs polling

從上圖可以看出,WebSocket具有很大的性能優勢,流量和負載增大的情況下,優勢更加明顯。

WebSocket 協議解決了瀏覽器和服務器之間的全雙工通信問題。在WebSocket出現之前,瀏覽器如果需要從服務器及時獲得更新,則需要不停的對服務器主動發起請求,也就是 Web 中常用的 poll 技術。這樣的操作非常低效,這是因爲每發起一次新的 HTTP 請求,就需要單獨開啓一個新的 TCP 鏈接,同時 HTTP 協議本身也是一種開銷非常大的協議。爲了解決這些問題,所以出現了 WebSocket 協議。WebSocket 使得瀏覽器和服務器之間能通過一個持久的 TCP 鏈接就能完成數據的雙向通信。關於 WebSocket 的 RFC 提案,可以參看 RFC6455。

WebSocket 和 HTTP 協議一般情況下都工作在瀏覽器中,但 WebSocket 是一種完全不同於 HTTP 的協議。儘管,瀏覽器需要通過 HTTP 協議的 GET 請求,將 HTTP 協議升級爲 WebSocket 協議。升級的過程被稱爲 握手(handshake)。當瀏覽器和服務器成功握手後,則可以開始根據 WebSocket 定義的通信幀格式開始通信了。像其他各種協議一樣,WebSocket 協議的通信幀也分爲控制數據幀和普通數據幀,前者用於控制 WebSocket 鏈接狀態,後者用於承載數據。下面我們將一一分析 WebSocket 協議的握手過程以及通信幀格式。

websocket協議握手報文

握手的過程也就是將 HTTP 協議升級爲 WebSocket 協議的過程。前面我們說過,握手開始首先由瀏覽器端發送一個 GET 請求開發,該請求的 HTTP 頭部信息如下:

客戶端請求

在客戶端,new WebSocket實例化一個新的WebSocket客戶端對象,

請求類似 ws://yourdomain:port/ws 的服務端WebSocket URL,

客戶端WebSocket對象會自動解析並識別爲WebSocket請求,並連接服務端端口,執行雙方握手過程,客戶端發送數據格式類似:

GET /ws  HTTP/1.1
Upgrade: websocket
Connection: Upgrade
Host: example.com
Origin: http://localhost:8080
Sec-WebSocket-Key: sN9cRrP/n9NdMgdcy2VJFQ==
Sec-WebSocket-Version: 13

可以看到,客戶端發起的WebSocket連接報文類似傳統HTTP報文,

可以看到,瀏覽器發送的 HTTP 請求中,增加了一些新的字段,其作用如下所示:

  • Upgrade: 規定必需的字段,其值必需爲 websocket, 如果不是則握手失敗;
  • Connection: 規定必需的字段,值必需爲 Upgrade, 如果不是則握手失敗;
  • Sec-WebSocket-Key: 必需字段,一個隨機的字符串;
  • Sec-WebSocket-Version: 必需字段,代表了 WebSocket 協議版本,值必需是 13, 否則握手失敗;

Upgrade:websocket參數值表明這是WebSocket類型請求,

Sec-WebSocket-Key是WebSocket客戶端發送的一個 base64編碼的密文,要求服務端必須返回一個對應加密的Sec-WebSocket-Accept應答,否則客戶端會拋出Error during WebSocket handshake錯誤,並關閉連接。

服務器迴應

當服務器端,成功驗證了以上信息後,則會返回一個形如以下信息的響應:

服務端收到報文後返回的數據格式類似:

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: fFBooB7FAkLlXgRSz0BT3v4hq5s=
Sec-WebSocket-Origin: null
Sec-WebSocket-Location: ws://example.com/

返回的響應中,如果握手成功會返回狀態碼爲 101 的 HTTP 響應。同時其他字段說明如下:

  • Upgrade: 規定必需的字段,其值必需爲 websocket, 如果不是則握手失敗;
  • Connection: 規定必需的字段,值必需爲 Upgrade, 如果不是則握手失敗;
  • Sec-WebSocket-Accept: 規定必需的字段,該字段的值是通過固定字符串258EAFA5-E914-47DA-95CA-C5AB0DC85B11加上請求中Sec-WebSocket-Key字段的值,然後再對其結果通過 SHA1 哈希算法求出的結果。

HTTP/1.1 101 Switching Protocols表示服務端接受WebSocket協議的客戶端連接,

Sec-WebSocket-Accept的值是服務端採用與客戶端一致的密鑰計算出來後返回客戶端的,

客戶端過來的 Sec-WebSocket-Key是隨機的,服務器端會用這些數據來構造出一個SHA-1的信息摘要。把Sec-WebSocket-Key加上一個魔幻字符串,使用 SHA-1 加密,之後進行 BASE-64編碼,將結果作爲 Sec-WebSocket-Accept 頭的值,返回給客戶端。

經過這樣的請求-響應處理後,兩端的WebSocket連接握手成功, 後續就可以進行TCP通訊了。

在開發方面,WebSocket API 也十分簡單:只需要實例化 WebSocket,創建連接,然後服務端和客戶端就可以相互發送和響應消息。在WebSocket 實現及案例分析部分可以看到詳細的 WebSocket API 及代碼實現。

WebSocket 協議數據幀

當瀏覽器和服務器端成功握手後,就可以傳送數據了,傳送數據是按照 WebSocket 協議的數據格式生成的。

數據幀的定義類似於 TCP/IP 協議的格式定義,具體看下圖:

0                   1                   2                   3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| opcode|M| Payload len |    Extended payload length    |
|I|S|S|S|  (4)  |A|     (7)     |             (16/64)           |
|N|V|V|V|       |S|             |   (if payload len==126/127)   |
| |1|2|3|       |K|             |                               |
+-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
|     Extended payload length continued, if payload len == 127  |
+ - - - - - - - - - - - - - - - +-------------------------------+
|                               |Masking-key, if MASK set to 1  |
+-------------------------------+-------------------------------+
| Masking-key (continued)       |          Payload Data         |
+-------------------------------- - - - - - - - - - - - - - - - +
:                     Payload Data continued ...                :
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
|                     Payload Data continued ...                |
+---------------------------------------------------------------+

以上這張圖,一行代表 32 bit (位) ,也就是 4 bytes。總體上包含兩份,幀頭部和數據內容。每個從 WebSocket 鏈接中接收到的數據幀,都要按照以上格式進行解析,這樣才能知道該數據幀是用於控制的還是用於傳送數據的。

WebSocket與HTTP的關係

相比HTTP長連接,WebSocket有以下特點:

1)是真正的全雙工方式,建立連接後客戶端與服務器端是完全平等的,可以互相主動請求。而HTTP長連接基於HTTP,是傳統的客戶端對服務器發起請求的模式。
2)HTTP長連接中,每次數據交換除了真正的數據部分外,服務器和客戶端還要大量交換HTTP header,信息交換效率很低。Websocket協議通過第一個request建立了TCP連接之後,之後交換的數據都不需要發送 HTTP header就能交換數據,這顯然和原有的HTTP協議有區別所以它需要對服務器和客戶端都進行升級才能實現(主流瀏覽器都已支持HTML5)。此外還有 multiplexing、不同的URL可以複用同一個WebSocket連接等功能。這些都是HTTP長連接不能做到的。

WebSocket與Http相同點

  • 都是一樣基於TCP的,都是可靠性傳輸協議。

  • 都是應用層協議。

WebSocket與Http不同點

  • WebSocket是雙向通信協議,模擬Socket協議,可以雙向發送或接受信息。HTTP是單向的。
  • WebSocket是需要瀏覽器和服務器握手進行建立連接的。而http是瀏覽器發起向服務器的連接,服務器預先並不知道這個連接。

傳統HTTP客戶端與服務器請求響應模式如下圖所示:

img

WebSocket模式客戶端與服務器請求響應模式如下圖:

img

上圖對比可以看出,相對於傳統HTTP每次請求-應答都需要客戶端與服務端建立連接的模式,WebSocket是類似Socket的TCP長連接通訊模式。一旦WebSocket連接建立後,後續數據都以幀序列的形式傳輸。在客戶端斷開WebSocket連接或Server端中斷連接前,不需要客戶端和服務端重新發起連接請求。

WebSocket與Http聯繫

傳統的http通訊模式是:客戶端發起請求,服務端接收請求並作出響應。
clipboard.png

WebSocket在建立握手時,數據是通過HTTP傳輸的。

第一步,建立連接,客戶端使用http報文的格式發起協議升級的請求,服務端響應協議升級。
clipboard.png

但是建立之後,在真正傳輸時候是不需要HTTP協議的。而websocket協議複用了http的握手通道,具體指的是,客戶端通過HTTP請求與WebSocket服務端協商升級協議。

第二步,交換數據,客戶端與服務端可以使用websocket協議進行雙向通訊。
clipboard.png

在WebSocket中,只需要服務器和瀏覽器通過HTTP協議進行一個握手的動作,然後單獨建立一條TCP的通信通道進行數據的傳送。
WebSocket連接的過程是:
1)客戶端發起http請求,經過3次握手後,建立起TCP連接;

http請求裏存放WebSocket支持的版本號等信息,如:Upgrade、Connection、WebSocket-Version等;
2)服務器收到客戶端的握手請求後,同樣採用HTTP協議回饋數據;
3)客戶端收到連接成功的消息後,開始藉助於TCP傳輸信道進行全雙工通信。

瀏覽器兼容性

最新的主流瀏覽器對WebSocket支持良好:

  • Chrome 4+
  • Firefox 4+
  • Internet Explorer 10+
  • Opera 10+
  • Safari 5+

客戶端案例

JavaScript客戶端

WebSocket協議本質上是一個基於TCP的協議,爲了建立一個WebSocket連接,瀏覽器需要向服務器發起一個HTTP請求,這個請求和普通的HTTP請求不同,它包含了一些附加頭信息,服務器解析這些附加頭信息後產生應答信息返回給客戶端,客戶端和服務端的WebSocket連接就建立起來了,雙方可以通過連接通道自由的傳遞信息,並且這個連接會持續存在直到客戶端或服務端某一方主動關閉連接。

function webSocket(){
  if("WebSocket" in window){
    console.log("您的瀏覽器支持WebSocket");
    var ws = new WebSocket("ws://localhost:8080"); //創建WebSocket連接
    //...
  }else{
    console.log("您的瀏覽器不支持WebSocket");
  }
}

客戶端支持WebSocket的瀏覽器中,在創建socket後,可以通過onopen、onmessage、onclose和onerror四個事件對socket進行響應。

  瀏覽器通過Javascript向服務器發出建立WebSocket連接的請求,連接建立後,客戶端和服務器就可以通過TCP連接直接交換數據。當你獲取WebSocket連接後,可以通多send()方法向服務器發送數據,可以通過onmessage事件接收服務器返回的數據。

var ws = new WebSocket("ws://localhost:8080"); 
//申請一個WebSocket對象,參數是服務端地址,同http協議使用http://開頭一樣,WebSocket協議的url使用ws://開頭,另外安全的WebSocket協議使用wss://開頭
ws.onopen = function(){
  //當WebSocket創建成功時,觸發onopen事件
   console.log("open");
  ws.send("hello"); //將消息發送到服務端
}
ws.onmessage = function(e){
  //當客戶端收到服務端發來的消息時,觸發onmessage事件,參數e.data包含server傳遞過來的數據
  console.log(e.data);
}
ws.onclose = function(e){
  //當客戶端收到服務端發送的關閉連接請求時,觸發onclose事件
  console.log("close");
}
ws.onerror = function(e){
  //如果出現連接、處理、接收、發送數據失敗的時候觸發onerror事件
  console.log(error);
}

WebSocket的所有操作都是採用事件的方式觸發的,這樣不會阻塞UI,是的UI有更快的響應時間,有更好的用戶體驗。

WebSocke的方法

img

WebSocke的屬性

  img

Socket.IO客戶端

Socket.IO是一個封裝了WebSocket的JavaScript模塊。

因爲完全使用JavaScript編寫,所以在每個瀏覽器和移動設備中都可以方便地通過Socket.IO使用WebSocket。

服務器端

var io = require('socket.io').listen(80);

io.sockets.on('connection', function (socket) {
  socket.emit('news', { hello: 'world' });
  socket.on('my other event', function (data) {
    console.log(data);
  });
});

客戶端

  var socket = io.connect('http://localhost');
  socket.on('news', function (data) {
    console.log(data);
    socket.emit('my other event', { my: 'data' });
  });

netty客戶端模塊

package com.crazymaker.springcloud.websocket.client;

import com.crazymaker.springcloud.common.constants.SessionConstants;
import com.crazymaker.springcloud.common.util.JsonUtil;
import io.netty.bootstrap.Bootstrap;
import io.netty.channel.*;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioSocketChannel;
import io.netty.handler.codec.http.DefaultHttpHeaders;
import io.netty.handler.codec.http.HttpClientCodec;
import io.netty.handler.codec.http.HttpHeaders;
import io.netty.handler.codec.http.HttpObjectAggregator;
import io.netty.handler.codec.http.websocketx.TextWebSocketFrame;
import io.netty.handler.codec.http.websocketx.WebSocketClientHandshaker;
import io.netty.handler.codec.http.websocketx.WebSocketClientHandshakerFactory;
import io.netty.handler.codec.http.websocketx.WebSocketVersion;
import io.netty.handler.logging.LogLevel;
import io.netty.handler.logging.LoggingHandler;

import java.net.URI;
import java.util.HashMap;
import java.util.Map;

/**
 * 基於websocket的netty客戶端
 */
public class WebSocketMockClient {

    private static String account = "1860000000";

//    static   String  uriString = "ws://127.0.0.1:9999/push";
    static   String  uriString = "ws://cdh2:9999/push";
    static   String token = "eyJhbGciOiJSUzI1NiJ9.eyJqdGkiOiIxIiwic2lkIjoiNGFiMzVkNDMtZWNhZC00ZDhkLTkwN2MtZjA4NTIxYjU2ODVkIiwiZXhwIjoxNjQ5MzI2NDA4LCJpYXQiOjE2NDkyOTQwMDh9.cN6QTW__p3-RznkU4TqUo1sFIz2Ww_piWFTOvFJ7QoGqcq93ynNsE7RTMgGGYpX3Dpe6W_3vaWmJsHdzt8hme3kxwfKPnZfUF3hUwYCCU4WvXpQjwCFH1W_FSMZjZT2tvyPAmP75_4NDbTJ6sAw1hPVoEKIiGVkO0Aml_CixgqTY0UIyY0nCcz8T1yGkR5wPMhIyxQKPSjWU0UfyPovzIfwSKePfxnqgF42-_BA_YnrVL2qS9pNtTrtm-Bd2LNp5XLbOg-1mWCrHBl7DrYsBj9Q5hMSgy2cJxteyOz2gmfj4HiGeE_KCQO5ZcIChBkOJ9JV5HrzQ8xjGGoPtIReRiA";

    public static void main(String[] args) throws Exception {
        //netty基本操作,線程組
        EventLoopGroup group = new NioEventLoopGroup();
        //netty基本操作,啓動類
        Bootstrap boot = new Bootstrap();
        boot.option(ChannelOption.SO_KEEPALIVE, true)
                .option(ChannelOption.TCP_NODELAY, true)
                .group(group)
                .handler(new LoggingHandler(LogLevel.INFO))
                .channel(NioSocketChannel.class)
                .handler(new ChannelInitializer<SocketChannel>() {
                    protected void initChannel(SocketChannel socketChannel) throws Exception {
                        ChannelPipeline pipeline = socketChannel.pipeline();
                        pipeline.addLast("http-codec", new HttpClientCodec());
                        pipeline.addLast("aggregator", new HttpObjectAggregator(1024 * 1024 * 10));
                        pipeline.addLast("ws-handler", new WebSocketClientHandler());
                    }
                });
        //websocke連接的地址,/hello是因爲在服務端的websockethandler設置的
        URI websocketURI = new URI(uriString);
        HttpHeaders httpHeaders = new DefaultHttpHeaders();
        httpHeaders.set(SessionConstants.AUTHORIZATION_HEAD, token);
        httpHeaders.set(SessionConstants.APP_ACCOUNT, account);
        //進行握手
        WebSocketClientHandshaker handshaker = WebSocketClientHandshakerFactory.newHandshaker(websocketURI, WebSocketVersion.V13, (String) null, true, httpHeaders);
        //客戶端與服務端連接的通道,final修飾表示只會有一個
        final Channel channel = boot.connect(websocketURI.getHost(), websocketURI.getPort()).sync().channel();
        WebSocketClientHandler handler = (WebSocketClientHandler) channel.pipeline().get("ws-handler");
        handler.setHandshaker(handshaker);
        handshaker.handshake(channel);
        //阻塞等待是否握手成功
        handler.handshakeFuture().sync();
        System.out.println("握手成功");
        //給服務端發送的內容,如果客戶端與服務端連接成功後,可以多次掉用這個方法發送消息
        sengMessage(channel);
    }

    public static void sengMessage(Channel channel) {

        Map<String, String> map = new HashMap<>();
        map.put("type", "msg");
        map.put("msg", "你好,我是 " + account);
        //發送的內容,是一個文本格式的內容
        String putMessage = JsonUtil.pojoToJson(map);
        TextWebSocketFrame frame = new TextWebSocketFrame(putMessage);
        channel.writeAndFlush(frame).addListener(new ChannelFutureListener() {
            public void operationComplete(ChannelFuture channelFuture) throws Exception {
                if (channelFuture.isSuccess()) {
                    System.out.println("消息發送成功,發送的消息是:" + putMessage);
                } else {
                    System.out.println("消息發送失敗 " + channelFuture.cause().getMessage());
                }
            }
        });
    }

}

handler

package com.crazymaker.springcloud.websocket.client;

import io.netty.channel.*;
import io.netty.handler.codec.http.FullHttpResponse;
import io.netty.handler.codec.http.websocketx.*;
import io.netty.util.CharsetUtil;

public class WebSocketClientHandler extends SimpleChannelInboundHandler<Object> {
    //握手的狀態信息
    WebSocketClientHandshaker handshaker;
    //netty自帶的異步處理
    ChannelPromise handshakeFuture;

    @Override
    protected void channelRead0(ChannelHandlerContext ctx, Object msg) throws Exception {
        System.out.println("當前握手的狀態"+this.handshaker.isHandshakeComplete());
        Channel channel = ctx.channel();
        FullHttpResponse response;
        //進行握手操作
        if (!this.handshaker.isHandshakeComplete()) {
            try {
                response = (FullHttpResponse)msg;
                //握手協議返回,設置結束握手
                this.handshaker.finishHandshake(channel, response);
                //設置成功
                this.handshakeFuture.setSuccess();
                System.out.println("服務端的消息"+response.headers());
            } catch (WebSocketHandshakeException var7) {
                FullHttpResponse res = (FullHttpResponse)msg;
                String errorMsg = String.format("握手失敗,status:%s,reason:%s", res.status(), res.content().toString(CharsetUtil.UTF_8));
                this.handshakeFuture.setFailure(new Exception(errorMsg));
            }
        } else if (msg instanceof FullHttpResponse) {
            response = (FullHttpResponse)msg;
            throw new IllegalStateException("Unexpected FullHttpResponse (getStatus=" + response.status() + ", content=" + response.content().toString(CharsetUtil.UTF_8) + ')');
        } else {
            //接收服務端的消息
            WebSocketFrame frame = (WebSocketFrame)msg;
            //文本信息
            if (frame instanceof TextWebSocketFrame) {
                TextWebSocketFrame textFrame = (TextWebSocketFrame)frame;
                System.out.println("客戶端接收的消息是:"+textFrame.text());
            }
            //二進制信息
            if (frame instanceof BinaryWebSocketFrame) {
                BinaryWebSocketFrame binFrame = (BinaryWebSocketFrame)frame;
                System.out.println("BinaryWebSocketFrame");
            }
            //ping信息
            if (frame instanceof PongWebSocketFrame) {
                System.out.println("WebSocket Client received pong");
            }
            //關閉消息
            if (frame instanceof CloseWebSocketFrame) {
                System.out.println("receive close frame");
                channel.close();
            }

        }
    }

    /**
     * Handler活躍狀態,表示連接成功
     *
     * @param ctx
     * @throws Exception
     */
    @Override
    public void channelActive(ChannelHandlerContext ctx) throws Exception {
        System.out.println("與服務端連接成功");
    }

    /**
     * 非活躍狀態,沒有連接遠程主機的時候。
     *
     * @param ctx
     * @throws Exception
     */
    @Override
    public void channelInactive(ChannelHandlerContext ctx) throws Exception {
        System.out.println("主機關閉");
    }

    /**
     * 異常處理
     * @param ctx
     * @param cause
     * @throws Exception
     */
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
        System.out.println("連接異常:"+cause.getMessage());
        ctx.close();
    }

    public void handlerAdded(ChannelHandlerContext ctx) {
        this.handshakeFuture = ctx.newPromise();
    }

    public WebSocketClientHandshaker getHandshaker() {
        return handshaker;
    }

    public void setHandshaker(WebSocketClientHandshaker handshaker) {
        this.handshaker = handshaker;
    }

    public ChannelPromise getHandshakeFuture() {
        return handshakeFuture;
    }

    public void setHandshakeFuture(ChannelPromise handshakeFuture) {
        this.handshakeFuture = handshakeFuture;
    }

    public ChannelFuture handshakeFuture() {
        return this.handshakeFuture;
    }
}

Netty中Websocket握手安全驗證

在使用Netty開發Websocket服務時,通常需要解析來自客戶端請求的URL、Headers等等相關內容,並做相關檢查或處理。

這裏將討論兩種實現方法。

方法一:基於HandshakeComplete事件進行安全驗證

特點:使用簡單、校驗在握手成功之後、失敗信息可以通過Websocket發送回客戶端。

下面的代碼展示瞭如何監聽自定義事件。

通過拋出異常可以終止鏈接,同時可以利用ctx向客戶端以Websocket協議返回錯誤信息。

private final class ServerHandler extends SimpleChannelInboundHandler<DeviceDataPacket> {
    @Override
    public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
        
        if (evt instanceof WebSocketServerProtocolHandler.HandshakeComplete) {
            // 在此處獲取URL、Headers等信息並做校驗,通過throw異常來中斷鏈接。
       
       //比如:通過url中的參數,來檢驗
       String requestUri = handshakeComplete.requestUri();
      requestUri = URLDecoder.decode(requestUri, "UTF-8");
       log.info("HANDSHAKE_COMPLETE,ID->{},URI->{}", channel.id().asLongText(), requestUri);
       String socketKey = requestUri.substring(requestUri.lastIndexOf(dataKey) + dataKey.length());
       
       對key進行校驗
       
       }
        super.userEventTriggered(ctx, evt);
    }
}

驗證案例

package com.crazymaker.springcloud.websocket.netty;

import com.crazymaker.springcloud.common.dto.UserDTO;
import com.crazymaker.springcloud.websocket.netty.event.SecurityCheckCompleteEvent;
import com.crazymaker.springcloud.websocket.processer.RpcProcesser;
import com.crazymaker.springcloud.websocket.session.ServerSession;
import com.crazymaker.springcloud.websocket.session.SessionMap;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.handler.codec.http.websocketx.TextWebSocketFrame;
import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler;
import io.netty.handler.timeout.IdleState;
import io.netty.handler.timeout.IdleStateEvent;
import lombok.extern.slf4j.Slf4j;

/**
 * Created by 尼恩 @ 瘋狂創客圈
 * <p>
 * WebSocket 幀:WebSocket 以幀的方式傳輸數據,每一幀代表消息的一部分。一個完整的消息可能會包含許多幀
 */
@Slf4j
public class TextWebSocketFrameHandler extends SimpleChannelInboundHandler<TextWebSocketFrame> {

    @Override
    protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame msg) throws Exception {
        //增加消息的引用計數(保留消息),並將他寫到 ChannelGroup 中所有已經連接的客戶端

        ServerSession session = ServerSession.getSession(ctx);
        String result = RpcProcesser.inst().onMessage(msg.text(), session);

        if (result != null) {
            SessionMap.getSingleton().sendMsg(ctx, result);

        }
    }

    @Override
    public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
        //是否握手成功,升級爲 Websocket 協議
        if (evt == WebSocketServerProtocolHandler.ServerHandshakeStateEvent.HANDSHAKE_COMPLETE) {
            // 握手成功,移除 HttpRequestHandler,因此將不會接收到任何消息
            // 並把握手成功的 Channel 加入到 ChannelGroup 中
            doAuth(....)

        } else if (evt instanceof IdleStateEvent) {
            IdleStateEvent stateEvent = (IdleStateEvent) evt;
            if (stateEvent.state() == IdleState.READER_IDLE) {
                ServerSession session = ServerSession.getSession(ctx);
                String ack = RpcProcesser.inst().onIdleTooLong(session);
                SessionMap.getSingleton().closeSessionAfterAck(session, ack);
            }
        } 
    }
    public void doAuth(ChannelHandlerContext ctx, Object msg) throws Exception {
        if (msg instanceof FullHttpMessage) {
            //extracts token information  from headers
            HttpHeaders headers = ((FullHttpMessage) msg).headers();
            String token = Objects.requireNonNull(headers.get(SessionConstants.AUTHORIZATION_HEAD));
            //extracts account information  from headers
            String account = Objects.requireNonNull(headers.get(SessionConstants.APP_ACCOUNT));

            if (null == token || null == account) {
                // 參數校驗、設置響應
                String content = "請登陸之後,再發起websocket連接!!!";
                closeUnauthChannelAfterWrite(ctx, content);
                return;

            }
            Payload<String> payload = null;
            // 在此處獲取URL、Headers等信息並做校驗,通過throw異常來中斷鏈接。
            try {
                payload = AuthUtils.decodeRsaToken(token);
            } catch (Exception e) {
                // 解碼異常、設置響應
                String content = "請登陸之後,再發起websocket連接!!!";
                closeUnauthChannelAfterWrite(ctx, content);
                return;
            }
            if (null == payload) {
                // 解碼異常、設置響應
                String content = "請登陸之後,再發起websocket連接!!!";
                closeUnauthChannelAfterWrite(ctx, content);
                return;

            }
            String appId = payload.getId();
            SecurityCheckCompleteEvent complete = new SecurityCheckCompleteEvent(token,appId, account);
            ctx.channel().attr(SECURITY_CHECK_COMPLETE_ATTRIBUTE_KEY).set(complete);
            ctx.fireUserEventTriggered(complete);
        }
     }

}

WebSocketServerProtocolHandshakeHandler源碼分析

一般地,我們將netty內置的WebSocketServerProtocolHandler作爲Websocket協議的主要處理器。

通過研究其代碼我們瞭解到在本處理器被添加到PiplinehandlerAdded方法將會被調用。

此方法經過簡單的檢查後將WebSocketHandshakeHandler添加到了本處理器之前,用於處理握手相關業務。

我們都知道Websocket協議在握手時是通過HTTP(S)協議進行的,那麼這個WebSocketHandshakeHandler應該就是處理HTTP相關的數據的吧?

package io.netty.handler.codec.http.websocketx;

public class WebSocketServerProtocolHandler extends WebSocketProtocolHandler {

    @Override
    public void handlerAdded(ChannelHandlerContext ctx) {
        ChannelPipeline cp = ctx.pipeline();
        if (cp.get(WebSocketServerProtocolHandshakeHandler.class) == null) {
            // Add the WebSocketHandshakeHandler before this one.
            cp.addBefore(ctx.name(), WebSocketServerProtocolHandshakeHandler.class.getName(),
                    new WebSocketServerProtocolHandshakeHandler(serverConfig));
        }
        //...
    }
}

我們來看看WebSocketServerProtocolHandshakeHandler都做了什麼操作。

channelRead方法會嘗試接收一個FullHttpRequest對象,表示來自客戶端的HTTP請求,隨後服務器將會進行握手相關操作,此處省略了握手大部分代碼,感興趣的同學可以自行閱讀。

可以注意到,在確認握手成功後,channelRead將會調用兩次fireUserEventTriggered,此方法將會觸發自定義事件。

其他(在此處理器之後)的處理器會觸發userEventTriggered方法。

其中一個方法傳入了WebSocketServerProtocolHandler對象,此對象保存了HTTP請求相關信息。

那麼解決方案逐漸浮出水面,通過監聽自定義事件即可實現檢查握手的HTTP請求。

package io.netty.handler.codec.http.websocketx;

/**
 * Handles the HTTP handshake (the HTTP Upgrade request) for {@link WebSocketServerProtocolHandler}.
 */
class WebSocketServerProtocolHandshakeHandler extends ChannelInboundHandlerAdapter {
    @Override
    public void channelRead(final ChannelHandlerContext ctx, Object msg) throws Exception {
        final FullHttpRequest req = (FullHttpRequest) msg;
        if (isNotWebSocketPath(req)) {
            ctx.fireChannelRead(msg);
            return;
        }

        try {

            //...
                
            if (!future.isSuccess()) {
                
            } else {
                localHandshakePromise.trySuccess();
                // Kept for compatibility
                ctx.fireUserEventTriggered(
                        WebSocketServerProtocolHandler.ServerHandshakeStateEvent.HANDSHAKE_COMPLETE);
                ctx.fireUserEventTriggered(
                        new WebSocketServerProtocolHandler.HandshakeComplete(
                                req.uri(), req.headers(), handshaker.selectedSubprotocol()));
            }
        } finally {
            req.release();
        }
    }
}

說明: 以上源碼比較複雜,具體的解讀,請參見19章視頻

方法一的流水線裝配

附上Channel初始化代碼作爲參考。

private final class ServerInitializer extends ChannelInitializer<SocketChannel> {

    @Override
    protected void initChannel(SocketChannel ch) {
        ch.pipeline()
                .addLast("http-codec", new HttpServerCodec())
                .addLast("chunked-write", new ChunkedWriteHandler())
                .addLast("http-aggregator", new HttpObjectAggregator(8192))
                .addLast("log-handler", new LoggingHandler(LogLevel.WARN))
                .addLast("ws-server-handler", new WebSocketServerProtocolHandler(endpointUri.getPath()))
                .addLast("server-handler", new ServerHandler());
    }
}

方法一的問題:

方法一中,ws握手已經完成,所以雖然這種方案簡單的過分,但是效率並不高,耗費服務端資源(都握手了又給人家踢了)。

方法二:在ws握手之前,進行安全檢查處理器

特點:使用相對複雜、校驗在握手成功之前、失敗信息可以通過HTTP返回客戶端。

解決方案

編寫一個入站處理器,接收FullHttpMessage消息,在Websocket處理器之前檢測攔截請求信息。

下面的例子主要做了四件事情:

  1. 從HTTP請求中提取關心的數據
  2. 安全檢查
  3. 將結果和其他數據綁定在Channel
  4. 觸發安全檢查完畢自定義事件
package com.crazymaker.springcloud.websocket.netty.handler;

import com.crazymaker.springcloud.base.auth.AuthUtils;
import com.crazymaker.springcloud.base.auth.Payload;
import com.crazymaker.springcloud.common.constants.SessionConstants;
import com.crazymaker.springcloud.websocket.netty.event.SecurityCheckCompleteEvent;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;
import io.netty.handler.codec.http.FullHttpMessage;
import io.netty.handler.codec.http.HttpHeaders;
import io.netty.util.AttributeKey;
import lombok.extern.slf4j.Slf4j;

import java.util.Objects;

import static com.crazymaker.springcloud.netty.util.HttpUtil.closeUnauthChannelAfterWrite;

@Slf4j
public class AuthCheckHandler extends ChannelInboundHandlerAdapter {

    public static final AttributeKey SECURITY_CHECK_COMPLETE_ATTRIBUTE_KEY =
            AttributeKey.valueOf("SECURITY_CHECK_COMPLETE_ATTRIBUTE_KEY");

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
        if (msg instanceof FullHttpMessage) {
            //extracts token information  from headers
            HttpHeaders headers = ((FullHttpMessage) msg).headers();
            String token = Objects.requireNonNull(headers.get(SessionConstants.AUTHORIZATION_HEAD));
            //extracts account information  from headers
            String account = Objects.requireNonNull(headers.get(SessionConstants.APP_ACCOUNT));

            if (null == token || null == account) {
                // 參數校驗、設置響應
                String content = "請登陸之後,再發起websocket連接!!!";
                closeUnauthChannelAfterWrite(ctx, content);
                return;

            }
            Payload<String> payload = null;
            // 在此處獲取URL、Headers等信息並做校驗,通過throw異常來中斷鏈接。
            try {
                payload = AuthUtils.decodeRsaToken(token);
            } catch (Exception e) {
                // 解碼異常、設置響應
                String content = "請登陸之後,再發起websocket連接!!!";
                closeUnauthChannelAfterWrite(ctx, content);
                return;
            }
            if (null == payload) {
                // 解碼異常、設置響應
                String content = "請登陸之後,再發起websocket連接!!!";
                closeUnauthChannelAfterWrite(ctx, content);
                return;

            }
            String appId = payload.getId();
            SecurityCheckCompleteEvent complete = new SecurityCheckCompleteEvent(token,appId, account);
            ctx.channel().attr(SECURITY_CHECK_COMPLETE_ATTRIBUTE_KEY).set(complete);
            ctx.fireUserEventTriggered(complete);
        }
        //other protocols
        super.channelRead(ctx, msg);
    }

}

在業務邏輯處理器中,可以通過組合自定義的安全檢查事件和Websocket握手完成事件。

方法二流水線裝配

附上Channel初始化代碼作爲參考。

package com.crazymaker.springcloud.websocket.netty;

import com.crazymaker.springcloud.standard.context.SpringContextUtil;
import com.crazymaker.springcloud.websocket.netty.handler.AuthCheckHandler;
import com.crazymaker.springcloud.websocket.netty.handler.ServerExceptionHandler;
import com.crazymaker.springcloud.websocket.netty.handler.TextWebSocketFrameHandler;
import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.*;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.handler.codec.http.HttpObjectAggregator;
import io.netty.handler.codec.http.HttpServerCodec;
import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler;
import io.netty.handler.codec.http.websocketx.extensions.compression.WebSocketServerCompressionHandler;
import io.netty.handler.stream.ChunkedWriteHandler;
import io.netty.handler.timeout.IdleStateHandler;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.stereotype.Component;

import java.net.InetSocketAddress;
import java.util.concurrent.TimeUnit;


/**
 * Netty 服務
 * Created by 尼恩 @ 瘋狂創客圈
 */
@Component
@Slf4j
public class WebSocketServer implements ApplicationContextAware {


    @Value("${tunnel.websocket.port}")
    private int websocketPort;


    @Value("${websocket.register.gateway}")
    private String websocketRegisterGateway;

    private final EventLoopGroup group = new NioEventLoopGroup();
    private Channel channel;


    /**
     * 停止即時通訊服務器
     */
    public void stopServer() {
        if (channel != null) {
            channel.close();
        }
        group.shutdownGracefully();
    }

    /**
     * 啓動即時通訊服務器
     */
    public void startServer(int port) {


        ChannelFuture channelFuture = null;
        ServerBootstrap bootstrap = new ServerBootstrap();
        bootstrap.group(group)
                .channel(NioServerSocketChannel.class)
                .childHandler(new ChatServerInitializer());
        InetSocketAddress address = new InetSocketAddress(port);
        channelFuture = bootstrap.bind(address);
//        channelFuture.syncUninterruptibly();

        channel = channelFuture.channel();
        // 返回與當前Java應用程序關聯的運行時對象
        Runtime.getRuntime().addShutdownHook(new Thread() {
            @Override
            public void run() {
                stopServer();
            }
        });

        log.info("\n----------------------------------------------------------\n\t" +
                "Nett WebSocket 服務 is running! Access Port:{}\n\t", websocketPort);

        channelFuture.channel().closeFuture().syncUninterruptibly();
    }

    /**
     * 內部類
     */
    class ChatServerInitializer extends ChannelInitializer<Channel> {
        private static final int READ_IDLE_TIME_OUT = 600; // 讀超時  s
        private static final int WRITE_IDLE_TIME_OUT = 0;// 寫超時
        private static final int ALL_IDLE_TIME_OUT = 0; // 所有超時


        @Override
        protected void initChannel(Channel ch) throws Exception {
            ChannelPipeline pipeline = ch.pipeline();
            // Netty自己的http解碼器和編碼器,報文級別 HTTP請求的解碼和編碼
            pipeline.addLast(new HttpServerCodec());
            // ChunkedWriteHandler 是用於大數據的分區傳輸
            // 主要用於處理大數據流,比如一個1G大小的文件如果你直接傳輸肯定會撐暴jvm內存的;
            // 增加之後就不用考慮這個問題了
            pipeline.addLast(new ChunkedWriteHandler());
            // HttpObjectAggregator 是完全的解析Http消息體請求用的
            // 把多個消息轉換爲一個單一的完全FullHttpRequest或是FullHttpResponse,
            // 原因是HTTP解碼器會在每個HTTP消息中生成多個消息對象HttpRequest/HttpResponse,HttpContent,LastHttpContent
            pipeline.addLast(new HttpObjectAggregator(64 * 1024));
            pipeline.addLast(new AuthCheckHandler());
            // WebSocket數據壓縮
            pipeline.addLast(new WebSocketServerCompressionHandler());
            // WebSocketServerProtocolHandler是配置websocket的監聽地址/協議包長度限制
            pipeline.addLast(new WebSocketServerProtocolHandler("/push", null, true, 10 * 1024));

            //當連接在60秒內沒有接收到消息時,就會觸發一個 IdleStateEvent 事件,
            // 此事件被 HeartbeatHandler 的 userEventTriggered 方法處理到
            pipeline.addLast(new IdleStateHandler(READ_IDLE_TIME_OUT, WRITE_IDLE_TIME_OUT, ALL_IDLE_TIME_OUT, TimeUnit.SECONDS));

            //WebSocketServerHandler、TextWebSocketFrameHandler 是自定義邏輯處理器,
            pipeline.addLast(new TextWebSocketFrameHandler());
            pipeline.addLast(new ServerExceptionHandler());

        }
    }


    @Override
    public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
        SpringContextUtil.setContext(applicationContext);
        new Thread(() -> {
            startServer(websocketPort);
        }).start();


    }
}

方法一與方法二的對比

上述兩種方式分別在握手完成後和握手之前攔截檢查;實現複雜度和性能略有不同,可以通過具體業務需求選擇合適的方法。

Netty增強了責任鏈模式,使用userEvent傳遞自定義事件使得各個處理器之間減少耦合,更專注於業務。

但是、相比於流動於各個處理器之間的"主線"數據來說,userEvent傳遞的"支線"數據往往不受關注。

通過閱讀Netty內置的各種處理器源碼,探索其產生的事件,同時在開發過程中加以善用,可以減少冗餘代碼。

另外在開發自定義的業務邏輯時,應該積極利用userEvent傳遞事件數據,降低各模塊之間代碼耦合。

Netty的WebSocket開發常見問題

1、proxy_http_version 1.1,爲什麼使用http1.1協議?

proxy_http_version 設置代理使用的HTTP協議版本。

proxy_http_version 默認使用的版本是1.0,而1.0版本默認是短鏈接,如果換成長鏈接,需要和 keepalive連接時一起使用。

http1.0沒有加keepalive選型,後端服務會返回101錯誤,然後斷開連接。

所以,默認情況下,1.0版本,顯然不合適ws協議

proxy_http_version 1.1版本默認爲長鏈接,推薦在使用

傳統HTTP客戶端與服務器請求響應模式如下圖所示:

img

WebSocket模式客戶端與服務器請求響應模式如下圖:

img

上圖對比可以看出,相對於傳統HTTP每次請求-應答都需要客戶端與服務端建立連接的模式,WebSocket是類似Socket的TCP長連接通訊模式。一旦WebSocket連接建立後,後續數據都以幀序列的形式傳輸。在客戶端斷開WebSocket連接或Server端中斷連接前,不需要客戶端和服務端重新發起連接請求。

2、爲什麼HTTP Upgrade的時候,需要Connection: upgrade

HTTP的Upgrade協議頭

HTTP的Upgrade協議頭機制用於將連接從HTTP連接升級到WebSocket連接,

但是,Upgrade機制使用了Upgrade協議頭和Connection協議頭;

結論是

爲了讓Nginx可以將來自客戶端的Upgrade請求發送到後端服務器,Upgrade和Connection的頭信息必須被顯式的設置。

也就是說:

WebSocket等協議的Upgrade請求,需要同時帶上Connection和Upgrade頭部。

如果是僅僅Upgrade的話,Connection頭部不就是多餘的設計了麼?

具體原因,這裏慢慢道來.

一個典型的WebSocket升級請求如下:

GET /chat HTTP/1.1
Host: example.com:8000
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13

Connection的起源

最開始,在HTTP/1.0出現沒多久,人們就意識到HTTP持久連接的重要性(畢竟三次握手還是很慢的)

所以各個服務器實現都採用了Keep-Alive頭部來表示這個請求支持連接持久化。

HTTP/1.1中的Connection

HTTP/1.1中,正式標準化了Connection頭部:

Connection頭部一般表示後面的頭部屬於逐跳頭部(hop-by-hop header)類型,比如Connection: Custom-Header

就表示在這個連接中,Custom-Header是一個逐跳頭部,不應當被代理原樣傳遞給upstream。

有兩個例外:close表示會話不持久化,keep-alive表示會話支持持久化(雖然有一個Keep-Alive頭部,但是大小寫不一樣)。

什麼是: 逐跳頭部(hop-by-hop header)

用來描述當前瀏覽器與直連服務器(比如Nginx反向代理)的連接信息。

比如Keep-Alive頭部,僅僅表示瀏覽器嘗試和Nginx之間連接持久化,而不管Nginx和後端服務器之間的連接。

proxy要處理這些頭部,並按照自己的需要來修改這些頭部。

默認的逐跳頭部(hop-by-hop header)如下:

出了上面的hop-by-hop header,還有一大類型的頭部,叫做端到端頭部(end-to-end header)

端到端頭部(end-to-end header) 用來描述這個瀏覽器和最終處理請求的服務器之間的信息,比如Accept頭部,表示客戶端想從後端服務器得到的數據類型,而和中間的Nginx無關。

proxy不能修改這些端到端頭部(end-to-end header),但是,可以處理 逐跳頭部(hop-by-hop header)

再回到HTTP 1.1的Connection頭部,這兒有一個兼容性問題:

我們以Upgrade頭部爲例,某個proxy實現了HTTP 1.0協議,將Upgrade原樣轉發給後端,後端和proxy升級協議,

但是,這個情況下,proxy不認識升級後的協議啊。

所以,RFC有增加了一條規定:

如果只有Upgrade: xxx,而沒有Connection: Upgrade,那麼就當作普通請求來處理。

WebSocket的Upgrade請求:

Connection: Upgrade
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits
Sec-WebSocket-Key: lGrvj+i7B76RB3YYbScQ9g==
Sec-WebSocket-Version: 13
Upgrade: websocket



結論

Connection頭部和Upgrade頭部有不同的語義和使用場景:

  • Connection: Upgrade 表示Upgrade是一個hop-by-hop的字段。這個頭部是給proxy看的
  • Upgrade: websocket 表示瀏覽器想要升級到WebSocket協議。這個頭部是給最終處理請求的程序看的。
  • 如果只有Upgrade: websocket,說明proxy不支持WebSocket升級,按照標準應該視爲普通HTTP請求。

3、map的作用

報錯內容:nginx: [emerg] unknown "connection_upgrade" variable

clipboard.png
一天更新完主分支後啓動nginx,結果報錯:nginx: [emerg] unknown "connection_upgrade" variable

解決辦法:在nginx.conf配置文件http區塊頂部加上一段配置

map $http_upgrade $connection_upgrade{
    default upgrade;
    '' close;
  }
  server {
        listen       80;
        ------

nginx反向代理websocket

clipboard.png
首先,客戶端發起協議升級的請求,而nginx在攔截時需要識別出這是一個協議升級(upgrade)的請求,所以必須顯式設置升級(Upgrade head)和連接頭(Connection head),如下:

    location /ws/ {
    proxy_pass http://127.0.0.1:4200/ws/;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection $connection_upgrade;
}

完成後,nginx將其作爲WebSocket連接處理。

clipboard.png

根據配置,需要根據變量 $http_upgrade 的值創建新的變量 $connection_upgrade。

map指令的作用:

根據客戶端請求中$http_upgrade 的值,來構造改變$ connection_upgrade的值

  • 即根據變量$http_upgrade的值創建新的變量$connection_upgrade,

創建$connection_upgrade的規則就是{}裏面的東西:

  • 其中的規則沒有做匹配,因此使用默認的.
  • 如果 變量$http_upgrade的值爲upgrade, 即 $connection_upgrade 的值會一直是 upgrade。
  • 如果 $http_upgrade爲空字符串的話, 那 $connection_upgrade 值會是 close。

有點複雜,具體的介紹,請參考視頻第19章

4、Nginx代理webSocket經常中斷的解決方法(也就是如何保持長連接)

現象描述:

用nginx反代代理某個業務,發現平均1分鐘左右,就會出現webSocket連接中斷,然後查看了一下,是nginx出現的問題。
產生原因:

nginx等待第一次通訊和第二次通訊的時間差,超過了它設定的最大等待時間,簡單來說就是超時!

解決方法1

其實只要配置nginx.conf的對應localhost裏面的這幾個參數就好

proxy_connect_timeout;
proxy_read_timeout;
proxy_send_timeout;

proxy_connect_timeout

語法 proxy_connect_timeout time
默認值 60s
上下文 http server location
說明 該指令設置與upstream server的連接超時時間,有必要記住,這個超時不能超過75秒。
這個不是等待後端返回頁面的時間,那是由proxy_read_timeout聲明的。

如果你的upstream服務器起來了,並且在系統層面完成了三次或者握手,只是沒有傳輸數據(例如,Java應用STW卡頓,沒有足夠的線程處理請求,所以把你的請求放到請求池裏稍後處理),那麼這個聲明是沒有用的,由於與upstream服務器的連接已經建立了。

proxy_read_timeout

語法 proxy_read_timeout time
默認值 60s
上下文 http server location
說明 該指令設置與代理服務器的讀超時時間。它決定了nginx會等待多長時間來獲得響應的數據,業務數據。

這個時間不僅僅是單次response到達的時間,還包括兩次業務數據之間的間隔時間。

proxy_send_timeout

語法 proxy_send_timeout time
默認值 60s
上下文 http server location
說明 這個指定設置了發送請求給upstream服務器的超時時間。

超時設置不是爲了整個發送期間,而是在兩次write操作期間。如果超時後,沒有數據發送出去,或者說,upstream沒有收到新的數據,nginx會關閉連接

配置示例:

http {
    server {
        location / {
            root   html;
            index  index.html index.htm;
            proxy_pass http://webscoket;
            proxy_http_version 1.1;
            proxy_connect_timeout 4s;                #配置點1
            proxy_read_timeout 60s;                  #配置點2,如果沒效,可以考慮這個時間配置長一點
            proxy_send_timeout 12s;                  #配置點3
            proxy_set_header Upgrade $http_upgrade; 
            proxy_set_header Connection "Upgrade";  
        }
    }
}

關於上面配置2的解釋

這個是服務器對你等待最大的時間,也就是說當你webSocket使用nginx轉發的時候,

用上面的配置2來說,如果60秒內沒有通訊,依然是會斷開的,所以,你可以按照你的需求來設定。

比如說,我設置了10分鐘,那麼如果我10分鐘內有通訊,或者10分鐘內有做心跳的話,是可以保持連接不中斷的,詳細看需求

解決方法2

發心跳包,原理就是在有效地再讀時間內進行通訊,重新刷新再讀時間

參考網上的前端代碼:

href = "ws://"+baseIP+"/user/connect/"
ws = new WebSocket(href)
var heartCheck = {
    timeout: 5000,        //5秒發一次心跳
    timeoutObj: null,
    serverTimeoutObj: null,
    reset: function(){
        clearTimeout(this.timeoutObj);
        clearTimeout(this.serverTimeoutObj);
        return this;
    },
    start: function(){
        var self = this;
        this.timeoutObj = setTimeout(function(){
            ws.send("keepalive");
            console.log("發送:keepalive")
            self.serverTimeoutObj = setTimeout(function(){
                ws.close();     
            }, self.timeout)
        }, this.timeout)
    }
}
ws.onopen = function(){
    console.log("websocket已連接")
    heartCheck.reset().start()
    ws.send(user_id)
}
ws.onmessage = function(evt){
    heartCheck.reset().start();
    if (evt.data != "keepalive"){
        msg = JSON.parse(evt.data)
        that.messageNotice(msg)
    }else{
        console.log("接收:"+evt.data)
    }
}
ws.onclose = function(e){
    console.log("websocket已斷開")
    console.log(e)
}

Nginx的負載均衡

本節就聊聊採用Nginx負載均衡之後碰到的問題:

  • Session問題
  • 文件上傳下載

通常解決服務器負載問題,都會通過多服務器分載來解決。常見的解決方案有:

  • 網站入口通過分站鏈接負載(天空軟件站,華軍軟件園等)
  • DNS輪詢
  • F5物理設備
  • Nginx等輕量級架構

那我們看看Nginx是如何實現負載均衡的,Nginx的upstream目前支持以下幾種方式的分配
1、輪詢(默認)
每個請求按時間順序逐一分配到不同的後端服務器,如果後端服務器down掉,能自動剔除。
2、weight
指定輪詢機率,weight和訪問比率成正比,用於後端服務器性能不均的情況。
2、ip_hash
每個請求按訪問ip的hash結果分配,這樣每個訪客固定訪問一個後端服務器,可以解決session的問題。
3、fair(第三方)
按後端服務器的響應時間來分配請求,響應時間短的優先分配。
4、url_hash(第三方)
按訪問url的hash結果來分配請求,使每個url定向到同一個後端服務器,後端服務器爲緩存時比較有效。

Upstream配置如何實現負載

http {    
    
    upstream  www.test1.com {
          ip_hash;
          server   172.16.125.76:8066 weight=10;
          server   172.16.125.76:8077 down;
          server   172.16.0.18:8066 max_fails=3 fail_timeout=30s;
          server   172.16.0.18:8077 backup;
     }
      
     upstream  www.test2.com {
          server   172.16.0.21:8066;
          server   192.168.76.98:8066;         
     }


     server {
        listen       80;
        server_name  www.test1.com;        
       
        location /{
           proxy_pass        http://www.test1.com;
           proxy_set_header   Host             $host;
           proxy_set_header   X-Real-IP        $remote_addr;
           proxy_set_header   X-Forwarded-For  $proxy_add_x_forwarded_for;
        }      
     }  
     
     server {
        listen       80;
        server_name  www.test2.com;        
       
        location /{
           proxy_pass        http://www.test2.com;
           proxy_set_header   Host             $host;
           proxy_set_header   X-Real-IP        $remote_addr;
           proxy_set_header   X-Forwarded-For  $proxy_add_x_forwarded_for;
     }
}

當有請求到www.test1.com/www.test2.com 時請求會被分發到對應的upstream設置的服務器列表上。

test2的每一次請求分發的服務器都是隨機的,就是第一種情況列舉的。

而test1剛是根據來訪問ip的hashid來分發到指定的服務器,也就是說該IP的請求都是轉到這個指定的服務器上。

根據服務器的本身的性能差別及職能,可以設置不同的參數控制。

down 表示負載過重或者不參與負載

weight 權重過大代表承擔的負載就越大

backup 其它服務器時或down時纔會請求backup服務器

max_fails 失敗超過指定次數會暫停或請求轉往其它服務器

fail_timeout 失敗超過指定次數後暫停時間

以上就Nginx的負載均衡的簡單配置。那繼續我們的本節討論內容:

一、Session問題

當我們確定一系列負載的服務器後,那我們的WEB站點會分佈到這些服務器上。

這個時候如果採用Test2 每一次請求隨機訪問任何一臺服務器上,這樣導致你訪問A服務器後,下一次請求又突然轉到B服務器上。這個時候與A服務器建立的Session,傳到B站點服務器肯定是無法正常響應的。我們看一下常用的解決方案:

  • Session或憑據緩存到獨立的服務器
  • Session或憑據保存數據庫中
  • nginx ip_hash 保持同一IP的請求都是指定到固定的一臺服務器

第一種緩存的方式比較理想,緩存的效率也比較高。但是每一臺請求服務器都去訪問Session會話服務器,那不是加載重了這臺Session服務器的負擔嗎?

第二種保存到數據庫中,除了要控制Session的有效期,同時加重了數據庫的負擔,所以最終的轉變爲SQL Server 負載均衡,涉及讀,寫,過期,同步。

第三種通過nginx ip_hash負載保持對同一服務器的會話,這種看起來最方便,最輕量。

正常情況下架構簡單的話, ip_hash可以解決Session問題,但是我們來看看下面這種情況

img

這個時候ip_hash 收到的請求都是來自固定IP代理的請求,如果代理IP的負載過高就會導致ip_hash對應的服務器負載壓力過大,這樣ip_hash就失去了負載均衡的作用了。

如果緩存可以實現同步共享的話,我們可以通過多session服務器來解決單一負載過重的問題。

那Memcached是否可以做Session緩存服務器呢?MemcachedProvider提供了Session的功能,即將Session保存到數據庫中。

那爲什麼不直接保存到數據庫中,而要通過Memcached保存到數據庫中呢?

很簡單,如果直接保存到數據庫中,每一次請求Session有效性都要回數據庫驗證一下。

其次,即使我們爲數據庫建立一層緩存,那這個緩存也無法實現分佈式共享,還是針對同一臺緩存服務器負載過重。

網上也看到有用Memcached實現Session緩存的成功案例,當然數據庫方式實現的還是比較常用的,比如開源Disuz.net論壇。

緩存實現的小範圍分佈式也是比較常用的,比如單點登錄也是一種特殊情況。

Nginx進行WebSocket代理

然後修改 Hosts, 添加, 比如 ws.repo, 指向 127.0.0.1
然後是 Nginx 配置:

map $http_upgrade $connection_upgrade {
        default upgrade;
        '' close;
}


server {
  listen 80;
  server_name ws.repo;

  location / {
    proxy_pass http://127.0.0.1:3000/;
    proxy_redirect off;

    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
  }
}

Reload Nginx 然後從瀏覽器控制檯嘗試鏈接, OK

new WebSocket('ws://ws.repo/')

或者通過 Upstream 的寫法:

map $http_upgrade $connection_upgrade {
        default upgrade;
        '' close;
}

upstream ws_server {
  server 127.0.0.1:3000;
}

server {
  listen 80;
  server_name ws.repo;

  location / {
    proxy_pass http://ws_server/;
    proxy_redirect off;

    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection $connection_upgrade;

  }
}

WebSocket 先是通過 HTTP 建立連接,
然後通過 101 狀態碼, 表示切換協議,, 在配置裏是 Upgrade

動態負載均衡

具體思路

利用lua中 "lua_shared_dict" 指令開闢一個共享內存空間;
通過API動態根據key值&參數修改 upstream (這裏使用 host 作爲key);
利用 proxy_pass 可使用變量特性及lua指令 "set_by_lua" 動態修改當前 upstream 變量即可;

結合 lua 實現一個 http協議負載均衡

含三個文件

  • updateserver.lua

  • takeoneserver.lua

  • nginx.conf

主要是利用ngx.upstreamngx.balancer 這兩個模塊,動態設置upstream,

lua/updateserver.lua

local balancer = require "ngx.balancer"
local upstream = require "ngx.upstream"

-- 加載cjson
local cjson = require("cjson");

local cache = ngx.shared.cache

--讀取get參數
--local uri_args = ngx.req.get_uri_args()
--讀取post參數
ngx.req.read_body();

--local uri_args = ngx.req.get_post_args()
local data = ngx.req.get_body_data(); --獲取消息體

--ngx.say(data)

local restOut = { respCode = 0, resp_msg = "操作成功", datas = {} };
local errorOut = { respCode = -1, resp_msg = "操作失敗", datas = {} };


ngx.log(ngx.DEBUG,"data=" .. data);

local args=cjson.decode(data);
ngx.log(ngx.DEBUG,"args=" .. tostring(args));

local serverCount =args["serverCount"];
ngx.log(ngx.DEBUG,"serverCount=" .. tostring(serverCount));

if not serverCount or serverCount == ngx.null then
    errorOut.resp_msg="serverCount 爲空!";
    ngx.say(cjson.encode(errorOut));
    return ;
end

local iServerCount = tonumber(serverCount)-1;
ngx.log(ngx.DEBUG,"iServerCount=" .. iServerCount);

for i = 0,iServerCount do
    cache:set(i, args["server"..tostring(i)])
    ngx.log(ngx.DEBUG,"i=" .. args["server"..tostring(i)])

end
cache:set("serverCount",tonumber(serverCount));


restOut.datas = "更新的server數:"..serverCount;
ngx.say(cjson.encode(restOut));

takeoneserver.lua

local balancer = require "ngx.balancer"
local upstream = require "ngx.upstream"

local upstream_name = 'backend'

local cache = ngx.shared.cache

local serverCount = cache:get("serverCount")
ngx.log(ngx.DEBUG, "serverCount=" .. tostring(serverCount));

local key = "req_index"

local req_index = cache:get(key);
ngx.log(ngx.DEBUG, "req_index=" .. tostring(req_index));
--ngx.log(ngx.DEBUG, "0==nil=" .. tostring(not 0));

if not req_index or req_index == ngx.null or req_index >= serverCount then
    req_index = 0
    cache:set(key, req_index)
end

cache:incr(key, 1)

ngx.log(ngx.DEBUG, "req_index=" .. tostring(req_index));

local server = cache:get(req_index);

local index = string.find(server, ':')
local host = string.sub(server, 1, index - 1)
local port = string.sub(server, index + 1)

ngx.log(ngx.DEBUG, "host=" .. host);

balancer.set_current_peer(host, tonumber(port))

nginx.conf


#user  nobody;
worker_processes  1;
#worker_processes  8;

#開發環境
error_log  logs/error.log  debug;
#生產環境
#error_log  logs/error.log;


#一個Nginx進程打開的最多文件描述數目 建議與ulimit -n一致
#如果面對高併發時 注意修改該值 ulimit -n 還有部分系統參數 而並非這個單獨確定
worker_rlimit_nofile 2000000;

pid     logs/nginx.pid;


events {
     use epoll;
     worker_connections 409600;
     multi_accept on;
     accept_mutex off;
}


http {
    default_type 'text/html';
    charset utf-8;

    log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
    '$status $body_bytes_sent "$http_referer" '
    '"$http_user_agent" "$http_x_forwarded_for"';

    access_log off;
    #access_log  logs/access_main.log  main;

    sendfile        on;
    #tcp_nopush     on;

    #keepalive_timeout  0;
    #keepalive_timeout  65;
    keepalive_timeout 1200s;        #客戶端鏈接超時時間。爲0的時候禁用長連接。即長連接的timeout
    keepalive_requests 20000000;      #在一個長連接上可以服務的最大請求數目。當達到最大請求數目且所有已有請求結束後,連接被關閉。默認值爲100。即每個連接的最大請求數

    gzip  off;
    #gzip  on;

    #lua擴展加載

    # for linux
    # lua_package_path "./?.lua;/vagrant/LuaDemoProject/src/?.lua;/usr/local/ZeroBraneStudio-1.80/?/?.lua;/usr/local/ZeroBraneStudio-1.80/?.lua;;";
    # lua_package_cpath "/usr/local/ZeroBraneStudio-1.80/bin/clibs/?.so;;";
    lua_package_path "./?.lua;/vagrant/LuaDemoProject/src/?.lua;/vagrant/LuaDemoProject/vendor/template/?.lua;/vagrant/LuaDemoProject/src/?/?.lua;/usr/local/openresty/lualib/?/?.lua;/usr/local/openresty/lualib/?.lua;;";
    lua_package_cpath "/usr/local/openresty/lualib/?/?.so;/usr/local/openresty/lualib/?.so;;";

    # for windows
    # lua_package_path "./?.lua;C:/dev/refer/LuaDemoProject/src/vendor/jwt/?.lua;C:/dev/refer/LuaDemoProject/src/?.lua;E:/tool/ZeroBraneStudio-1.80/lualibs/?/?.lua;E:/tool/ZeroBraneStudio-1.80/lualibs/?.lua;E:/tool/openresty-1.13.6.2-win32/lualib/?.lua;;";
    # lua_package_cpath "E:/tool/ZeroBraneStudio-1.80/bin/clibs/?.dll;E:/tool/openresty-1.13.6.2-win32/lualib/?.dll;;";



    #調試模式(即關閉lua腳本緩存)
    # lua_code_cache off;

    # for windows
    # lua_package_path "C:/dev/refer/LuaDemoProject/src/vendor/jwt/?.lua;C:/dev/refer/LuaDemoProject/src/?.lua;E:/tool/ZeroBraneStudio-1.80/lualibs/?/?.lua;E:/tool/ZeroBraneStudio-1.80/lualibs/?.lua;E:/tool/openresty-1.13.6.2-win32/lualib/?.lua;;";
    # lua_package_cpath "E:/tool/ZeroBraneStudio-1.80/bin/clibs/?.dll;E:/tool/openresty-1.13.6.2-win32/lualib/?.dll;;";


    # 初始化項目
    #  init_by_lua_file luaScript/initial/loading_config.lua;

    # nginx跟後端服務器連接超時時間(代理連接超時)
    proxy_connect_timeout 600;
    proxy_read_timeout 600;


    #指定緩存信息
    #lua_shared_dict ngx_cache 128m;
    #保證只有一個線程去訪問redis或是mysql-lock for cache
    # lua_shared_dict cache_lock 100k;

    lua_shared_dict cache 1m;


     #調試模式(即關閉lua腳本緩存)
      lua_code_cache off;
     # lua_code_cache on;


    upstream backend {
          server  "127.0.0.1:8080";
          balancer_by_lua_file luaScript/module/dynamicBalance/takeOneServer.lua;
    }

    map $http_upgrade $connection_upgrade{
        default upgrade;
        ''  close;
    }

    server {
        listen       9999 default;
        server_name  localhost;

        location / {
          proxy_pass http://backend;
          proxy_set_header            Host $host;
          proxy_set_header            X-real-ip $remote_addr;
          proxy_set_header            X-Forwarded-For $proxy_add_x_forwarded_for;
          proxy_redirect              off;
          # proxy_set_header          X-Forwarded-For $http_x_forwarded_for;
          proxy_http_version 1.1;
          proxy_set_header Upgrade $http_upgrade;
          proxy_set_header Connection $connection_upgrade;
        }



    }
    server {
        listen       8000 ;
        server_name  localhost;

        lua_need_request_body on;
        #更新API接口
        location = / {
          content_by_lua_file luaScript/module/dynamicBalance/updateServers.lua;
        }

    }
}

具體的調試和使用,請參見視頻的第19.2章

使用 OpenResty Docker 鏡像

需要提前瞭解的內容:

  • Docker
  • Nginx 配置
  • OpenResty 基本瞭解

選擇 OpenResty 的原因:

  • 配置基本等同於 Nginx
  • 必要的時候可以使用 Lua 腳本
  • 提供基於 CentOS 的鏡像,調測方便

相關鏈接

https://github.com/openresty/docker-openresty/blob/master/README.md#nginx-config-files

鏡像內部信息

OpenResty 默認安裝位置:

/usr/local/openresty/

安裝目錄中 Nginx 相關文件:

/usr/local/openresty/nginx/

默認服務指向 Web 文件夾

/usr/local/openresty/nginx/html/

映射關係:

/bin/openresty -> /usr/local/openresty/nginx/sbin/nginx
/bin/opm -> /usr/local/openresty/bin/opm

默認配置文件位置(後續的配置會覆蓋這裏的內容):

/etc/nginx/conf.d/default.conf
/etc/nginx/conf.d/

在絕大多數情況,覆蓋上面的配置文件就可以了。

但是,這些配置文件的內容,只能是包含在 http 段內的配置,並不能作爲完整的配置文件使用。

比如:

可以包含:upstreamserver

不能包含:tcp

完整配置文件位置:

/usr/local/openresty/nginx/conf/nginx.conf

配置文件相關信息:

https://github.com/openresty/docker-openresty/blob/master/README.md#nginx-config-files

性能大坑:與 Docker 使用的網絡瓶頸

說說 Docker 與 OpenResty 之間的"坑"吧,大家肯定對這個更感興趣。

我們剛開始使用的時候,是這樣啓動的:

docker run -d -p 80:80 openresty

首次壓測過程中發現 Docker 進程 CPU 佔用率 100%,單機接口 4-5 萬的 QPS 就上不去了。

經過多方探討交流,終於明白原來是網絡瓶頸所致(OpenResty 太彪悍,Docker 默認的虛擬網卡受不了了 _)。

最終我們繞過這個默認的橋接網卡,使用 --net 參數即可完成。

docker run -d --net=host openresty

多麼簡單,就這麼一個參數,居然困擾了我們好幾天。

一度懷疑我們是否要忍受引入 Docker 帶來的低效率網絡。雖然這個點是自己挖出來的,但是在交流過程中還學到了很多好東西。

Docker Network settings,引自:http://www.lupaworld.com/article-250439-1.html

默認情況下,所有的容器都開啓了網絡接口,同時可以接受任何外部的數據請求。
--dns=[]         : Set custom dns servers for the container
--net="bridge"   : Set the Network mode for the container
                          'bridge': creates a new network stack for the container on the docker bridge
                          'none': no networking for this container
                          'container:<name|id>': reuses another container network stack
                          'host': use the host network stack inside the container
--add-host=""    : Add a line to /etc/hosts (host:IP)
--mac-address="" : Sets the container's Ethernet device's MAC address

你可以通過 docker run --net none 來關閉網絡接口,此時將關閉所有網絡數據的輸入輸出,你只能通過 STDIN、STDOUT 或者 files 來完成 I/O 操作。

默認情況下,容器使用主機的 DNS 設置,你也可以通過 --dns 來覆蓋容器內的 DNS 設置。

同時 Docker 爲容器默認生成一個 MAC 地址,你可以通過 --mac-address 12:34:56:78:9a:bc 來設置你自己的 MAC 地址。

Docker 支持的網絡模式有:

  • none。關閉容器內的網絡連接
  • bridge。通過 veth 接口來連接容器,默認配置。
  • host。允許容器使用 host 的網絡堆棧信息。注意:這種方式將允許容器訪問 host 中類似 D-BUS 之類的系統服務,所以認爲是不安全的。
  • container。使用另外一個容器的網絡堆棧信息。  

None 模式

將網絡模式設置爲 none 時,這個容器將不允許訪問任何外部 router。這個容器內部只會有一個 loopback 接口,而且不存在任何可以訪問外部網絡的 router。

Bridge 模式

Docker 默認會將容器設置爲 bridge 模式。此時在主機上面將會存在一個 docker0 的網絡接口,同時會針對容器創建一對 veth 接口。其中一個 veth 接口是在主機充當網卡橋接作用,另外一個 veth 接口存在於容器的命名空間中,並且指向容器的 loopback。Docker 會自動給這個容器分配一個 IP ,並且將容器內的數據通過橋接轉發到外部。

Host 模式

當網絡模式設置爲 host 時,這個容器將完全共享 host 的網絡堆棧。host 所有的網絡接口將完全對容器開放。容器的主機名也會存在於主機的 hostname 中。這時,容器所有對外暴露的端口和對其它容器的連接,將完全失效。

Bridge 模式

Bridge 模式是 Docker 的默認網絡模式,不指定 --net 參數,就是Bridge模式;

bridge 模式俗稱橋接模式,不難理解的是 bridge 的作用,bridge 可以連接不同的東西。

早期的二層網絡中,bridge 可以連接不同的 LAN 網,如下圖所示。

img

當主機 1 發出一個數據包時,LAN 1 的其他主機和網橋 br0 都會收到該數據包。

網橋再將數據包從入口端複製到其他端口上(本例中就是另外一個端口)。因此,LAN 2 上的主機也會接收到主機 A 發出的數據包,從而實現不同 LAN 網上所有主機的通信。

隨着網絡技術的發展,傳統 bridge 衍生出適用不同應用場景的模式,其中最典型要屬 Linux bridge 模式,它是 Linux Kernel 網絡模塊的一個重要組成部分,用以保障不同虛擬機之間的通信,或是虛擬機與宿主機之間的通信,如下圖所示 :

img

Docker bridge 是用來連接不同容器網絡,或是連接容器與宿主機的。

Docker bridge 模式不僅使用了 veth pair 技術,還使用了網絡命名空間技術,採用了 NAT 方式。

Docker bridge 和 Linux bridge 二者,初看如出一轍,再看又相去甚遠,還真是傻傻分不清楚。

先從 Linux bridge 模式的基本工作原理入手分析。

Linux bridge 模式的虛擬機

Linux bridge 模式下,Linux Kernel 會創建出一個虛擬網橋 ,用以實現主機網絡接口與虛擬網絡接口間的通信。

從功能上來看,Linux bridge 像一臺虛擬交換機,所有橋接設置的虛擬機分別連接到這個交換機的一個接口上,接口之間可以相互訪問且互不干擾,這種連接方式對物理主機而言也是如此。

Linux bridge 模式

Linux bridge 模式下,Linux Kernel 會創建出一個虛擬網橋 ,用以實現主機網絡接口虛擬網絡接口*間的通信。從功能上來看,Linux bridge 像一臺虛擬交換機,所有橋接設置的虛擬機分別連接到這個交換機的一個接口上,接口之間可以相互訪問且互不干擾,這種連接方式對物理主機而言也是如此。

img

在橋接的作用下,虛擬網橋會把主機網絡接口接收到的網絡流量轉發給虛擬網絡接口,於是後者能夠接收到路由器發出的 DHCP(動態主機設定協議,用於獲取局域網 IP)信息及路由更新。

這樣的工作流程,同樣適用於不同虛擬網絡接口間的通信。

具體的實現方式如下:

  • 虛擬機與宿主機通信: 用戶可以手動爲虛擬機配置IP 地址、子網掩碼,該 IP 需要和宿主機 IP 處於同一網段,這樣虛擬機才能和宿主機進行通信。

  • 虛擬機與外界通信: 如果虛擬機需要聯網,還需爲它手動配置網關,該網關也要和宿主機網關保持一致。

除此之外,還有一種較爲簡單的方法,那就是虛擬機通過 DHCP 自動獲取 IP,實現與宿主機或宿主機以外的世界通信,小白親測有效。

Docker bridge 模式

大致清楚 Linux bridge 模式後,再來看 Docker bridge 模式。

Docker Daemon 會創建出一個名爲 docker0 的虛擬網橋 ,用來連接宿主機容器,或者連接不同的容器

Docker 利用 veth pair 技術,在宿主機上創建了兩個虛擬網絡接口 veth0 和 veth1(veth pair 技術的特性可以保證無論哪一個 veth 接收到網絡報文,都會無條件地傳輸給另一方)。

img

容器與宿主機通信 : 在橋接模式下,Docker Daemon 將 veth0 附加到 docker0 網橋上,保證宿主機的報文有能力發往 veth0。再將 veth1 添加到 Docker 容器所屬的網絡命名空間,保證宿主機的網絡報文若發往 veth0 可以立即被 veth1 收到。

容器與外界通信 : 容器如果需要聯網,則需要採用 NAT 方式。準確的說,是 NATP (網絡地址端口轉換) 方式。NATP 包含兩種轉換方式:SNAT 和 DNAT 。

  • DNAT——目的 NAT (Destination NAT,DNAT): 修改數據包的目的地址。

當宿主機以外的世界需要訪問容器時,數據包的流向如下圖所示:

img

由於容器的 IP 與端口對外都是不可見的,所以數據包的目的地址爲宿主機ip端口,爲 192.168.1.10:24 。

數據包經過路由器發給宿主機 eth0,再經 eth0 轉發給 docker0 網橋。由於存在 DNAT 規則,會將數據包的目的地址轉換爲容器ip端口,爲 172.17.0.n:24 。

宿主機上的 docker0 網橋識別到容器 ip 和端口,於是將數據包發送附加到 docker0 網橋上的 veth0 接口,veth0 接口再將數據包發送給容器內部的 veth1 接口,容器接收數據包並作出響應。

img

  • SNAT——源 NAT (Source NAT,SNAT): 修改數據包的源地址。

當容器需要訪問宿主機以外的世界時,數據包的流向爲下圖所示:

img

此時數據包的源地址爲容器的ip和端口,爲 172.17.0.n:24,容器內部的 veth1 接口將數據包發往 veth0 接口,到達 docker0 網橋。

宿主機上的 docker0 網橋發現數據包的目的地址爲外界的 IP 和端口,便會將數據包轉發給 eth0 ,並從 eth0 發出去。

由於存在 SNAT 規則,會將數據包的源地址轉換爲宿主機ip端口,爲 192.168.1.10:24 。

由於路由器可以識別到宿主機的 ip 地址,所以再將數據包轉發給外界,外界接受數據包並作出響應。

這時候,在外界看來,這個數據包就是從 192.168.1.10:24 上發出來的,Docker 容器對外是不可見的。

img

Docker 網橋上容器之間的網絡流量

默認情況下,默認網橋上同一主機上的容器之間允許所有網絡通信。

如果不需要,限制所有的容器間通信,將需要通信的特定容器鏈接在一起,或者創建自定義網絡,並只加入需要與該自定義網絡通信的容器。

參看網絡參數

[root@cdh2 ~]# docker network ls -q | xargs docker network inspect --format '{{.Name}}:{{.Options}}'
base-env-network:map[]
base-env_default:map[]
bridge:map[com.docker.network.bridge.host_binding_ipv4:0.0.0.0 com.docker.network.bridge.name:docker0 com.docker.network.driver.mtu:1500 com.docker.network.bridge.default_bridge:true com.docker.network.bridge.enable_icc:true com.docker.network.bridge.enable_ip_masquerade:true]
host:map[]
monitor-network:map[]
mysql-canal-network:map[]
none:map[]

MTU

最大傳輸單元(Maximum Transmission Unit,MTU)用來通知對方所能接受數據服務單元的最大尺寸,說明發送方能夠接受的有效載荷大小。 [1]

是包或幀的最大長度,一般以字節記。如果MTU過大,在碰到路由器時會被拒絕轉發,因爲它不能處理過大的包。如果太小,因爲協議一定要在包(或幀)上加上包頭,那實際傳送的數據量就會過小,這樣也划不來。大部分操作系統會提供給用戶一個默認值,該值一般對用戶是比較合適的。 [2]

爲啥缺省的MTU爲1500

這個問題不是非常嚴謹,應該說標準以太網接口缺省的MTU爲1500,而現在的以太網接口普遍可以通過配置使得MTU遠遠大於1500。

以太網幀長度上下限標準以太網幀長度下限爲:64 字節標準以太網幀長度上限爲:1518 字節

最早的以太網工作方式:載波多路複用/衝突檢測CSMA/CD,因爲網絡是共享的,即任何一個節點發送數據之前,先要偵聽線路上是否有數據在傳輸,如果有,需要等待,如果線路可用,纔可以發送。

假設A發出第一個bit位,到達B,而B也正在傳輸第一個bit位,於是產生衝突,衝突信號得讓A在完成最後一個bit位之前到達A,這個一來一回的時間間隙slot time是57.6μs.
在10Mbps的網絡中,在57.6μs的時間內,能夠傳輸576個bit,所以要求以太網幀最小長度爲576個bits,從而讓最極端的碰撞都能夠被檢測到。

這個576bit換算一下就是72個字節,去掉8個字節的前導符和幀開始符,以太網幀的最小長度爲64字節。

img

如果說以太網幀的最小長度64byte是由CSMA/CD限制所致,那最大長度1500byte又是處於什麼考慮的呢?

IP頭total length爲兩個byte,理論上IP packet可以有65535 byte,加上Ethernet Frame頭和尾,可以有65535 +14 + 4 = 65553 byte。

如果在10Mbps以太網上,將會佔用共享鏈路長達50ms,這將嚴重影響其它主機的通信,特別是對延遲敏感的應用是無法接受的。

由於線路質量差而引起的丟包,發生在大包的概率也比小包概率大得多,所以大包在丟包率較高的線路上不是一個好的選擇。

但是如果選擇一個比較小的長度,傳輸效率又不高,拿TCP應用來說,如果選擇以太網長度爲218byte,

TCP payload = 218 - Ethernet Header -IP Header - TCP Header=[218-18 - 20](tel:218-18 - 20) -20= 160 byte

那有效傳輸效率=160/218= 73%

而如果以太網長度爲1518,那有效傳輸效率=1460/1518=96%通過比較,選擇較大的幀長度,有效傳輸效率更高,

而更大的幀長度同時也會造成上述的問題,於是最終選擇一個折衷的長度:1518 byte !

對應的IP packet 就是 1500 byte,這就是最大傳輸單元MTU的由來。

參考文獻

https://segmentfault.com/a/1190000000382788

http://www.cnblogs.com/mecity/archive/2011/06/20/2085529.html

https://www.cnblogs.com/kevingrace/p/9512287.html

https://blog.csdn.net/weixin_33725272/article/details/92036693

https://blog.csdn.net/wukai1211/article/details/122100769

https://www.jianshu.com/p/b3b0bf529a0b

https://moonbingbing.gitbooks.io/openresty-best-practices/content/web/docker.html

https://blog.csdn.net/mergerly/article/details/79819318

https://blog.csdn.net/zhichunqi/article/details/103197038

https://www.zhihu.com/question/21524257

https://www.cnblogs.com/llljpf/p/10830651.html

https://blog.csdn.net/weixin_38405253/article/details/107739175

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章