Задача: підняти 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.