SSD Nodes Learn 🎉 VPS từ $5.50/tháng
Hướng dẫn Matt ConnorBởi Matt Connor

df báo đầy nhưng du không thấy dung lượng ở đâu

VPS báo disk đầy nhưng du không tìm thấy dung lượng? Tìm file đã xóa vẫn bị process giữ mở bằng lsof và xử lý thêm inode, mount point, block reserve.

Vì sao df báo đầy nhưng du lại cho kết quả khác

df báo disk đã đầy, còn du không tìm thấy phần dung lượng đó vì một process vẫn giữ file đã bị xóa. Xóa file chỉ xóa tên của file khỏi directory. Các data block chỉ được giải phóng khi file descriptor cuối cùng trỏ đến inode đó được đóng. du duyệt theo tên, nên không tính file này. df hỏi filesystem có bao nhiêu block đã được cấp phát, nên vẫn tính cả file không còn tên đó.

Hướng dẫn này tái hiện lỗi trên một Ubuntu VPS mặc định bằng các tool đã được cài sẵn, tìm process đang giữ file thông qua /proc và giải phóng dung lượng mà không cần reboot. Các nguyên nhân khác gây ra cùng triệu chứng cũng được trình bày: inode table không còn entry trống, file bị che dưới một mount point và các block được reserve cho root.

Chạy từng command và đọc output trên chính máy của bạn. Các giá trị phụ thuộc vào disk, vì vậy hãy so sánh kết quả trước và sau trên máy của bạn thay vì so sánh với một con số trong hướng dẫn.

df đếm gì và du đếm gì

df (disk free) hỏi từng filesystem đã mount về số liệu riêng của nó: có bao nhiêu block tồn tại, bao nhiêu block đã được cấp phát và bao nhiêu block còn trống. Nó không bao giờ mở thư mục. Kết quả bao gồm mọi block đã được cấp phát, kể cả block thuộc về file không còn directory entry nào trỏ đến.

du (disk usage) hoạt động ngược lại. Nó bắt đầu từ path bạn cung cấp, đọc các thư mục, lấy thông tin của mọi entry tìm thấy rồi cộng tổng số block. File không có tên sẽ không được tính. Tương tự, mọi thư mục mà nó không được phép đọc cũng bị bỏ qua. Vì vậy, tổng của user thông thường nhỏ hơn tổng của root. Hãy chạy du bằng sudo trước khi kết luận từ phép so sánh này.

Có 2 tùy chọn luôn cần dùng khi so sánh hai công cụ.

  • -x giữ du trong một filesystem. Nếu không dùng, du / sẽ đi vào mọi filesystem được mount bên dưới / và tạo ra tổng mà df / vốn không đo.
  • -s in một dòng tổng hợp cho mỗi đối số thay vì in một dòng cho mỗi thư mục.

Như vậy, đây là cặp lệnh cần chạy song song trên filesystem bạn muốn kiểm tra.

df -h /
sudo du -xhs / 2>/dev/null

df trả lời ngay lập tức. du có thể mất vài phút trên filesystem lớn vì nó lấy thông tin của từng file trên đường đi. Khi hai tổng chênh lệch lớn, và du được chạy bằng root cùng với -x, phần dung lượng bị thiếu thuộc về một đối tượng không có tên.

Cố tình tái hiện điểm không khớp

Thực hiện việc này trên một VPS thử nghiệm. Toàn bộ nội dung bên dưới dùng bash và coreutils, nên không cần cài thêm gì.

Ghi lại trạng thái ban đầu của filesystem chứa /var/tmp.

cd /var/tmp
df -h .
df --output=used -B1 .

Lệnh thứ hai in ra số byte đã sử dụng mà không làm tròn, nên phép kiểm tra ở cuối sẽ chính xác tuyệt đối.

Bây giờ tạo một file. Kích thước file lấy từ dung lượng trống do chính máy báo cáo, nên phần minh họa này phù hợp với mọi dung lượng đĩa.

free=$(df --output=avail -B1 . | tail -n 1)
fallocate -l $((free / 10)) ghost.bin
ls -l ghost.bin
df -h .

$(...) là command substitution: shell chạy lệnh bên trong đó, rồi dùng output làm giá trị của free. Nếu cú pháp này còn mới với bạn, command substitution trong bash giải thích đầy đủ. fallocate dành trước các block thực mà không ghi dữ liệu, nên lệnh hoàn tất ngay lập tức. Trên filesystem không hỗ trợ tính năng này, lệnh sẽ fail; head -c $((free / 10)) /dev/zero > ghost.bin thực hiện cùng công việc bằng cách ghi các byte ra.

So sánh df -h . này với kết quả bạn đã ghi lại. Cột đã sử dụng tăng lên và cột còn trống giảm xuống.

Bây giờ giữ file mở từ một process khác, rồi xóa file đó.

sleep infinity < ghost.bin &
holder=$!
rm ghost.bin
ls -l ghost.bin
df -h .
sudo du -xhs . 2>/dev/null

Phần chuyển hướng là toàn bộ điểm mấu chốt. sleep infinity < ghost.bin & khởi chạy một process chạy nền có standard input là file đó, nên shell mở file rồi chuyển file descriptor cho sleep, process này giữ descriptor luôn mở. $! chứa process ID của background job đó. rm sau đó xóa tên file trong khi descriptor vẫn còn mở.

Đọc output. ls không tìm thấy file vì tên file đã bị xóa. du gần trở lại giá trị ban đầu vì nó duyệt qua các tên file. df không thay đổi vì các block vẫn được cấp phát. Lúc này filesystem và cây thư mục không còn khớp nhau; khoảng chênh giữa chúng chính là file bạn vừa xóa.

Tìm tiến trình đang giữ file đã bị xóa

Mỗi file descriptor đang mở xuất hiện dưới /proc/<pid>/fd/ dưới dạng symbolic link trỏ đến file mà nó tham chiếu. Khi file đã bị unlink, kernel đánh dấu đích của link đó là đã bị xóa. Vì vậy, để tìm tiến trình đang giữ file, hãy tìm link có đích chứa marker này.

sudo find /proc/[0-9]*/fd -lname '*(deleted)' -printf '%p -> %l\n' 2>/dev/null

-lname khớp với đích của symbolic link thay vì tên của link, %p in đường dẫn của descriptor, còn %l in nội dung mà descriptor trỏ đến. Process ID là phần tử thứ hai trong đường dẫn mà lệnh in ra. Chạy lệnh với sudo, vì nếu không bạn chỉ đọc được /proc/<pid>/fd của các process do chính bạn chạy. Redirect stderr loại bỏ các thông báo từ những process đã thoát trong lúc find duyệt qua danh sách.

Một server đang bận có thể giữ nhiều file đã bị xóa cùng lúc, và phần lớn chúng nhỏ, không gây vấn đề. Hãy sắp xếp theo kích thước để các file đáng chú ý nằm ở đầu danh sách.

sudo bash -c 'for fd in /proc/[0-9]*/fd/*; do
  target=$(readlink "$fd" 2>/dev/null) || continue
  case "$target" in
    *"(deleted)") echo "$(stat -Lc %s "$fd" 2>/dev/null) $fd $target" ;;
  esac
done' | sort -rn | head

stat -L lần theo link đến chính inode, nên %s báo kích thước của file không còn tên. Sắp xếp theo số này sẽ đưa file lớn nhất lên đầu.

Tiếp theo, xác định process đứng sau descriptor đó. Đường dẫn ở đầu danh sách chứa cả hai số cần dùng, nên hãy gán chúng vào các biến trước. Thay PID và N bằng giá trị mà listing của bạn in ra.

pid=PID
n=N
ps -o pid,user,etime,args -p "$pid"
sudo stat -L "/proc/$pid/fd/$n"

ps cho biết tên chương trình và thời gian chương trình đã chạy. stat -L in kích thước và số block đã cấp phát của inode đã bị xóa. Hai thông tin này trả lời câu hỏi quan trọng: service nào đang giữ file này tồn tại.

Nếu máy đã có lsof, sudo lsof +L1 sẽ liệt kê các file đang mở có link count giảm xuống 0 và hiển thị kích thước trong cùng một bảng. Công cụ này không có sẵn trên Ubuntu image tối giản, và việc cài package trên filesystem không còn dung lượng trống cũng có thể thất bại. Vì vậy, thao tác duyệt bằng /proc là cách luôn hoạt động.

Giải phóng dung lượng mà không cần reboot

Reboot thực sự có thể khắc phục lỗi, nhưng đó là cách xử lý đầu tiên không phù hợp: nó làm service ngừng hoạt động và xóa mất bằng chứng. Có 4 cách nhẹ nhàng hơn, nên thử theo thứ tự sau.

Trước tiên, hãy sao chép dữ liệu ra nơi khác nếu bạn vẫn cần dữ liệu đó. Đọc đường dẫn descriptor sẽ đọc inode đang được sử dụng.

sudo cp /proc/<pid>/fd/<n> /root/recovered.log

Đây là trường hợp duy nhất mà file đã bị xóa vẫn dễ khôi phục. Vì vậy, khôi phục file bị xóa bằng rm -rf bắt đầu bằng việc kiểm tra xem một process có còn giữ file đang mở hay không. Khi descriptor cuối cùng được đóng, cách khôi phục này không còn hiệu lực.

Thứ hai, làm trống file thông qua descriptor. Đường dẫn /proc trỏ đến cùng inode, nên việc truncate file sẽ giải phóng các block trong khi process vẫn tiếp tục chạy.

sudo truncate -s 0 "/proc/$pid/fd/$n"
df -h /

Cách này hoạt động đúng khi writer mở file ở chế độ append, vì mọi lần ghi sau đó đều đi đến cuối file hiện tại. Nếu không, process vẫn giữ write offset cũ. Lần ghi tiếp theo sẽ nằm rất xa trong file và tạo lại file với một hole ở phía trước. Hole không được cấp phát block, nên các block vẫn còn trống và df vẫn giữ nguyên dung lượng vừa được giải phóng. Chỉ kích thước file tăng trở lại: chạy lại sudo stat -L "/proc/$pid/fd/$n" sau khi process ghi dữ liệu. Lệnh sẽ báo kích thước cũ, trong khi số block không còn khớp với kích thước đó. Hãy restart process nếu bạn muốn kích thước file cũng bắt đầu lại từ 0.

Thứ ba, yêu cầu service mở lại các log. Một daemon có file log bị xóa trong khi vẫn đang chạy là phiên bản thực tế phổ biến của vấn đề này. Nhiều daemon mở lại file log khi nhận signal: nginx dùng SIGUSR1, còn rsyslog dùng SIGHUP. Hãy kiểm tra tài liệu của daemon đang chạy thay vì đoán, vì gửi sai signal cho sai daemon có thể làm daemon dừng.

sudo systemctl kill -s USR1 nginx

Thứ tư, restart unit. sudo systemctl restart <unit> đóng mọi descriptor mà process cũ đang giữ, nên các block chắc chắn được trả lại. Trong ví dụ trên, holder là một sleep do bạn tự khởi chạy, nên chỉ cần kết thúc nó.

kill $holder
df -h .
df --output=used -B1 .
sudo find /proc/[0-9]*/fd -lname '*(deleted)' -printf '%p -> %l\n' 2>/dev/null

So sánh số byte đã sử dụng với giá trị bạn đã ghi lại trước khi tạo file. Hai giá trị lại khớp nhau, và find không còn báo descriptor của bạn. Hãy duy trì thói quen xác minh bằng chính lệnh đã phát hiện vấn đề.

Theo dõi giá trị đó thay đổi sẽ dễ hơn việc liên tục chạy df bằng tay. watch lặp lại một lệnh theo khoảng thời gian cố định rồi in lại output tại chỗ, nên watch df -h / hiển thị cột dung lượng đã sử dụng thay đổi khi dung lượng được giải phóng.

Khi các tổng khớp nhau nhưng disk vẫn đầy

Nếu dfdu -x của root khớp nhau, thì không có file nào đã xóa đang được giữ mở. Các nguyên nhân còn lại thuộc nhóm khác và mỗi nguyên nhân có cách kiểm tra riêng.

Hết inode, không hết block

Một inode lưu metadata của một file. ext4 tạo số lượng inode cố định khi tạo filesystem, vì vậy filesystem có thể hết inode trong khi vẫn còn block trống. Khi đó, việc tạo file mới sẽ thất bại dù df -h vẫn cho thấy còn dung lượng.

df -h /
df -i /

Lệnh đầu đếm block, còn lệnh thứ hai đếm inode. Hãy so sánh cột use của từng lệnh. Nếu mức sử dụng block thấp nhưng mức sử dụng inode đã chạm giới hạn, nguyên nhân là có số lượng rất lớn file rất nhỏ.

df không chấp nhận -i--output trong cùng một lần gọi, vì vậy khi cần đọc các giá trị thô hoặc chuyển chúng cho lệnh khác, hãy chọn các trường inode theo tên và bỏ -i.

df --output=itotal,iused,iavail,ipcent /

Các cột đó chứa cùng thông tin accounting mà df -i in ra, nhưng ở dạng có thể tách riêng.

Hãy tìm các file bằng cách đếm số entry thay vì số byte.

sudo du --inodes -x -d 1 / 2>/dev/null | sort -rn | head

Lặp lại lệnh tương tự ở cấp thấp hơn trên directory đứng đầu, cho đến khi tìm được cây thư mục đang tạo ra các file. Nếu du của bạn không hỗ trợ --inodes, sudo find /var -xdev -type f | wc -l sẽ đếm subtree theo cách chậm hơn.

Cách xử lý là xóa hoặc di chuyển các file đó. Bạn không thể thêm inode vào một filesystem ext4 hiện có, vì số lượng này được cố định tại thời điểm mkfs. Muốn tăng số lượng inode, bạn phải tạo lại filesystem rồi khôi phục dữ liệu từ backup. XFS cấp phát inode theo nhu cầu, nên không gặp giới hạn cố định theo cùng cách. Máy chạy container thường chạm cả hai giới hạn sớm hơn, vì các image layer chứa nhiều file nhỏ. Trên máy đó, dọn disk usage của Docker trên một VPS là cách xử lý cụ thể và sẽ thu hồi nhiều dung lượng hơn so với việc quét dọn filesystem chung.

Dung lượng bị che dưới mount point

Một thư mục có thể chứa file trước khi có filesystem nào được mount vào đó. Khi mount một filesystem lên thư mục đó, các file bên dưới vẫn nằm nguyên vị trí: vẫn chiếm dung lượng, vẫn được df tính, nhưng không còn truy cập được bằng tên. du không thể nhìn thấy chúng vì mount point đã che các file đó.

Hãy minh họa bằng tmpfs, không cần thêm disk trống. Phần này cần một máy mà bạn được phép mount, nên có thể chạy trên KVM VPS.

sudo mkdir -p /srv/covered
sudo cp /etc/services /srv/covered/
ls /srv/covered
sudo mount -t tmpfs tmpfs /srv/covered
ls /srv/covered
sudo umount /srv/covered
ls /srv/covered

Phần ls ở giữa hiển thị một thư mục trống. Bản sao không biến mất: nó vẫn nằm trên root filesystem và sẽ xuất hiện lại ngay khi bạn unmount. Bây giờ hãy hình dung một service đã ghi log vào path đó trong một tháng trước khi có người mount một volume lên trên.

Để tìm dữ liệu thực trên một server đang chạy, hãy mount root filesystem lần thứ hai vào một vị trí khác. Bind mount hiển thị một filesystem mà không hiển thị các filesystem được mount bên trong nó.

sudo mkdir -p /mnt/rootcheck
sudo mount --bind / /mnt/rootcheck
sudo du -xhs /mnt/rootcheck/* 2>/dev/null | sort -h
sudo umount /mnt/rootcheck

Mọi thứ xuất hiện trong listing đó nhưng không xuất hiện dưới path thông thường đều đang bị chôn dưới một mount point. Hãy unmount bind mount khi hoàn tất, nếu không một du sau đó mà không có -x sẽ tính cùng các file hai lần.

Các block dành riêng cho root

ext4 dành riêng một phần block cho user root để khi disk đầy, root vẫn có thể đăng nhập và sửa máy. Process chạy dưới user thông thường sẽ chạm giới hạn đó trước, trong khi df vẫn hiển thị còn một ít chỗ. Hãy đọc thiết lập trên filesystem của bạn thay vì mặc định rằng nó luôn dùng giá trị mặc định.

dev=$(df --output=source / | tail -n 1)
sudo tune2fs -l "$dev" | grep -i 'block count'

Lệnh này in tổng số block và số block dành riêng theo cùng một đơn vị, nên có thể tính trực tiếp tỷ lệ giữa hai giá trị. df báo cột available là dung lượng mà user thông thường vẫn có thể sử dụng. Vì vậy, used cộng available sẽ nhỏ hơn size. Phần chênh lệch là dung lượng dự phòng.

Dùng sudo tune2fs -m <percent> "$dev" để thay đổi giá trị này. Thay đổi có hiệu lực ngay và không cần remount. Giảm dung lượng dự phòng trên filesystem dữ liệu riêng là hợp lý. Với root filesystem, hãy giữ lại đủ dung lượng để root vẫn có thể ghi dữ liệu, vì root filesystem hoàn toàn không còn chỗ trống sẽ khó sửa hơn nhiều. tune2fs hoạt động với ext2, ext3 và ext4. XFS không có thiết lập tương đương.

Khi du cho kết quả sai nếu chỉ dùng riêng

Bốn thói quen của du tạo ra các tổng dung lượng trông có vẻ không đúng.

  • Hard link: du chỉ đếm một lần cho mỗi inode, ngay cả khi có nhiều tên cùng trỏ đến inode đó. Vì vậy, một cây thư mục chứa đầy hard link sẽ báo dung lượng nhỏ hơn tổng dung lượng các file.
  • Sparse file: du báo số block thực sự đã được cấp phát, còn ls -l báo kích thước biểu kiến. Thêm --apparent-size để xem giá trị còn lại.
  • Quyền truy cập: khi chạy bằng user thường, du bỏ qua những gì user không thể đọc nên báo thiếu dung lượng. Các lỗi mà lệnh in ra thường bị redirect sang /dev/null, rồi người dùng không đọc tiếp.
  • Ranh giới filesystem: nếu không có -x, du / sẽ đếm mọi filesystem được mount bên dưới /, nên tổng có thể lớn hơn giá trị df / báo.

df cũng có một đặc điểm đáng biết. Lệnh này báo cáo từng filesystem riêng biệt, nên hãy chạy nó trên đúng path mà thao tác ghi bị lỗi nhắm tới. Một /boot riêng có thể đầy theo lịch riêng khi các kernel package tích lũy, và xóa kernel cũ trên Ubuntu là công việc khác với việc giải phóng dung lượng trên /.

Thứ tự xử lý khi có sự cố thực tế

  1. Chạy df -h <path>df -i <path> trên filesystem mà thao tác ghi bị lỗi đang nhắm tới, không phản xạ chạy trên /.
  2. Chạy sudo du -xh -d 1 <mountpoint> 2>/dev/null | sort -h, sau đó đi xuống thư mục lớn nhất.
  3. Nếu du không giải thích được phần dung lượng mà df báo đã sử dụng, tìm trong /proc các file đã bị xóa nhưng vẫn đang được mở.
  4. Nếu hai kết quả khớp nhau, bind mount filesystem vào một vị trí khác rồi tìm các file nằm bên dưới mount point.
  5. Nếu inode use chạm giới hạn, hãy đếm số file thay vì số byte.

Mỗi bước đều có một command với output mà bạn có thể đọc và kiểm tra. Đó là điểm khác biệt giữa việc khắc phục vấn đề này và đoán mò.

FAQ

Vì sao df cho thấy disk đã đầy trong khi du tìm thấy ít hơn nhiều?

Nguyên nhân thường gặp là một file đã bị xóa trong khi một process vẫn đang mở file đó. Xóa file sẽ xóa directory entry của nó, nên du không còn tên để duyệt và dừng đếm file đó. Inode và các block của file vẫn được cấp phát cho đến khi descriptor cuối cùng được đóng, còn df đếm các block đã cấp phát. Tìm trong /proc/<pid>/fd các symbolic link có target được đánh dấu là đã xóa để tìm cả file và process đang giữ file đó. Trước khi tin vào kết quả so sánh, hãy xác nhận bạn đã chạy du với quyền root và có -x, vì user thông thường sẽ âm thầm bỏ qua các directory mà user đó không thể đọc.

Làm cách nào để tìm một file đã xóa nhưng vẫn đang mở mà không dùng lsof?

Hãy dùng thông tin mà kernel lưu về các descriptor đang mở. sudo find /proc/[0-9]*/fd -lname '*(deleted)' -printf '%p -> %l\n' 2>/dev/null liệt kê mọi descriptor trỏ đến một file không còn tên, còn process ID nằm trong path mà lệnh in ra. Dùng sudo stat -Lc %s trên một trong các path của descriptor đó để xem kích thước của file. Sau đó bạn có thể sắp xếp kết quả và chọn file cần xử lý. Cách này không cần cài thêm package, điều quan trọng vì việc cài package trên filesystem không còn free space có thể thất bại.

Có thể giải phóng space mà không dừng process không?

Đôi khi có thể. sudo truncate -s 0 /proc/<pid>/fd/<n> truy cập cùng inode thông qua descriptor và giải phóng các block của file trong khi process vẫn tiếp tục chạy. Cách này an toàn nhất khi process mở file ở chế độ append, vì mọi lần ghi đều đi đến cuối file hiện tại. Nếu process không mở file ở chế độ đó, write offset vẫn giữ nguyên vị trí cũ. Lần ghi tiếp theo sẽ tạo lại file với một hole ở đầu, nên kích thước được báo cáo sẽ tăng trở lại trong khi các block bên dưới hole vẫn free. Restart unit hoặc gửi signal yêu cầu process mở lại log theo signal được nêu trong tài liệu của process là cách xử lý không để lại sparse file.

df cho thấy còn free space nhưng ghi file vẫn thất bại. Còn nguyên nhân nào khác?

Kiểm tra inode bằng df -i trên cùng path, vì filesystem còn free block nhưng hết free inode sẽ từ chối tạo file mới. Kiểm tra xem thao tác ghi có chạy dưới quyền một user không phải root trên filesystem ext4 chỉ còn reserved block hay không. sudo tune2fs -l trên device sẽ hiển thị thông tin này. Kiểm tra bạn có đang đọc đúng filesystem mà thao tác ghi thực sự sử dụng không, vì một /boot hoặc /var riêng có thể đầy độc lập với /.

Vì sao du báo tổng lớn hơn df?

du khi không có -x sẽ đi qua mọi filesystem được mount bên dưới path bạn cung cấp. Vì vậy, lệnh này cộng nhiều filesystem, trong khi df chỉ mô tả một filesystem. Bind mount làm vấn đề nghiêm trọng hơn, vì cùng một file có thể bị đếm một lần dưới mỗi path mà file đó xuất hiện. Thêm -x để giới hạn du trong một filesystem duy nhất, đồng thời cung cấp cho df cùng path để hai lệnh mô tả cùng một phạm vi.