В проде летит Too many open files — определите причину по трейсу и lsof
Сервис выгрузки отчётов работает шесть суток. Под обычной нагрузкой он начинает падать с ошибкой ниже; рестарт помогает примерно на сутки, потом всё повторяется. Артефакты сняты с живого процесса.
Ограничения:
- на машине есть запас CPU, памяти и диска; свопа нет, том не заполнен
- код не менялся два года, но объём выгрузок вырос примерно в 5 раз за квартал
- поднять лимит вам разрешено — скажите, является ли это исправлением
java.io.FileNotFoundException: /var/data/reports/r-88213.csv (Too many open files)
at java.base/java.io.FileOutputStream.open0(Native Method)
at java.base/java.io.FileOutputStream.<init>(FileOutputStream.java:236)
at com.acme.report.ExportJob.writeReport(ExportJob.java:74)
$ ulimit -n
4096
$ lsof -p 8123 | wc -l
4092
$ lsof -p 8123 | awk '{print $5}' | sort | uniq -c | sort -rn | head -3
3968 REG <- /var/data/reports/r-*.csv
94 IPv4 <- соединения с postgres
18 CHR
$ jcmd 8123 GC.run
$ lsof -p 8123 | wc -l
231 <- за час снова около 4000
Определите причину.
Это утечка файловых дескрипторов, а не заниженный лимит: 3968 из 4092 хендлов — это CSV-отчёты, которые процесс уже дописал. Принудительный GC роняет счётчик до 231, а значит потоки закрываются только своим Cleaner при сборке — writeReport не вызывает close(). Оберните его в try-with-resources; поднятие ulimit -n лишь отодвинет падение.
- ✗Поднять
ulimit -nи считать дело закрытым, пока счётчик хендлов снова доползает до нового потолка - ✗Игнорировать подсказку, что принудительный GC освобождает хендлы, — почерк потока, закрываемого только своим cleaner
- ✗Полагать, что
FileOutputStreamзакрывается, когда его переменная выходит из области видимости
- →Почему try-with-resources всё равно закрывает поток, если
writeReportпадает на середине? - →Какая метрика уровня JDK показала бы рост числа хендлов ещё до первого падения?
Решение
Три улики складываются в одну картину. Первая: почти все 4092 хендла — это REG-файлы /var/data/reports/r-*.csv, то есть отчёты, которые процесс уже дописал и отдал. Вторая: принудительный jcmd GC.run роняет счётчик до 231. Освобождение дескрипторов сборкой мусора означает ровно одно — close() не вызывается, и хендл отпускает только Cleaner потока при утилизации объекта. Третья: рестарт помогает на сутки, то есть счётчик растёт монотонно вместе с числом выгрузок.
Значит, это утечка дескрипторов, а не малый лимит. Пятикратный рост объёма лишь ускорил достижение потолка.
// ❌ было: поток закрывается только при сборке мусора
void writeReport(Path path, List<Row> rows) {
var out = new BufferedWriter(new OutputStreamWriter(
new FileOutputStream(path.toFile()), UTF_8));
for (Row r : rows) {
out.write(r.toCsv()); // ⚠️ исключение здесь — и close() уже не будет
out.newLine();
}
out.flush(); // flush есть, close отсутствует
}
// ✅ стало: детерминированное закрытие, в том числе на исключении
void writeReport(Path path, List<Row> rows) throws IOException {
try (var out = Files.newBufferedWriter(path, UTF_8)) {
for (Row r : rows) {
out.write(r.toCsv());
out.newLine();
}
}
}
Почему ulimit не исправление. Поднятый лимит лишь отодвигает падение: темп утечки не изменился, счётчик так же дойдёт до 65536, просто позже. Лимит поднимают отдельно и осознанно — когда доказано, что все дескрипторы нужны одновременно.
Как ловить раньше. OperatingSystemMXBean в UNIX-варианте отдаёт getOpenFileDescriptorCount() и getMaxFileDescriptorCount() — этот gauge выводят в мониторинг и алертят на растущий тренд, а не на достигнутый потолок.