解决Linux实例磁盘空间满问题_云服务器 ECS-阿里云帮助中心
磁盘空间不足的问题通常是如下四类原因:
根据上面的排查思路,可以定位到存在已经删除未释放的僵尸文件
lsof | grep deleted
看看是否删除掉的文件仍然被进程占用而没有进行实际删除。
lsof(List Open Files) 用于查看你进程开打的文件,打开文件的进程,进程打开的端口(TCP、UDP),找回/恢复删除的文件。
lsof +L1
该命令可以列出当前使用了文件句柄的进程,并且以文件大小进行排序,这样可以很方便地找到磁盘占用较大的进程。其中,+L1
选项表示只显示文件大小大于1个块的文件。
lsof /mnt
查看正在占用磁盘的文件
根据上述的排查发现存在/mnt/log/jcloud-vpc-service/1.jcloud-vpc-service/api.log.10的僵尸文件,根据日志的滚动,为什么只有api.log.10是产生僵尸文件,而1~9没有呢?
1、盲猜的话,就认为是日志滚动 最后的api.log.10被删除了,导致僵尸文件的产生,但真的是这样吗?
2、再次看上面的排查思路,其中提到了inode,这个是fd句柄操作的唯一标识,那么问题就清楚了,谁动了这个delete文件,根据下面的代码review可知,文件的rename是从9开始的,那么10的就是要求强覆盖的一个(也就是inode会被改写的一个)
func (h *RotatingFileHandler) doRollover() { f, err := h.fd.Stat() if err != nil { return } if h.maxBytes <= 0 { return } else if f.Size() < int64(h.maxBytes) { return } if h.backupCount > 0 { h.fd.Close() for i := h.backupCount - 1; i > 0; i-- { sfn := fmt.Sprintf("%s.%d", h.fileName, i) dfn := fmt.Sprintf("%s.%d", h.fileName, i+1) os.Rename(sfn, dfn) } dfn := fmt.Sprintf("%s.1", h.fileName) os.Rename(h.fileName, dfn) h.fd, _ = os.OpenFile(h.fileName, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) } }
3、接下来就是谁一直在占用这个inode,根据上文的进程句柄占用可知,是进程的1&2这俩句柄,是程序的标准输入和标准输出
//TODO 看似找到,但是还有个疑问,为什么上文中的 (deleted)僵尸文件通过lsof查看有78个,而不是仅仅一个?
文件IO:理解Linux的文件描述符FD与Inode - 知乎
在Linux的服务器上,我们创建3个日志文件:heks.log、heks.log.1、heks.log.2
根据上面的日志滚动代码可知是做了rename操作,对应的Linux的mv命令(跟到golang的OS底层Unix系统代码调用可知)
func rename(oldname, newname string) error { fi, err := Lstat(newname) if err == nil && fi.IsDir() { // There are two independent errors this function can return: // one for a bad oldname, and one for a bad newname. // At this point we've determined the newname is bad. // But just in case oldname is also bad, prioritize returning // the oldname error because that's what we did historically. // However, if the old name and new name are not the same, yet // they refer to the same file, it implies a case-only // rename on a case-insensitive filesystem, which is ok. if ofi, err := Lstat(oldname); err != nil { if pe, ok := err.(*PathError); ok { err = pe.Err } return &LinkError{"rename", oldname, newname, err} } else if newname == oldname || !SameFile(fi, ofi) { return &LinkError{"rename", oldname, newname, syscall.EEXIST} } } err = ignoringEINTR(func() error { return syscall.Rename(oldname, newname) }) if err != nil { return &LinkError{"rename", oldname, newname, err} } return nil }
我们来模拟操作:依次执行
1、mv heks.log.1 heks.log.2
2、mv heks.log heks.log.1
3、touch heks.log
分别查看执行前的inode和执行后的inod
1.echo重定向输出到指定泄露句柄
进 程所打开的每个文件都有一个符号连接在该子目录里, 以文件描述符命名, 这个名字实际上是指向真正的文件的符号连接,(和 exe 记录一样).例如, 0 是标准输入, 1 是标准输出, 2 是标准错误, 等等. 程序有时可能想要读取一个文件却不想要标准输入,或者想写到一个文件却不想将输出送到标准输出去,那么就可以很有效地用如下的办法骗过(假定 -i 是输入文件的标志, 而 -o 是输出文件的标志)
echo > /proc/133/fd/1
2.将1&2的输出重定向到/dev/null
这种方式也是网上常见的形式,但是当初的业务将console的日志输出到api.log是有考虑的,根据openapi的服务可知大量引入的SDK的日志,没有指定目录的情况下就是输出到console的标注输出,那么会因此丢失线上日志
nohup ./bin/app >> /dev/null 2>&1 &
根据上面的复现方法可知,文件的inode进行了变化,那么我们能不能操作日志的滚动,不更改inode的值,而是将文件内容的移动呢?
又或者说,怎么在日志进行滚动的时候,进程的标准输入和标准输出的inode重定向到新的?
logrotate机制和原理-腾讯云开发者社区-腾讯云
新建一个文件,然后有些程序提供了重新打开日志的接口,比如可以通过信号通知nginx。各种IPC方式都可以,前提是程序自身要支持这个功能。将程序的日志输出inode重定向到当前最新日志文件下
这个方案的思路是把正在输出的日志拷(copy)一份出来,再清空(trucate)原来的日志。
参考一下开源实现:
lumberjack:实现采用的是压缩copy(这个目前看也是存在同样的问题,这边提交了issue)
// compressLogFile compresses the given log file, removing the // uncompressed log file if successful. func compressLogFile(src, dst string) (err error) { f, err := os.Open(src) if err != nil { return fmt.Errorf("failed to open log file: %v", err) } defer f.Close() fi, err := osStat(src) if err != nil { return fmt.Errorf("failed to stat log file: %v", err) } if err := chown(dst, fi); err != nil { return fmt.Errorf("failed to chown compressed log file: %v", err) } // If this file already exists, we presume it was created by // a previous attempt to compress the log file. gzf, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, fi.Mode()) if err != nil { return fmt.Errorf("failed to open compressed log file: %v", err) } defer gzf.Close() gz := gzip.NewWriter(gzf) defer func() { if err != nil { os.Remove(dst) err = fmt.Errorf("failed to compress log file: %v", err) } }() if _, err := io.Copy(gz, f); err != nil { return err } if err := gz.Close(); err != nil { return err } if err := gzf.Close(); err != nil { return err } if err := f.Close(); err != nil { return err } if err := os.Remove(src); err != nil { return err } return nil }
glog:采用的是删除旧的,将旧的symlink迁移到新的(因为自己实现的没有rm逻辑索引不行)
// create creates a new log file and returns the file and its filename, which // contains tag ("INFO", "FATAL", etc.) and t. If the file is created // successfully, create also attempts to update the symlink for that tag, ignoring // errors. func create(tag string, t time.Time) (f *os.File, filename string, err error) { onceLogDirs.Do(createLogDirs) if len(logDirs) == 0 { return nil, "", errors.New("log: no log dirs") } name, link := logName(tag, t) var lastErr error for _, dir := range logDirs { fname := filepath.Join(dir, name) f, err := os.Create(fname) if err == nil { symlink := filepath.Join(dir, link) os.Remove(symlink) // ignore err os.Symlink(name, symlink) // ignore err return f, fname, nil } lastErr = err } return nil, "", fmt.Errorf("log: cannot create log: %v", lastErr) }
优化后的实现:采用copy truncate方案,因为软连接留下来的话会产生歧义(业务日志有本地cache不会丢)
CopyFile(dst, src)
os.Truncate(h.fileName, 0)