პატარა Python ვებ-აპს თითო worker-ზე 100-დან 300 MB-მდე სჭირდება, worker-ებს კი ორიდან ოთხამდე გაუშვებ. ამიტომ ტიპური API-სთვის 1 GB საკმარისია, Django-სთვის ან მონაცემებით დატვირთული აპისთვის კი 2 GB არის ის ზომა, სადაც ჩვეულებრივ კომფორტულად ეტევა. ეს საწყისი ვარაუდია და არა პასუხი. მნიშვნელობა აქვს მხოლოდ ერთ რიცხვს: შენი აპის ერთი worker-ის resident მეხსიერებას, რეალური ტრაფიკის მომსახურების შემდეგ გაზომილს, გამრავლებულს worker-ების რაოდენობაზე, პლუს მარაგი პიკებისთვის. ეს პოსტი გაჩვენებს, როგორ მივიღოთ ეს რიცხვი, როგორ გადავაქციოთ gunicorn-ის ან uvicorn-ის worker-ების რაოდენობად და როგორ გავარკვიოთ, როდის არის სწორი გამოსავალი უფრო დიდი პლანი და არა კიდევ ერთი გამოსწორება.
საუბარი მხოლოდ მეხსიერებაზეა. აპის გასაშვებად, პორტზე მისაბმელად და proxy header-ებისთვის წაიკითხე FastAPI-ისა და Flask-ის გაშვება და Django-ს გაშვება production-ში. დამოკიდებულებების ჩასაკეცად იხილე Python-ის requirements და virtualenv. Node-ის ანალოგი ამ პოსტისა არის Node-ის მეხსიერების ლიმიტები.
რატომ ხარჯავს Python მეტ მეხსიერებას, ვიდრე ელოდები#
Python ყველაფერს ობიექტებად ინახავს. მთელი რიცხვი, მოკლე სტრიქონი, dict-ის გასაღები - თითოეულს აქვს header, reference count და ტიპის მაჩვენებელი, ამიტომ მილიონი პატარა მნიშვნელობის სია ათეულობით მეგაბაიტი ჯდება და არა იმ რამდენიმე, რასაც ნედლი მონაცემები გვაფიქრებინებდა. თვითონ ინტერპრეტატორი იმ სტანდარტული ბიბლიოთეკის მოდულებით, რომლებსაც შენი ფრეიმვორკი იმპორტირებს, დაახლოებით 10-20 MB-ს იკავებს, სანამ შენი კოდი რამეს გააკეთებს.
ამას ემატება სამი რამ, რომელსაც შენს ლოგიკასთან კავშირი არ აქვს:
- იმპორტირებული ბიბლიოთეკები. ცარიელი Flask აპი პატარაა. Django თავისი ORM-ით, ადმინკით და რამდენიმე აპით ბევრად დიდია. ყველაფერი, რაც NumPy-ს, pandas-ს, ბევრი მოდელის მქონე SQLAlchemy-ს, დიდი სქემების მქონე Pydantic-ს ან machine-learning ბიბლიოთეკას იმპორტირებს, მარტო იმპორტისას 100 MB-ს ან მეტს ამატებს.
- ალოკატორი. CPython პატარა ობიექტებს საკუთარი arena-დან გასცემს და ოპერაციულ სისტემას იშვიათად უბრუნებს. მოთხოვნის შემდეგ, რომელმაც დროებით დიდი სტრუქტურა ააწყო, პროცესი მეხსიერებას ინარჩუნებს და მოგვიანებით კვლავ იყენებს. გრაფიკი ადის და რჩება ზემოთ - ეს ნორმალურია და თავისთავად გაჟონვას არ ნიშნავს.
- მოთხოვნის სამუშაო მეხსიერება. 5 MB JSON body-ს დამუშავება, შაბლონის რენდერი, 20 000 სტრიქონიანი ბაზის მოთხოვნიდან პასუხის აწყობა - თითოეული მოკლევადიანი პიკია საბაზო დონის ზემოთ და სწორედ პიკი წყვეტს, ეტევი თუ არა.
პრაქტიკული დასკვნა: Python პროცესის მეხსიერება კიბეს ჰგავს. ის იზრდება გახურებისას, კვლავ იზრდება, როცა მძიმე endpoint პირველად გამოიძახება, და შემდეგ პლატოზე გადადის. სერვერის გაშვებისთანავე აღებული მაჩვენებელი ძალიან ოპტიმისტურია.
მიახლოებითი დიაპაზონები, რომ გქონდეს რასთან შეადარო. ეს ტიპური დაკვირვებებია და არა გარანტია; შენი დამოკიდებულებები მათ ძალიან ცვლის.
| აპის ტიპი | RSS თითო worker-ზე, გახურებული | შენიშვნა |
|---|---|---|
| Flask, რამდენიმე route | 40-80 MB | ძირითადად ინტერპრეტატორი და ფრეიმვორკი |
| FastAPI Pydantic მოდელებით | 60-120 MB | იზრდება მოდელების რაოდენობასთან ერთად |
| Django ORM-ითა და ადმინკით | 90-180 MB | მეტია, როცა ბევრი დაყენებული აპია |
| ზემოთ ნებისმიერი plus pandas | 150-350 MB | NumPy და pandas ბატონობენ |
| ყველაფერი, რაც ML მოდელს ტვირთავს | მოდელის ზომა plus 150 MB | ხშირად მთელი პლანია თავისთავად |
RSS, VSZ და რა უნდა გავზომოთ#
Linux პროცესზე რამდენიმე მეხსიერების მაჩვენებელს გვაძლევს და ზომის შესარჩევად სასარგებლოა მხოლოდ ერთი.
- VSZ (virtual size) ითვლის ყველა მისამართს, რომელიც პროცესმა დაამაპა, იმათაც, რომლებიც დაიკავა, მაგრამ არასდროს შეხებია. პატარა Python პროცესისთვის რამდენიმე გიგაბაიტიც შეიძლება იყოს და აქ არაფერს ნიშნავს.
- RSS (resident set size) არის ფიზიკური მეხსიერება, რომელსაც პროცესი ახლა RAM-ში ინახავს. სწორედ ეს უნდა წაიკითხო, ერთი დაჭერით, რომელიც ქვემოთაა.
- PSS (proportional set size) გაზიარებულ გვერდებს ყოფს იმ პროცესებს შორის, რომლებიც მათ იზიარებენ. ეს სამართლიანი რიცხვია, როცა რამდენიმე worker ერთი მშობლისგან არის fork-ით გაკეთებული.
დაჭერა ასეთია: RSS გაზიარებულ გვერდებს თითოეული პროცესისთვის სრულად ითვლის. ერთი master-იდან fork-ით გაკეთებული ოთხი gunicorn worker თითოეული, ვთქვათ, 120 MB RSS-ს აჩვენებს, მაგრამ ამის დიდი ნაწილი ერთი და იგივე მხოლოდ-წაკითხვადი კოდი და მონაცემებია, ფიზიკურ მეხსიერებაში ერთხელ შენახული. worker-ებზე RSS-ის შეკრება ჯამს ზრდის. PSS-ის შეკრება სწორ პასუხს იძლევა.
დაიწყე მარტივით. ნებისმიერ Linux shell-ში, VDS-ზე SSH სესიაშიც:
$ ps -eo pid,ppid,rss,vsz,cmd --sort=-rss | head -8rss კილობაიტებშია. gunicorn-ის master-ს და თითოეულ worker-ს ცალკე ხაზებად დაინახავ. ერთი პროცესისთვის ბირთვის საკუთარი ანგარიში ასეთია:
$ grep -E 'VmRSS|VmHWM' /proc/$(pgrep -o -f gunicorn)/statusVmRSS არის მიმდინარე resident ზომა, VmHWM კი ყველაზე მაღალი, რაც ოდესმე ყოფილა - high-water mark, ანუ რიცხვი, რომელიც გჭირდება, როცა წყვეტ, რა ჯდება უარესი შემთხვევა. მთელ worker-ჯგუფზე PSS-ის მისაღებად არითმეტიკას smem აკეთებს:
$ smem -P gunicorn -k -c "pid pss rss command"თუ აკლია, დააყენე შენი დისტრიბუციიდან. თუ ამას ვერ გაუშვებ - მართულ კონტეინერზე მხოლოდ კონსოლი გაქვს - გამოიყენე თვითონ აპი. ეს პატარა endpoint-ია ან ლოგის ხაზი, რომელიც საკუთარ რიცხვებს აცხადებს:
import osimport resourcedef memory_report() -> dict: # ru_maxrss is kilobytes on Linux (bytes on macOS) peak_mb = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024 with open("/proc/self/status") as f: rss_kb = next(int(l.split()[1]) for l in f if l.startswith("VmRSS")) return {"pid": os.getpid(), "rss_mb": rss_kb // 1024, "peak_mb": round(peak_mb)}გახსენი ის შიდა route-ზე ან ჩაწერე ლოგში ყოველ რამდენიმე წუთში. რადგან თითოეული worker ცალკე პროცესია, გამოტანილი pid გეუბნება, რომელმა worker-მა უპასუხა; რამდენჯერმე გამოიძახე და სხვადასხვა pid-ს სხვადასხვა რიცხვებით დაინახავ, რაც თავისთავად ინფორმაციულია.
რას ზღუდავს პლანი სინამდვილეში#
პლანის მეხსიერება მთელი კონტეინერის ლიმიტია და არა ერთი პროცესის. ყველაფერი შიგნით ერთად ითვლება: gunicorn-ის master, ყველა worker, ნებისმიერი ფონური thread ან ქვეპროცესი და ფაილების page cache, რომელსაც შენი აპი იწვევს. cgroup ლიმიტების მქონე კონტეინერში ლიმიტსა და მიმდინარე მოხმარებას პირდაპირ წაიკითხავ:
$ cat /sys/fs/cgroup/memory.max$ cat /sys/fs/cgroup/memory.current$ cat /sys/fs/cgroup/memory.peakეს cgroup v2-ია, დღეს გავრცელებული განლაგება; ძველ cgroup v1 ჰოსტებზე ფაილებია /sys/fs/cgroup/memory/memory.limit_in_bytes და memory.usage_in_bytes. memory.max არის ზღვარი ბაიტებში, memory.current - ის, რასაც ჯგუფი ახლა იყენებს, memory.peak (ახალ ბირთვებზე) კი - ყველაზე მაღალი მნიშვნელობა. memory.current ფაილის cache-საც შეიცავს, რომელსაც ბირთვი წნევისას გაათავისუფლებს, ამიტომ სრულიად ჯანსაღ სერვერზეც შეიძლება შემაშფოთებლად გამოიყურებოდეს. out-of-memory გაჩერებას წინასწარმეტყველებს შენი პროცესების ანონიმური მეხსიერების ჯამი, რომელიც PSS-ების ჯამთან ახლოსაა.
ლიმიტზე რა ხდება, ის განსაზღვრავს, რამდენი მარაგი დატოვო. RE:NODE-ზე, როცა სერვერი მეხსიერების ლიმიტს მიაღწევს, ბირთვი კონტეინერს აჩერებს და ის სუფთად ეშვება თავიდან, swap-ში ჩავარდნის ნაცვლად; ეს პლატფორმის აღწერილი ქცევაა. ნელი დეგრადაცია საყურებელი არ არის - პროცესი უბრალოდ ქრება, მიმდინარე მოთხოვნები იკარგება და აპი თავიდან იწყება. upstream Pterodactyl პირიქით იქცევა და კონტეინერს swap-ში უშვებს, რაც ცოცხალს ინარჩუნებს და ძალიან ანელებს. ორივე შემთხვევაში დასკვნა ერთია: ლიმიტზე მუშაობას ნუ გეგმავ. თუ მექანიზმი გაინტერესებს, Linux-ის swap და OOM killer ხსნის.
პანელის კონსოლი მეხსიერების, CPU-ს და დისკის გრაფიკებს აჩვენებს პლანის ლიმიტების წინააღმდეგ. ეს გრაფიკი რეალობის ყველაზე სწრაფი შემოწმებაა: დატოვე ღია დატვირთვის ტესტისას და უყურე, სად დგას ხაზი ჭერთან შედარებით. რა კიდევ არის იმ ეკრანზე, კონსოლის კითხვაში წერია.
Workers, threads და ფორმულა#
gunicorn-ის ან uvicorn-ის "worker" არის ოპერაციული სისტემის პროცესი, რომელსაც შენი აპლიკაციის მეხსიერების საკუთარი ასლი აქვს. worker-ები ორი მიზეზით არსებობს: რომ ერთზე მეტი CPU ბირთვი გამოიყენო (Python-ის global interpreter lock საშუალებას აძლევს ერთ პროცესს Python bytecode ერთ ბირთვზე შეასრულოს) და რომ მომსახურება გაგრძელდეს, სანამ სხვა worker დაბლოკილია ან თავიდან იწყება. ისინი ერთ მოთხოვნას არ აჩქარებენ.
აქედან მიიღება ზომის ფორმულა და მას ორი ზღვარი აქვს და არა ერთი:
workers_by_memory = floor((plan_RAM - baseline - headroom) / RSS_per_worker)workers_by_cpu = vCPU, rounded up, times 1 to 2 for I/O-bound appsworkers = min(workers_by_memory, workers_by_cpu)baseline არის ის, რასაც კონტეინერი worker-ების გარეშე ხარჯავს: gunicorn-ის master (დაახლოებით 30-60 MB), ნებისმიერი scheduler და მარაგი shell-ისა და ლოგირებისთვის. headroom უსაფრთხოების მარაგია; აიღე პლანის 20-25 პროცენტი. RSS_per_worker არის შენი გაზომილი high-water mark თითო worker-ზე და არა გაშვების მომენტის მაჩვენებელი.
გავრცელებული რჩევა (2 x cores) + 1 worker-ის შესახებ CPU-ს წესია სინქრონული, I/O-ზე დამოკიდებული აპების ეპოქიდან დიდ მანქანებზე. პატარა პლანზე, სადაც მეხსიერება უფრო მკაცრი ლიმიტია, ის რეგულარულად ბევრად მეტ worker-ს იძლევა, ვიდრე ეტევა. პლანის vCPU ასევე მკაცრი შეზღუდვაა შეძენილ წილზე: 0.5 ან 1 vCPU-იან პლანს ხუთი CPU-ზე დამოკიდებული worker-ისგან არაფერი ემატება, გარდა მეხსიერების ხუთმაგი ხარჯისა.
განვიხილოთ მაგალითი. FastAPI სერვისი, რომელიც high-water mark-ზე 110 MB-ად გაიზომა თითო worker-ზე, 1 GB პლანზე:
plan RAM 1024 MBbaseline 60 MB (master, logging)headroom 250 MB (about 25 percent)left for workers 714 MBworkers by memory = floor(714 / 110) = 6workers by CPU = 1 vCPU, async I/O-bound -> 2 to 3workers = 2 or 3მეხსიერება ექვსს დაუშვებდა; CPU-ს სამზე მეტი არ სჭირდება. გაუშვი სამი და გამოიყენებ პლანის დაახლოებით 390 MB-ს, პიკებისთვის ადგილი რჩება. პატარა პლანებზე ტიპური შედეგია: CPU-ს ლიმიტი მეხსიერებამდე იკეტება, სანამ აპი მსუბუქია.
ახლა იგივე არითმეტიკა Django აპისთვის pandas-ით, 280 MB თითო worker-ზე, 2 GB პლანზე: (2048 - 60 - 500) / 280 არის 5.3, ანუ მეხსიერებით ხუთი, 1 vCPU-ზე კი ორ-სამს გაუშვებდი. თუ Celery worker-ს ან მეორე პროცესს დაამატებ, ჯერ მისი მეხსიერება გამოაკელი.
Threads უფრო იაფი ბერკეტია. gunicorn-ის gthread worker-ს, რამდენიმე thread-ით, აპლიკაციის მეხსიერების ერთ ასლს ყველა thread იზიარებს, ამიტომ thread-ები პარალელიზმს ამრავლებენ RSS-ის გამრავლების გარეშე. სინქრონული Flask ან Django აპისთვის, რომელიც ბაზას ელოდება, --workers 2 --threads 4 რვა მოთხოვნას ერთდროულად ემსახურება დაახლოებით ორი პროცესის მეხსიერებით. გასამრჯელო GIL-ია: thread-ები I/O-ს ლოდინს ეხმარება და არა გამოთვლას, შენი კოდი და ბიბლიოთეკები კი thread-უსაფრთხო უნდა იყოს.
gunicorn-ის კონფიგურაცია#
პარამეტრები ჩასვი ფაილში და არა გრძელ ბრძანების ხაზში, რომ გადაიხედოს. gunicorn.conf.py ავტომატურად იკითხება სამუშაო დირექტორიიდან:
import osbind = f"0.0.0.0:{os.environ.get('PORT', '8000')}"workers = int(os.environ.get("WEB_CONCURRENCY", "2"))threads = int(os.environ.get("GUNICORN_THREADS", "1"))worker_class = "gthread" if threads > 1 else "sync"timeout = 30graceful_timeout = 30keepalive = 5# Recycle workers to cap slow growth. Jitter staggers the restarts.max_requests = 1000max_requests_jitter = 100# Put the heartbeat file in RAM, not on disk.worker_tmp_dir = "/dev/shm"მეხსიერებისთვის მნიშვნელოვანი პარამეტრები:
| პარამეტრი | ნაგულისხმევი | გავლენა მეხსიერებაზე |
|---|---|---|
workers | 1 | თითოეული აპის სრული ასლია |
threads | 1 | პარალელიზმი დამატებითი ასლების გარეშე |
max_requests | 0 (გამორთული) | worker-ს N მოთხოვნის შემდეგ რესტარტავს |
max_requests_jitter | 0 | ამ N-ს ამთხვევით ცვლის, რომ worker-ები ერთად არ დარესტარტდეს |
preload_app | False | აპს ერთხელ master-ში ტვირთავს, შემდეგ fork-ს აკეთებს |
timeout | 30 | კლავს worker-ს, რომელიც ამდენ ხანს გაჩუმებულია |
max_requests უსაფრთხოების ღვედია და არა გამოსწორება. თუ worker ყოველ ასეულ მოთხოვნაზე 1 MB-ით იზრდება cache-ის ან გაჟონვის გამო, ყოველ ათას მოთხოვნაზე გადატვირთვა ზიანს ზღუდავს, სანამ მიზეზს ეძებ. ის ამავე დროს რეალურ გაჟონვას გრაფიკზე მალავს, ამიტომ დააყენე შეგნებულად და ძიება გააგრძელე. jitter ყოველთვის დაამატე; მის გარეშე ყველა worker ერთსა და იმავე მომენტში აღწევს ერთ რიცხვს და აპი მცირე ხნით ჩერდება, სანამ ყველა worker ერთად რესტარტდება.
preload_app = True (ან --preload) აპს master-ში იმპორტირებს fork-მდე. fork-ით გაკეთებული worker-ები უკვე ჩატვირთულ გვერდებს იზიარებენ, რაც მთლიან მეხსიერებას ამცირებს და გაშვებას აჩქარებს. ეს რეალურია, მაგრამ ორი ზღვარი აქვს. Python-ის reference counting ობიექტებში წერს გამოყენებისას, რაც გაზიარებულ გვერდებს ერთმანეთის მიყოლებით ამრუდებს, ამიტომ დაზოგვა მცირდება, როცა worker-ები მუშაობენ; დიაგრამებზე ნაჩვენებზე მცირე სარგებელი ელოდე. და ყველაფერი, რაც იმპორტისას იქმნება - ბაზის კავშირები, thread pool-ები, ღია სოკეტები - ახლა ყველა fork-ით გაკეთებულ worker-ს ერთობლივად აქვს, რაც ყველაფერს არღვევს. კავშირები გახსენი post_fork hook-ში ან პირველი გამოყენებისას. Connection pool-ები და ლიმიტები განმარტავს, რატომ ამრავლებს pool თითო worker-ზე ბაზის კავშირებსაც.
worker_tmp_dir ხაზი პატარაა, მაგრამ ღირს: gunicorn თითო worker-ისთვის heartbeat ფაილს წერს და ზოგ კონტეინერულ ფაილურ სისტემაში ის ნელ დისკზე ხვდება და ცრუ worker timeout-ებს იწვევს. /dev/shm მეხსიერებაზეა დაფუძნებული, ამიტომ რამდენიმე კილობაიტ RAM-ს ხარჯავს და პრობლემას აგვარებს.
uvicorn-ის კონფიგურაცია#
uvicorn ASGI აპებს (FastAPI, Starlette, Django Channels) უშვებს. მარტო მას რამდენიმე პროცესის გაშვებაც შეუძლია:
$ uvicorn main:app --host 0.0.0.0 --port 8000 --workers 2 --limit-max-requests 5000--workers N N პროცესს აკეთებს fork-ით. --limit-max-requests აიძულებს პროცესს, ამდენი მოთხოვნის შემდეგ გავიდეს, რომ ზედამხედველმა ჩაანაცვლოს; უბრალო uvicorn --workers-ით მშობელი მკვდარ worker-ს თავიდან უშვებს, ანუ ეს მაინც რესტარტად მუშაობს, თუმცა gunicorn-ზე ნაკლებად ლამაზად. თუ ერთი worker გაქვს, --workers საერთოდ გამოტოვე - ერთპროცესიანი გაშვება უფრო მარტივია და ps-ში ერთ ხაზად ჩანს.
production-ისთვის გავრცელებული სქემაა gunicorn პროცესების მენეჯერად და uvicorn მუშად:
$ gunicorn main:app -k uvicorn.workers.UvicornWorker -w 2 -b 0.0.0.0:8000იღებ gunicorn-ის გადატვირთვას (--max-requests), მის გრაციოზულ რესტარტებს და timeout-ებს, ASGI სერვერით შიგნით. ერთი რამ შეამოწმე შენს ვერსიებზე: ახალ რელიზებში uvicorn.workers მოძველებულია ცალკე uvicorn-worker პაკეტის სასარგებლოდ და მაშინ კლასია uvicorn_worker.UvicornWorker. წაიკითხე იმ ვერსიის ცვლილებები, რომელსაც აფიქსირებ, და არა ძველი ტუტორიალიდან დააკოპირებული ბრძანება.
Async არითმეტიკას ცვლის. ერთ async worker-ს ათასობით უმოქმედო კავშირის და ბევრი მიმდინარე მოთხოვნის შენახვა შეუძლია, რადგან ლოდინი თითქმის არაფერი ჯდება. ამიტომ async API-ის ზომა ჩვეულებრივ CPU-ით განისაზღვრება და არა მეხსიერებით: პატარა პლანზე ხშირად ერთი-ორი worker სწორია, მეტის დამატება მხოლოდ CPU-ზე დამოკიდებული სამუშაოს პარალელიზმს იძლევა. გამონაკლისია, როცა event loop იბლოკება - სინქრონული ბიბლიოთეკის გამოძახება ან მძიმე გამოთვლა async def-ის შიგნით ამ worker-ის ყველა მოთხოვნას აჩერებს, პასუხი კი მეტი worker ან სამუშაოს გატანაა და არა უფრო დიდი პლანი.
გაზომვა დატვირთვის ქვეშ#
უმოქმედო სერვერიდან აღებული რიცხვი უსარგებლოა; იზომე, სანამ აპი მუშაობს. განმეორებადი პროცესი:
- გაუშვი აპი განზრახული worker-ების რაოდენობით და ჩაიწერე თითოეული worker-ის
psRSS. ეს ქვედა ზღვარია. - გაუშვი რეალისტური ტრაფიკი. დატვირთვის ინსტრუმენტი, როგორიცაა
heyანwrk, შენს ყველაზე ნელ და მძიმე endpoint-ებზე საკმარისია:
``bash $ hey -z 120s -c 20 https://your-app.example/api/heavy-endpoint ``
- სანამ ის მუშაობს, ყოველ რამდენიმე წამში ჩაწერე მეხსიერება ფაილში:
``bash $ while true; do date +%T; ps -C gunicorn -o rss= | awk '{s+=$1} END {print s/1024 " MB"}'; sleep 5; done ``
- შეაჩერე დატვირთვა და ერთი წუთი კიდევ გააგრძელე ჩაწერა. მეხსიერება, რომელიც უკან არ ეშვება, შენარჩუნებული პულია, რაც გარკვეულ ზომამდე მოსალოდნელია.
- ჩაიწერე ნებისმიერი worker-ის უმაღლესი
VmHWMდა უმაღლესი ჯამი. ეს არისRSS_per_workerზემოთ მოცემული ფორმულისთვის.
გაიმეორე იმ deploy-ის შემდეგ, რომელიც დამოკიდებულებებს ცვლის, და ნებისმიერი ცვლილების შემდეგ დიდ კოდის გზაზე. ახალ ბიბლიოთეკას შეუძლია თითო worker-ს 80 MB დაამატოს შენი კოდის ერთი ხაზის შეცვლის გარეშე.
როცა ერთი worker დანარჩენებზე ბევრად დიდია ან გრაფიკი პლატოს გარეშე ადის, გაჟონვა ან შეუზღუდავი cache გაქვს. Python-ის სტანდარტული tracemalloc აჩვენებს, საიდან მოდის ალოკაციები:
import tracemalloctracemalloc.start(10)def top_allocations(limit: int = 10): snapshot = tracemalloc.take_snapshot() for stat in snapshot.statistics("lineno")[:limit]: print(stat)გადაიღე ორი snapshot რამდენიმე წუთის დაშორებით და შეადარე snapshot2.compare_to(snapshot1, "lineno")-ით, რომ ნახო, რა გაიზარდა. tracemalloc თვითონაც ხარჯავს მეხსიერებას და სიჩქარეს, ამიტომ გამოიყენე staging ასლზე ან ხანმოკლედ. ნატიური გაფართოებების ალოკაციებისთვის, რომლებსაც tracemalloc ვერ ხედავს, უფრო მძიმე ინსტრუმენტია memray. ჩვეულებრივი დამნაშავეები არიან პროცესში შეუზღუდავი cache (გამოიყენე functools.lru_cache(maxsize=...) ან გარე cache), გლობალური სია, რომელსაც ყოველ მოთხოვნაზე ემატება ელემენტი, "ბოლო მოთხოვნების" სტრუქტურაში შენახული პასუხის body-ები და ORM-ის შედეგები, რომლებსაც მიმართვა ცოცხლად ინახავს. Game server-ის მეხსიერების გაჟონვები გაჟონვისა და პლატოს გარჩევის ზოგად მეთოდს აღწერს.
პიკები, ატვირთვები და სამუშაო, რომელიც მოთხოვნაში არ ეტევა#
მეხსიერების გრაფიკის საშუალო კი არა, პიკი გაჩერებს. Python ვებ-აპებში პიკების უმეტესობას სამი მოდელი იწვევს:
- მთელი ფაილების მეხსიერებაში წაკითხვა.
await request.body()ანrequest.files["upload"].read()200 MB ატვირთვაზე ამ worker-ში მთელ 200 MB-ს ინახავს, ორი ერთდროული ატვირთვა კი 400-ს. ფაილი ნაწილებად ჩაწერე დისკზე და body-ს ზომის ლიმიტი დააყენე proxy-ზე ან ფრეიმვორკში. - შეუზღუდავი მოთხოვნები.
Model.objects.all()ანSELECT *სიაში ყველა სტრიქონს Python ობიექტებად ტვირთავს, რაც ბაზაში მონაცემების ზომაზე ბევრჯერ მეტი ჯდება. გამოიყენე გვერდებად დაყოფა, server-side კურსორები ან iterator-ები (.iterator()Django-ში) და აირჩიე მხოლოდ საჭირო სვეტები. - ანგარიშებისა და ექსპორტის endpoint-ები. CSV-ის ან PDF-ის აწყობა ვებ-მოთხოვნაში კლასიკური გზაა, როგორც ერთმა მომხმარებელმა აპი ჩააგდოს. შექმენი ის ფონურ სამუშაოში და გადასცი ბმული.
ეს უკანასკნელი მძიმე სამუშაოს სტრუქტურული გამოსავალია: გაიტანე ის ვებ worker-ებიდან. სამუშაოებისთვის ცალკე პროცესი ვებ-ფენას პატარას და პროგნოზირებადს ინახავს და სამუშაოს მეხსიერებას ცალკე გადაწყვეტილებად აქცევს. ფონური სამუშაოები პატარა სერვერზე და სამუშაოთა რიგები Valkey-ით ვარიანტებს აღწერს. გახსოვდეს, რომ იმავე კონტეინერში მყოფი job runner იმავე მეხსიერების ლიმიტს ემატება, ამიტომ baseline-ში გაითვალისწინე.
ნიშნები, რომ პლანს გადაუზარდე#
ყველა მეხსიერების პრობლემა ფულით არ წყდება და ყველა დაზუსტებით არ წყდება. ეს ნიშნები თითოეულზე მიუთითებს.
ჯერ დააზუსტე (უფრო დიდი პლანი არ დაგეხმარება):
- მეხსიერება არასდროს ბრტყელდება, რამდენიც არ უნდა დაელოდო. ეს გაჟონვაა; მეტი RAM მხოლოდ რესტარტს აგვიანებს.
- ყველა პიკს ერთი endpoint იწვევს. გაასწორე endpoint.
- worker-ების რაოდენობა vCPU გამრავლებული ორზე მეტია მცირე ტრაფიკით. იხდი უმოქმედო ასლებში.
- ერთი მოთხოვნა შეუზღუდავ შედეგების სიმრავლეს ტვირთავს.
გადადი უფრო დიდ პლანზე:
- გონივრული worker-ების რაოდენობითა და გაჟონვის გარეშეც გახურებული high-water ჯამი პლანის დაახლოებით 75 პროცენტზე მაღლა დგას. ნორმალური პიკისთვის მარაგი აღარ დარჩა.
- კონტეინერი მეხსიერების ლიმიტზე ჩერდება და თავიდან ეშვება და მიზეზი კანონიერი სამუშაოა - უფრო დიდი დამოკიდებულება, მოდელი, უფრო დიდი სამუშაო ნაკრები - და არა დეფექტი.
- ერთ worker-მდე ჩამოხვედი, რადგან მხოლოდ ეს ეტევა, და მოთხოვნები რიგში დგას. დაყოვნება ტრაფიკთან ერთად იზრდება, CPU კი გაჯერებული არ არის.
- CPU-ს გრაფიკი პლანის ლიმიტზე დგას და worker-ები უკვე მასზეა მორგებული. CPU შეძენილ წილზე მკაცრი შეზღუდვაა, ამიტომ მიჭედებული CPU ნიშნავს ნელ პასუხებს და არა შეცდომებს, წამალი კი მეტი vCPU-ა. Python-ის ხაზზე პლანები ასეთია: 1 GB 0.5 vCPU-ით, 2 GB 1 vCPU-ით, 4 GB 1.5 vCPU-ით, 6 GB 2 vCPU-ით და 8 GB 3 vCPU-ით, ამიტომ უფრო დიდი პლანი ორივეს ამატებს.
RE:NODE-ზე პლანის გაზრდა შენს არსებულ სერვერზე ლიმიტებს ცვლის; სერვერი თავიდან არ შენდება, ამიტომ ფაილები და გარემო იქვე რჩება. კიბე, Python-ის პლანებიდან: 1 GB თვეში $4-დან, 2 GB $7-დან, 4 GB $12-დან, 6 GB $17-დან, 8 GB $23-დან. გადაწყვეტამდე გადაამოწმე მიმდინარე ფასები გვერდზე, რადგან ისინი შეიძლება შეიცვალოს.
აი, როგორ ეწერება წინა მაგალითები პლანებზე:
| დატვირთვა | გაზომილი | პლანი |
|---|---|---|
| Flask ან FastAPI, 2-3 worker | ჯამში 250-400 MB | 1 GB |
| Django ადმინკით, 3 worker | ჯამში 500-700 MB | 2 GB |
| Django plus pandas, 3 worker | ჯამში 1.2 GB | 2 GB მჭიდროა, 4 GB კომფორტული |
| აპი, რომელიც ML მოდელს ინახავს | მოდელის ზომა + 400 MB | ზომა მოდელიდან დაიწყე |
ბაზა და cache ცალკეა. Python აპი, რომელიც იმავე პლანში PostgreSQL-სა და Valkey-საც უშვებს, მათთან მეხსიერებას ეჯიბრება, ამიტომ ასე ნუ გააკეთებ; გაუშვი ისინი ცალკე სერვერებად. Hosting bundle-ების გვერდი აღწერს აპის, ბაზისა და cache-ის ერთად ყიდვას, და Python, PostgreSQL და Valkey bundle სწორედ ამ სტეკისთვის არსებობს.
რა ტყდება#
worker-ები კვდებიან `[CRITICAL] WORKER TIMEOUT`-ით და ლოგში შემდეგ რესტარტი ჩანს. ან მოთხოვნას ნამდვილად timeout-ზე მეტი დრო დასჭირდა, ან worker გარედან გააჩერეს. თუ დრო მეხსიერების პიკს ემთხვევა, კონტეინერი ალბათ ლიმიტზე გაჩერდა და timeout-ის შეტყობინება სიმპტომია. timeout-ის გაზრდამდე შეამოწმე memory.peak ან კონსოლის გრაფიკი.
აპი რამდენიმე საათში ერთხელ რესტარტდება ლოგში შეცდომის გარეშე. out-of-memory გაჩერება აპს ჩაწერის საშუალებას არ აძლევს. გრაფიკში ფორმა ეძებე: ზრდა, შემდეგ ვარდნა ნულამდე. გაასწორე ზრდა ან გაზარდე ლიმიტი; უფრო დიდი timeout არაფერს ცვლის.
მთლიანი მეხსიერება `workers x RSS`-ზე ბევრად მეტია. კონტეინერში სხვა რამეა: ქვეპროცესი, რომელსაც შენი კოდი უშვებს, thread pool, რომელიც ბუფერებს ინახავს, ან იმპორტირებული ბიბლიოთეკა, რომელიც ფონურ პროცესს იწყებს. ps -eo pid,ppid,rss,cmd --sort=-rss სრულ სიას აჩვენებს.
ლოკალურად მეხსიერება კარგია, სერვერზე კი უზარმაზარი. სხვადასხვა დამოკიდებულებების ვერსიები, Python-ის სხვა აწყობა ან დარჩენილი debug პარამეტრი. Django DEBUG = True-ით ყველა SQL მოთხოვნას პროცესის სიცოცხლის განმავლობაში მეხსიერებაში ინახავს, რაც დიდხანს მომუშავე სერვერზე დიზაინით გაჟონვაა. დააყენე False.
Preload-მა ყველაფერი გააუარესა. იმპორტისას შექმნილი კავშირები, რომლებიც fork-ით გაკეთებულ worker-ებს შორის იზიარებს, დატვირთვის ქვეშ უცნაურ შეცდომებს იძლევა. შექმენი ისინი fork-ის შემდეგ.
ყველაფერი კარგადაა დღის პირველ მოთხოვნამდე. ზარმაცი იმპორტები და ცივი cache პირველ მოთხოვნებს ნელს და მათ მეხსიერების პიკს სტაბილურ მდგომარეობაზე დიდს ხდის. თუ ეს მნიშვნელოვანია, გაშვების შემდეგ health check-იდან გამოიძახე warm-up route.
FAQ#
რამდენი gunicorn worker გავუშვა?
აიღე ორი რიცხვიდან უფრო მცირე: worker-ები, რომლებსაც მეხსიერება დაუშვებს (პლანის RAM მინუს baseline და 20-25 პროცენტი მარაგი, გაყოფილი გაზომილ RSS-ზე თითო worker-ზე), და დაახლოებით ერთი-ორი თითო vCPU-ზე I/O-ზე დამოკიდებული აპისთვის. 1 GB პლანზე 1 vCPU-ით ეს ჩვეულებრივ ორი ან სამია. თუ აპი ბაზას უფრო ელოდება, ვიდრე ითვლის, პროცესების დამატებამდე thread-ები გამოიყენე.
უბრუნდება თუ არა Python პროცესის მეხსიერება ოდესმე სისტემას?
ნაწილობრივ. CPython დიდ ალოკაციებს ოპერაციულ სისტემას უბრუნებს, მაგრამ პატარა ობიექტების arena-ებს ხშირად ინახავს და ფრაგმენტაცია გვერდებს იჭერს. პროცესი, რომელმაც მძიმე მოთხოვნა დაამუშავა, ჩვეულებრივ წინაზე დიდი რჩება. გეგმე high-water mark-ით და არა საშუალოთი, და გამოიყენე max_requests, თუ პერიოდული განულება გინდა.
მჭირდება თუ არა async მეხსიერების დასაზოგად?
Async მეხსიერებას ზოგავს, როცა ბევრი ერთდროული კავშირი გაქვს, რომლებიც ძირითადად ელოდებიან, რადგან ერთ worker-ს ყველა მათგანის შენახვა შეუძლია და არ სჭირდება thread ან პროცესი თითო კავშირზე. ის თვითონ აპის საბაზო დონეს არ ამცირებს და CPU-ზე დამოკიდებულ სამუშაოს არ ეხმარება. ბლოკირებადი გამოძახება async ფუნქციის შიგნით სარგებელს აუქმებს.
ღირს თუ არა --preload-ის ჩართვა?
ზოგჯერ. ის worker-ებს შორის იმპორტისას ჩატვირთულ კოდსა და მონაცემებს ინაწილებს და გაშვებას ამოკლებს, მაგრამ დაზოგვა მცირდება, როცა reference counting გაზიარებულ გვერდებს ეხება, და იმპორტისას გახსნილი ნებისმიერი კავშირი არასწორად ზიარდება. სანამ სარგებელს ენდობი, გაზომე PSS მასთან და მის გარეშე.
რამდენ მეხსიერებას იყენებს gunicorn-ის master?
ტიპურად თავისთავად 30-60 MB და მოთხოვნებს არ ემსახურება. preload_app-ით ის ჩატვირთულ აპლიკაციასაც ინახავს. არითმეტიკისას baseline-ში ჩართე.
როდის არის 1 GB პლანი Python-ისთვის მცირე?
როცა საჭირო worker-ების გახურებული ჯამი მისი დაახლოებით სამ მეოთხედს აჭარბებს - მაგალითად, სამი Django worker, თითო 200 MB, master-ამდე და job runner-ამდე უკვე 600 MB-ს იყენებს. მაშინ გადადი 2 GB-ზე და არა ერთ worker-ზე ჩამოსვლით, სადაც მოთხოვნები რიგში დგას.


