პატარა სერვერზე gunicorn-ის worker-ების სწორი რაოდენობა ჩვეულებრივ ერთია ან ორი და არა დოკუმენტაციაში მითითებული (2 x cores) + 1. შემდეგ მნიშვნელობა აქვს იმ პარამეტრებს, რომლებსაც თითქმის არავინ ცვლის: timeout, რომელიც შენს ყველაზე ნელ კეთილსინდისიერ მოთხოვნას შეესაბამება, max_requests jitter-ით, რომ გაჟონვადი worker-ები გაითიშოს, preload_app მხოლოდ მაშინ, თუ შენი კოდი fork-ს უძლებს, და keepalive, რომელიც აპლიკაციის წინ მდგარ proxy-ს ერგება. uvicorn-ს ნაკლები პარამეტრი აქვს, მაგრამ იგივე ფორმა: --workers პროცესებისთვის, --timeout-keep-alive, --limit-max-requests. ეს სტატია კონფიგურაციასა და ექსპლუატაციაზეა. რამდენ მეხსიერებას იკავებს თითო worker, ცალკე კითხვაა და მას განიხილავს რამდენი RAM სჭირდება Python აპლიკაციას.
ყველაფერი დაწერილია კონტეინერისთვის, რომელსაც მეხსიერების მკაცრი ლიმიტი და CPU-ის მკაცრი წილი აქვს - ასე გამოიყურება $4-დან $7-მდე Python გეგმა - მაგრამ ნებისმიერ პატარა VPS-ზეც მუშაობს. თუ ჯერ სრულ საფუძვლებს არკვევ, ანუ როგორ ავიტანოთ აპლიკაცია ონლაინ, დაიწყე FastAPI-ის ან Flask-ის დეპლოითი ან Django-ის პროდაქშენში დეპლოითი და მერე დაბრუნდი.
რას უშვებენ სინამდვილეში gunicorn და uvicorn#
gunicorn პროცესების მენეჯერია, რომელიც შემთხვევით HTTP-ზეც ლაპარაკობს. არის ერთი master პროცესი, რომელიც მოთხოვნას არასდროს ეხება. ის უსმენს სოკეტს, წარმოშობს worker-ებს, უთვალთვალებს მათ და ცვლის იმას, ვინც მოკვდა ან გაჭედა. თითო worker სრულფასოვანი Python ინტერპრეტატორია შენი აპლიკაციის საკუთარი ასლით, საკუთარი იმპორტირებული მოდულებით, ბაზასთან საკუთარი კავშირებით და საკუთარი cache-ებით. worker-ებს შორის გაზიარებულია მხოლოდ მოსასმენი სოკეტი, ამიტომ მოდულის დონის ცვლადში მდგომი მთვლელი თითო worker-ში ცალ-ცალკე ითვლის, მეხსიერებაში მყოფი cache კი სინამდვილეში N ცალკე cache-ია.
uvicorn ASGI აპლიკაციების სერვერია (FastAPI, Starlette, Django asgi.py-ით). uvicorn-ის ერთი პროცესი ერთ event loop-ს უშვებს და ბევრ მოთხოვნას ერთდროულად ემსახურება, სანამ შენი კოდი await-ს იყენებს და არ ბლოკავს. --workers N-ით ის პატარა სუპერვაიზორი ხდება, gunicorn-ის master-ის მსგავსი: უშვებს N პროცესს, თითოს საკუთარი loop-ით, და ხელახლა ამუშავებს ყველას, ვინც დასრულდა.
აქედან ორი დასკვნა გამომდინარეობს და ქვემოთ ყველა გადაწყვეტილება მათზეა დამოკიდებული. პირველი: თითო worker მეხსიერებაში აპლიკაციის სრულ ასლად ჯდება, ამიტომ მეხსიერების ლიმიტიან გეგმაზე worker-ების რაოდენობა ბიუჯეტია და არა დასარეგულირებელი სახელური. მეორე: worker-ის შიგნით პარალელურობა worker-ის კლასზეა დამოკიდებული: sync worker ერთდროულად ზუსტად ერთ მოთხოვნას ამუშავებს, thread-იანი - თითო thread-ზე თითოს, ასინქრონული - იმდენს, რამდენსაც მისი event loop მონაცვლეობით ემსახურება.
gunicorn-ის ნაგულისხმევი ბრძანება უსმენს 127.0.0.1:8000-ს და უშვებს ერთ sync worker-ს. კონტეინერის შიგნით ეს მისამართი გარედან მიუწვდომელია, ამიტომ ნებისმიერ ჰოსტზე პირველი ცვლილებაა --bind 0.0.0.0:<port>, სადაც პორტი გარემოდან მოდის. RE:NODE-ის Python სერვერზე გამოყოფილი პორტი Startup ტაბზე ჩანს; მეზობელ სტატიებში მისთვის SERVER_PORT გამოიყენება, ამიტომ სანამ ხაზს დააკოპირებ, შეამოწმე სახელი შენს ტაბზე.
worker-ების რაოდენობის არჩევა#
დოკუმენტაციის (2 x cores) + 1 ვარაუდობს ბირთვებს, რომლებიც შენია, და მეხსიერებას, რომელიც არ დაგიანგარიშებია. პატარა გეგმაზე არც ერთი სწორი არ არის. CPU-ის წილი მკაცრი შეზღუდვაა: ნახევარბირთვიანი გეგმა პროცესების მთელ ხეს ნახევარი ბირთვის დროს აძლევს, რამდენი worker-იც არ უნდა გაუშვა. დამატებითი worker-ები აპლიკაციას არ აჩქარებს, ისინი იმავე ნახევარ ბირთვს მეტ ნაწილად ყოფენ და მეხსიერების ანგარიშს ამრავლებენ.
ორი ლიმიტიდან მიმდევრობით იმუშავე.
- CPU. პროცესორზე დამოკიდებული აპლიკაციისთვის
ceil(cpu share)-ზე მეტი worker არაფერს იძლევა. აპლიკაციისთვის, რომელიც უმეტესად ბაზას ან გარე API-ს ელოდება, worker-ების რაოდენობაზე მნიშვნელოვანია ერთ worker-ზე მიმდინარე მოთხოვნების რაოდენობა, და სწორედ ამისთვის არსებობს thread-ები და async. - მეხსიერება. გაზომე ერთი worker-ის resident ზომა რეალური ტრაფიკის შემდეგ, გაამრავლე worker-ების რაოდენობაზე, დაუმატე master (რამდენიმე ათეული MB) და ლიმიტის სულ მცირე მეოთხედი თავისუფალი დატოვე მოთხოვნების პიკებისა და ინტერპრეტატორის საკუთარი ზრდისთვის.
$ ps -o pid,rss,cmd -C gunicorn PID RSS CMD 4121 31204 gunicorn: master [app.main:app] 4128 96740 gunicorn: worker [app.main:app] 4129 97112 gunicorn: worker [app.main:app]RSS კილობაიტებშია. გაუშვი რამდენიმე ასეული მოთხოვნის შემდეგ და არა გაშვებისთანავე - worker, რომელსაც ჯერ ზარმაცად ჩასატვირთი მოდულები არ დაუიმპორტებია და cache-ები არ გაუთბია, ერთი საათის შემდეგ რაც იქნება, ზედ მასზე პატარა ჩანს.
Python ხაზის საწყისი წერტილი, თითო worker-ზე დაახლოებით 100 MB-ის ვარაუდით, რაც ტიპურია FastAPI-ის ან Flask-ის აპლიკაციისთვის ORM-ითა და ბაზის დრაივერით (შენი შეიძლება ორჯერ მეტი იყოს):
| გეგმა | მეხსიერება / CPU წილი | საწყისი კონფიგურაცია |
|---|---|---|
| Starter | 1 GB / 0.5 core | 1 worker, 4 thread ან async |
| Standard | 2 GB / 1 core | 2 worker, თითოზე 2-4 thread |
| Advanced | 4 GB / 1.5 cores | 2-3 worker |
| Pro | 6 GB / 2 cores | 3-4 worker |
მთელი კიბე Python-ის გეგმების გვერდზეა, თვეში $4-დან. შეამჩნიე, რომ უფრო დიდ ხაზებზე პირველი ლიმიტი იშვიათად მეხსიერებაა - ის CPU-ის წილია. ბირთვების ერთნიშნა წილზე ორი-სამი worker-ის შემდეგ ძირითადად კონტექსტის გადართვას ყიდულობ.
ერთის ნაცვლად ორი worker-ის არჩევის კიდევ ერთი მიზეზია, თუ შეგიძლია: ერთადერთი worker გაჭედვის ერთადერთი წერტილია. თუ ის ნელ მოთხოვნას, garbage collector-ის პაუზას ან გადატვირთვას დაეჯახა, სერვისი მთელი ამ დროის განმავლობაში მიუწვდომელია. ორი worker ნიშნავს, რომ მეორე განაგრძობს პასუხს, სანამ პირველი გამოცოცხლდება. 1 GB გეგმაზე ასეთი კომპრომისი ხშირად ხელმისაწვდომი არ არის და სწორედ მაშინ იმართლებენ თავს thread-ები ან async.
worker-ის კლასები: sync, gthread და uvicorn#
gunicorn-ში worker_class განსაზღვრავს, როგორ ამუშავებს worker მოთხოვნებს, და რაოდენობის შემდეგ ეს ყველაზე დიდი ბერკეტია.
| worker-ის კლასი | პარალელურობა თითო worker-ზე | რისთვის |
|---|---|---|
sync (ნაგულისხმევი) | 1 მოთხოვნა | Flask ან Django, CPU-ზე დამოკიდებული, ბევრი worker |
gthread | threads მოთხოვნა | Flask ან Django, ბაზის მომლოდინე |
uvicorn_worker.UvicornWorker | Event loop | FastAPI, Starlette, Django ASGI |
`sync` მარტივი და წინასწარ გასააზრებელია და სწორედ მას სჭირდება ყველაზე მეტად ბუფერიზაციის მქონე proxy წინ: sync worker დაკავებულია მთელი იმ დროის განმავლობაში, სანამ კლიენტი თავის მოთხოვნას დაასრულებს გაგზავნას, ამიტომ ნელი კავშირი მთელ worker-ს ბლოკავს. proxy-ს უკან, რომელიც მოთხოვნას სრულად კითხულობს გადაგზავნამდე, ეს პრობლემა არ არის.
`gthread` თითოეულ worker-ს thread-ების pool-ს აძლევს. ერთ პროცესში ოთხი thread ოთხ მოთხოვნას ემსახურება, რომლებიც ყველა ბაზას ელოდება, დაახლოებით ერთი პროცესის მეხსიერების ფასად, და ეს ყველაზე ხელსაყრელი პარამეტრია I/O-ზე დამოკიდებული WSGI აპლიკაციისთვის პატარა გეგმაზე:
$ gunicorn wsgi:app --bind 0.0.0.0:8000 \ --workers 2 --worker-class gthread --threads 4ქვედა დონეზე არითმეტიკას მიაქციე ყურადღება: ორ worker-ს ოთხ-ოთხი thread-ით ერთდროულად რვა კავშირის დაჭერა შეუძლია. pool-ის ზომა და ბაზის გეგმა ამ რიცხვს მოარგე და არა worker-ების რაოდენობას. --threads-ის 1-ზე მეტად დაყენება ნაგულისხმევ sync კლასთან ერთად ახლანდელ ვერსიებში კლასს თავისით gthread-ზე ცვლის, მაგრამ პირდაპირ ჩაწერა ვარაუდს გვარიდებს.
uvicorn-ის worker-ები gunicorn-ის ქვეშ gunicorn-ის ზედამხედველობას uvicorn-ის event loop-თან აერთიანებს:
$ gunicorn app.main:app -k uvicorn_worker.UvicornWorker \ --bind 0.0.0.0:8000 --workers 2ამისთვის საჭიროა uvicorn-worker პაკეტი. ძველი uvicorn.workers.UvicornWorker, რომელიც uvicorn-ის შიგნით იყო, მოძველებულად არის გამოცხადებული და შენს მიერ დაფიქსირებულ ვერსიაში შეიძლება უკვე აღარ არსებობდეს, ამიტომ ძველი სახელმძღვანელოდან ხაზის დაკოპირებამდე შეამოწმე pip show uvicorn. ალტერნატივად gunicorn სრულად შეგიძლია გამოტოვო:
$ uvicorn app.main:app --host 0.0.0.0 --port ${SERVER_PORT:-8000} --workers 2რომელი ავირჩიოთ? uvicorn-ის საკუთარი --workers ერთი დამოკიდებულებით ნაკლებია და პატარა აპლიკაციისთვის სავსებით საკმარისია. gunicorn ამატებს ექსპლუატაციის დახვეწილ შესაძლებლობებს: max_requests jitter-ით, თითო worker-ის timeout-ებს, რომლებიც გაჭედილ პროცესს კლავენ, post_fork hook-ებს, დიდი ხნის დამკვიდრებულ სიგნალების პროტოკოლს და ლოგების უფრო ზუსტ მართვას. uvicorn ბოლო რელიზებში ამ ნაკლს ავსებს (იხილე გადატვირთვის განყოფილება), მაგრამ თუ დარწმუნებული უნდა იყო, რომ გაჭედილი worker მოკვდება, gunicorn ჯერჯერობით უფრო საიმედო სუპერვაიზორია.
წესი async worker-ებისთვის: არასოდეს დაბლოკო event loop. ბაზის სინქრონული გამოძახება ან requests.get async def handler-ში ამ პროცესის ყველა დანარჩენ მოთხოვნას აჩერებს, სანამ არ დაბრუნდება. დაბლოკილი loop შეცდომას არ აგდებს. ის გამოვლინდება შეფერხებად, gunicorn-ის ქვეშ კი საბოლოოდ WORKER TIMEOUT-ად, რადგან loop-ს worker-ის heartbeat-იც master-ისკენ მიაქვს.
timeout-ები, რომლებიც მოთხოვნებს კლავენ#
gunicorn-ის timeout (ნაგულისხმევი 30 წამი) ყველაზე ხშირად გაუგებრად აქვთ გააზრებული. ეს მოთხოვნის timeout არ არის. ეს ის მაქსიმალური დროა, რომლის განმავლობაშიც worker-ს შეუძლია master-ს არ შეატყობინოს, რომ ცოცხალია. sync worker-ებისთვის heartbeat მოთხოვნებს შორის ხდება, ამიტომ ის თითო მოთხოვნის ლიმიტივით იქცევა: 31 წამიანი მოთხოვნა worker-ს სიცოცხლე უჯდება. ლოგის ხაზი არაფერში აგერევა:
[2026-10-17 09:14:02 +0000] [4121] [CRITICAL] WORKER TIMEOUT (pid:4128)[2026-10-17 09:14:03 +0000] [4128] [INFO] Worker exiting (pid: 4128)[2026-10-17 09:14:03 +0000] [4140] [INFO] Booting worker with pid: 4140gthread და async worker-ებისთვის heartbeat სხვა ადგილიდან მოდის, ამიტომ ნელი მოთხოვნა თავისით timeout-ს არ იწვევს; იწვევს დაბლოკილი მთავარი thread ან event loop. ეს განსხვავება გამართვისას მნიშვნელოვანია: async worker-ზე WORKER TIMEOUT ნიშნავს, რომ loop-ი რამემ გაყინა და არა იმას, რომ ერთი მოთხოვნა დიდხანს გაგრძელდა.
რომელი timeout-ები მონაწილეობს და ვის ეკუთვნის:
| პარამეტრი | ნაგულისხმევი | რას ზღუდავს |
|---|---|---|
gunicorn timeout | 30 | worker-ის დუმილი მის მოკვლამდე |
gunicorn graceful_timeout | 30 | რამდენ ხანს შეუძლია გამორთვის პროცესში მყოფ worker-ს მოთხოვნების დასრულება |
gunicorn keepalive | 2 | keep-alive კავშირის უმოქმედობა წამებში |
uvicorn --timeout-keep-alive | 5 | keep-alive კავშირის უმოქმედობა წამებში |
uvicorn --timeout-graceful-shutdown | არ აქვს | კავშირების ლოდინი გამორთვისას |
timeout-ის 120-მდე აწევა ერთი ნელი რეპორტის გამო გავრცელებული არასწორი პასუხია. ის ასევე ყველა ჭეშმარიტად გაჭედილ worker-ს ორი წუთი უმოქმედოდ დგომის უფლებას აძლევს იმ გეგმაზე, სადაც სულ ორი მათგანია. უკეთესი ვარიანტები თანმიმდევრობით:
- ნელი რამ სწრაფი გახადე: ინდექსი, პატარა მოთხოვნა, cache.
- გამოიტანე მოთხოვნიდან: დააბრუნე
202 Accepted, სამუშაო ფონურ ამოცანაში შეასრულე და შედეგი მერე გამოკითხე. - თუ მართლა სინქრონული უნდა იყოს, ასწიე
timeoutდა გვერდით დაწერე კომენტარი, რატომ, და დატოვე thread-ები ან დამატებითი worker, რომ ერთმა გრძელმა მოთხოვნამ მთელი აპლიკაცია თან არ წაიყოლოს.
uvicorn-ს მოთხოვნის timeout საერთოდ არ აქვს. თუ გარე გამოძახება გაჭედა, handler-იც გაჭედავს, ამიტომ timeout-ები დააყენე თითოეულ გამავალ კლიენტზე (httpx.AsyncClient(timeout=10), ბაზის statement_timeout) და ნუ ელი, რომ სერვერი გიხსნის. აპლიკაციის წინ მდგარ proxy-საც საკუთარი read timeout აქვს, ჩვეულებრივ gunicorn-ის ნაგულისხმევზე გრძელი; თუ proxy პირველი დანებდა, კლიენტი 504-ს დაინახავს, worker კი კიდევ რამდენიმე წამს დაკავებული იქნება.
და ბოლოს, worker_tmp_dir. gunicorn heartbeat-ის ფაილებს დროებით დირექტორიაში წერს, ხოლო კონტეინერებში, სადაც ის ნელ ან overlay ფაილურ სისტემაზეა, ეს ჩაწერები შეიძლება დაიბლოკოს და ცრუ timeout-ები გამოიწვიოს. დოკუმენტირებული გამოსწორება მეხსიერებაში მდებარე დირექტორიაა:
$ gunicorn app.main:app --worker-tmp-dir /dev/shmPreload, hook-ები და fork-ის ხაფანგი#
ნაგულისხმევად თითო worker შენს აპლიკაციას თავად აიმპორტებს fork-ის შემდეგ. --preload-ით (ან preload_app = True-ით) master მას ერთხელ აიმპორტებს worker-ების წარმოშობამდე. აქედან ორი რამ გამომდინარეობს.
რას იღებ. გაშვების შეცდომები დაუყოვნებლივ master-ში ჩნდება, იმის ნაცვლად, რომ ყველა worker ციკლში ვერ ახერხებდეს ჩატვირთვას. worker-ები უფრო სწრაფად იწყებენ, იმპორტირებული კოდის გვერდები კი copy-on-write-ით პროცესებს შორის შეიძლება გაზიარდეს, ამიტომ საერთო მეხსიერება N დამოუკიდებელ იმპორტზე ნაკლებია.
რაზე არ უნდა დაგეყრდნო. Python-ის reference counting ობიექტებში წერს ყოველ გამოყენებაზე, რაც გვერდებს ამურდავს და დროთა განმავლობაში გაზიარებას წყვეტს. დანაზოგი რეალურია, მაგრამ ნაწილობრივი: ელოდე, რომ worker-ების დაბერებასთან ერთად ის მცირდება. სანამ გადაწყვეტ, ღირს თუ არა სირთულე, გაზომე resident მეხსიერება --preload-ით და მის გარეშე.
რას იხდი. ყველაფერი, რაც იმპორტისას იქმნება, ერთხელ იქმნება და ყველა worker-ი მემკვიდრეობით იღებს. კონფიგურაციისთვის ეს უვნებელია, კავშირებისთვის კი კატასტროფული:
- ბაზის engine ან კავშირი, რომელიც იმპორტისას გაიხსნა, ახლა რამდენიმე პროცესს ერთსა და იმავე სოკეტზე აქვს გაზიარებული. მოთხოვნები ერევა და იღებ შეცდომებს, რომლებიც მხოლოდ დატვირთვისას და მხოლოდ ზოგჯერ ჩნდება.
- იმპორტისას გაშვებული thread-ები მხოლოდ master-ში არსებობს. worker-ებს ისინი არ ერგებათ.
- შემთხვევითი რიცხვების seed-ები და პროცესის სხვა მდგომარეობა კოპირდება, ამიტომ worker-ები ერთნაირად იქცევიან იქ, სადაც განსხვავებულს ელოდი.
გამოსავალია კავშირები fork-ის შემდეგ შექმნა, hook-ში. gunicorn-ის კონფიგურაციის ფაილი ჩვეულებრივი Python-ია, ამიტომ hook-ები ფუნქციებია:
def post_fork(server, worker): # runs in each worker after the fork, before it serves anything from app.db import engine engine.dispose(close=False)engine.dispose(close=False) არის SQLAlchemy 2.x-ის გზა, master-ისგან მემკვიდრეობით მიღებული pool გადააგდო იმ კავშირების დახურვის გარეშე, რომლებსაც master ჯერ კიდევ ფლობს. ნებისმიერი სხვა კლიენტური ბიბლიოთეკისთვის სქემა იგივეა: გადააგდე ის, რაც master-მა შექმნა, და თითოეულ worker-ს თავისი გააღებინე. თუ შენი აპლიკაცია კავშირებს პირველ გამოყენებაზე ზარმაცად ხსნის, როგორც ORM-ების უმეტესობა, preload ჩვეულებრივ ისედაც უსაფრთხოა.
preload-ს კიდევ ერთი ურთიერთქმედება აქვს, რომელზეც დეპლოისას ბევრი ფეხს ითრევს და რომელიც შემდეგ განყოფილებებშია აღწერილი: --preload-ით HUP სიგნალი კონფიგურაციას თავიდან კითხულობს, მაგრამ კოდს არ აიმპორტებს ხელახლა, რადგან კოდი master-ში ჩაიტვირთა, master კი არ გადატვირთულა.
Keepalive და proxy აპლიკაციის წინ#
HTTP keep-alive კლიენტს საშუალებას აძლევს ერთი TCP კავშირი ბევრი მოთხოვნისთვის გამოიყენოს. პატარა აპლიკაციისთვის მნიშვნელოვანია კავშირი proxy-სა და შენს სერვერს შორის. RE:NODE-ის აპლიკაციებისა და ვებ-ჰოსტინგის გეგმებში შედის reverse-proxy სლოტი, რომელიც TLS-ს წყვეტს და მარტივ HTTP-ს შენს პორტზე გადასცემს, კლიენტის რეალურ მისამართს კი X-Forwarded-For-ში ატარებს. ზოგადი სურათისთვის იხილე რას აკეთებს reverse proxy.
ორი პარამეტრი:
- gunicorn-ის `keepalive`, ნაგულისხმევი 2 წამი, მხოლოდ
gthreadდა async კლასის worker-ებზე მოქმედებს.syncworker ყოველი პასუხის შემდეგ კავშირს ხურავს, რადგან ვერ იტანს უმოქმედო კავშირის ლოდინს, როცა ტრაფიკს ის ერთადერთი ემსახურება. - uvicorn-ის `--timeout-keep-alive`, ნაგულისხმევი 5 წამი.
კლასიკური შეცდომა race-ია. proxy შენს აპლიკაციასთან უმოქმედო კავშირს ინახავს და მასზე მოთხოვნას სწორედ იმ წამს აგზავნის, როცა შენი სერვერი წყვეტს, რომ საკმარისად დიდხანს უმოქმედო იყო, და ხურავს. proxy reset-ს ხედავს და 502-ს აბრუნებს მოთხოვნაზე, რომელიც შენს კოდამდე არასოდეს მისულა. ეს წყვეტილად ხდება, დაბალი ტრაფიკისას უფრო ხშირად, ვიდრე მაღალისას, და აპლიკაციის ლოგში არაფერია. წესი ასეთია: აპლიკაციის keep-alive timeout უნდა აღემატებოდეს proxy-ს უმოქმედობის timeout-ს აპლიკაციისკენ. გაიგე, რამდენ ხანს ინახავს შენი proxy უმოქმედო კავშირებს აპლიკაციისკენ; proxy სლოტის მნიშვნელობას ეს სტატია ვერ გეტყვის, ამიტომ გაზომე. თუ აპლიკაციის სუფთა ლოგის პირობებში იშვიათ 502-ებს ხედავ, ექსპერიმენტად keepalive დაახლოებით 10-მდე აწიე და დააკვირდი, შეწყდება თუ არა.
ძალიან მაღალ მნიშვნელობას ფასი აქვს: ყოველი უმოქმედო კავშირი ადგილს იკავებს. gthread worker-ში გახსნილი კავშირი thread-ს იკავებს. ოთხი thread-ითა და უხვი keep-alive-ით ოთხმა უმოქმედო კავშირმა worker შეიძლება მეხუთე კლიენტისთვის უხელოდ დატოვოს. მნიშვნელობა მოკრძალებული იყოს, 5-დან 10 წამამდე, thread-ების რაოდენობა კი proxy-დან მოსალოდნელი კავშირების რაოდენობაზე მეტი.
მეორე ნახევარი გადაცემული header-ების ნდობაა. ორივე სერვერი X-Forwarded-For-სა და X-Forwarded-Proto-ს მხოლოდ სანდო მისამართებიდან სწამს: gunicorn forwarded_allow_ips-ით (გარემოს ცვლადი FORWARDED_ALLOW_IPS), uvicorn --forwarded-allow-ips-ით. ორივეს ნაგულისხმევი 127.0.0.1 აქვს, რაც სხვა მისამართიდან დაკავშირებულ proxy-ს არ ემთხვევა. შედეგად ყოველი მოთხოვნა proxy-დან და უბრალო HTTP-ით მოსულად გამოიყურება, ამიტომ გადამისამართების URL-ები http://-ით იგება, ლოგებში კი ერთი ვიზიტორი ჩანს. მნიშვნელობა * ამას ასწორებს და დასაშვებია მხოლოდ მაშინ, თუ აპლიკაციის პორტამდე proxy-ს გარდა სხვა გზით მისვლა შეუძლებელია. თუ დარწმუნებული არ ხარ, სიაში proxy-ს მისამართი ჩაწერე და ნახე, რომელ peer მისამართს ლოგავს შენი სერვერი.
max-requests და ნელი გაჟონვები#
max_requests gunicorn-ს ეუბნება, worker მოთხოვნების მოცემული რაოდენობის დამუშავების შემდეგ გაითიშოს და ახლით შეცვალოს. ეს უხეში, მაგრამ ეფექტური ინსტრუმენტია შენს კოდში ან შენთვის უკონტროლო დამოკიდებულებაში ნელი გაჟონვის წინააღმდეგ, აგრეთვე თანდათანობითი ფრაგმენტაციის წინააღმდეგ, რის გამოც დიდხანს მომუშავე Python პროცესები დროთა განმავლობაში იზრდებიან. ყოველთვის jitter-თან გამოიყენე:
max_requests = 1000max_requests_jitter = 100jitter თითოეული worker-ის ლიმიტს 0-დან 100-მდე შემთხვევით რიცხვს ამატებს, რომ ყველა ერთსა და იმავე მოთხოვნაზე არ მიადგეს ლიმიტს. მის გარეშე ორ-worker-იან გეგმაზე ორივე თითქმის ერთდროულად შეიძლება გადამუშავდეს და წარმოიქმნას ფანჯარა, როცა მომსახურებას არავინ ეწევა. შესაცვლელი worker ჯერ მიმდინარე მოთხოვნას ასრულებს და მერე გადის, ამიტომ მომხმარებლები შეცდომას ვერ ხედავენ; უარეს შემთხვევაში ცოტა უფრო ნელ პასუხს ხედავენ, სანამ ახალი worker ჩაიტვირთება.
რიცხვი საკუთარი მეხსიერების გრაფიკიდან აირჩიე და არა ბლოგიდან. ერთი დღის განმავლობაში დააკვირდი Console ტაბის მეხსიერების გრაფიკს. თუ worker-ის resident ზომა მოთხოვნების რაოდენობასთან ერთად სტაბილურად იზრდება, max_requests ისე დააყენე, რომ worker იმ წერტილამდე დიდი ხნით ადრე გაითიშოს, სადაც გეგმას მარაგი ამოეწურება. თუ მეხსიერება მოსწორებულია, max_requests არ გჭირდება, გადამუშავება კი უბრალოდ გათბობილ cache-ებს და იმპორტზე გაწეულ შრომას აგდებს.
uvicorn-ში იგივეს --limit-max-requests N ჰქვია: პროცესი N მოთხოვნის შემდეგ სრულდება. gunicorn-ის ზედამხედველობით ის ხელახლა წარმოიშობა. uvicorn-ის საკუთარი --workers-ით ახალი რელიზები მას ასევე აღადგენენ, ძველები კი - არა, და ყოველი გადამუშავების შემდეგ სერვისში ნაკლები პროცესი დარჩებოდა. სანამ ამას დაეყრდნობი, შეამოწმე შენი დაფიქსირებული რელიზი და თუ დადასტურება არ შეგიძლია, gunicorn ამჯობინე.
max_requests გაჟონვას მალავს და არ ასწორებს. თუ მას მიმართავ, ჩაიწერე რატომ და მიზეზი მაშინ მოძებნე, როცა დრო გექნება. მეხსიერების მხარეს აგრძელებს Node-ის მეხსიერების ლიმიტები იმაზე, როგორ გამოიყურება ეს Node-ში, ხოლო სათამაშო სერვერების მეხსიერების გაჟონვები აღწერს, როგორ გამოვარჩიოთ გაჟონვა ნორმალური ზრდისგან.
გადატვირთვა მოთხოვნების დაკარგვის გარეშე#
gunicorn-ის master სიგნალებზე რეაგირებს და ეს მისი ექსპლუატაციის მთელი ინტერფეისია:
| სიგნალი | ეფექტი |
|---|---|
HUP | კონფიგურაციას თავიდან კითხულობს, ახალ worker-ებს უშვებს, ძველებს ნაზად ხსნის |
TERM | ნაზი გამორთვა, ელოდება graceful_timeout-მდე |
INT, QUIT | დაუყოვნებელი გამორთვა |
TTIN, TTOU | ამატებს ან აკლებს ერთ worker-ს |
USR1 | ლოგ-ფაილებს თავიდან ხსნის (როტაციის შემდეგ) |
USR2 | ძველის გვერდით ახალ master-ს უშვებს |
HUP დეპლოისთვის განკუთვნილი სიგნალია. ახალი worker-ები ახალი კოდითა და კონფიგურაციით იწყებენ მანამ, სანამ ძველებს გაჩერებას უბრძანებენ, ძველები კი მიმდინარეს ასრულებენ, ასე რომ, მიმდინარე მოთხოვნები სრულდება. შენს მანქანაზე:
$ gunicorn app.main:app -c gunicorn.conf.py --pid /run/myapp.pid$ kill -HUP $(cat /run/myapp.pid)systemd-ის ქვეშ, რაც VDS-ზე გონივრული გზაა, ეს reload მოქმედებად გამოაჩინე:
[Service]User=appWorkingDirectory=/srv/myappExecStart=/srv/myapp/.venv/bin/gunicorn app.main:app -c gunicorn.conf.pyExecReload=/bin/kill -s HUP $MAINPIDKillMode=mixedTimeoutStopSec=40მაშინ systemctl reload myapp გაგზავნის HUP-ს. TimeoutStopSec უნდა აღემატებოდეს graceful_timeout-ს, თორემ systemd პროცესს მაშინ მოკლავს, როცა worker-ები ჯერ კიდევ ამთავრებენ. სანამ ამაზე დაეყრდნობი, რამდენიმე დათქმა უნდა იცოდე:
- `preload_app`-ით `HUP` ახალ კოდს არ იღებს. კოდი master-ში ცხოვრობს. preload-ისას ახალი კოდის გასაშვებად გადატვირთე ან გამოიყენე
USR2-ით გადაცემა (ახალი master გაუშვი, მერე ძველს გაუგზავნეWINCHდაQUIT), რაც იმდენად რთულია, რომ პატარა განთავსებების უმეტესობა გადატვირთვას ირჩევს. - გაფუჭებულმა ახალმა რელიზმა სერვისი შეიძლება დააგდოს. თუ ახალი კოდი არ აიმპორტება, ახალი worker-ები ვერ ჩაიტვირთებიან (
Worker failed to boot), ძველები კი გაითიშება. სიგნალის გაგზავნამდე გაუშვიpython -c "import app.main"ანgunicorn --check-config. - ახალი დამოკიდებულებები წინასწარ უნდა დაინსტალირდეს.
HUPარ უშვებსpip-ს. იხილე Python-ის requirements და virtualenv-ები. - ბაზის სქემის ცვლილებები შენი საზრუნავია. ძველი და ახალი worker-ები რამდენიმე წამს გვერდიგვერდ მუშაობენ, ამიტომ მიგრაცია, რომელიც ძველ კოდს ამტვრევს, მას გააფუჭებს.
uvicorn-მა მრავალპროცესიან რეჟიმში მსგავსი ქცევა შეიძინა: ახალი რელიზები worker-ებს SIGHUP-ზე სათითაოდ რესტარტავენ და SIGTTIN-სა და SIGTTOU-ზე worker-ებს ამატებენ ან აკლებენ. uvicorn-ის სხვა დეტალების მსგავსად, ეს დაადასტურე იმ ვერსიის release notes-ში, რომელიც დააფიქსირე.
პანელიან ჰოსტზე სურათი სხვაა. პანელის Restart ღილაკი სერვერის პროცესის სრული გაჩერება და გაშვებაა, რაც დეპლოის სუფთა, მარტივი და უსაფრთხო გზაა, და ის კავშირებს გაწყვეტს იმ რამდენიმე წამით, რაც აპლიკაციას ჩასატვირთად სჭირდება. პანელის კონსოლი ტექსტს პროცესში წერს და სიგნალებს არ უგზავნის, ამიტომ იქიდან HUP-ს ნუ ელი; სიგნალის გაგზავნის შესაძლებლობა ჩვენ არ დაგვიდასტურებია. პატარა გეგმაზე შეფერხების გარეშე დეპლოისთვის წაიკითხე დეპლოი შეფერხების გარეშე პატარა სერვერზე, ხოლო იმაზე, რა ევალება გამორთვის პროცესში მყოფ პროცესს თავისი კლიენტების წინაშე - ნაზი გამორთვა და health-შემოწმებები. თუ Git-დან დეპლოი ჩართულია, push სერვერს რესტარტავს, ამიტომ იგივე ჩატვირთვის შუალედი მოქმედებს.
სრული კონფიგურაცია პატარა გეგმისთვის#
პარამეტრები ჩააწერე აპლიკაციის სამუშაო დირექტორიაში gunicorn.conf.py-ში, სადაც gunicorn მას ნაგულისხმევად ეძებს, რომ გაშვების ბრძანება მოკლე დარჩეს, პარამეტრები კი git-ში review-ს გადიოდეს, როგორც ყველაფერი დანარჩენი. ეს შეესაბამება FastAPI აპლიკაციას Standard გეგმაზე (2 GB, 1 core):
import osbind = f"0.0.0.0:{os.environ.get('SERVER_PORT', '8000')}"workers = int(os.environ.get("WEB_CONCURRENCY", "2"))worker_class = "uvicorn_worker.UvicornWorker"timeout = 30graceful_timeout = 30keepalive = 10max_requests = 1000max_requests_jitter = 100worker_tmp_dir = "/dev/shm"forwarded_allow_ips = os.environ.get("FORWARDED_ALLOW_IPS", "127.0.0.1")accesslog = "-"errorlog = "-"loglevel = "info"capture_output = Trueგაშვების ბრძანება მაშინ უბრალოდ gunicorn app.main:app -c gunicorn.conf.py-ია. ლოგები სტანდარტულ გამოსავალსა და შეცდომების გამოსავალზე მიდის, საიდანაც მათ Console ტაბი კითხულობს; როტაციას არაფერი სჭირდება. იმავე გეგმაზე Flask-ის ან Django-ს აპლიკაციისთვის ჩაწერე worker_class = "gthread", დაამატე threads = 4 და uvicorn-ის ხაზი ამოაგდე.
ფაილი სერვერის გაშვებამდე შეამოწმე და მაშინაც, როცა არ ირთვება:
$ gunicorn app.main:app -c gunicorn.conf.py --check-config$ gunicorn app.main:app -c gunicorn.conf.py --print-config | grep -E "^(workers|timeout)"პირველი ბრძანება პარამეტრების სახელებში შეცდომებს და იმპორტის შეცდომებს იჭერს; მეორე ბეჭდავს იმას, რაც gunicorn-მა სინამდვილეში გამოითვალა გარემოს ცვლადებისა და ბრძანების ხაზის გამოყენების შემდეგ. ბრძანების ხაზის flag-ები ფაილს გადაფარავს და ასე ხვდები იმ პარამეტრის გამართვაში, რომელიც „აშკარად არის კონფიგურაციაში" და არ მოქმედებს.
პრობლემების აღმოფხვრა#
`WORKER TIMEOUT` მეორდება. worker-მა timeout-ზე მეტხანს იდუმა. sync worker-ებისთვის იპოვე ნელი მოთხოვნა (მოთხოვნის ხანგრძლივობა ჩაწერე %(D)s-ით access_log_format-ში). async worker-ებისთვის ეძებე ბლოკირებადი გამოძახებები async def-ის შიგნით. თუ ეს მხოლოდ ჩატვირთვისას ხდება, აპლიკაცია timeout-ზე დიდხანს აიმპორტება; ეხმარება preload და ასევე timeout-ის გაზრდა მხოლოდ გაშვებისთვის.
`Worker (pid:N) was sent SIGKILL! Perhaps out of memory?` რამემ worker gunicorn-ის მონაწილეობის გარეშე მოკლა, მეხსიერების ლიმიტიან გეგმაზე კი პირველი ეჭვმიტანილი მეხსიერების ლიმიტია. ლიმიტზე მიღწევისას ბირთვი პროცესებს აჩერებს და კონტეინერი სუფთად გადაიტვირთება და არა swap-ში გადადის. გამოსავალია ნაკლები worker, ნაკლები thread ან უფრო პატარა აპლიკაცია, სწორედ ამ თანმიმდევრობით. განმეორებითი გადატვირთვები შეიმჩნევა: საათში სამი ნიშნავს გაფრთხილებას სერვერის გვერდზე და ბილეთს, ექვსი კი - სერვერის შეჩერებას.
`Worker failed to boot` და master სრულდება. იმპორტი ჩავარდა. იგივე იმპორტი ხელით გაუშვი venv-ის ინტერპრეტატორით და წაიკითხე traceback. --preload-ის გარეშე ეს თითო worker-ზე ერთხელ ჩნდება, რაც ხმაურიანია, მაგრამ უვნებელი.
პერიოდული 502 proxy-დან აპლიკაციის სუფთა ლოგით. ზემოთ აღწერილი keep-alive race. ასწიე keepalive ან --timeout-keep-alive და დააკვირდი.
ყველაფერი ნელია 100% CPU-ზე და თავისუფალი მეხსიერების სიმრავლისას. CPU გზღუდავს. მეტი worker მდგომარეობას გააუარესებს. აპლიკაცია დაპროფილე, cache გამოიყენე ან გადადი გეგმაზე უფრო დიდი CPU წილით. 100% CPU-ზე მყოფი სერვერი ნელია, მაგრამ ამის გამო არასდროს ჩერდება.
ბაზის შეცდომები მხოლოდ დატვირთვისას. ან კავშირები fork-მდე გაიხსნა (--preload), ან worker-ების, thread-ებისა და pool-ის ზომის ნამრავლი ბაზის დასაშვებს აღემატება. დათვალე: worker-ები გამრავლებული thread-ებზე გამრავლებული pool-ის ზომაზე.
გადამისამართებები `http://`-ზე მიდის. სერვერი X-Forwarded-Proto-ს არ ენდობა. დააყენე forwarded_allow_ips, როგორც ზემოთ წერია, Django-ში კი - SECURE_PROXY_SSL_HEADER.
FAQ#
რამდენი gunicorn worker გავუშვა 1 GB გეგმაზე?
ერთი, რამდენიმე thread-ით ან async worker-ის კლასით, თუ გაზომვა არ აჩვენებს, რომ შენი აპლიკაცია თითო worker-ზე სულ დაახლოებით 200 MB-ზე ნაკლებს იკავებს. მეორე worker ერთჯერადი გაჭედვის მიმართ გამძლეობას იძლევა, მაგრამ 1 GB-სა და 0.5 core-ზე ხშირად მეტი მეხსიერება და CPU-ისთვის ბრძოლა უჯდება, ვიდრე ზოგავს.
საკმარისია uvicorn --workers-ით, თუ gunicorn მჭირდება?
პატარა აპლიკაციისთვის მხოლოდ uvicorn საკმარისია და ერთი დამოკიდებულებით ნაკლები აქვს. gunicorn აირჩიე, როცა უფრო ძლიერი ზედამხედველობა გჭირდება: timeout, რომელიც გაჭედილ worker-ს კლავს, max_requests jitter-ით, hook-ები და დიდი ხნის დამკვიდრებული სიგნალების ინტერფეისი.
უნდა ჩავრთო preload?
მხოლოდ თუ გაშვება მძიმეა ან გინდა იმპორტის შეცდომები master-ში დაინახო, და მხოლოდ მას მერე, რაც დარწმუნდები, რომ იმპორტისას არაფერი ხსნის კავშირს და არ უშვებს thread-ს. გაზომე მეხსიერება მასთან და მის გარეშე; copy-on-write-ის დანაზოგი Python-ში იმაზე ნაკლებია, ვიდრე ხალხს ელის.
რა არის გონივრული max_requests?
ისეთი, რომელიც worker-ს გათიშავს, სანამ მისი მეხსიერება შენს მარაგს მიაღწევს. ათასი ასიანი jitter-ით ჩვეულებრივი საწყისი წერტილია. თუ შენი მეხსიერების გრაფიკი მოსწორებულია, ის საერთოდ არ გჭირდება.
რატომ კლავს gunicorn მოთხოვნას, რომელიც 31 წამს გრძელდება?
იმიტომ, რომ timeout ნაგულისხმევად 30-ია და sync worker, რომელმაც ამ დრომდე არ უპასუხა, გაჭედილად ითვლება. ლიმიტის აწევამდე მოთხოვნა დააჩქარე ან ფონურ ამოცანაში გაიტანე და ასწიე მხოლოდ იმდენ ხანს, რამდენიც საჭიროა.
შეიძლება gunicorn-ის გადატვირთვა შეფერხების გარეშე პანელიან ჰოსტზე?
ჩვენ მიერ დადასტურებული სიგნალით - არა. Restart სუფთა გაჩერება და გაშვებაა რამდენიმეწამიანი შუალედით. თუ შუალედი დაუშვებელია, გაუშვი ორი ეგზემპლარი იმის უკან, რასაც ერთის ბრუნვიდან გამოყვანა შეუძლია, ან გადადი VDS-ზე, სადაც systemd შენს ხელშია და HUP-ის გაგზავნა შეგიძლია.


