暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

定位"too many open files"问题

零君聊软件 2020-09-06
3370

"too many open files"意味着当前进程用完了所有的文件描述符。本文简要描述如何处理这种问题。


一切皆文件

在Linux系统中,一切皆文件。普通文件、目录、设备以及socket都是当作文件来对待。Linux在所有这些类型的文件系统上面封装了一层虚拟文件系统(VFS),从而为上层应用提供了统一的编程接口。这里推荐一篇讲得比较好的入门级别文章:

https://ops.tips/blog/what-is-slash-proc/


下面这个图片也是来自上面这篇文章。vfs_read会将上层的read请求翻译成相应的底层文件系统的请求。

现在的应用程序一般都是分布式运行,相互通过网络通信。所以一般遇到“too many open files”问题都是与socket相关。本文也主要是围绕socket来描述。


文件描述符泄漏or设计缺陷?

一旦发生"too many open files",一般有两种可能,要么是代码有BUG,导致socket泄漏,要么是有设计缺陷。


如果遇到突发流量,导致socket激增,但是当流量减弱或消失后,socket数量能逐渐回归到正常。那就说明没有socket泄漏。这时往往是设计问题,由于没有相应的熔断限流措施,以及没有设置合理的最大文件描述符数。


如果当流量减弱后,socket数量还是居高不下,那就说明有泄漏,就需要排查代码问题。


定位工具

一般来说,定位这类问题,有三种常用的方法或者工具。


/proc/<pid>/fd

第一种方式是直接查看"/proc/<pid>/fd"这个目录中的文件。其中<pid>是要查看的进程的ID,例如下面就是进程29716打开的所有文件,


用下列命令就可以快速统计进程打开的文件描述符的总数(注意:要刨去第一行:)),

    ls -lrt proc/29716/fd | wc -l


    如果是进程在代码中查看自己占用的文件描述符时,则可以直接访问/proc/self/fd这个目录,因为/proc/self其实就是指向/proc/<pid>的一个链接。Golang代码如下,

      fds, err := ioutil.ReadDir("/proc/self/fd")


      lsof

      使用"lsof -p <pid>"也可以查询某个进程占用的所有文件描述符。例如下面就是查询进程29716打开的所有文件描述符。lsof显示的信息比/proc/<pid>/fd更全一点,而且条目稍多,因为它将进程所在的目录以及加载的动态库也算在内。但是最终系统报"too many open files"时,是以/proc/<pid>/fd中所包含的条目为准。


      netstat

      netstat可以很方便的查看本机的网络连接情况。例如下面的截图就是执行如下命令的输出。如果要查看某个进程的连接信息,可以根据pid过滤。

        netstat -nap | grep tcp


        TCP状态机
        分析socket问题,一定要会看TCP状态机,要清楚各个状态分别代表什么含义,以及相互之间如何转化。下图来自google搜索。


        首先,一定要明白TCP是双工的,当一方关闭连接,只表示它没有数据要发送了,但是它还可以接受数据。只有双方都关闭了连接,才算最终关闭了连接。


        在上面众多的状态中,CLOSE_WAIT这个状态需要重点关注。其含义是对方已经关闭了TCP连接,但自己这方还没有关闭连接。如果不是故意这么设计或实现,往往意味着代码中有BUG,没有及时关闭连接。如果由于处于CLOSE_WAIT状态的socket过多,导致“too many open files”,那八成就是代码中在需要调用close关闭连接的地方遗漏了。


        另外,TIME_WAIT是一种正常的状态。当双方都关闭了连接,主动关闭的一方最后会进入TIME_WAIT状态,等待2MSL的时间之后,就会自动变成CLOSED状态。其目的是确保对方能收到最后一个ACK包,以及等待网络中延迟的各种包消失。


        如果看到很多SYNC_WAIT,那说明对方网络不通,和对方的连接无法建立。


        getrlimit & setrlimit

        如果需要,可以设置更大的文件描述符数量。可以通过ulimit命令,或者系统调用setrlimit来设置。ulimit是一个shell命令,它设置shell以及通过shell启动的进程的资源限制,它最后也是调用setrlimit。这里推荐一篇很简单实用的文章:

        https://www.robustperception.io/dealing-with-too-many-open-files


        获取或设置当前进程的资源限制,可以使用系统调用getrlimit和setrlimit,

          #include <sys/time.h>
          #include <sys/resource.h>
          int getrlimit(int resource, struct rlimit *rlim);
          int setrlimit(int resource, const struct rlimit *rlim);


          Golang中对应的系统调用定义在syscall包中,不过从go1.4之后,应用使用golang.org/x/sys中的包。

            func Getrlimit(resource int, rlim *Rlimit) (err error)
            func Setrlimit(resource int, rlim *Rlimit) (err error)


            这里一定要分清soft limit和hard limit。首先, soft limit肯定小于或等于hard limit。对于某个进程来说,起作用的是soft limit。如果不是特权用户,只能调整soft limit,最大可以调成与hard limit一样大。对于特权用户,可以同时修改soft limti和hard limit,但同样soft limit不能超过hard limit。例如Golang中,Getrlimit返回的结构体中,Cur对应的是soft limit,而Max则是hard limit。

              type Rlimit struct {
              Cur uint64
              Max uint64
              }


              一般来说,在linux系统中,soft limit和hard limit默认分别是1024和4096。


              etcd的做法

              etcd有一个goroutine来监控文件描述符的使用情况,代码如下,

                // https://github.com/etcd-io/etcd/blob/master/etcdserver/metrics.go#L201


                func monitorFileDescriptor(lg *zap.Logger, done <-chan struct{}) {
                ticker := time.NewTicker(10 * time.Minute)
                defer ticker.Stop()
                for {
                used, err := runtime.FDUsage()
                if err != nil {
                lg.Warn("failed to get file descriptor usage", zap.Error(err))
                return
                }
                fdUsed.Set(float64(used))
                limit, err := runtime.FDLimit()
                if err != nil {
                lg.Warn("failed to get file descriptor limit", zap.Error(err))
                return
                }
                fdLimit.Set(float64(limit))
                if used >= limit/5*4 {
                lg.Warn("80% of file descriptors are used", zap.Uint64("used", used), zap.Uint64("limit", limit))
                }
                select {
                case <-ticker.C:
                case <-done:
                return
                }
                }
                }


                从上面的代码中,不难看出,通过两个metrics (fdUsed和fdLimit) 与普罗米修斯(prometheus)做了集成。


                上面代码中的,FDLimit和FDUsage的实现如下。其中FDUsage就是通过目录"/proc/self/fd"来统计当前进程打开的文件描述符总数的。而FDLimit是利用了系统调用Getrlimit。

                  func FDLimit() (uint64, error) {
                  var rlimit syscall.Rlimit
                  if err := syscall.Getrlimit(syscall.RLIMIT_NOFILE, &rlimit); err != nil {
                  return 0, err
                  }
                  return rlimit.Cur, nil
                  }


                  func FDUsage() (uint64, error) {
                  return countFiles("/proc/self/fd")
                  }


                  正确使用Golang中Transport

                  在Golang中,客户端通过http访问服务器时,会使用到http.Transport。这里要注意一点,Transport维护了一个连接池。所以在实际使用中,不要为每一个HTTP请求创建一个新的Transport。正确的做法是只创建一个http.Client和http.Transport,以后每个http请求都使用同一个client。具体参考下面这个issue,

                    https://github.com/golang/go/issues/24719


                    --END--


                    相关文章

                    Prometheus + Grafana构建云时代的monitoring解决方案

                    文章转载自零君聊软件,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

                    评论