מגבלות זיכרון ב-Docker Compose למניעת OOM
למדו להגדיר מגבלות זיכרון ומעבד ב-Docker Compose, להבין את השגיאה exit 137, להבדיל בין deploy.resources ל-mem_limit ולתכנן swap נכון ב-VPS.
מה עושה מגבלת זיכרון ב-Docker Compose
מגבלת זיכרון ב-Docker Compose היא תקרה קשיחה שגרעין Linux מציב על קבוצת הבקרה (cgroup) של מכל אחד. קבוצת הבקרה היא תכונה של הגרעין שמודדת את צריכת המשאבים של קבוצת תהליכים. הגדירו את deploy.resources.limits.memory בשירות, והמכל לא יוכל להשתמש ביותר מהערך שציינתם. כאשר הוא מנסה לחרוג ממנו, הגרעין הורג תהליך בתוך המכל, והמכל מסתיים בדרך כלל עם קוד 137.
הדבר חשוב במיוחד ב-VPS, שבו כמות ה-RAM קבועה ואין זיכרון פנוי נוסף במארח שאפשר לשאול. מכל אחד עם דליפת זיכרון או שאילתה שגויה עלול לצרוך כל דף זיכרון פנוי בשרת בעל 8GB. לאחר מכן הגרעין הורג את התהליך שהוא מזהה כבעייתי ביותר. לעיתים קרובות זהו מסד נתונים או חיבור ה-SSH שלכם, ולא המכל שגרם לבעיה. מגבלות הופכות השבתה של השרת כולו לשירות יחיד שמופעל מחדש.
services:
app:
image: ghcr.io/example/app:1.4
deploy:
resources:
limits:
cpus: "1.5"
memory: 1g
reservations:
memory: 256mהחילו את ההגדרה ואמתו שהמגבלה פעילה:
docker compose up -d
docker stats --no-streamהעמודה MEM USAGE / LIMIT אמורה להציג ערך הדומה ל-142MiB / 1GiB. אם עמודת המגבלה מציגה את מלוא זיכרון ה-RAM של המארח, ההגדרה לא הוחלה. במקרה כזה, המשך המדריך לא יעזור עד שתפתרו זאת. אם קובץ ה-compose חדש לכם, המדריך הבסיסי ל-Docker Compose עבור VPS מסביר את מבנה הקובץ שעליו מבוססת ההגדרה.
deploy.resources.limits או mem_limit: איזה מהם חל
קיימים שני כתיבים לאותו רעיון, ולכן הנושא מבלבל.
mem_limit, mem_reservation, memswap_limit, cpus ו-cpu_shares הם מפתחות שירות ברמה העליונה, שעברו בירושה מתבניות Compose הישנות. deploy.resources הגיע מסכמת Swarm וכיום הוא חלק מ-Compose Specification, הפורמט ש-docker compose קורא כיום.
שניהם פועלים במארח יחיד. Compose V2, התוסף docker compose, מחיל את deploy.resources.limits ואת deploy.resources.reservations כאשר מריצים docker compose up, ללא אשכול Swarm. החלקים של הבלוק deploy שבלעדיים ל-Swarm הם המפתחות האחרים: mode, placement, update_config ו-endpoint_mode משמעותיים עבור docker stack deploy, ומתעלמים מהם ב-docker compose up. לכן ההנחיה הנפוצה שלפיה "deploy דורש Swarm" שגויה עבור תת-הסעיף resources, ובעקבותיה השירותים נותרים ללא מגבלה כלל.
בחרו כתיב אחד לכל פרויקט. כתיבה של mem_limit: 512m ושל deploy.resources.limits.memory: 1g באותו שירות יוצרת קובץ שקשה להבין במבט חטוף. במקום לנחש איזו הגדרה גברה, שאלו את הדימון:
docker inspect --format '{{.HostConfig.Memory}} {{.HostConfig.MemoryReservation}} {{.HostConfig.NanoCpus}}' app-1ערכי זיכרון נמדדים בבתים, לכן 1g יודפס בתור 1073741824. ערך ה-CPU נמדד ב-nano CPUs, לכן 1.5 יודפס בתור 1500000000. ערך 0 בכל שדה מציין שלא הוגדרה מגבלה. מגבלת הזיכרון הקטנה ביותר ש-Docker מקבל היא 6m, ומתחת לערך זה הקונטיינר מסרב להתחיל.
מה קורה כאשר קונטיינר מגיע למגבלה
הקונטיינר אינו נעשה איטי יותר. הוא מסתיים.
כאשר תהליך מבקש דף זיכרון ו-cgroup כבר נמצא ב-memory.max שלו, הליבה משחררת תחילה את מה שניתן לשחרר בתוך אותו cgroup: מטמון דפים נקי, ולאחר מכן דפים שניתן להעביר ל-swap. אם השחרור אינו מפנה מספיק זיכרון, מנגנון OOM (מחוץ לזיכרון) של ה-cgroup בוחר תהליך בתוך הקונטיינר ושולח לו SIGKILL. הריגת ה-PID 1 של הקונטיינר מסיימת את הקונטיינר. קוד היציאה 137 הוא פשוט 128 בתוספת אות 9, ולכן 137 הוא הסימן המזהה של כל SIGKILL, ולא הוכחה ל-OOM בפני עצמו.
docker compose ps -a
docker inspect --format '{{.State.OOMKilled}} {{.State.ExitCode}}' app-1true 137 הוא הריגת OOM. false 137 פירושו שגורם אחר שלח SIGKILL, והסיבה הרגילה היא ש-docker compose stop הגיע לתקופת החסד של עשר שניות, משום שהיישום התעלם מ-SIGTERM. ההבחנה הזאת חוסכת שעות, משום שלשתי הבעיות אין קשר זו לזו.
שני מקומות נוספים מתעדים את האירוע. עקוב אחר הדמון בזמן אמת:
docker events --filter event=oomלאחר מכן קרא את יומן הליבה, שהוא הרשומה שנשמרת גם לאחר הפעלה מחדש:
sudo dmesg -T | grep -i -E 'memory cgroup out of memory|killed process'הריגת cgroup מציגה שורה שמתחילה ב-Memory cgroup out of memory: Killed process 24713 (node). שורה ללא הקידומת Memory cgroup היא OOM של המארח, כלומר למכונה עצמה לא נותר זיכרון RAM. זהו הכשל שהמגבלות נועדו למנוע, ולכן הופעתו מעידה שסכום המגבלות שלך גבוה מדי, או שלחלק מהשירותים אין מגבלה כלל.
עם restart: unless-stopped, לולאת OOM עלולה להסתתר היטב, משום שהשירות מופיע כפעיל ב-docker compose ps שנייה לאחר שהסתיים. בדוק את עמודת זמן הפעילות ואת מספר ההפעלות מחדש, ושלב את המגבלה עם בדיקת תקינות שמדווחת שהיישום אינו תקין, כדי שקונטיינר שממשיך להסתיים יהיה גלוי בלי שתצטרך לעקוב אחריו.
Reservation הוא רמז, וה-limit הוא הכלל
reservations.memory (הגרסה הישנה יותר, mem_reservation) הוא סף רך. Docker מתאר אותו כ-limit רך שמופעל כאשר ה-daemon מזהה תחרות על משאבים או מחסור בזיכרון ב-host. הוא לעולם אינו מונע מ-container לחרוג ממנו, ולעולם אינו מבטיח שהזיכרון יהיה פנוי כאשר ה-container יבקש אותו. הוא רק מטה את ה-kernel להשיב זיכרון קודם מ-containers שנמצאים מעל ה-reservation שלהם.
לכן reservation אינו מגן על דבר בפני עצמו. השתמשו בו כדי לסמן שירות שתרצו שיקבל יחס מועדף בעת עומס, והסתמכו על ה-limit לצורכי הגנה. השאירו את ה-reservation נמוך מה-limit, אחרת ה-container לא יופעל: Docker דוחה את התצורה עם Minimum memory limit can not be less than memory reservation limit.
ניהול swap, באופן מדויק
ברוב דימויי ה-VPS אין כלל קובץ swap. הפעילו את swapon --show ואת free -h. אם כמות ה-swap הכוללת היא אפס, כל ההגדרות הקשורות ל-swap שלהלן אינן משפיעות, ומגבלת הזיכרון שלכם היא מגבלת RAM בלבד.
memswap_limit אינו כמות ה-swap. הוא הסכום של הזיכרון ושל ה-swap. באמצעות mem_limit: 1g ו-memswap_limit: 2g, ה-container מקבל 1GB של RAM ו-1GB של swap. הגדרת שני הערכים לאותו ערך אינה משאירה ל-container swap כלל. הגדרת mem_limit והשארת memswap_limit ללא הגדרה מאפשרות ל-container להשתמש ב-swap עד לגודל מגבלת הזיכרון שלו.
Ubuntu 24.04 ו-Debian 13 משתמשות כברירת מחדל ב-cgroup v2, שבו ה-swap הוא מונה נפרד (memory.swap.max), וההגדרה פועלת ללא הכנה נוספת. ההודעה הישנה Your kernel does not support swap limit capabilities מופיעה במארחים המשתמשים ב-cgroup v1 ואשר הופעלו ללא swapaccount=1. במארחים אלה מגבלת הזיכרון עדיין חלה, ואילו רכיב ה-swap מתעלמים ממנו.
יש להגדיר באופן מדויק מה נותן לכם swap. הוא מאט את פעולת ה-OOM kill, אך אינו מפחית את הסבירות לה, משום שתהליך שדלף ממלא את ה-swap באותה קלות שבה הוא ממלא את ה-RAM. בינתיים, container שמבצע thrashing של swap באחסון משותף של VPS מאט כל שירות אחר במערכת. עבור כל עומס עבודה הרגיש לזמן השהיה, מגבלה נכונה ללא swap נכשלת מהר יותר ובאופן צפוי יותר.
מדוע השימוש בזיכרון נראה חמור מכפי שהוא
הנתון MEM USAGE ב-docker stats כולל את מטמון הדפים, ולכן Container שקורא קבצים גדולים מתקרב לתקרה שלו ונשאר שם. זה תקין ואינו דליפה, משום שמטמון נקי מוחזר לפני שהמערכת מפעילה אי-פעם את OOM killer. שירות כמו שרת מדיה Jellyfin באירוח עצמי ייראה קרוב לצמיתות לתקרה שלו בדיוק מסיבה זו.
פצלו את הנתון למטמון ולמערך העבודה בפועל מתוך ה-Container:
docker compose exec app grep -E '^(anon|file) ' /sys/fs/cgroup/memory.stat
docker compose exec app cat /sys/fs/cgroup/memory.eventsanon הוא זיכרון אנונימי — מערך העבודה שאי אפשר לשחרר. file הוא מטמון דפים, שאפשר לשחרר. הגדירו את המגבלה לפי anon בתוספת מרווח, ולא לפי הסכום הכולל. הקובץ memory.events מכריע את הדיון: מונה oom_kill גדול מאפס פירושו שה-Kernel הרג משהו בתוך ה-Container מאז הפעלתו, ומונה max שעולה פירושו שה-Container מוחזק כעת בתקרה שלו. שתי הפקודות דורשות Shell ו-coreutils בתוך ה-Image, ולכן הן נכשלות ב-Image מסוג distroless או scratch.
מגבלות הקצאת משאבים ב-VPS בנפח 8GB
התחילו מהמארח, ולא מהיישומים. ב-VPS בנפח 8GB השאירו כ-1GB עבור הליבה, ה-Docker daemon, sshd, journald ומעטפת הכניסה שלכם. כך נותרים בערך 7GB להקצאה, וסכום המגבלות של כל הקונטיינרים צריך להישאר מתחת לערך זה. הקצאת יתר פועלת עד היום שבו שני שירותים מגיעים לשיא בו-זמנית.
חלוקה מעשית למערכת בנפח 8GB:
- פרוקסי הפוך: מגבלת 128m. זהו תהליך קטן, ומגבלה הדוקה כזו מזהה מיד טעינה מחדש בלתי מבוקרת של תצורה.
- PostgreSQL: מגבלת 2g, כאשר
shared_buffersמוגדר לכ-512MB בתצורת מסד הנתונים. - קונטיינר היישום: מגבלת 1g.
- עובד רקע: מגבלת 512m.
- שירות מדיה או קבצים: מגבלת 2g, שרובם ישמשו כמטמון דפים.
אל תעתיקו את המספרים האלה למחסנית שלכם. הפעילו את השירותים תחת עומס אמיתי במשך יום, נטרו את docker stats, קחו לכל קונטיינר את ערך השיא anon והוסיפו בערך חצי ממנו כמרווח ביטחון. מגבלה הדוקה מדי גרועה מהיעדר מגבלה, מכיוון שהיא מפסיקה שירות תקין במהלך עלייה רגילה בתעבורת הרשת.
יש מלכודת אחת שראויה להערה נפרדת. רוב סביבות זמן הריצה אינן מזהות את המגבלה, אלא אם מיידעים אותן עליה. PostgreSQL יקבע ללא בעיה את shared_buffers ואת work_mem מעבר למגבלת הקונטיינר שלו, ולאחר מכן יופסק. JVM (מכונה וירטואלית של Java) זקוק ל--XX:MaxRAMPercentage=75 כדי לקבוע את גודל הערימה לפי מגבלת ה-cgroup ולא לפי זיכרון ה-RAM של המארח. Node.js זקוק ל---max-old-space-size במגה-בתים, כשהערך מוגדר מתחת למגבלת הקונטיינר, אחרת אוסף האשפה שלו יאפשר לערימה לגדול עד שהליבה תתערב. ה-cgroup אינו מנהל משא ומתן. הוא מפסיק את התהליך.
מגבלות CPU מתנהגות בצורה שונה לחלוטין
cpus: "1.5" פירושו 150% מליבה אחת, הנאכפים כמכסת CFS (מתזמן הוגן לחלוטין). הקונטיינר מקבל 150ms של זמן CPU בכל פרק זמן של 100ms, המשותף לכל ה-threads שלו. לאחר שהוא מנצל את המכסה, ה-kernel ממתין עד לתחילת פרק הזמן הבא.
זהו ההבדל החשוב. קונטיינר שחורג ממגבלת הזיכרון שלו מופסק. קונטיינר שחורג ממגבלת ה-CPU שלו מוגבל בקצב וממשיך לפעול, אך באיטיות רבה יותר. לכן בטוח להגדיר מגבלת CPU באופן מחמיר, ואילו מגבלת זיכרון דורשת מרווח.
cpu_shares הוא כלי אחר: משקל יחסי שרלוונטי רק כאשר ה-CPU-ים אכן רוויים. שני קונטיינרים עם shares בערכים 1024 ו-512 מתחלקים בליבה עמוסה ביחס של בערך שניים לאחד, ובשרת לא עמוס אף אחד מהם אינו מוגבל. השתמשו ב-shares כדי לקבוע את סדר העדיפויות של שירותים לפי חשיבותם, והשתמשו ב-cpus כאשר נדרשת תקרה ממשית, לדוגמה כדי למנוע ממשימת transcode לילית למנוע משרת ה-web שלכם לקבל משאבים.
FAQ
האם deploy.resources.limits פועל ללא Docker Swarm?
כן. Compose V2 מחיל את deploy.resources.limits ואת deploy.resources.reservations כאשר מריצים docker compose up במארח יחיד. ניתן לאמת זאת באמצעות docker inspect --format '{{.HostConfig.Memory}}' <container>, שמדפיס את המגבלה בבתים ומדפיס 0 כאשר לא הוחלה מגבלה. המפתחות בתוך deploy שדורשים בפועל את Swarm הם mode, placement, update_config ו-endpoint_mode.
מה המשמעות של קוד היציאה 137 ב-Docker Compose?
משמעותו שהתהליך הראשי קיבל SIGKILL, מכיוון ש-137 הוא 128 בתוספת אות 9. הגורם הנפוץ הוא מנגנון OOM killer של ליבת המערכת, אך פסק זמן בכיבוי מפיק את אותו קוד כאשר יישום מתעלם מ-SIGTERM. הריצו docker inspect --format '{{.State.OOMKilled}} {{.State.ExitCode}}' <container> כדי להבדיל בין המקרים. true 137 מציין הרג עקב מחסור בזיכרון, ואילו false 137 אינו מציין זאת.
האם עליי להגדיר mem_limit או deploy.resources.limits.memory?
שניהם פועלים עם docker compose. deploy.resources.limits.memory הוא התחביר העדכני במפרט Compose, ולכן הוא ברירת המחדל העדיפה בקובץ חדש. השאירו את mem_limit אם שאר הקובץ כבר משתמש במפתחות הישנים ברמה העליונה. הגדרת שניהם עבור אותו שירות רק מקשה על קריאת הקובץ, לכן בחרו באחד מהם ואמתו את התוצאה באמצעות docker inspect.
מדוע המכולה שלי צורכת את מלוא מגבלת הזיכרון בלי להיסגר?
נתון השימוש ב-docker stats כולל מטמון דפים, שהליבה משחררת תחת עומס במקום להפעיל הרג עקב OOM. הריצו docker compose exec <service> grep -E '^(anon|file) ' /sys/fs/cgroup/memory.stat וקראו את הערך anon, שמייצג את קבוצת העבודה שאינה ניתנת להשבה. ערך file גבוה לצד ערך anon נמוך מצביע על מכולה שמבצעת קלט ופלט בדיסק, ולא על מכולה שעומדת להיסגר.
כמה RAM עליי להשאיר לא מוקצה ב-VPS בנפח 8GB?
השאירו כ-1GB עבור הליבה, הדמון של Docker, sshd, journald וה-Shell שלכם, ולאחר מכן הקפידו שסכום כל מגבלות המכולות יישאר מתחת ל-7GB הנותרים. עקבו במשך יום, תחת עומס אמיתי, אחר ערך השיא anon בכל מכולה לפני שתקבעו את המספרים, והתייחסו לסכום כאל תקציב ולא כאל יעד שיש למלא.