571
社区成员
发帖
与我相关
我的任务
分享目录
QUIC(Quick UDP Internet Connection)是谷歌推出的一套基于 UDP 的传输协议,它实现了 TCP + HTTPS + HTTP/2 的功能,目的是保证可靠性的同时降低网络延迟。因为 UDP 是一个简单传输协议,基于 UDP 可以摆脱 TCP 传输确认、重传慢启动等因素,建立安全连接只需要一的个往返时间,它还实现了 HTTP/2 多路复用、头部压缩等功能。
网络传输层的TCP协议虽然保证了安全的数据传输,但是需要TCP连接的双方进行多次通信,这些通信带来的传输损耗在有些场景下并不小。其次TCP为了保证安全完整的传输,使用了很多操作系统内核的特性,如果将用户使用的TCP协议升级,那么就要对操作系统进行升级,这显然对大部分的客户来说是不能接受的,因为你永远不知道升级了操作系统之后会给其他业务带来什么影响。而QUIC协议基于UDP改造,不仅不和内核深度绑定,而且能独立发挥,只要有通信的双方都可以做到。
在传统的TCP协议中,如果连接的某一条件发生了变动,那么就会引起TCP的重新连接。例如手机连接的WIFI发生了切换而导致IP地址发生了切换,那么基于TCP的HTTP协议就无法保持原有的状态而必须重新连接;如果使用公共 NAT 出口时,有些连接竞争时需要重新绑定端口,导致客户端的端口发生变化,同样需要重新建立 TCP 连接。
在QUIC协议中,连接将不再由TCP四元组和IP地址进行标识,这种标识方式是造成TCP连接必须重新连接的“罪魁祸首”,四元组和IP任何一个发生了变化,就是一条新的TCP连接了。QUIC使用一个随机的64位标识符表示一个连接,就算IP地址和端口发生了变化,只要ID不变,这条连接依然存在和有效,因此可以继续使用该连接。
随机ID的生成方式很多,UUID算法或者雪花算法都能生成全局唯一ID,而QUIC有自己的生成模式,确保了任意连接不会发生冲突。
目前任何一个连接,只要使用了TCP连接,就必有一个RTT的TCP握手延迟,这是TCP协议的“通病”,而且在TCP握手阶段无法传输任何数据,传输数据最快也得等到TCP握手结束。
而QUIC基于UDP协议,无须进行TCP的握手阶段,在最好情况下,短连接下 QUIC 可以做到 0RTT 开启数据传输。而基于 TCP 的 HTTPS,即使在最好的 TLS1.3 的 early data 下仍然需要 1RTT 开启数据传输。而对于目前线上常见的 TLS1.2 完全握手的情况,则需要 3RTT 开启数据传输。对于 RTT 敏感的业务,QUIC 可以有效的降低连接建立延迟。
QUIC具体握手过程如下:
1. 客户端判断本地是否已有服务器的全部配置参数(证书配置信息),如果有则直接跳转到(5),否则继续 。
2. 客户端向服务器发送 `inchoate client hello(CHLO)` 消息,请求服务器传输配置参数。
3. 服务器收到 `CHLO`,回复 `rejection(REJ)` 消息,其中包含服务器的部分配置参数
4. 客户端收到 `REJ`,提取并存储服务器配置参数,跳回到 (1) 。
5. 客户端向服务器发送 `full client hello` 消息,开始正式握手,消息中包括客户端选择的公开数。此时客户端根据获取的服务器配置参数和自己选择的公开数,可以计算出初始密钥 `K1`。
6. 服务器收到 `full client hello`,如果不同意连接就回复 `REJ`,同(3);如果同意连接,根据客户端的公开数计算出初始密钥 `K1`,回复 `server hello(SHLO)` 消息, `SHLO` 用初始密钥 `K1` 加密,并且其中包含服务器选择的一个临时公开数。
7. 客户端收到服务器的回复,如果是 `REJ` 则情况同(4);如果是 `SHLO`,则尝试用初始密钥 `K1` 解密,提取出临时公开数。
8. 客户端和服务器根据临时公开数和初始密钥 `K1`,各自基于 `SHA-256` 算法推导出会话密钥 `K2`。
9. 双方更换为使用会话密钥 `K2` 通信,初始密钥 `K1` 此时已无用,QUIC 握手过程完毕。之后会话密钥 `K2` 更新的流程与以上过程类似,只是数据包中的某些字段略有不同。

拥塞控制技术是网络在出现阻塞,超时等因流量过多情况而“自我调节”的能力。TCP提供了拥塞控制的功能,但是对于用户来说是“透明的”,及拥塞控制的流程和用户不相关,是自动进行的,而QUIC提供了一种方式让用户能够自定义拥塞控制信息。比如对于每一个包,不管是原始包还是重传包,都带有一个新的序列号(seq),这使得 QUIC 能够区分 ACK 是重传包还是原始包,从而避免了 TCP 重传模糊的问题。QUIC 同时还带有收到数据包与发出 ACK 之间的时延信息。这些信息能够帮助更精确的计算 RTT。
这种拥塞控制方法不再依赖内核的算法,而且能够在业务层根据业务场景实现自定义拥塞控制,配置和使用不同的拥塞控制算法和参数。
实验环境为CentOS7操作系统,内存2GB,带宽2MB,硬盘100GB。
* OS相关依赖安装
* CentOS 安装libunwind扩展库
yum install libunwind-devel -y
* 进入 Nginx 安装目录
cd /www/server/nginx
* 下载编译需要用到的相关
* 下载nginx
wget http://nginx.org/download/nginx-1.19.4.tar.gz
* 下载quiche
git clone --recursive https://github.com/cloudflare/quiche
* go的下载(注意,若系统之前已经安装过,需要先卸载旧版)
yum remove go
* 下载 go
wget https://dl.google.com/go/go1.15.2.linux-amd64.tar.gz
* cmake 的下载(注意,若系统之前已经安装过,请确认所安装版本大于 3.0,负责需要先卸载旧版)
* 查看当前 cmake 版本
cmake --version
* 卸载旧版
yum remove cmake
* 下载cmake-3.11.4(高于该版本的后边可能会编译出错。)
wget https://github.com/Kitware/CMake/releases/download/v3.11.4/cmake-3.11.4.tar.gz
* 编译安装
tar zxf cmake-3.11.4.tar.gz && cd cmake-3.11.4 && ./bootstrap && make && make install
* 设置软连接
ln -s /usr/local/bin/cmake /usr/bin/cmake
* 解压go
tar -C /usr/local -xzf go1.15.2.linux-amd64.tar.gz
* 安装rust
curl https://sh.rustup.rs -sSf | sh
* 设置系统环境变量,也可以写入到profile中
export PATH=$PATH:/usr/local/go/bin
export PATH=$PATH:$HOME/.cargo/bin
export GOROOT=/usr/local/go
export GOBIN=$GOROOT/bin
export PATH=$PATH:$GOBIN
* 使其生效
source ~/.profile
```
* 安装`nginx-quic`源码
```bash
git clone https://github.com/evansun922/nginx-quic.git
```
* 安装`chromium`源码
* 拉取源码
git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git
* 设置环境变量
export PATH=/path/to/depot_tools:$PATH
* 下载chromium
fetch chromium
```
* 在工作目录下执行命令来完成依赖准备
```bash
.gclient # A configuration file for you source checkout
src/ # Top-level Chromium source checkout.
cd src && ./build/install-build-deps.sh
gclient sync
```
* 依赖安装完成之后可以检查安装情况(每条命令都有相应的输出)
```bash
git checkout 86.0.4224.0
gclient sync -D
```
* 安装nginx,直接从官网下载解压即可
* 在`nginx-quic`源码目录下执行
```bash
python3 mk2gn.py "/home/zxilly/Downloads/nginx-1.19.2" \
"/home/zxilly/quic/quic/src" "--user=www --group=www \
--prefix=/www/server/nginx \
--add-module=/www/server/nginx/src/ngx_devel_kit \
--add-module=/www/server/nginx/src/lua_nginx_module \
--add-module=/www/server/nginx/src/ngx_cache_purge \
--add-module=/www/server/nginx/src/nginx-sticky-module \
--with-pcre=pcre-8.43 \
--with-http_v2_module \
--with-stream --with-stream_ssl_module --with-stream_ssl_preread_module \
--with-http_stub_status_module \
--with-http_ssl_module --with-http_image_filter_module \
--with-http_gzip_static_module \
--with-http_gunzip_module \
--with-http_sub_module \
--with-http_flv_module \
--with-http_addition_module \
--with-http_realip_module \
--with-http_mp4_module \
--with-ld-opt=-Wl,-E \
--with-cc-opt=-Wno-error \
--with-ld-opt=-ljemalloc \
--with-http_dav_module \
--add-module=/www/server/nginx/src/nginx-dav-ext-module \
--add-module=/root/ngx_brotli"
```
* 在`chromium`目录下进行编译
```bash
gn gen out/Release --args="is_component_build=false is_debug=false"
```
**如果此项编译失败,可以转为执行下面的步骤** :
1. 把需要的头文件单独软链接到其他目录
```bash
executable("nginx") {
sources = [
# 所用到的.c或者.cc源码,一般不用修改
]
include_dirs = [
#头文件dir,类似与 "-I"
]
lib_dirs = [
# 库的搜索目录,类似与-L your libdir
]
libs = [
# dynamic library: pthread or static library: /path/xxx.a
]
cflags_c = [
# 编译选项
"-D_FORTIFY_SOURCE=2",
"-DTCP_FASTOPEN=23",
"-DNDK_SET_VAR",
]
}
```
2. 执行 `ninja -C out/Release nginx`,编译好的 nginx-quic 就在 `out/Release` 目录中。
3. 执行`./nginx -V` 来检查编译是否成功
* 命令行配置Nginx的QUIC支持
* 测试一下
sudo objs/nginx -t
* 复制新的编译好的执行文件 nginx,覆盖替换旧版本执行文件 nginx
mv /www/server/nginx/sbin/nginx /www/server/nginx/sbin/nginx.bak
cp /www/server/nginx/nginx-1.19.4/objs/nginx /www/server/nginx/sbin/nginx
* 升级
make upgrade
* 查看版本,Nginx 名称和版本号被我修改了。
Nginx -V
nginx version: QUIC/1.1.1 (--build=quiche-fd5e028)
built by gcc 4.8.5 20150623 (Red Hat 4.8.5-39) (GCC)
built with OpenSSL 1.1.0 (compatible; BoringSSL) (running with BoringSSL)
TLS SNI support enabled
configure arguments: --user=www --group=www --prefix=/www/server/nginx --with-openssl=../quiche/deps/boringssl --with-quiche=../quiche --add-module=/www/server/nginx/src/lua_nginx_module --add-module=/www/server/nginx/src/redis2-nginx-module --add-module=/www/server/nginx/src/ngx_devel_kit --add-module=/www/server/nginx/src/ngx-pagespeed --add-module=/www/server/nginx/src/ngx_cache_purge --add-module=/www/server/nginx/src/nginx-sticky-module --add-module=/www/server/nginx/src/ngx_brotli --add-module=/www/server/nginx/src/nginx-http-concat --add-module=/www/server/nginx/src/nginx-sorted-querystring-module --add-module=/www/server/nginx/src/ngx_http_substitutions_filter_module --add-module=/www/server/nginx/src/headers-more-nginx-module --add-module=/www/server/nginx/src/nginx_cookie_flag_module --add-module=/www/server/nginx/src/ModSecurity-nginx --add-module=/www/server/nginx/src/ngx_webp --with-http_stub_status_module --with-pcre=/www/server/nginx/src/pcre-8.44 --with-zlib=/www/server/nginx/src/zlib --with-http_ssl_module --with-http_v2_module --with-http_image_filter_module --with-http_gzip_static_module --with-http_gunzip_module --with-stream --with-stream_ssl_module --with-ipv6 --with-http_sub_module --with-http_flv_module --with-http_addition_module --with-http_realip_module --with-http_mp4_module --with-http_v2_hpack_enc --with-google_perftools_module --with-http_geoip_module --with-http_v3_module --build=--build=quiche-fd5e028
* 开启QUIC特性
# Enable QUIC and HTTP/3.
listen 443 quic reuseport;
# Enable HTTP/2 (optional).
listen 443 ssl http2;
# 只支持tls1.3
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers [TLS13+AESGCM+AES128|TLS13+CHACHA20]:TLS13+AESGCM+AES256:[EECDH+ECDSA+AESGCM+AES128|EECDH+ECDSA+CHACHA20]:EECDH+ECDSA+AESGCM+AES256:EECDH+ECDSA+AES128+SHA:EECDH+ECDSA+AES256+SHA:[EECDH+aRSA+AESGCM+AES128|EECDH+aRSA+CHACHA20]:EECDH+aRSA+AESGCM+AES256:EECDH+aRSA+AES128+SHA:EECDH+aRSA+AES256+SHA;
ssl_prefer_server_ciphers on;
ssl_early_data on;
ssl_ecdh_curve X25519:P-256:P-384;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
error_page 497 https://$host$request_uri;
# OCSP Stapling 启用
ssl_stapling on;
ssl_stapling_verify on;
ssl_stapling_file /home/others/ocsp/nange.cn.ocsp.resp;
# Add Alt-Svc header to negotiate HTTP/3.
add_header alt-svc 'quic=":443"; ma=2592000; ';
```
* nginx config文件的通用配置(在nginx的配置文件中配置)
```bash
http {
...
server {
listen 443 quic reuseport sndbuf=1048576 rcvbuf=1048576;
ssl_certificate ssl/tv.test.com.crt;
ssl_certificate_key ssl/tv.test.com.pkcs8;
...
}
```
* nginx-rtmp配置(在nginx的配置文件中配置)
```bash
rtmp {
...
server {
listen 1935 so_keepalive=on;
listen 1935 quic reuseport;
ssl_certificate ssl/tv.test.com.crt;
ssl_certificate_key ssl/tv.test.com.key;
application live {
live on;
idle_streams off;
drop_idle_publisher 1800s;
sync 1s;
wait_key on;
wait_video on;
}
}
}
```
本测试基于QUIC内置的demo程序进行。
* 下载源码
```bash
git clone https://github.com/lucas-clemente/quic-go.git
```
* 编译服务端源码
```bash
cd example
go build main.go
```
* 编译客户端源码
修改`example/client/main.go`,在60行之后加上`qconf.Versions = []protocol.VersionNumber{protocol.VersionDraft29}`,选择quic版本为draft-29。
```bash
cd example/client
go build main.go
```
* 启动服务端,首次启动必须加上`-v tcp`参数,否则无法访问
```bash
./main -qlog -v -tcp
```
* 启动客户端
```bash
./main -v -insecure -keylog ssl.log https://quic.rocks:4433
```
在firefox中打开`about:config`,搜索HTTP3,将值设为True以打开HTTP3的实验特性。打开`https://localhost:6121/demo/tile`网页,通过调试工具查看请求,当第一次请求该网页时,会通过TCP协议进行,并且得到的响应如下:
```bash
200 GET localhost:6121 tile HTTP/1.1
```
并且在响应的头部会带上`Alt-Svc`字段,告诉浏览器对方网站支持HTTP3协议:
```markdown
Response Header:
* Alt-Svc: h3-29=".6121"; ma=34102120; h3-29=":6121"; ma=24316500
* Content-Length: 83
```
当第一次成功访问之后,再次访问网页,有了之前的沟通,网页就会通过QUIC协议访问:
```bash
200 GET localhost:6121 title HTTP/3 # 这里变成了HTTP/3
```
服务器的启动调用的是`ListenAndServe()`函数:
```go
func (s *Server) ListenAndServe() error {
if s.Server == nil {
return errors.New("use of http3.Server without http.Server")
}
return s.serveImpl(s.TLSConfig, nil)
}
```
具体调用了`#serveImpl`来实现HTTP3服务器的启动:
```go
func (s *Server) serveImpl(tlsConf *tls.Config, conn net.PacketConn) error {
if s.closed.Get() {
return http.ErrServerClosed
}
if s.Server == nil {
return errors.New("use of http3.Server without http.Server")
}
s.loggerOnce.Do(func() {
s.logger = utils.DefaultLogger.WithPrefix("server")
})
// 解析tls配置项
baseConf := &tls.Config{
GetConfigForClient: func(ch *tls.ClientHelloInfo) (*tls.Config, error) {
// determine the ALPN from the QUIC version used
proto := nextProtoH3Draft29
if qconn, ok := ch.Conn.(handshake.ConnWithVersion); ok {
// 检查quic版本
if qconn.GetQUICVersion() == protocol.Version1 {
proto = nextProtoH3
}
}
config := tlsConf
if tlsConf.GetConfigForClient != nil {
getConfigForClient := tlsConf.GetConfigForClient
var err error
// 根据配置创建一个新的client端
conf, err := getConfigForClient(ch)
if err != nil {
return nil, err
}
if conf != nil {
config = conf
}
}
if config == nil {
return nil, nil
}
// 为了节省资源,采用clone的方式复制配置项
config = config.Clone()
config.NextProtos = []string{proto}
return config, nil
},
}
var ln quic.EarlyListener
var err error
quicConf := s.QuicConfig
if quicConf == nil {
quicConf = &quic.Config{}
} else {
quicConf = s.QuicConfig.Clone()
}
if s.EnableDatagrams {
quicConf.EnableDatagrams = true
}
if conn == nil {
ln, err = quicListenAddr(s.Addr, baseConf, quicConf)
} else {
ln, err = quicListen(conn, baseConf, quicConf)
}
if err != nil {
return err
}
s.addListener(&ln)
defer s.removeListener(&ln)
for {
// 监听器监听数据
sess, err := ln.Accept(context.Background())
if err != nil {
return err
}
// 启动一个新的协程处理请求
go s.handleConn(sess)
}
}
```
而核心逻辑是处理新的连接请求,即`#handleConn`函数
```go
func (s *Server) handleConn(sess quic.EarlySession) {
decoder := qpack.NewDecoder(nil)
// send a SETTINGS frame
// 开启框架流
str, err := sess.OpenUniStream()
if err != nil {
s.logger.Debugf("Opening the control stream failed.")
return
}
buf := &bytes.Buffer{}
quicvarint.Write(buf, streamTypeControlStream) // stream type
(&settingsFrame{Datagram: s.EnableDatagrams}).Write(buf)
str.Write(buf.Bytes())
go s.handleUnidirectionalStreams(sess)
// Process all requests immediately.
// It's the client's responsibility to decide which requests are eligible for 0-RTT.
// 采用0RTT的方式立即处理所有请求
for {
// 获取请求上下文信息
str, err := sess.AcceptStream(context.Background())
if err != nil {
s.logger.Debugf("Accepting stream failed: %s", err)
return
}
go func() {
// 采用goroutine并行处理req
// ######################### 这里是0RTT的核心逻辑 ##################################3
rerr := s.handleRequest(sess, str, decoder, func() {
sess.CloseWithError(quic.ApplicationErrorCode(errorFrameUnexpected), "")
})
if rerr.err != nil || rerr.streamErr != 0 || rerr.connErr != 0 {
s.logger.Debugf("Handling request failed: %s", err)
if rerr.streamErr != 0 {
str.CancelWrite(quic.StreamErrorCode(rerr.streamErr))
}
if rerr.connErr != 0 {
var reason string
if rerr.err != nil {
reason = rerr.err.Error()
}
sess.CloseWithError(quic.ApplicationErrorCode(rerr.connErr), reason)
}
return
}
str.Close()
}()
}
}
```
```go
func (s *Server) handleRequest(sess quic.Session, str quic.Stream, decoder *qpack.Decoder, onFrameError func()) requestError {
frame, err := parseNextFrame(str)
if err != nil {
return newStreamError(errorRequestIncomplete, err)
}
hf, ok := frame.(*headersFrame)
if !ok {
return newConnError(errorFrameUnexpected, errors.New("expected first frame to be a HEADERS frame"))
}
if hf.Length > s.maxHeaderBytes() {
return newStreamError(errorFrameError, fmt.Errorf("HEADERS frame too large: %d bytes (max: %d)", hf.Length, s.maxHeaderBytes()))
}
headerBlock := make([]byte, hf.Length)
if _, err := io.ReadFull(str, headerBlock); err != nil {
return newStreamError(errorRequestIncomplete, err)
}
hfs, err := decoder.DecodeFull(headerBlock)
if err != nil {
// TODO: use the right error code
return newConnError(errorGeneralProtocolError, err)
}
req, err := requestFromHeaders(hfs)
if err != nil {
// TODO: use the right error code
return newStreamError(errorGeneralProtocolError, err)
}
req.RemoteAddr = sess.RemoteAddr().String()
req.Body = newRequestBody(str, onFrameError)
if s.logger.Debug() {
s.logger.Infof("%s %s%s, on stream %d", req.Method, req.Host, req.RequestURI, str.StreamID())
} else {
s.logger.Infof("%s %s%s", req.Method, req.Host, req.RequestURI)
}
ctx := str.Context()
ctx = context.WithValue(ctx, ServerContextKey, s)
ctx = context.WithValue(ctx, http.LocalAddrContextKey, sess.LocalAddr())
req = req.WithContext(ctx)
r := newResponseWriter(str, s.logger)
defer func() {
if !r.usedDataStream() {
r.Flush()
}
}()
handler := s.Handler
if handler == nil {
handler = http.DefaultServeMux
}
var panicked bool
func() {
defer func() {
if p := recover(); p != nil {
// Copied from net/http/server.go
const size = 64 << 10
buf := make([]byte, size)
buf = buf[:runtime.Stack(buf, false)]
s.logger.Errorf("http: panic serving: %v\n%s", p, buf)
panicked = true
}
}()
handler.ServeHTTP(r, req)
}()
if !r.usedDataStream() {
if panicked {
r.WriteHeader(500)
} else {
r.WriteHeader(200)
}
// If the EOF was read by the handler, CancelRead() is a no-op.
str.CancelRead(quic.StreamErrorCode(errorNoError))
}
return requestError{}
}
```
至此就完成了处理一次HTTP请求的核心流程,当然上述代码只显示了最核心的处理逻辑,实际上需要处理的内容远远不止这么多。
```go
const MethodGet0RTT = "GET_0RTT" // 请求方式统一为0RTT
const (
defaultUserAgent = "quic-go HTTP/3"
defaultMaxResponseHeaderBytes = 10 * 1 << 20 // 10 MB
)
var defaultQuicConfig = &quic.Config{
MaxIncomingStreams: -1, // don't allow the server to create bidirectional streams
KeepAlive: true,
Versions: []protocol.VersionNumber{protocol.VersionTLS},
}
// 拨号地址,默认为本地地址
var dialAddr = quic.DialAddrEarly
// HTTP RoundTriper改写,不同于原本的TCP RoundTriper
type roundTripperOpts struct {
DisableCompression bool
EnableDatagram bool
MaxHeaderBytes int64
}
// client is a HTTP3 client doing requests
type client struct {
// tls连接配置
tlsConf *tls.Config
config *quic.Config
// roundtripper相关参数
opts *roundTripperOpts
// 是否只调用一次
dialOnce sync.Once
dialer func(network, addr string, tlsCfg *tls.Config, cfg *quic.Config) (quic.EarlySession, error)
handshakeErr error
// 请求写容器
requestWriter *requestWriter
decoder *qpack.Decoder
// 主机地址
hostname string
// quic session,用唯一uuid来标识一个连接
session quic.EarlySession
logger utils.Logger
}
```
```go
func (c *client) dial() error {
var err error
if c.dialer != nil {
c.session, err = c.dialer("udp", c.hostname, c.tlsConf, c.config)
} else {
c.session, err = dialAddr(c.hostname, c.tlsConf, c.config)
}
if err != nil {
return err
}
// send the SETTINGs frame, using 0-RTT data, if possible
go func() {
if err := c.setupSession(); err != nil {
c.logger.Debugf("Setting up session failed: %s", err)
c.session.CloseWithError(quic.ApplicationErrorCode(errorInternalError), "")
}
}()
go c.handleUnidirectionalStreams()
return nil
}
```
客户端发送请求实际上调用的是`dial`函数,里面的逻辑相较于`Server`端很简单,主要就是客户端发送一次0RTT请求,之后再异步监听来自服务器的响应,这个和TCP原本的连接没有太大区别。
作者:NP437+王魏