磁盘增长不释放问题

解决Linux实例磁盘空间满问题

解决Linux实例磁盘空间满问题_云服务器 ECS-阿里云帮助中心

磁盘空间不足的问题通常是如下四类原因:

  1. 磁盘分区空间使用率达到100%。
  2. 磁盘分区Inode使用率达到100%。
  3. 磁盘存在已删除未释放的僵尸文件。
  4. 挂载点覆盖。

定位问题

根据上面的排查思路,可以定位到存在已经删除未释放的僵尸文件

产生的根本原因

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机制和原理

logrotate机制和原理-腾讯云开发者社区-腾讯云

方案1:create

新建一个文件,然后有些程序提供了重新打开日志的接口,比如可以通过信号通知nginx。各种IPC方式都可以,前提是程序自身要支持这个功能。将程序的日志输出inode重定向到当前最新日志文件下

方案2:copytruncate

这个方案的思路是把正在输出的日志拷(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)

你可能感兴趣的:(linux,io,file)