文章很长,建议收藏起来慢慢读!疯狂创客圈总目录 语雀版 | 总目录 码云版| 总目录 博客园版 为您奉上珍贵的学习资源 :


SpringCloud 微服务 精彩博文
nacos 实战(史上最全) sentinel (史上最全+入门教程)
SpringCloud gateway (史上最全)分库分表sharding-jdbc底层原理与实操(史上最全,5W字长文,吐血推荐)

WebSocket协议+Nginx动态负载均衡(史上最全)

HTML5 拥有众多引人注目的新特性,如 Canvas、本地存储、多媒体编程接口、WebSocket 等等。

其中,WebSocket 的出现使得浏览器提供对 Socket 的支持成为可能,从而在浏览器和服务器之间提供了一个基于 TCP 连接的双向通道。

使用 WebSocket,web开发人员可以很方便地构建实时 web 应用。

背景

以前,很多网站使用轮询实现推送技术。轮询是在特定的的时间间隔(比如1秒),由浏览器对服务器发出HTTP request,然后由服务器返回最新的数据给浏览器。轮询的缺点很明显,浏览器需要不断的向服务器发出请求,然而HTTP请求的header是非常长的,而实际传输的数据可能很小,这就造成了带宽和服务器资源的浪费。

Comet使用了AJAX改进了轮询,可以实现双向通信。但是Comet依然需要发出请求,而且在Comet中,普遍采用了长链接,这也会大量消耗服务器带宽和资源。

于是,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

WebSocket协议

浏览器通过 JavaScript 向服务器发出建立 WebSocket 连接的请求,连接建立以后,客户端和服务器通过 TCP 连接直接交换数据。WebSocket 连接本质上是一个 TCP 连接。

WebSocket在数据传输的稳定性和数据传输量的大小方面,具有很大的性能优势。Websocket.org 比较了轮询和WebSocket的性能优势:

websocket vs polling

从上图可以看出,WebSocket具有很大的性能优势,流量和负载增大的情况下,优势更加明显。

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 头部信息如下:

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

当服务器端,成功验证了以上信息后,则会返回一个形如以下信息的响应:

Connection: upgrade
Sec-WebSocket-Accept: nImJE2gpj1XLtrOb+5cBMJn7bNQ=
Upgrade: websocket

可以看到,浏览器发送的 HTTP 请求中,增加了一些新的字段,其作用如下所示:

  • Upgrade: 规定必需的字段,其值必需为 websocket, 如果不是则握手失败;
  • Connection: 规定必需的字段,值必需为 Upgrade, 如果不是则握手失败;
  • Sec-WebSocket-Key: 必需字段,一个随机的字符串;
  • Sec-WebSocket-Version: 必需字段,代表了 WebSocket 协议版本,值必需是 13, 否则握手失败;

返回的响应中,如果握手成功会返回状态码为 101 的 HTTP 响应。同时其他字段说明如下:

  • Upgrade: 规定必需的字段,其值必需为 websocket, 如果不是则握手失败;
  • Connection: 规定必需的字段,值必需为 Upgrade, 如果不是则握手失败;
  • Sec-WebSocket-Accept: 规定必需的字段,该字段的值是通过固定字符串258EAFA5-E914-47DA-95CA-C5AB0DC85B11加上请求中Sec-WebSocket-Key字段的值,然后再对其结果通过 SHA1 哈希算法求出的结果。

当浏览器和服务器端成功握手后,就可以传送数据了,传送数据是按照 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协议报文

客户端请求

在客户端,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报文,

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/

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支持良好:

  • 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指令的作用:

根据客户端请求中 h t t p u p g r a d e 的 值 , 来 构 造 改 变 http_upgrade 的值,来构造改变 httpupgrade connection_upgrade的值

  • 即根据变量 h t t p u p g r a d e 的 值 创 建 新 的 变 量 http_upgrade的值创建新的变量 httpupgradeconnection_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论坛。

缓存实现的小范围分布式也是比较常用的,比如单点登录也是一种特殊情况。

动态负载均衡

具体思路

利用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

此处涉及实操,请参见19章视频

性能大坑:与 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

Logo

为开发者提供学习成长、分享交流、生态实践、资源工具等服务,帮助开发者快速成长。

更多推荐