标签 计算机网络

我的家庭AIO服务器方案

发布于
分类 网络

618期间给家里换了个雷鸟的电视,想着给小孩看纪录片用。既然播放设备有了那还得有个纪录片存储的设备,所以就本着够用就好的原则在闲鱼捡了套垃圾DIY了一台飞牛NAS,硬件配置如下

  • CPU:NAS神U 奔腾G4560
  • 主板:梅捷H110M
  • 散热器:卖家送的垃圾散热器
  • 电源:卖家送的垃圾电源180W
  • 内存:多多20买的杂牌8G
  • 机箱:多多20多买的铁皮机箱
  • 网卡:主板自带的为百兆网卡,自己加了块之前买的2.5G网卡,型号为8125B

之所以要选G4560这颗U呢,首先因为它便宜,才10元左右,另外功耗比较低,核显为HD 610,支持硬解的格式比较多

image.png

结构

image.png

远程开机

因为飞牛NAS上没有部署需要24h跑的作业,仅仅用来作为下载机下载一些电影或者资源和备份相册,所以平时都会关机,只有要使用时才会开机。但是又有需要在外远程开机的诉求,因此我在家里找到了一台闲置多年的荣耀手机,安装了termux,然后在上面部署了一个我用Go写的IOT程序,这个程序会与巴法云建立连接,监听来自巴法云的开机消息,收到开机消息后会向内网的飞牛主机发送网络唤醒魔术包(即WOL)来唤醒飞牛主机。

我在我的阿里云服务器上部署了一个web服务,在外可以随时通过web服务向巴法云发送开机消息,这样就达到了在外远程开机的效果。

公网IP获取

开机后就要想办法能够连接到家里的NAS主机。在没有公网IP的情况下只能通过类似于frp这样的工具做内网穿透,这种方案的原理是通过一个公网的服务器进行转发,效率不高。

幸运的是我家联通宽带有公网IP,只需要将现有的通过光猫拨号修改为路由器拨号即可。不过这需要你拿到光猫的超管密码,具体的获取方式我是参考的这篇博客:《中国联通光猫G-140W-UG超管密码获取》,有需要的同学可以参考自取。拿到光猫超管密码后进到光猫后台和路由器后台一通配置即可,具体的配置步骤网上有很多这里我就不过多赘述了。

家宽的IP是会动态变化的,所以我们需要通过DDNS来将一个固定的域名解析到动态的公网IP上。恰好我的域名采购自腾讯云,使用的是DNSPOD作为域名解析服务商,而我的中兴路由器支持DNSPOD作为DDNS的服务商,所以DDNS的配置对于我来说还是非常简单方便的。

wireguard回家

拿到公网IP后我们就有了连接到家里NAS的基础条件,那么要连接到家里NAS一个最直接的方案就是暴露出飞牛的端口号到公网,但是这个方案有两个风险:

  • 安全问题,暴露到公网意味都任何人都能访问我们的NAS,纯靠账号密码进行安全验证的方式对于我来说还是有些大胆了
  • 从一些社区和论坛得知某些地域运营商不允许架设web server,发现后会进行封禁

我采用的方案是在NAS中通过docker部署wireguard来架设VPN服务,通过VPN达到“回家”的目的。我实际使用的是github.com/wg-easy/wg-easy这个项目来架设的wireguard服务,部署简单管理也非常简单。

监控

作为一个后端工程师,不能实时观测到服务器的状态始终让人感觉到不太可靠,虽然飞牛APP上提供了一些监控指标,但是没有提供web界面。因此我在NAS上通过docker部署了一个galance服务,然后将其web监控页面通过iframe嵌套到了我用AI帮我做的一个AIO导航页中,整体效果如下:

image.png

...

阅读全文 »

Go数据库连接池设置不合理导致大量TIME_WAIT连接占满端口问题排查与解决

发布于
分类 Golang
标签 Go

1. 问题与背景

最近公司内部准备尝试使用下腾讯的TDSQL,因此组内同学写了一段很简单的查询TDSQL的go web程序,使用ab对其进行一个简单压测以获取TDSQL的性能表现,go代码如下:

package main

import (
    "crypto/md5"
    "database/sql"
    "fmt"
    "log"
    "math/rand"
    "net/http"
    "strconv"
    "time"

    "github.com/gin-gonic/gin"
    _ "github.com/go-sql-driver/mysql"
)

func main() {
    r := gin.New()
    r.Use(gin.Logger())
    r.GET("/test", func(c *gin.Context) {
        c.JSON(200, gin.H{
            "message": "test",
        })
    })

    dbconnect, err := sql.Open("mysql", "user:passwd@tcp(10.43.0.43:3306)/dbname")
    if err != nil {
        panic(err)
    }
    dbconnect.SetMaxIdleConns(5)
    dbconnect.SetMaxOpenConns(10)
    dbconnect.SetConnMaxLifetime(time.Hour)
    dbconnect.SetConnMaxIdleTime(time.Hour)
    r.GET("tdsql_test", func(context *gin.Context) {
        muid := fmt.Sprintf("%x", md5.Sum([]byte(strconv.Itoa(rand.Intn(1000000000000)))))
        rows, err := dbconnect.Query(fmt.Sprintf("select muid from rtb_channel_0 where muid='%s'", muid))
        if err != nil {
            log.Fatal(err)
            context.JSON(http.StatusInternalServerError, gin.H{
                "error_code": -3,
            })
            return
        }

        rows.Close()

        context.JSON(http.StatusOK, gin.H{
            "error_code": 0,
            "error_msg":  muid,
            "data":       "result",
        })
    })

    r.Run(":9000")
}

ab压测命令如下:

ab -c 10 -n 500000 "http://127.0.0.1:9000/tdsql_test"

压测开始不久之后代码log.Fatal(err)就打印出了错误信息并退出了: image.png

dial tcp 10.43.0.43:3306: connect: cannot assign requested address

这段错误信息是说无法连接到10.43.0.43:3306 ,原因是无法分配请求地址号,就是说本地端口号都被占用了。那么我们就开始进行排查,端口号究竟是被谁占满的?

2. 排查过程

2.1 通过netstat命令查看端口都被谁占用

netstat -nta | grep 10.43.0.43

有如下输出: image.png 可以看到有大量处于TIME_WAIT状态的TCP连接,这些连接占用了大量的端口。那么这些TIMI_WAIT状态的TCP连接是从哪来的呢?

为了弄清楚这个问题,我们必须知道TIME_WAIT状态是怎么回事。

2.1.1 TIME_WAIT

image.png

上图是经典的TCP四次挥手断开连接的过程。可以看到在四次挥手的过程中,主动关闭连接的一端在收到对端发送的FIN包之后会进入TIME_WAIT状态,会等待2MSL之后才能真正关闭连接。

MSL: 最长报文段寿命(Maximum Segment Lifetime),是一个工程值(经验值),RFC标准是2分钟,不过有点太长了,一般是30秒,1分钟,2分钟。

为什么客户端TIME_WAIT状态等待2MSL呢?四次握手最后一步客户端向服务端响应ACK,后有两种情况:

  1. 服务端没有收到ACK,这时服务端会超时重传FIN
  2. 服务端收到了ACK,但是客户端不知道服务端有没有收到

无论1还是2,客户端都需要等待,要取这两种情况等待的最大值以应对最坏情况的发生,这个最坏的情况就是: 去向ACK消息的最大生存时间(MSL) + 来向FIN消息的最大生存时间(MSL) 。可以看到加起来正好是2MSL。等待2MSL,客户端就可以放心大胆的释放TCP连接了,此时可以使用该端口号连接任何服务器。

如果没有TIME_WAIT,新连接直接复用该连接占用的端口话,恰好回复的ACK包没有达到对端,导致对方重传FIN包,这时新连接就会被错误的关闭。

2.1.2 使用了连接池为什么还会出现大量的TIME_WAIT连接呢

首先大量的TIME_WAIT连接说明了我们的go程序建立了大量的连接然后又关闭了,但是理论上使用了连接池连接都应该得到复用,不会建立大量的连接才对。

这时我首先检查了是不是连接池的ConnMaxLifetimeConnMaxIdleTime设置的太小导致连接被关闭。我回看了代码发现同事设置了一个小时的时长,那么就不可能是这个原因了。

然后我将怀疑的矛头指向了TDSQL,因为TDSQL是我们首次使用,之前使用Mysql时也没有遇到过这个问题,会不会是TDSQL发送/回复了某个特殊的包导致了客户端主动断开呢?

2.2 验证是否是TDSQL的问题

为了验证上述的猜想,使用tcpdump在服务器上抓了个包

tcpdump -i any host 10.43.0.43 -w output.pcap

然后将抓到的outout.pcap包down下来后丢到本机wireshark中进行分析,选择一个端口过滤下可以看到整个tcp连接的所有包。

image.png

可以看到TDSQL发送的都是正常的mysql协议包,并没有什么特殊的包,因此到这里基本可以确认不是TDSQL的锅。

那么排查的重点又回到了golang连接池,golang连接池为什么会主动断开连接?

2.3 golang为什么会主动断开连接?

由于golang整个sql包非常复杂,我们可以自底向上的思路来排查问题,首先我们找到mysql驱动包go-sql-driver/mysql中关闭连接的函数:

func (mc *mysqlConn) Close() (err error)

它位于connection.go文件中。

下面我们使用dlv 来启动上面的go程序

$ dlv debug main.go

进入到dlv控制台,然后在控制台中输入break mysql.(*mysqlConn).Close在这个函数上打个断点,继续输入c 命令让程序继续执行,然后使用ab命令进行这个web程序进行压测。

不出所料程序断在了mysql.(*mysqlConn).Close。然后我们使用bt命令来打印下调用栈: image.png

可以清楚的看到整个核心调用链是:

rwos.Close -> driverConn.releaseConn -> DB.putConn -> driverConn.Close -> mysqlConn.Close

问题的关键是DB.putConn ,我们可以分析下源码:

func (db *DB) putConn(dc *driverConn, err error, resetSession bool) {
    ...
    added := db.putConnDBLocked(dc, nil)
    db.mu.Unlock()

    if !added {
        dc.Close()
        return
    }
}

func (db *DB) putConnDBLocked(dc *driverConn, err error) bool {
    if db.closed {
        return false
    }
    if db.maxOpen > 0 && db.numOpen > db.maxOpen {
        return false
    }
    if c := len(db.connRequests); c > 0 {
        var req chan connRequest
        var reqKey uint64
        for reqKey, req = range db.connRequests {
            break
        }
        delete(db.connRequests, reqKey) // Remove from pending requests.
        if err == nil {
            dc.inUse = true
        }
        req <- connRequest{
            conn: dc,
            err:  err,
        }
        return true
    } else if err == nil && !db.closed {
        if db.maxIdleConnsLocked() > len(db.freeConn) { // db.maxIdleConnsLocked()取自db.maxIdleCount
            db.freeConn = append(db.freeConn, dc)
            db.startCleanerLocked()
            return true
        }
        db.maxIdleClosed++
    }
    return false
}

从源码中我们可以得知是DB.putConnDBLocked返回了false导致了连接关闭。为了验证这一点可以继续使用dlv 在dc.Close()这一行打个断点,然后重新压测这个程序: image.png 可以看到程序确实是走到了dc.Close()这一行,我们继续打印上下文的数据: image.png

到这里我们就知道了是由于db.maxIdleCount == len(db.freeConn)导致了连接没有被复用。

db.maxIdleCount是我们代码中设置的dbconnect.SetMaxIdleConns(5)也就是5

那么问题的原因其实就很简单了,我们设置了最大闲置连接数为5,最大可建立连接数为10,那么进程中最多可出现10个连接,这10个连接中只有5个可以被丢回到连接池中复用,而另外5个连接由于超过了我们设置最大闲置连接数5所以不会被丢回到连接池中复用,因此使用完就close了。当并发高的情况下就会出现大量的连接打开与关闭。

3. 解决

最大闲置连接数设置成一个大于等于最大连接数的值即可,比如下面这样:

dbconnect.SetMaxIdleConns(10)
dbconnect.SetMaxOpenConns(10)

...

阅读全文 »

网络编程中的tips

发布于
分类 网络

Connection reset by peer

网络编程中某一端可能会产生Connection reset by peer的报错,这是因为收到了对端发送的RST包。RST包是在tcp异常关闭时发出的,产生的情形很多。

我在写cat-agent 也遇到了这个报错,经排查发现是客户端发送数据后没有读取服务端的响应而直接关闭了连接(之所以不读取就关闭是因为客户端为提高发送效率不care服务端返回,后来为避免大量的RST包选择服务端不回包来解决)

EPOLL_CLOEXEC

epoll_create1() flags中有个EPOLL_CLOEXEC,类似的还有open()函数的O_CLOEXEC,它们的用处在于:当父进程fork出子进程之后,子进程会继承父进程的文件描述符,当子进程执行exec系统调用之后保存文件描述符的变量就不存在了,这些继承过来的文件描述符将无法关闭。

xxx_CLOEXEC选项就可以标识当子进程执行exec系统调用之后就内核自动关闭继承过来的文件描述符。

syscall.Forklock

在go标准库和其他一些网络库创建socket通常会有如下的代码:

syscall.ForkLock.RLock()
s, err = socketFunc(family, sotype, proto)
if err == nil {
	syscall.CloseOnExec(s)
}
syscall.ForkLock.RUnlock()

syscall.CloseOnExec(s)与上一节的xxx_CLOEXEC选项作用的相同的。这里ForkLock的作用是保证fork操作时socket的创建与设置CloseOnExec原子性。

持续更新中

...

阅读全文 »