2025, Dec 29 00:02

Как исправить ошибку Too many open files в Selenium на Linux

Как исправить Too many open files в Selenium на Linux: утечки pyvirtualdisplay, корректное закрытие, повышение ulimit и пул переиспользуемых WebDriver.

Долгие сессии Selenium в Linux нередко падают не из‑за ошибок в вашей логике, а потому, что операционная система решает: хватит. Если парсер работает часами, а затем внезапно останавливается с невнятным сообщением, скорее всего, где‑то течёт ресурс. Признак очевиден.

OSError: [Errno 24] Too many open files

В этом руководстве разберём точный характер сбоя, что именно его провоцирует в связке Selenium и pyvirtualdisplay, и как убрать утечки: закрывать всё детерминированно, переиспользовать виртуальный дисплей, поднять ulimit и, где уместно, перейти на пул переиспользуемых WebDriver’ов.

Постановка проблемы

Сценарий: безголовый парсер перебирает поисковые запросы, запускает Chrome‑драйвер с прокси‑аутентификацией и использует pyvirtualdisplay в Linux. Через 10 часов он падал с «Too many open files». Ниже — показательная версия кода с той же структурой и поведением, но другими именами.

import logging
import time
import random
import platform
import traceback
import zipfile
from datetime import datetime
from selenium.webdriver.common.by import By
from fake_useragent import UserAgent
import undetected_chromedriver as uc
from selenium_stealth import stealth

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s",
    handlers=[
        logging.FileHandler("scan_worker.log"),
        logging.StreamHandler()
    ]
)
log = logging.getLogger('ScanLogger')


def pick_ua_chrome():
    ua = UserAgent(browsers='chrome', os='windows', platforms='pc')
    return ua.random


def make_driver(proxy_conf: dict, SHOW_UI=False):
    chr_opts = uc.ChromeOptions()
    if not SHOW_UI:
        chr_opts.add_argument('--headless')
        chr_opts.add_argument('--no-sandbox')
        chr_opts.add_argument('--disable-dev-shm-usage')
    chr_opts.add_argument('--start-maximized')
    chr_opts.add_argument('--disable-blink-features=AutomationControlled')
    chr_opts.add_argument("user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36")

    manifest_json = """
    {
        "version": "1.0.0",
        "manifest_version": 2,
        "name": "Chrome Proxy",
        "permissions": [
            "proxy",
            "tabs",
            "unlimitedStorage",
            "storage",
            "<all_urls>",
            "webRequest",
            "webRequestBlocking"
        ],
        "background": {
            "scripts": ["background.js"]
        },
        "minimum_chrome_version":"22.0.0"
    }
    """

    background_js = """
    var config = {
            mode: "fixed_servers",
            rules: {
              singleProxy: {
                scheme: "http",
                host: "%s",
                port: parseInt(%s)
              },
              bypassList: ["localhost"]
            }
          };

    chrome.proxy.settings.set({value: config, scope: "regular"}, function() {});

    function callbackFn(details) {
        return {
            authCredentials: {
                username: "%s",
                password: "%s"
            }
        };
    }

    chrome.webRequest.onAuthRequired.addListener(
                callbackFn,
                {urls: ["<all_urls>"]},
                ['blocking']
    );
    """ % (proxy_conf["proxy"], proxy_conf["proxy_port"], proxy_conf["proxy_login"], proxy_conf["proxy_password"])

    if proxy_conf:
        ext_zip = 'proxy_auth_plugin.zip'
        with zipfile.ZipFile(ext_zip, 'w') as zp:
            zp.writestr("manifest.json", manifest_json)
            zp.writestr("background.js", background_js)
        chr_opts.add_extension(ext_zip)

    browser = uc.Chrome(chrome_options=chr_opts)

    stealth(browser,
        languages=["ru-RU", "ru", "en-US", "en"],
        vendor="Google Inc.",
        platform="Linux x86_64",
        webgl_vendor="Intel Inc.",
        renderer="Intel Open Source Technology Center Mesa DRI Intel(R) UHD Graphics 620 (KBL GT2)",
        fix_hairline=True,
    )

    return browser


class YaSearchScraper:
    def __init__(self, proxy_conf, SHOW_UI=False, SAVE_SHOT=False):
        print(f"SHOW_UI={SHOW_UI} SAVE_SHOT={SAVE_SHOT}")
        self.save_snap = SAVE_SHOT
        self.proxy_conf = proxy_conf
        self.show_ui = SHOW_UI
        self.browser = None
        self.vdisplay = None
        self._boot_driver()

    def _boot_driver(self):
        if platform.system() == 'Darwin':
            self._chr_opts = uc.ChromeOptions()
            self._chr_opts.add_argument('--headless=new')
            self._chr_opts.add_argument('--disable-gpu')
            self._chr_opts.add_argument('--no-sandbox')
            self._chr_opts.add_argument('--disable-dev-shm-usage')
            self.browser = make_driver(proxy_conf=self.proxy_conf, SHOW_UI=self.show_ui)
        else:
            from pyvirtualdisplay import Display
            self.vdisplay = Display(visible=0, size=(800, 600))
            self.vdisplay.start()
            self.browser = make_driver(proxy_conf=self.proxy_conf, SHOW_UI=self.show_ui)

        self.browser.execute_cdp_cmd('Page.addScriptToEvaluateOnNewDocument', {
            'source': '''
                Object.defineProperty(navigator, 'webdriver', {get: () => undefined});
            '''
        })
        self.browser.get("https://ya.ru/")
        time.sleep(random.uniform(1.5, 3.5))

    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        self.shutdown()

    def shutdown(self):
        try:
            if self.browser:
                self.browser.quit()
                self.browser = None
        except Exception as e:
            log.error(f"Error closing driver: {e}")
        try:
            if self.vdisplay:
                self.vdisplay.stop()
                self.vdisplay = None
        except Exception as e:
            log.error(f"Error stopping display: {e}")

    def tick_captcha(self):
        now_str = str(datetime.now()).replace(' ', '_')
        if "showcaptcha" in self.browser.current_url:
            log.info("Captcha found")
            if self.save_snap:
                self.browser.save_screenshot(f'screens/img_captcha_{now_str}.png')
            btns = self.browser.find_elements(By.XPATH, "//input[@class='CheckboxCaptcha-Button']")
            if btns:
                btns[0].click()
                log.info("Button clicked")
                time.sleep(15)
                if self.save_snap:
                    self.browser.save_screenshot(f'screens/img_captcha_afterclick_{now_str}.png')
        elif self.save_snap:
            self.browser.save_screenshot(f'screens/img_{now_str}.png')

    def harvest(self, title: str):
        log.info(f"Start parse {title}")
        found = []
        try:
            self.browser.get(f"https://ya.ru/search/?text={title}&lr=213&search_source=yaru_desktop_common&search_domain=yaru")
            self.tick_captcha()
            for i in range(1, 5):
                found.extend(self.harvest_page(page_id=i))
                self.flip_page()
                self.tick_captcha()
                time.sleep(random.uniform(2, 5))
        except Exception:
            log.error(f"Exception in {traceback.format_exc()}")
        finally:
            log.info(f"Found {len(found)} for film {title}: {found}")

    def harvest_page(self, page_id):
        acc = []
        nodes = self.browser.find_elements(By.XPATH, value='//a[@class="Link Link_theme_normal OrganicTitle-Link organic__url link"]')
        for node in nodes:
            href = node.get_attribute("href")
            if href and "yabs.yandex.ru" not in href:
                acc.append(href)
        log.info(f"Found {len(acc)} urls on page {page_id}")
        return acc

    def flip_page(self):
        nxt = self.browser.find_elements(By.XPATH, '//div[@class="Pager-ListItem Pager-ListItem_type_next"]')
        if nxt:
            nxt[0].click()
            time.sleep(random.uniform(3, 6))


if __name__ == "__main__":
    proxy_book = [
        # Данные прокси
    ]
    titles = ["Terminator smotret", "Sasha Tanya smotret", "John Wick smotret onlayn"]
    turn = 0
    while True:
        try:
            with YaSearchScraper(SHOW_UI=False, proxy_conf=proxy_book[turn % 2]) as runner:
                item = titles[turn]
                turn = (turn + 1) % len(titles)
                runner.harvest(item)
            time.sleep(random.uniform(8, 15))
        except Exception:
            log.error(f"Exception {traceback.format_exc()}")
            time.sleep(1)

Почему число дескрипторов файлов растёт

Это классический случай накопления ресурсов при длинных прогонах. Три точки давления. Во‑первых, каждый раз создаются новые виртуальные дисплеи через pyvirtualdisplay.Display, и они не всегда корректно глушатся — процесс накапливает открытые дескрипторы. Во‑вторых, Selenium и Chrome сами по себе держат множество дескрипторов, поэтому даже небольшой «подтёк» быстро набегает. Наконец, Linux навешивает предельное число открытых файлов на процесс, обычно 1024, и при часах работы петли этот лимит пробивается.

Как остановить утечку и сохранить стабильность

Решение послойное. Сначала сделайте завершение работы безотказным, чтобы каждый цикл гарантированно чистил драйверы, Xvfb и временные артефакты вроде прокси‑расширения. Затем снизьте churn — переиспользуйте дисплей вместо создания на каждом заходе. После этого поднимите системные лимиты до разумных для вашей нагрузки. Добавьте явный сборщик мусора и небольшие гигиенические флаги в Chrome, а также периодические «передышки», чтобы система успевала подчищать хвосты. И, наконец, там, где подходит, используйте пул драйверов, чтобы вообще не плодить их бесконечно.

Укрепляем путь завершения

Убедитесь, что все ресурсы закрываются, и удаляйте файл расширения, если он создавался. Ниже — доработанный метод с упорядоченным выключением и удалением zip.

def shutdown(self):
    if self.browser:
        try:
            self.browser.quit()
        except Exception as e:
            log.error(f"Error closing driver: {e}")
        finally:
            self.browser = None

    if self.vdisplay:
        try:
            self.vdisplay.stop()
        except Exception as e:
            log.error(f"Error stopping display: {e}")
        finally:
            self.vdisplay = None

    import os
    try:
        ext_zip = 'proxy_auth_plugin.zip'
        if os.path.exists(ext_zip):
            os.remove(ext_zip)
    except Exception as e:
        log.error(f"Error removing plugin file: {e}")

Переиспользуйте виртуальный дисплей

Создавать новый Xvfb в каждом цикле расточительно. Одиночка на уровне класса предотвращает появление дисплея на каждый прогон. Дисплей запускается один раз и шарится между экземплярами.

class YaSearchScraper:
    _shared_display = None

    def _boot_driver(self):
        if platform.system() == 'Darwin':
            self._chr_opts = uc.ChromeOptions()
            self._chr_opts.add_argument('--headless=new')
            self._chr_opts.add_argument('--disable-gpu')
            self._chr_opts.add_argument('--no-sandbox')
            self._chr_opts.add_argument('--disable-dev-shm-usage')
            self.browser = make_driver(proxy_conf=self.proxy_conf, SHOW_UI=self.show_ui)
        else:
            from pyvirtualdisplay import Display
            if YaSearchScraper._shared_display is None:
                YaSearchScraper._shared_display = Display(visible=0, size=(800, 600))
                YaSearchScraper._shared_display.start()
            self.vdisplay = YaSearchScraper._shared_display
            self.browser = make_driver(proxy_conf=self.proxy_conf, SHOW_UI=self.show_ui)

Поднимите лимиты дескрипторов в ОС

Если нагрузка действительно тяжёлая, разумно увеличить лимиты. Отредактируйте конфигурацию limits и поднимите nofile. Понадобятся повышенные привилегии.

nano /etc/security/limits.conf

Добавьте строки ниже и сохраните через Ctrl + x, затем y и Enter.

* soft nofile 4096
* hard nofile 8192

Добавьте явный GC и «облегчающие» флаги Chrome

Принудительная сборка мусора после каждой итерации помогает быстрее освобождать объекты на стороне Python, что хорошо сочетается с детерминированным закрытием драйвера. Дополнительные флаги Chrome чуть сокращают потребление ресурсов.

import gc
# в блоке finally цикла или обслуживающем разделе
gc.collect()
# при сборке опций Chrome
chr_opts.add_argument('--disable-extensions')
chr_opts.add_argument('--disable-software-rasterizer')
chr_opts.add_argument('--disable-logging')

Давайте циклу передышку

Небольшая пауза каждые несколько итераций даёт системе время на фоновую уборку.

if turn % 10 == 0:
    time.sleep(30)

Сделайте основной цикл устойчивым

Собрав всё вместе, цикл может регулировать темп, вызывать сборку мусора и корректно остановить общий дисплей при завершении программы.

if __name__ == "__main__":
    proxy_book = [/* ваши прокси */]
    titles = ["Terminator smotret", "Sasha Tanya smotret", "John Wick smotret onlayn"]
    turn = 0

    try:
        while True:
            try:
                with YaSearchScraper(SHOW_UI=False, proxy_conf=proxy_book[turn % 2]) as runner:
                    item = titles[turn]
                    turn = (turn + 1) % len(titles)
                    runner.harvest(item)

                if turn % 10 == 0:
                    time.sleep(30)
                    import gc
                    gc.collect()
                else:
                    time.sleep(random.uniform(8, 15))

            except Exception:
                log.error(f"Exception {traceback.format_exc()}")
                time.sleep(30)
    finally:
        if YaSearchScraper._shared_display:
            YaSearchScraper._shared_display.stop()

Используйте пул WebDriver вместо постоянного пересоздания

Ещё один эффективный шаг — не поднимать драйвер для каждого запроса. Небольшой пул, переиспользуемый между задачами, резко снижает churn дескрипторов и накладные на запуск. Ниже — реализация пула Chrome‑драйверов с поддержкой прокси и stealth‑настроек: экземпляры возвращаются в пул после использования и автоматически заменяются при поломке.

from selenium.webdriver import Chrome
from selenium.webdriver.chrome.options import Options
import threading
import queue
import time
import random
import logging
from fake_useragent import UserAgent
import zipfile
import os
from selenium_stealth import stealth

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s",
    handlers=[
        logging.FileHandler("driver_pool.log"),
        logging.StreamHandler()
    ]
)
pool_log = logging.getLogger('DriverPoolLogger')

class DriverStash:
    def __init__(self, pool_size=3, proxy_conf=None, headless=True):
        self.pool_size = pool_size
        self.proxy_conf = proxy_conf
        self.headless = headless
        self._q = queue.Queue(maxsize=pool_size)
        self._lock = threading.Lock()
        self._seed()

    def _spawn(self):
        opts = Options()
        if self.headless:
            opts.add_argument('--headless')
            opts.add_argument('--no-sandbox')
            opts.add_argument('--disable-dev-shm-usage')
        opts.add_argument('--start-maximized')
        opts.add_argument('--disable-blink-features=AutomationControlled')

        ua = UserAgent(browsers='chrome', os='windows', platforms='pc')
        opts.add_argument(f"user-agent={ua.random}")

        if self.proxy_conf:
            addon = self._proxy_addon()
            opts.add_extension(addon)

        d = Chrome(options=opts)

        stealth(d,
            languages=["ru-RU", "ru", "en-US", "en"],
            vendor="Google Inc.",
            platform="Linux x86_64",
            webgl_vendor="Intel Inc.",
            renderer="Intel Open Source Technology Center Mesa DRI Intel(R) UHD Graphics 620 (KBL GT2)",
            fix_hairline=True,
        )

        d.execute_cdp_cmd('Page.addScriptToEvaluateOnNewDocument', {
            'source': '''
                Object.defineProperty(navigator, 'webdriver', {get: () => undefined});
            '''
        })

        return d

    def _proxy_addon(self):
        manifest_json = """
        {
            "version": "1.0.0",
            "manifest_version": 2,
            "name": "Chrome Proxy",
            "permissions": [
                "proxy",
                "tabs",
                "unlimitedStorage",
                "storage",
                "<all_urls>",
                "webRequest",
                "webRequestBlocking"
            ],
            "background": {
                "scripts": ["background.js"]
            },
            "minimum_chrome_version":"22.0.0"
        }
        """

        background_js = """
        var config = {
                mode: "fixed_servers",
                rules: {
                  singleProxy: {
                    scheme: "http",
                    host: "%s",
                    port: parseInt(%s)
                  },
                  bypassList: ["localhost"]
                }
              };

        chrome.proxy.settings.set({value: config, scope: "regular"}, function() {});

        function callbackFn(details) {
            return {
                authCredentials: {
                    username: "%s",
                    password: "%s"
                }
            };
        }

        chrome.webRequest.onAuthRequired.addListener(
                    callbackFn,
                    {urls: ["<all_urls>"]},
                    ['blocking']
        );
        """ % (self.proxy_conf["proxy"], self.proxy_conf["proxy_port"], 
               self.proxy_conf["proxy_login"], self.proxy_conf["proxy_password"])

        addon_zip = 'proxy_auth_plugin.zip'
        with zipfile.ZipFile(addon_zip, 'w') as zp:
            zp.writestr("manifest.json", manifest_json)
            zp.writestr("background.js", background_js)
        return addon_zip

    def _seed(self):
        for _ in range(self.pool_size):
            d = self._spawn()
            self._q.put(d)

    def acquire(self, timeout=30):
        try:
            d = self._q.get(timeout=timeout)
            d.delete_all_cookies()
            return d
        except queue.Empty:
            raise TimeoutError("No available drivers in the pool")

    def release(self, d):
        try:
            d.delete_all_cookies()
            d.get("about:blank")
            self._q.put(d)
        except Exception as e:
            pool_log.error(f"Error returning driver to pool: {e}")
            try:
                d.quit()
            except Exception:
                pass
            fresh = self._spawn()
            self._q.put(fresh)

    def cleanup(self):
        while not self._q.empty():
            try:
                d = self._q.get_nowait()
                d.quit()
            except Exception as e:
                pool_log.error(f"Error cleaning up driver: {e}")

    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        self.cleanup()

Ниже — обновлённый каркас программы с двумя пулами, работающими через разные прокси. Он чередует пулы и периодически делает «обслуживающие» паузы, чтобы удерживать ресурсы в стабильных рамках.

if __name__ == "__main__":
    proxies = [
        {"proxy": "proxy1.example.com", "proxy_port": "8080", "proxy_login": "user1", "proxy_password": "pass1"},
        {"proxy": "proxy2.example.com", "proxy_port": "8080", "proxy_login": "user2", "proxy_password": "pass2"}
    ]

    titles = ["Terminator smotret", "Sasha Tanya smotret", "John Wick smotret onlayn"]

    farms = [
        DriverStash(pool_size=2, proxy_conf=proxies[0], headless=True),
        DriverStash(pool_size=2, proxy_conf=proxies[1], headless=True)
    ]

    k = 0
    rounds = 0

    try:
        while True:
            try:
                farm_idx = k % len(farms)
                title = titles[k % len(titles)]

                # Пример использования, если YaSearchScraper интегрирован с пулом драйверов
                # with YaSearchScraper(driver_pool=farms[farm_idx]) as runner:
                #     runner.harvest(title)

                k += 1
                rounds += 1

                if rounds % 20 == 0:
                    pool_log.info("Performing periodic maintenance")
                    time.sleep(30)
                time.sleep(random.uniform(8, 15))

            except Exception:
                pool_log.error(f"Exception {traceback.format_exc()}")
                time.sleep(30)
    finally:
        for farm in farms:
            farm.cleanup()

Почему это важно

Долгоживущие системы парсинга или автотесты редко рушатся от одного катастрофического события. Чаще они медленно деградируют из‑за мелких утечек, которые ОС в какой‑то момент перестаёт терпеть. Понимая, как накапливаются дескрипторы, где их потребляют Selenium и Xvfb, и как ulimit ограничивает процесс, вы получаете рычаги для построения устойчивых сервисов. Особенно это критично для headless‑нагрузок в Linux, где драйверы, прокси и stealth‑настройки крутятся сутками.

Практические заключительные заметки

Пара рабочих привычек сильно помогает. Мониторьте число открытых файлов и мягкие/жёсткие лимиты штатными утилитами, смотрите на вывод lsof в долгих прогонах и проверяйте, не растёт ли количество дескрипторов монотонно. Если даже с одним дисплеем наблюдаете рост, пересмотрите порядок остановки и убедитесь, что временные файлы действительно удаляются. Переиспользуйте всё, что можно — от дисплеев до драйверов — и периодически замедляйте цикл, чтобы сборщики успевали отрабатывать.

Решения тут просты: агрессивно закрывайте ресурсы, переиспользуйте Display, разумно поднимайте потолок nofile, собирайте мусор, «облегчайте» Chrome и, где уместно, перестаньте создавать драйверы в горячем цикле, заменив это пулом. С этими мерами «Too many open files» перестаёт быть роковой стеной и превращается в рутинную операционную задачу.