Меня зовут Игорь Симаков, работаю engineering manager’ом и руковожу командами разработки
На одном из наших сервисов, который работает с XLSX-файлами, прилетел production-алерт на высокое потребление памяти. Стандартный P3, обычно решается рестартом. Пошёл смотреть поды и нашёл проблему, к памяти отношения не имеющую, но представляющую больший риск, чем сам алерт. Об этом и расскажу ниже: чем «утечка диска» отличается от «утечки памяти», как мы наткнулись на грабли в Apache POI и как закрыли их на уровне архитектуры
Утечка памяти и утечка диска: в чём разница
В обоих случаях логика одинаковая: приложение выделило ресурс и не освободило. Со временем расход растёт, пока не упрётся в потолок
Утечка памяти. Объекты копятся в куче Java (heap) или в native-памяти за её пределами - direct buffers (память для сетевого I/O в обход heap), нативные библиотеки, стеки потоков. Ссылка живёт там, где не должна, GC её не собирает. Память пода растёт, упирается в limits.memory, Kubernetes делает OOMKill, под перезапускается. Локальная проблема: страдает только сам сервис, лечится рестартом
Утечка диска. Приложение создаёт файлы и не удаляет. Здесь начинается интересное: упирается это не в потолок пода, а в свободное место на диске ноды. По умолчанию /tmp в поде - это emptyDir без sizeLimit, физически он лежит на диске ноды (тот же /dev/vda2, где живёт ОС, container runtime, логи контейнеров). У пода лимита нет, он пишет, пока место есть на ноде. Когда место заканчивается, write() возвращает ENOSPC, и падает не только наш сервис - падает всё, что пишет на этот диск, включая чужие поды и kubelet
Утечка памяти |
Утечка диска (host disk) |
|
|---|---|---|
Где предел |
|
свободное место на ноде |
Что происходит при упоре |
OOMKill, под перезапускается |
I/O начинает возвращать ENOSPC |
Кого задевает |
только сам под |
все поды на ноде |
Автоматическое лечение |
да (рестарт) |
нет |
Утечка памяти - локальная: Kubernetes отрабатывает её рестартом. Утечка диска распределённая, физический диск ноды один на всех, и автоматически его никто не «перезагрузит»
Что такое RSS
Дальше я буду писать «RSS пода = N MiB». Расшифрую сразу, чтобы не отвлекало.
RSS (Resident Set Size) — это сколько физической оперативной памяти процесс реально занимает прямо сейчас. Сюда входит всё: JVM heap, non-heap (метаспейс, code cache), native-аллокации (direct buffers, пулы Netty), стеки потоков, замапленные в память файлы
Виртуальной памяти процесс может «зарезервировать» больше, чем есть физической. Но в RSS считается только то, что реально лежит в RAM. Именно RSS показывает
topв одноимённой колонке, и именно RSS Kubernetes сравнивает сlimits.memoryпода, чтобы решить, не пора ли OOMKill
Чем мы измеряем память: контейнер и JVM
Под «потреблением памяти» в Kubernetes обычно смешивают два разных набора метрик. Разведу их явно.
Метрики контейнера. Собирает
cAdvisor, отдаёт в Prometheus. Ключевая —container_memory_working_set_bytes(грубо: RSS пода минус inactive file cache). По ней Kubernetes решает делать OOMKill, и по ней же триггерится наш алертPodMemoryHigh85-working_set / limits.memory > 0.85. Нотификация идёт через Alertmanager в Grafana OnCallМетрики JVM. Spring Boot Actuator + Micrometer на
/actuator/prometheus. Полезные:jvm.memory.used{area="heap"},jvm.memory.used{area="nonheap"}(метаспейс, code cache),jvm.gc.pause. Видна только память, которой управляет JVM. Direct buffers, нативные либы, стеки потоков, OTel-агент для этих метрик невидимыЕсли контейнер растёт, а heap стоит, то это утечка в native-памяти,
-Xmxтут не поможет
Декорации
Spring Boot 3, JDK 21, Apache POI 5.x
Сервис отдаёт продавцам XLSX-шаблон с их остатками и принимает обратно заполненный. Файлы бывают большие, поэтому используется
SXSSFWorkbook, streaming-вариант POI: он держит в памяти только окно последних N строк, а остальное сбрасывает на дискПрод: Kubernetes, два пода,
limits.memory=2Gi,requests.memory=512Mi./tmpсмонтирован на host disk, не tmpfs
Что произошло
В понедельник в 05:41 UTC прилетел PodMemoryHigh85 на одном из подов: RSS выше 85% от limits.memory=2Gi
Состояние пода:
uptime 6 суток 4 часа, не рестартился;
RSS 1731 MiB (84.5% от 2Gi);
JVM heap занимает всего 263 MiB, non-heap 327 MiB. То есть около 1.1 GiB RSS лежит вне JVM;
Тот самый разрыв между метриками контейнера и метриками JVM, про который я писал выше: 1.1 GiB лежит вне JVM, и actuator его не видит. Чтобы понять, кто конкретно ест память, нужен heap dump плюс знание про native-аллокаторы
Я снял heap dump через kubectl port-forward + jcmd, открыл в Eclipse MAT и нашёл главного аллокатора за пределами heap: Netty PoolArena, пул direct buffers для сетевого I/O. 21 арена по 16 MiB, около 336 MiB. Поведение штатное: Netty по умолчанию делает арену на каждый CPU, а в k8s availableProcessors() возвращает 21 ядро ноды. Лечится флагами -Dio.netty.allocator.numDirectArenas=2 -XX:MaxDirectMemorySize=256m. Оформил отдельным action item - вопрос с memory alert закрыт
Но пока копался в поде через kubectl exec, краем глаза заметил странное в du. Вот как выглядит состояние прода прямо сейчас (фикс ещё на ревью, баг живёт):
$ kubectl -n pl-seller-storage-service exec seller-storage-service-...-fpznf -c app -- bash $ du -sh /tmp/poifiles 1.9G /tmp/poifiles $ ls /tmp/poifiles | wc -l 10046 $ ls -lh /tmp/poifiles | head -7 total 1.9G -rw------- 1 10001 root 564K May 26 13:48 poi-sxssf-sheet10003417958849786613.xml -rw------- 1 10001 root 607K May 19 11:05 poi-sxssf-sheet10004469281468030521.xml -rw------- 1 10001 root 248K May 26 18:34 poi-sxssf-sheet10004610036281220997.xml -rw------- 1 10001 root 142K May 26 20:45 poi-sxssf-sheet10006089183010237705.xml -rw------- 1 10001 root 0 May 21 16:07 poi-sxssf-sheet10006300192782898050.xml -rw------- 1 10001 root 0 May 21 05:17 poi-sxssf-sheet10009984612842940696.xml $ df -h /tmp Filesystem Size Used Avail Use% Mounted on /dev/vda2 367G 276G 76G 79% /tmp
Почти 2 GiB временных файлов на одном поде, на втором - 10103 файла, тоже 1.9 GiB. Для масштаба: RSS пода в этот момент 1.7 GiB, то есть на диске под держит даже больше, чем занимает в памяти. Файлы датированы от 19 мая (дата старта uptime), часть нулевого размера - следы незакрытых workbook’ов после исключений
Я понаблюдал ещё час: счётчик растёт по мере работы сервиса. Каждый раз, когда пользователь загружает Excel, POI создаёт новые временные файлы и оставляет их на диске. По данным с момента первого разбора это даёт около 800 файлов в сутки на под, или полгигабайта в сутки на ноду от одного нашего сервиса
И последняя строчка вывода df -h /tmp показывает, что диск ноды уже на 79% (76 GiB свободно из 367 GiB). На той же ноде живут чужие сервисы, и часть «работы» по заполнению диска уже на нас. Тут P3 на сервис превращается в потенциальный P1 на инфраструктуру: если на ноде живут ещё десять сервисов и пара из них тоже «протекает», диск кончается за дни и кладёт всех соседей
Почему SXSSFWorkbook.close() не удаляет временные файлы
SXSSFWorkbook это streaming-вариант POI для записи XLSX. Идея простая: вместо того чтобы держать весь документ в памяти, библиотека сохраняет последние N строк в RAM, а более старые сериализует в файлы poi-sxssf-sheet*.xml в java.io.tmpdir. Это даёт возможность писать XLSX на сотни тысяч строк без OOM
Подвох в том, что SXSSFWorkbook реализует AutoCloseable. То есть выглядит, что try-with-resources сделает всё сам, и временные файлы тоже подчистятся. На практике нет
В Javadoc Apache POI это сказано прямо:
dispose() — Dispose of temporary files backing this workbook on disk. Calling this method will render the workbook unusable
close() корректно закрывает базовый workbook, дописывает ZIP-стрим, отдаёт ресурсы. А вот удаление временных sheet-файлов с диска это отдельная операция, dispose(). В POI 5.x внутри close() dispose() вызывается только в определённой ветке: если запись прошла без исключений. А если посреди записи что-то упало (например, IOException на OutputStream), temps остаются. На практике именно это и копится в проде
Наш старый код выглядел как образец try-with-resources:
try (InputStream in = ...; XSSFWorkbook template = new XSSFWorkbook(Objects.requireNonNull(in)); SXSSFWorkbook wb = new SXSSFWorkbook(template, ROW_ACCESS_WINDOW_SIZE)) { wb.setCompressTempFiles(true); // ... запись XLSX ... wb.write(os); os.flush(); } catch (IOException e) { log.error("XLSX write failed: {}", e.getMessage(), e); throw new ServiceException(FILE_GENERATION_FAILED, "xlsx"); }
Фикс
dispose() нужно вызывать явно, в finally, и до того, как закроется обёрнутый XSSFWorkbook-template. Если template закроется первым, dispose() может бросить IllegalStateException, потому что внутри он лезет в template за списком sheet’ов. Поэтому я вытащил SXSSFWorkbook из try-with-resources в обычное объявление и обернул в свой try/finally
@Override @SuppressWarnings("PMD.CloseResource") public <T> void write(List<T> records, OutputStream os, Class<T> schemaClass) { var xlsxProfile = xlsxProfileResolver.resolve(schemaClass); try (InputStream in = Thread.currentThread().getContextClassLoader() .getResourceAsStream(xlsxProfile.templatePath()); XSSFWorkbook template = new XSSFWorkbook(Objects.requireNonNull(in))) { SXSSFWorkbook wb = new SXSSFWorkbook(template, ROW_ACCESS_WINDOW_SIZE); try { wb.setCompressTempFiles(true); writeWorkbook(wb, records, schemaClass, xlsxProfile, os); } finally { disposeQuietly(wb); } } catch (IOException e) { log.error("XLSX write failed: {}", e.getMessage(), e); throw new ServiceException(FILE_GENERATION_FAILED, "xlsx"); } } private static void disposeQuietly(SXSSFWorkbook wb) { try { wb.dispose(); } catch (Exception e) { log.warn("SXSSFWorkbook dispose failed", e); } try { wb.close(); } catch (IOException e) { log.warn("SXSSFWorkbook close failed", e); } }
dispose() идёт первым: он удаляет sheet-temps. close() потом закрывает zip-output. Оба завёрнуты в try/catch с log.warn — если из write() уже летит ServiceException, не хочется маскировать его cleanup-ошибкой
Тест, который ловит регресс
Apache POI даёт удобный хук: TempFile.setTempFileCreationStrategy(...). Через него можно подменить директорию, куда POI создаёт временные файлы. В тесте это позволяет показать на @TempDir и проверить, что после write() там не осталось ничего с префиксом poi-sxssf-sheet
class XlsxStreamingFileServiceTest extends BaseTest { private static final String POI_SHEET_PREFIX = "poi-sxssf-sheet"; @Autowired private XlsxStreamingFileService xlsxStreamingFileService; @AfterEach void restorePoiTempStrategy() { TempFile.setTempFileCreationStrategy( new DefaultTempFileCreationStrategy(new File(System.getProperty("java.io.tmpdir"))) ); } @Test void writeShouldDisposePoiTempFilesAfterSuccess(@TempDir Path poiTempDir) throws IOException { TempFile.setTempFileCreationStrategy(new DefaultTempFileCreationStrategy(poiTempDir.toFile())); var records = List.of(/* ... */); xlsxStreamingFileService.write(records, new ByteArrayOutputStream(), MySchemaDto.class); assertNoPoiSheetFilesLeft(poiTempDir); } @Test void writeShouldDisposePoiTempFilesEvenIfOutputStreamFails(@TempDir Path poiTempDir) throws IOException { TempFile.setTempFileCreationStrategy(new DefaultTempFileCreationStrategy(poiTempDir.toFile())); var records = List.of(/* ... */); try (OutputStream failing = new OutputStream() { @Override public void write(int b) throws IOException { throw new IOException("boom"); } }) { assertThatThrownBy(() -> xlsxStreamingFileService.write(records, failing, MySchemaDto.class)) .isInstanceOf(ServiceException.class); } assertNoPoiSheetFilesLeft(poiTempDir); } private static void assertNoPoiSheetFilesLeft(Path poiTempDir) throws IOException { try (Stream<Path> walk = Files.walk(poiTempDir)) { var leftover = walk .filter(Files::isRegularFile) .filter(p -> p.getFileName().toString().startsWith(POI_SHEET_PREFIX)) .toList(); assertThat(leftover) .as("POI streaming temp files must be deleted via SXSSFWorkbook.dispose() after write") .isEmpty(); } } }
Второй тест я считаю важнее первого. Он покрывает кейс, когда OutputStream.write(...) бросает IOException посреди записи. Без dispose() в finally именно этот сценарий и оставлял файлы. На happy path старый код тоже проходил без ошибок, поэтому проблема пряталась именно в exception-ветке
Защита от регрессии через ArchUnit
Тест выше проверяет один конкретный flow. Но SXSSFWorkbook- публичный класс POI, и завтра кто-то использует его в другом месте и снова получит утечку. Это случай для ArchUnit: правило архитектурного уровня закрывает класс проблем целиком, а не один эндпоинт
@ArchTest static final ArchRule SXSSF_WORKBOOK_USAGE_LIMITED_TO_FILE_PACKAGE = noClasses() .that().resideOutsideOfPackage("..file..") .should().dependOnClassesThat() .haveFullyQualifiedName("org.apache.poi.xssf.streaming.SXSSFWorkbook") .because("SXSSFWorkbook создаёт POI tmp-файлы в системном tmp-dir и требует явного dispose() " + "для их удаления; использовать только через сервис в пакете ..file..");
Перевод на русский: никакой класс вне пакета ..file.. не имеет права зависеть от SXSSFWorkbook. Если завтра кто-то напишет new SXSSFWorkbook(...) в другом месте, билд упадёт с понятным сообщением. Проблема решится на ревью за пять минут, а не через шесть суток uptime на проде
Логика общая: один раз нашёл нетривиальные грабли в библиотеке, закрепи запрет на уровне архитектуры. Тест на конкретный flow и ArchUnit-правило - два разных уровня защиты, и они дополняют друг друга, а не дублируют
Что бы я сделал по-другому
Про устройство
/tmpв Kubernetes я узнал, только когда полез копать утечку. По умолчаниюemptyDir—-это диск ноды, а tmpfs нужно включать явно черезmedium: Memory. Стоит закладывать это в Pod spec осознанно, не оставлять «как-нибудь сложится»Алерт на свободное место на диске ноды у нас был, но триггерился на 90%. Для роста 250 MiB в сутки это даёт мало времени на реакцию - стоит понизить порог
Полезно иметь отдельную метрику по размеру
emptyDirкаждого пода (cAdvisor умеет). Тогда утечка ловится отдельно от RSS, и сразу видно, на каком сервисе она копится, а не «диск ноды растёт, ищите виноватого»
Итоги
SXSSFWorkbook.close()в Apache POI не удаляет временные sheet-файлы. Это поведение по доке, а не баг библиотеки. Нужен явныйdispose()вfinallytry-with-resourcesне гарантирует cleanup, если у класса в цепочке «закрытие» и «освобождение ресурсов» - разные операцииВ Kubernetes
/tmpпо умолчанию лежит на диске ноды. Утечка диска одного пода ставит под угрозу всех соседей по ноде. Это сильно меняет приоритет таких баговЕсли нашёл нетривиальный гвоздь в библиотеке, имеет смысл закрепить запрет на уровне ArchUnit. Это сильнее, чем тест на конкретный flow
Комментарии (8)

tsypanov
27.05.2026 15:30Это поведение по доке, а не баг библиотеки.
Немного странное поведение, ИМХО. Насколько я вижу в https://poi.apache.org/apidocs/dev/org/apache/poi/xssf/streaming/SXSSFWorkbook.html#close-- написано, что
Once this has been called, no further operations, updates or reads should be performed on the Workbook.
Т. е. смысла держать временные файлы нет, ведь исходный том уже протух и при его повторной обработке всё начнётся с самого начала.

igoresha_s Автор
27.05.2026 15:30Да, тут как раз и прикол, что дока сама себе противоречит. С одной стороны после close() с воркбуком работать уже нельзя. С другой - tmp-файлы спокойно остаются лежать. По-факту это баг, по доке это фича.
В общем, починил у себя через dispose() и докинул в functional-тесты прямо проверку этого контракта. Получилось четыре теста:
с dispose() после успешной записи - tmp-файлов нет
с dispose() в finally после исключения - tmp-файлов нет
без dispose() после успешной записи - tmp-файл остался
без dispose() после исключения - tmp-файл остался
Последние два и есть пруф, что без dispose() файлы реально текут. Если в будущей версии POI причешут поведение и close() сам начнёт чистить - эти два теста покраснеют, и сразу станет видно, что костыль с dispose() уже не нужен

igoresha_s Автор
27.05.2026 15:30Вод код тестов
@Test void withoutDisposeShouldKeepPoiTempFilesAfterSuccessfulClose(@TempDir Path poiTempDir) throws IOException { TempFile.setTempFileCreationStrategy(new DefaultTempFileCreationStrategy(poiTempDir.toFile())); // window=1 POI флашит каждую строку на диск, tmp-файл гарантирован try (SXSSFWorkbook wb = new SXSSFWorkbook(1)) { wb.setCompressTempFiles(true); var sheet = wb.createSheet("contract-doc"); for (int i = 0; i < 100; i++) { sheet.createRow(i).createCell(0).setCellValue("row " + i); } wb.write(new ByteArrayOutputStream()); } // try-with-resources вызвал close() но НЕ dispose() assertPoiSheetFilesPresent(poiTempDir); // tmp-файл всё ещё на диске } @Test void withoutDisposeShouldKeepPoiTempFilesAfterFailedWrite(@TempDir Path poiTempDir) throws IOException { TempFile.setTempFileCreationStrategy(new DefaultTempFileCreationStrategy(poiTempDir.toFile())); SXSSFWorkbook wb = new SXSSFWorkbook(1); try (OutputStream failing = new OutputStream() { @Override public void write(int b) throws IOException { throw new IOException("boom"); } }) { wb.setCompressTempFiles(true); var sheet = wb.createSheet("contract-doc"); for (int i = 0; i < 100; i++) { sheet.createRow(i).createCell(0).setCellValue("row " + i); } assertThatThrownBy(() -> wb.write(failing)).isInstanceOf(IOException.class); } finally { wb.close(); // close без dispose — симулируем «забытый» dispose } assertPoiSheetFilesPresent(poiTempDir); }

igoresha_s Автор
27.05.2026 15:30Спасибо за уточнение, докинул дополнительно 2 тесты, которые воспроизводят проблему тоже

Filex
27.05.2026 15:30Давно не смотрел Apache POI, как у вас реализована потоковая обработка файлов? Раньше была какая-то доп.библиотека, потом ее перестали поддерживать и она перестала работать с новыми версиями POI. А потом я ушел из проекта, где надо было парсить excel ))

igoresha_s Автор
27.05.2026 15:30На запись как раз тот самый SXSSFWorkbook из статьи. Параметры у нас такие: rowAccessWindowSize = 500 (сколько строк держим в памяти), setCompressTempFiles(true) (gzip временных листов на диске) и руками sheet.flushRows(500) каждые 5000 строк, чтобы окно гарантированно не разъезжалось при больших файлах. Плюс особенность - мы пишем поверх XLSX-шаблона (new SXSSFWorkbook(xssfTemplate, 500)), так что в finally закрываем оба воркбука (со всеми нюансами dispose(), про которые в статье)
На чтение используем Poiji. Это обёртка над POI, которая по аннотациям @ExcelRow / @ExcelCell маппит строки в POJO. Под капотом тот же POI, но без ручного хождения по Row/Cell. Поддерживается, с актуальной POI 5.x работает
Доп. библиотека, про которую вспоминаешь это, видимо, excel-streaming-reader. Сейчас живой форк есть- pjfanning/excel-streaming-reader, нормально дружит с POI 5.x, его можно брать, если нужен именно стриминговый reader в SAX-режие. У нас объёмы на чтение небольшие, поэтому хватает Poiji’я и стандартного POI
reder222boy
Это все конечно замечательно, но что мешало обновить версию зависимости, в которой dispose делается автоматически?
igoresha_s Автор
Да я не знал, что у меня вообще есть проблема в проде - диск тек тихо, а узнал по инциденту, а не по changelog’у POI. «Просто обновись» работает, когда заранее знаешь, что искать
Плюс апгрейд POI в живом проекте — не однострочник: поведение форматтеров/стилей, конфликты версий с другими либами. Защитный try/finally + ArchUnit + тест на tmp-файлы дают гарантию здесь и сейчас, независимо от версии.
Но большое спасибо за наводку, посмотрю свежие версии либы!