Skip to Content

Скільки воркерів потрібно Odoo 19 у проді

Задача: підняти Odoo 19 у режимі prefork (workers > 0) і порахувати, скільки воркерів, крон-тредів і памʼяті виставити під конкретний сервер, а не переносити число з чужого odoo.conf.

Скільки воркерів на скільки CPU

Офіційна документація Odoo для sizing дає три рядки, дослівно:

Rule of thumb : (#CPU * 2) + 1
Cron workers need CPU
1 worker ~= 6 concurrent users

Перший рядок — арифметика, яку легко порахувати самому. Для сервера на 4 ядрах:

cpu = 4
workers_max = cpu * 2 + 1  # 9

Третій рядок («1 воркер ≈ 6 користувачів») — орієнтир самих авторів формули, не наш вимір і не гарантія. Наводжу його тільки тому, що на ньому побудований офіційний приклад нижче.

Куди дівається «+1»

Розглянемо офіційний приклад для сервера на 4 CPU і 60 одночасних користувачів:

60 users / 6 = 10   <- скільки воркерів потрібно теоретично
(4 * 2) + 1 = 9      <- скільки воркерів можна теоретично

Далі документація бере 8 воркерів + 1 крон-воркер. Це не округлення в мінус, а розкладка самої формули: подвоєні ядра йдуть під HTTP, а «+1» — під крон. Важливо! Щоб сума збіглася рівно (8+1=9), офіційний приклад виставляє max_cron_threads = 1, а не дефолтний 2 — на дефолтному значенні розкладка вже не така рівна, і це нормально: формула — орієнтир для першого наближення, не константа, яку треба відтворити один в один.

Чому це не лінійно

Кожен воркер — окремий процес (os.fork()), не тред, тому GIL тут ні до чого. Але ядра CPU фізичні: воркерів понад (#CPU × 2) + 1 — це вже не паралелізм, а перемикання контексту, і кожен зайвий воркер однаково коштує памʼяті. Документація дає формулу під памʼять — 20% запитів «важкі» (~1 ГБ), 80% «легкі» (~150 МБ):

workers = 9
light, heavy = 150, 1024        # MB
needed_ram = workers * (0.8 * light + 0.2 * heavy)
# 9 * (120 + 204.8) = 2923.2 MB ≈ 3 GB

Неправильно: рахувати памʼять як workers × limit_memory_hard — це стеля для одного зіпсованого запиту, а не типове споживання. Правильно: рахувати за формулою вище, а limit_memory_* лишити аварійним запобіжником, не бюджетом.

Крон-треди конкурують з HTTP-воркерами

max_cron_threads (дефолт 2) — окремий вид процесу, WorkerCron, а не потік усередині HTTP-воркера. Майстер-процес наповнює обидва пули двома незалежними циклами:

if config['http_enable']:
    while len(self.workers_http) < self.population:
        # ...
        self.worker_spawn(WorkerHTTP, self.workers_http)
while len(self.workers_cron) < config['max_cron_threads']:
    # ...
    self.worker_spawn(WorkerCron, self.workers_cron)

Цикли незалежні, але ядра CPU й зʼєднання до БД — спільні. Крон-джоба, що ганяє важкий перерахунок о третій ночі, забирає той самий ресурс, під який рахували workers, — просто не в тому лічильнику.

Окремий воркер під longpolling

У режимі prefork майстер-процес додатково піднімає ще один процес — не HTTP і не крон, а gevent-воркер на окремому порту (дефолт 8072). Він один на весь сервер, незалежно від workers, і обслуговує довгоживучі зʼєднання: чат, нотифікації, шину bus.bus.

Без проксі, що явно розводить трафік, запити на /websocket підуть на звичайні HTTP-воркери. Кожен WorkerHTTP обробляє один запит за раз послідовно — відкрита вкладка з чатом займе цілий воркер на весь час зʼєднання, і сервер почне «губити» слоти під звичайні HTTP-запити, хоча формально workers виставлений правильно. Розводимо на рівні nginx:

upstream odoo_http { server 127.0.0.1:8069; }
upstream odoo_realtime { server 127.0.0.1:8072; }

location /websocket {
    proxy_pass http://odoo_realtime;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
}
location / {
    proxy_pass http://odoo_http;
}

Важливо! Маршрут у коді Odoo зареєстрований без кінцевого слеша. location /websocket/ (зі слешем) під запит /websocket не підпадає — nginx мовчки віддає його в location / на 8069, handshake провалюється, а в логах при цьому нічого немає: конфіг виглядає правильним і просто не працює.

Ліміти памʼяті й часу вбивають воркер — і це нормально

limit_memory_soft (дефолт 2048 МіБ) і limit_memory_hard (2560 МіБ) — не одне й те саме. Мʼякий ліміт дає воркеру дообробити поточний запит і піти на перезапуск; жорсткий обриває виділення памʼяті одразу. limit_time_cpu (60с) і limit_time_real (120с) так само різні: перший рахує CPU-час одного запиту, другий — астрономічний час, і саме він ловить запит, що завис на блокуванні рядка в БД, а не на обчисленнях.

Офіційний приклад для того самого сервера на 4 CPU навмисно відходить від дефолтів в обидва боки:

limit_memory_soft = 629145600    ; 600 MiB, не 2048
limit_memory_hard = 1677721600   ; 1600 MiB, не 2560
limit_time_cpu = 600             ; 10 хв, не 60с
limit_time_real = 1200           ; 20 хв, не 120с

Памʼять — жорсткіше, щоб укластись у порахований бюджет 3 ГБ на 9 процесів; час — мʼякше, типова адмінська поправка під важкі звіти чи імпорти, які просто довше рахують. Рядок у логах на кшталт «virtual memory limit reached» чи «CPU time limit reached» — не аварія, а штатний recycle воркера; тривожитись варто, коли частота таких рестартів раптово зростає, а не від самого факту.

db_maxconn і стеля PostgreSQL

db_maxconn (дефолт 64) — ліміт не на Odoo в цілому, а на кожен процес окремо: кожен WorkerHTTP, WorkerCron і gevent-воркер тримає власний пул зʼєднань до PostgreSQL. Порахуємо стелю для прикладу вище (8 HTTP + 1 крон + 1 gevent = 10 процесів):

processes = 8 + 1 + 1
ceiling = processes * 64
# 640 — при дефолтному max_connections PostgreSQL = 100

640 проти 100 — вперлись задовго до того, як реально забракло памʼяті чи CPU. Або піднімаємо max_connections у PostgreSQL із запасом на службові зʼєднання, або опускаємо db_maxconn так, щоб processes × db_maxconn лишався з запасом нижче стелі бази — для цього прикладу вистачить і db_maxconn = 8.

Як зловити сам момент, коли впираєтесь саме в PostgreSQL, а не у власні воркери, — вимірювали окремо в статті про навантажувальне тестування Odoo 19: https://kitworks.systems/blog/adminstvo-5/load-testing-odoo-19-129.

Як зрозуміти з симптомів, у що вперлись

  • Черга запитів росте, а CPU кожного окремого воркера низький — не вистачає workers, ядра ще є.
  • CPU під 100% одразу на всіх воркерах — впираєтесь у ядра, піднімати workers понад формулу вже безглуздо.
  • У логах регулярно timeout after Ns — це limit_time_real, а не CPU: шукайте блокування рядків у БД, не важкі обчислення.
  • psycopg2.pool.PoolError або FATAL: too many connections у PostgreSQL — не сервер слабкий, а db_maxconn × кількість процесів перевищує max_connections.
  • Чат і нотифікації не оновлюються в реальному часі, хоча звичайний HTTP працює — перевіряємо, чи проксі справді розводить /websocket на gevent_port.

Перевіряємо останній пункт напряму, з боку сервера — простіше не піднімати сам handshake, а дернути службовий health-маршрут gevent-процесу, який заголовків Upgrade не потребує:

curl -s http://127.0.0.1:8072/websocket/health
# {"status": "pass"}

Тема за матеріалом https://www.cybrosys.com/blog/how-to-scale-workers-in-an-odoo-19-deployment. Код і поведінку перевірено на Odoo 19.0.

Скільки воркерів потрібно Odoo 19 у проді
KitWorks, Volodymyr Karabanov 30 липня 2026 р.
Поділитися цією публікацією
Теги
Архів