Platform & Tooling · پلتفرم و ابزار پایهBeginner ~61 دقیقه مطالعه~50 min read
لینوکس برای برنامهنویس بکاندLinux for the Backend Engineer
از صفر تا سطح سنیور روی سرور لینوکسی: فایلسیستم و دسترسیها، پروسهها و systemd، ابزارهای متنی برای شکار لاگ، شبکه و SSH، اسکریپتنویسی bash، و یک بخش کامل برای عیبیابی JVM در production شامل thread dump، heap dump، OOM killer و حافظهٔ container-aware.Zero-to-senior on a Linux server: filesystem and permissions, processes and systemd, text tools for real log hunting, networking and SSH, bash scripting, and a full section on troubleshooting a JVM in production — thread dumps, heap dumps, the OOM killer and container-aware memory.
یک روز صبح تلفن زنگ میزند: «سرویس بالا نمیآید.» IDE کمکت نمیکند، debugger هم نه. تنها چیزی که داری یک ترمینال سیاه است و یک prompt که منتظر است — و در آن لحظه فاصلهٔ بین یک برنامهنویس میدلول و یک سنیور دقیقاً همین است: سنیور میداند در سی ثانیهٔ اول چه تایپ کند.
این فصل لینوکس را نه بهعنوان «سیستمعامل» بلکه بهعنوان ابزار کار روزانهٔ یک بکاند آموزش میدهد؛ از صفر تا جایی که بتوانی روی سروری که JVM رویش در حال خفه شدن است بدون ترس کار کنی.
فایلسیستم و مسیرها ← کار با فایلها ← دسترسیها و مالکیت ← کاربر و sudo ← پروسهها و سیگنالها ← systemd و journalctl ← pipe و redirection و exit code ← ابزارهای متنی و شکار لاگ ← آرشیو و انتقال ← دیسک و حافظه ← شبکه ← SSH ← متغیرهای محیطی ← cron و systemd timer ← مدیریت بسته ← اسکریپت bash ← عیبیابی JVM روی لینوکس ← cheat-sheet بقا.
۱. مدل ذهنی: همهچیز فایل است
یک ساختمان اداری بزرگ را تصور کن: یک در ورودی، طبقاتی با کاربرد مشخص، و برای رفتن به هر اتاق یا «آدرس کامل از در ورودی» را میگویی یا «دو در جلوتر از همینجا». لینوکس همین است: یک درخت واحد از پوشهها، هر شاخه یک کاربرد قراردادی، و هر منبعی — حتی کارت شبکه و حافظه — پشت یک «در» به شکل فایل.
در ویندوز چند درخت داری (C:\، D:\)؛ در لینوکس فقط یک درخت با ریشهٔ / و دیسک دوم یا اشتراک شبکه «mount» میشود، یعنی به شاخهای از همین درخت وصل میشود.
«everything is a file» یعنی کرنل تقریباً همهٔ منابع را پشت یک واسط یکسان (باز کن، بخوان، بنویس، ببند) عرضه میکند. به همین دلیل grep ناگهان ابزار مانیتورینگ میشود: grep VmRSS /proc/1234/status یعنی «مصرف حافظهٔ فیزیکی پروسهٔ ۱۲۳۴»، بدون هیچ agent و داشبوردی.
استاندارد FHS تعیین میکند چه چیزی کجا برود؛ دانستنش یعنی روی هر توزیعی گم نشوی.
| مسیر | چه چیزی آنجاست | برای بکاند یعنی |
|---|---|---|
/etc |
پیکربندی سیستم | /etc/hosts, /etc/systemd/system/myapp.service |
/var/log, /var/lib |
لاگها و دادهٔ پایدار سرویسها | لاگ اپ، دیتادایرکتوری PostgreSQL |
/opt |
نرمافزار جانبی | محل رایج نصب اپ جاوا |
/usr/bin, /usr/local/bin |
باینریها | java, curl |
/proc |
فایلسیستم مجازی کرنل | /proc/<pid>/fd, /proc/meminfo |
/sys |
سختافزار و cgroup | /sys/fs/cgroup/memory.max |
مسیر مطلق با / شروع میشود و همیشه یک معنی دارد؛ مسیر نسبی از پوشهٔ فعلی حساب میشود. . پوشهٔ فعلی، .. یکی بالاتر، ~ خانهٔ کاربر، cd - پوشهٔ قبلی.
در اسکریپت همیشه مسیر مطلق بنویس. اگر در cd logs && rm -rf * دستور cd شکست بخورد، حذف در پوشهٔ فعلی — که ممکن است / باشد — اجرا میشود. یا مسیر مطلق بنویس یا cd logs || exit 1.
۲. پیمایش و نگاه کردن
pwd ; cd /var/log ; cd .. ; cd - # کجا هستم / رفت و برگشت
ls -lah # بلند + مخفیها + اندازهٔ خوانا | ls -lt جدیدترین اول
stat application.yml # inode، دسترسی octal، زمانها | file app.jar نوع واقعی فایل
readlink -f $(which java) # مسیر واقعی بعد از باز کردن symlink ها
خروجی ls -l را باید ستونبهستون بخوانی:
-rw-r--r-- 1 appuser appgroup 20480 Jul 30 11:02 application.yml
کاراکتر اول نوع است (- فایل، d دایرکتوری، l symlink، s سوکت)، نه کاراکتر بعد بیتهای دسترسی، 1 تعداد hard link، سپس مالک، گروه، اندازه و زمان آخرین تغییر.
هیچکس همهٔ فلگها را حفظ نیست؛ man و --help بخشی از مهارتاند. در مصاحبه هم «فلگ دقیقش را با man چک میکنم ولی منطقش این است» بهتر از حدس زدن است.
۳. خواندن و جابهجا کردن فایلها
less app.log # مرورگر صفحهای: q خروج، / جستجو، G انتها
head -n 50 app.log ; tail -n 200 app.log ; tail -F app.log # F: بعد از rotate هم ادامه بده
cp -a conf/ backup/ # archive: حفظ دسترسی، مالک و زمانها
mkdir -p /opt/myapp/logs/2026
روی production cat نزن: روی لاگ چندگیگابایتی کل فایل را به ترمینال میریزد و I/O سرور را تحت فشار میگذارد. و برای لاگی که rotate میشود tail -F بنویس نه tail -f؛ با -f کوچک بعد از rotate به فایلِ حذفشده چسبیده میمانی و فکر میکنی سرویس ساکت شده است.
لینک سخت و لینک نمادین
ln /opt/app/v2.1.0/app.jar /opt/app/hard.jar # hard link: نام دوم برای همان inode
ln -s /opt/app/v2.1.0 /opt/app/current # symlink: فایلی که یک مسیر را نگه میدارد
| ویژگی | Hard link | Symlink |
|---|---|---|
| به چه اشاره میکند | مستقیم به inode | به یک مسیر بهصورت متنی |
| بین دو فایلسیستم | ممکن نیست | ممکن است |
| به دایرکتوری | معمولاً ممنوع | مجاز |
| اگر اصل حذف شود | داده باقی میماند | لینک میشکند (dangling) |
| کاربرد در deploy | پشتیبان افزایشی (rsync --link-dest) |
current -> releases/... برای rollback آنی |
نسخهٔ جدید را در releases/<timestamp> باز کن، ln -sfn releases/<timestamp> current بزن و سرویس را restart کن؛ rollback یعنی همان symlink را برگردانی — عملیاتی اتمیک. فلگ -n حیاتی است: بدون آن اگر current قبلاً symlink به یک پوشه باشد، لینک جدید داخل آن ساخته میشود — باگی که وسط deploy خودش را نشان میدهد.
۴. دسترسیها و مالکیت — عمیق
هر فایل مثل یک اتاق است با سه دستهٔ آدم: صاحب اتاق (owner)، همتیمیهایش (group) و بقیهٔ ساختمان (other). کرنل مثل نگهبان عمل میکند: «تو صاحبی؟»، اگر نه «عضو گروهی؟»، اگر نه «پس other هستی» — و فقط همان یک دسته را بررسی میکند و متوقف میشود.
flowchart TD
A["Process opens a file"] --> B{"UID == file owner?"}
B -- yes --> C["Use OWNER bits only"]
B -- no --> D{"File GID in process groups?"}
D -- yes --> E["Use GROUP bits only"]
D -- no --> F["Use OTHER bits only"]
C --> G{"Access allowed?"}
E --> G
F --> G
G -- yes --> H["open() succeeds"]
G -- no --> I["EACCES: Permission denied"]
نتیجهٔ ظریف آن توقف: اگر مالک فایل باشی و بیت owner اجازهٔ خواندن ندهد، عضو بودن در گروه نجاتت نمیدهد. chmod 077 file یعنی مالک هیچ دسترسی ندارد ولی بقیه همهچیز دارند.
هر دسته سه بیت دارد: r (۴)، w (۲)، x (۱) — پس 7 = rwx، 6 = rw-، 5 = r-x، 4 = r--. یعنی chmod 640 secrets.properties = مالک بخواند و بنویسد، گروه فقط بخواند، بقیه هیچ.
روی فایل، x یعنی «قابل اجرا». روی دایرکتوری، x یعنی «حق عبور» — حق ورود و دسترسی به محتویات؛ و r فقط یعنی «میتوانی نامها را فهرست کنی». پس دایرکتوری با r-- میگذارد ls بزنی ولی هر cat dir/file شکست میخورد، و برای خواندن /a/b/c.txt باید روی /a و /a/b بیت x داشته باشی حتی اگر خود فایل 644 باشد.
chmod 750 start.sh ; chmod u+x start.sh ; chmod g-w,o-rwx config.yml
chmod -R u=rwX,g=rX,o= /opt/myapp # X بزرگ: بیت اجرا فقط به دایرکتوریها
chown -R appuser:appgroup /opt/myapp
namei -l /opt/myapp/conf/app.yml # کدام سطح از مسیر دسترسی را قطع کرده؟
chmod -R 755 /opt/myapp همهٔ فایلهای متنی و jar را هم executable میکند — نویز امنیتی. با u=rwX,g=rX,o= بیت اجرا فقط به دایرکتوریها و فایلهایی میرسد که از قبل executable بودند.
umask و بیتهای ویژه
فایلهای جدید با پایهٔ 666 و دایرکتوریها با 777 ساخته میشوند و بیتهای umask از آن کم میشود؛ با umask 022 یعنی فایل 644 و دایرکتوری 755.
| بیت | octal | روی فایل | روی دایرکتوری |
|---|---|---|---|
| setuid | 4000 | با هویت مالک فایل اجرا شود | بیاثر در لینوکس |
| setgid | 2000 | با هویت گروه فایل اجرا شود | فایلهای جدید گروه دایرکتوری را میگیرند |
| sticky | 1000 | بیاثر | فقط مالکِ فایل میتواند حذفش کند |
ls -ld /tmp # drwxrwxrwt <- t یعنی sticky
chmod 2775 /srv/shared # setgid روی پوشهٔ اشتراکی تیم
find / -perm -4000 -type f 2>/dev/null # ممیزی: چه چیزهایی setuid هستند؟
setfacl -m u:reportuser:r-x /srv/data # وقتی سه دسته کافی نیست
/tmp بهترین مثال sticky bit است: همه مینویسند، ولی t نمیگذارد کاربر A فایل کاربر B را پاک کند.
تلهٔ umask: اگر umask را در ~/.bashrc بگذاری سرویس systemd آن را نمیبیند. در unit بنویس UMask=0027، وگرنه لاگهای اپ ممکن است با 644 ساخته شوند و هر کاربری روی سرور محتوایشان را بخواند.
777 یعنی هر پروسهای روی آن ماشین میتواند آن فایل را بازنویسی کند؛ اگر jar، اسکریپت start یا config باشد، تازه یک مسیر اجرای کد دلخواه ساختهای. راه درست: با sudo -u appuser stat /path و namei -l /path بفهم کدام سطح میشکند، بعد chown کن یا کاربر را به گروه درست اضافه کن — تقریباً همیشه جواب chown است نه chmod.
پاسخ: 755 یعنی rwx برای owner و r-x برای group و other. روی فایل، x یعنی قابل اجرا بودن. روی دایرکتوری معنی بیتها عوض میشود: r یعنی میتوانی نامها را فهرست کنی، w یعنی میتوانی داخلش فایل بسازی یا حذف کنی، و x یعنی حق عبور به مسیرهای داخلی.
دو نکتهای که دنبالش هستند: برای خواندن /a/b/c.txt باید روی همهٔ دایرکتوریهای مسیر بیت x داشته باشی، و حذف یک فایل به دسترسی نوشتن روی دایرکتوری بستگی دارد نه روی خود فایل — به همین دلیل میتوانی فایلی را که اجازهٔ نوشتن رویش نداری حذف کنی.
پاسخ: نه، و جایگزین میدهم. اول تشخیص با sudo -u appuser stat /path و namei -l /path تا بفهمم کدام سطح از مسیر میشکند — خیلی وقتها یک دایرکتوری میانی است نه فایل نهایی.
بعد بسته به حالت: یک سرویس بنویسد → chown -R appuser:appgroup و 750؛ چند سرویس بنویسند → گروه مشترک با chgrp -R shared و chmod -R 2770 که بیت setgid تضمین میکند فایلهای جدید هم گروه درست را بگیرند؛ یک کاربر فقط بخواند → ACL با setfacl.
ریسک را هم صریح میگویم: با 777 هر پروسهای روی ماشین میتواند jar یا اسکریپت راهاندازی را عوض کند، یعنی از یک مشکل دسترسی به یک آسیبپذیری اجرای کد رسیدهایم.
۵. کاربر، گروه و sudo
id ; id appuser ; groups appuser
getent passwd appuser # از passwd + LDAP و ... میخواند
/etc/passwd نام و UID و شل را دارد (خواندنی برای همه؛ رمز اینجا نیست)، /etc/shadow هش رمزها را (فقط root) و /etc/group گروهها را.
sudo groupadd --system appgroup
sudo useradd --system --gid appgroup --home-dir /opt/myapp \
--no-create-home --shell /usr/sbin/nologin appuser
sudo usermod -aG docker deployer # -a حیاتی است
sudo -u appuser ls -l /opt # اجرای یک دستور بهنام کاربر دیگر
sudo -l ; sudo visudo # مجوزهای من / ویرایش امن sudoers
usermod -G بدون -a خطرناک است: کاربر را از همهٔ گروههای ثانویهٔ دیگر — از جمله sudo یا wheel — بیرون میاندازد. همیشه -aG. ضمناً عضویت جدید در نشستهای باز اعمال نمیشود؛ باید دوباره وارد شوی.
کمترین دسترسی، عملی: بهجای ALL=(ALL) NOPASSWD:ALL به deployer، در /etc/sudoers.d/deployer فقط همان چند دستور را مجاز کن:
deployer ALL=(root) NOPASSWD: /bin/systemctl restart myapp, /bin/systemctl status myapp
فایلهای /etc/sudoers.d/ باید 440 باشند و با visudo -c -f <file> اعتبارسنجی شوند؛ یک خطای نحوی میتواند sudo را برای همه از کار بیندازد.
۶. پروسهها و سیگنالها
هر پروسه یک آشپز است: شمارهای دارد (PID)، توسط آشپز دیگری استخدام شده (PPID)، منابع مصرف میکند و دستور میگیرد (سیگنال). مدیر آشپزخانه (کرنل) تصمیم میگیرد هر لحظه چه کسی روی کدام اجاق (CPU core) کار کند.
ps -ef --forest # درخت والد-فرزند
ps -eo pid,ppid,user,%cpu,%mem,rss,etime,cmd --sort=-%cpu | head -15
pgrep -af java # PID + خط فرمان کامل
top -p $(pgrep -d, -f myapp.jar)
خواندن top برای یک پروسهٔ جاوا:
%CPUمیتواند از ۱۰۰ بیشتر باشد؛ ۱۰۰ یعنی یک هستهٔ کامل، پس760روی ماشین ۸ هستهای یعنی تقریباً اشباع.RES= حافظهٔ فیزیکی واقعی و از-Xmxبزرگتر است و باید باشد: heap + metaspace + code cache + پشتهٔ نخها + direct buffer + خود JVM.VIRTبرای JVM تقریباً بیمعنی است.- کلید
1درtopهستهٔ به هسته را نشان میدهد وHبهجای پروسه، نخها را.
stateDiagram-v2
[*] --> R: fork + exec
R: R (running / runnable)
S: S (interruptible sleep)
D: D (uninterruptible sleep, disk I/O)
Z: Z (zombie, exited, not reaped)
R --> S: waits for I/O or lock
S --> R: event arrives
R --> D: blocking disk or NFS I/O
D --> R: I/O completes
R --> Z: exit()
Z --> [*]: parent calls wait()
D و zombie: پروسهای در وضعیت D با kill -9 هم نمیمیرد چون منتظر I/O است (دیسک کند یا NFS قطعشده)؛ راهحل رفع I/O است نه kill. پروسهٔ Z (<defunct>) مرده و فقط منتظر است والد کد خروجش را بخواند؛ حافظه مصرف نمیکند ولی انبوه شدنشان یعنی والد باگ دارد. در کانتینر اگر JVM را PID 1 اجرا کنی و فرزند بسازد zombie جمع میشود — به همین دلیل --init یا tini (فصل containers-jvm).
| سیگنال | عدد | معنی | قابل گرفتن؟ | برای JVM |
|---|---|---|---|---|
SIGTERM |
15 | «لطفاً تمام کن» (پیشفرض kill) |
بله | shutdown hook ها اجرا میشوند |
SIGINT |
2 | Ctrl+C | بله | مثل TERM |
SIGKILL |
9 | فوری، بدون مذاکره | خیر | هیچ hook ی اجرا نمیشود |
SIGHUP |
1 | قطع ترمینال | بله | در بسیاری سرویسها = reload config |
SIGQUIT |
3 | خروج + core | بله | JVM: thread dump روی stdout |
kill -TERM 1234 ; kill -9 1234 ; kill -3 1234 ; pkill -f 'myapp.jar'
./long-task.sh & ; jobs ; fg %1 ; disown -h %1 # job control
nohup ./run.sh > out.log 2>&1 & # مقاوم به SIGHUP
SIGKILL را کرنل مستقیم اعمال میکند و JVM اصلاً خبردار نمیشود: shutdown hook اجرا نمیشود، connection pool بسته نمیشود، تراکنشهای در جریان rollback نمیشوند و بافر لاگ flush نمیشود.
ترتیب درست: kill -15 (یا systemctl stop) ← ۳۰ ثانیه صبر ← thread dump بگیر تا بفهمی چرا گیر کرده ← بعد kill -9. اگر مستقیم -9 بزنی، شواهد لازم برای فهمیدن علت را هم نابود کردهای.
nohup java -jar app.jar & سرویس را تا اولین کرش یا ریبوت بالا نگه میدارد؛ بعد از آن هیچکس بالا نمیآوردش. نه restart خودکار دارد، نه محدودیت منابع، نه ترتیب راهاندازی. برای یک migration دستی خوب است؛ برای سرویس، systemd (یا کانتینر با restart policy) تنها پاسخ حرفهای است.
۷. systemd
systemd سیستم init مدرن است — PID 1 که بقیه را راهاندازی و بر آنها نظارت میکند؛ واحد کارش «unit» است.
# /etc/systemd/system/myapp.service
[Unit]
Description=My Backend Service
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=appuser
Group=appgroup
WorkingDirectory=/opt/myapp
EnvironmentFile=-/etc/myapp/env
ExecStart=/usr/bin/java -XX:MaxRAMPercentage=70 \
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/log/myapp \
-Xlog:gc*:file=/var/log/myapp/gc.log:time,uptime,level,tags:filecount=5,filesize=20M \
-jar /opt/myapp/app.jar
SuccessExitStatus=143
Restart=on-failure
TimeoutStopSec=60
LimitNOFILE=65535
UMask=0027
NoNewPrivileges=true
[Install]
WantedBy=multi-user.target
چهار نکتهٔ حرفهای در همین فایل: SuccessExitStatus=143 چون JVM بعد از SIGTERM با کد 128+15=143 خارج میشود و بدون آن systemd هر توقف عادی را «شکست» گزارش میکند؛ TimeoutStopSec مهلت graceful shutdown قبل از SIGKILL؛ LimitNOFILE سقف file descriptor؛ و NoNewPrivileges سختسازی رایگان.
sequenceDiagram
participant Admin
participant systemd
participant JVM
Admin->>systemd: systemctl start myapp
systemd->>JVM: fork + exec ExecStart
Note over systemd,JVM: stdout/stderr piped to journald
Admin->>systemd: systemctl stop myapp
systemd->>JVM: SIGTERM
JVM->>JVM: run shutdown hooks
JVM-->>systemd: exit 143
Note over systemd: after TimeoutStopSec -> SIGKILL
sudo systemctl daemon-reload # بعد از هر تغییر unit — فراموشش نکن
sudo systemctl enable --now myapp
systemctl cat myapp # محتوای واقعی unit + drop-in ها
systemctl show myapp -p LimitNOFILE -p MemoryMax
systemctl list-units --type=service --state=failed
sudo systemctl edit myapp # drop-in override بساز (ترجیح بده)
journalctl -u myapp -f
journalctl -u myapp --since "10 min ago" -p err --no-pager
journalctl -k --since "1 hour ago" # لاگ کرنل — اینجا OOM killer را میبینی
drop-in بهجای ویرایش unit اصلی: systemctl edit myapp فایلی در /etc/systemd/system/myapp.service.d/override.conf میسازد؛ تغییرات با بهروزرسانی بسته پاک نمیشوند و در systemctl cat دیده میشوند. برای بازنویسی یک لیست مثل ExecStart اول باید خالیاش کنی (ExecStart=) و بعد مقدار جدید بدهی.
ماندگاری لاگ: روی بعضی توزیعها journald بهطور پیشفرض فقط در حافظه (/run/log/journal) مینویسد و با ریبوت همهچیز میرود. اگر بعد از یک کرش لاگ لازم داری، Storage=persistent را در /etc/systemd/journald.conf تنظیم کن.
۸. Pipe، redirection و exit code
هر پروسه با سه جریان استاندارد شروع میشود: 0 = stdin، 1 = stdout، 2 = stderr.
cmd > out.txt # stdout به فایل (بازنویسی) | cmd >> out.txt افزودن
cmd 2> err.txt # فقط stderr
cmd > all.txt 2>&1 # هر دو به یک فایل
cmd | tee out.txt # هم روی صفحه هم در فایل | cmd 2>/dev/null دور ریختن خطاها
cmd > file 2>&1 یعنی اول stdout به فایل برود، بعد stderr به «هرجا که stdout الان هست» → هر دو در فایل. ✅
cmd 2>&1 > file یعنی اول stderr به مقصد فعلی stdout (ترمینال) برود، بعد stdout به فایل → خطاها همچنان روی صفحهاند و در لاگ نیستند. ❌
این یک باگ خاموش در اسکریپت deploy میسازد: فکر میکنی همهچیز لاگ شده، ولی همان stack trace که لازم داری هیچجا نیست.
هر دستور یک exit code برمیگرداند: 0 موفق، هر چیز دیگر خطا. cmd1 && cmd2 یعنی دومی فقط در صورت موفقیت اولی و || برعکس. قراردادها: 126 قابل اجرا نبود، 127 دستور پیدا نشد، 130 با Ctrl+C کشته شد، و 128+N یعنی با سیگنال N کشته شده — پس 137 یعنی SIGKILL، عددی که بعداً داستان OOM killer را لو میدهد.
تلهٔ exit code در pipeline: set -e جلوی ادامهٔ اسکریپت را میگیرد، اما در pipeline فقط کد آخرین دستور مهم است؛ false | true موفق حساب میشود. چاره set -o pipefail است.
۹. ابزارهای متنی: شکار واقعی در لاگ
grep -rn 'jdbc:postgresql' /opt/myapp # بازگشتی + شمارهٔ خط
grep -c ERROR app.log ; grep -v DEBUG app.log # شمارش / نفی
grep -A5 -B5 'NullPointerException' app.log # ۵ خط قبل و بعد
grep -E 'ERROR|FATAL|Exception' app.log # regex توسعهیافته
grep -o 'traceId=[a-f0-9]*' app.log # فقط بخش تطبیقیافته
grep -rl 'password' /etc --include='*.conf' # فقط نام فایلها
zgrep -h -F 'traceId=8f3ac91b' /var/log/myapp/app.log* # شامل فایلهای gz
برای یک رشتهٔ ثابت (UUID، IP، traceId) از grep -F استفاده کن: هم سریعتر است، هم کاراکترهایی مثل . و [ دیگر بهعنوان regex تفسیر نمیشوند — grep '10.0.0.1' عملاً 10x0y0z1 را هم میگیرد.
awk '{print $1, $7}' access.log # ستونهای دلخواه
awk -F: '{print $1}' /etc/passwd # جداکنندهٔ دلخواه
awk '$9 >= 500' access.log # فیلتر شرطی روی ستون
sed -n '100,200p' app.log # فقط خطوط ۱۰۰ تا ۲۰۰
sed 's/password=[^ ]*/password=***/g' app.log # ماسک کردن
sed -i.bak 's/8080/9090/g' application.properties # ویرایش درجا با پشتیبان
sort -k3 -n -r data.txt ; sort -u list.txt ; uniq -c ; wc -l app.log
دو مثال واقعی
# ۱) پرتکرارترین استثناها در یک ساعت گذشته
journalctl -u myapp --since "1 hour ago" -o cat \
| grep -oE '[A-Za-z.]+(Exception|Error)' | sort | uniq -c | sort -rn | head -10
# ۲) ده IP پرترافیک
awk '{print $1}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -10
find /var/log -name '*.log' -mtime +7 -size +100M # قدیمی و بزرگ
find /var/log -name '*.gz' -mtime +30 -exec rm -f {} + # کارآمدتر از \;
find /opt -type f -name '*.jar' -print0 | xargs -0 ls -lh
find / -xdev -type f -size +500M 2>/dev/null | head # -xdev: از مرز فایلسیستم رد نشو
\; در برابر +: حالت \; برای هر فایل یک پروسهٔ جدید میسازد و روی صد هزار فایل فاجعه است؛ + همهٔ نامها را یکجا پاس میدهد. و اگر نام فایلی فاصله داشته باشد xargs معمولی خط را میشکند؛ همیشه -print0 | xargs -0. قبل از هر -delete اول همان دستور را با -print اجرا کن.
۱۰. آرشیو و انتقال
tar -czf logs.tar.gz /var/log/myapp/ # ساخت (c) gzip (z) file (f)
tar -xzf logs.tar.gz -C /tmp/restore # استخراج | tar -tzf ... فهرست بدون استخراج
zcat huge.log.gz | head # بدون باز کردن | zstd -19 huge.log
scp app.jar deployer@server:/opt/myapp/
rsync -avzP --delete --exclude '*.tmp' build/ deployer@server:/opt/myapp/
چرا rsync بهتر از scp است: فقط تفاوتها را میفرستد، فشرده میکند، در قطعی ادامه میدهد، دسترسیها را حفظ میکند و --dry-run دارد. تلهٔ کلاسیکش اسلش پایانی مبدأ است: rsync -a src/ dst/ محتویات src را داخل dst میریزد ولی rsync -a src dst/ خودِ پوشه را میسازد. و --delete بدون --dry-run روی مسیر اشتباه یک ابزار حذف انبوه است.
۱۱. دیسک و حافظه
df -h ; df -i # فضا و inode — دومی را فراموش نکن
du -h --max-depth=1 /var | sort -h | tail -20
lsof +L1 ; lsof -nP | grep '(deleted)' # فایلهای حذفشدهٔ هنوز باز
free -h ; vmstat 1 5 ; iostat -xz 1
lsof -p 1234 ; lsof -i :8080 ; fuser -v /var/log/app.log
خواندن درست free -h: used مصرف واقعی برنامههاست، buff/cache کش صفحات کرنل است که به محض نیاز آزاد میشود، و available تنها عددی است که باید نگاه کنی. کم بودن free مشکل نیست؛ لینوکس عمداً RAM بلااستفاده را کش میکند.
سه علت کلاسیک:
۱) فایل حذفشده که هنوز باز است. اگر کسی rm app.log بزند در حالی که JVM بازش نگه داشته، نام میرود ولی بلاکها آزاد نمیشوند؛ du نمیبیندش ولی df میبیند. تشخیص با lsof +L1، درمان restart سرویس یا : > /proc/<pid>/fd/<n>.
۲) inode تمام شده. میلیونها فایل ریز inode ها را تمام میکنند در حالی که فضا هست؛ اگر در df -i ستون IUse% صد است، مشکل تعداد فایل است نه حجم.
۳) فایلهای پنهان زیر یک mount point که با mount --bind / /mnt پیدا میشوند.
۱۲. شبکه
ip -br a ; ip r ; ip neigh
ss -ltnp # سوکتهای listen، TCP، عددی، با پروسه
ss -tanp # همهٔ اتصالات TCP | ss -s خلاصهٔ آماری
ss -tn state established '( dport = :5432 )' # اتصالات باز به دیتابیس
netstat بخشی از بستهٔ منسوخ net-tools است و روی تصاویر مینیمال اغلب نصب نیست. ss از netlink میخواند و فیلترهای قویتری دارد؛ معادل netstat -tulpn میشود ss -tulpn.
curl -I https://api.example.com/health # فقط هدرها | -v کل مکالمه شامل TLS
curl -X POST https://api.example.com/orders \
-H 'Content-Type: application/json' -H 'Authorization: Bearer TOKEN' \
-d '{"sku":"A-1","qty":2}'
curl --resolve api.example.com:443:10.0.0.7 https://api.example.com/health # دور زدن DNS
curl -f -sS --max-time 5 --retry 3 https://api.example.com/health
curl -s -o /dev/null -w 'code=%{http_code} dns=%{time_namelookup} tcp=%{time_connect} tls=%{time_appconnect} ttfb=%{time_starttransfer} total=%{time_total}\n' https://api.example.com/health
همین یک خط میگوید کندی از DNS است، از TCP، از TLS handshake یا از خودِ اپلیکیشن — یک ابزار تشخیص کامل بدون هیچ نصبی.
dig api.example.com +short ; dig @8.8.8.8 api.example.com
getent hosts api.example.com # همان مسیری که اپ میرود (nsswitch)
nc -zv -w 3 db.internal 5432 # آیا پورت باز است؟
mtr -rwc 20 api.example.com # traceroute + ping پیوسته
tcpdump -i any -nn port 5432 -c 20 # ۲۰ بستهٔ اول روی پورت دیتابیس
dig و اپ تو یک مسیر نمیروند: dig مستقیم با DNS حرف میزند و /etc/hosts را نادیده میگیرد، ولی اپ جاوا از resolver سیستم میرود که طبق /etc/nsswitch.conf اول hosts را میبیند؛ برای مسیر واقعی اپ از getent hosts استفاده کن. نکتهٔ JVM: networkaddress.cache.ttl در java.security تعیین میکند DNS چقدر داخل JVM کش شود — در محیط ابری مقدار طولانی یعنی اپ ساعتها به یک IP مرده وصل میماند.
یک درخواست HTTP از اپ تا سرویس بالادستی چهار مرحلهٔ شکستپذیر دارد — DNS، TCP، TLS و خودِ HTTP — و جدول زیر میگوید هر خطا کدام مرحله را متهم میکند.
| نشانه | معنی فنی | علتهای محتمل | اولین دستور |
|---|---|---|---|
Connection refused |
TCP RST برگشت | هیچ پروسهای listen نمیکند؛ پورت اشتباه | ss -ltnp | grep :8080 روی مقصد |
Connection timed out |
هیچ پاسخی نیامد | فایروال/Security Group بسته؛ مسیریابی غلط | nc -zv host port · mtr |
Name or service not known |
DNS جواب نداد | نام اشتباه؛ resolver خراب | getent hosts name · dig |
Connection reset by peer |
RST وسط ارتباط | سرویس مقابل کرش کرد؛ idle timeout در LB | لاگ سمت مقابل · ss -s |
برای وقتی که باید از سمت دیتابیس هم نگاه کنی، اتصالات فعال را مستقیم از موتور بپرس:
SELECT state, count(*) AS conns, max(now() - state_change) AS oldest
FROM pg_stat_activity
WHERE datname = current_database()
GROUP BY state
ORDER BY conns DESC;-- معادل اوراکل: نشستها از v$session
SELECT status, COUNT(*) AS conns, MAX(SYSDATE - logon_time) * 86400 AS oldest_sec
FROM v$session
WHERE type = 'USER'
GROUP BY status
ORDER BY conns DESC;در PostgreSQL هر اتصال یک پروسهٔ سیستمعامل است؛ ۵۰۰ اتصال یعنی ۵۰۰ پروسه با هزینهٔ حافظهٔ واقعی. در Oracle حالت پیشفرض dedicated server هم یک پروسه بهازای نشست دارد، ولی با shared server چند نشست یک پروسه را به اشتراک میگذارند — یعنی سقف امن connection pool در این دو موتور یکسان نیست (فصل rdbms-tuning).
sudo ufw status numbered ; sudo ufw allow 22/tcp # Ubuntu/Debian
sudo ufw allow from 10.0.0.0/8 to any port 8080 proto tcp
sudo firewall-cmd --permanent --add-port=8080/tcp && sudo firewall-cmd --reload # RHEL
قبل از ufw enable حتماً ufw allow 22/tcp بزن، وگرنه همان لحظه از سرور راهدور بیرون میافتی و راه برگشتی جز کنسول فیزیکی/ابری نداری. در کارهای پرریسک یک sleep 300 && ufw disable در پسزمینه بهعنوان طناب نجات بگذار.
پاسخ: Connection refused یعنی بسته رسید و طرف مقابل با RST جواب داد؛ پس مسیر شبکه سالم است و مشکل «کسی آنجا listen نمیکند» است. این با timeout کاملاً فرق دارد (timeout یعنی بسته اصلاً نرسید، معمولاً فایروال).
ترتیب: (۱) getent hosts db.internal تا مطمئن شوم نام درست resolve میشود. (۲) از خود سرور اپ nc -zv db.internal 5432. (۳) روی سرور دیتابیس ss -ltnp | grep 5432؛ اگر چیزی نبود سرویس پایین است. (۴) اگر listen میکند ولی فقط روی 127.0.0.1، مشکل bind address است نه فایروال. (۵) در آخر به شبکهٔ کانتینر شک میکنم — localhost داخل کانتینر یعنی خود کانتینر نه هاست.
و اگر خطا متناوب باشد نه دائمی، به تمام شدن connection pool یا سقف max_connections شک میکنم که مسئلهٔ ظرفیت است نه شبکه.
۱۳. SSH
ssh-keygen -t ed25519 -C "deploy@laptop" -f ~/.ssh/id_ed25519_deploy
ssh-copy-id -i ~/.ssh/id_ed25519_deploy.pub deployer@server
ssh deployer@server 'systemctl status myapp --no-pager'
چرا ed25519: کوتاهتر، سریعتر و رمزنگاریاش مدرنتر است. اگر مجبور به RSA بودی حداقل -b 4096 بده؛ DSA منسوخ و در OpenSSH جدید حذف شده است.
# ~/.ssh/config — بزرگترین صرفهجویی روزانه
Host bastion
HostName bastion.example.com
User deployer
IdentityFile ~/.ssh/id_ed25519_deploy
IdentitiesOnly yes
Host prod-*
User appuser
IdentityFile ~/.ssh/id_ed25519_prod
ProxyJump bastion
ServerAliveInterval 30
Host prod-db
HostName 10.0.3.11
LocalForward 15432 127.0.0.1:5432
حالا ssh prod-db هم از bastion رد میشود، هم کلید درست را میفرستد، هم پورت دیتابیس را روی localhost:15432 باز میکند.
flowchart LR
L["Laptop 127.0.0.1:15432"] -->|encrypted SSH| B["Bastion host"]
B -->|ProxyJump| P["prod-db host"]
P -->|localhost:5432| D[("PostgreSQL")]
ssh -L 15432:127.0.0.1:5432 deployer@server # local: پورت من -> سرویس آنطرف
ssh -R 9000:127.0.0.1:8080 deployer@server # remote: پورت سرور -> سرویس من
ssh -N -f -L 15432:db.internal:5432 deployer@bastion # بدون شل، در پسزمینه
سه اشتباه امنیتی رایج: اول، StrictHostKeyChecking=no همان محافظی است که جلوی man-in-the-middle را میگیرد؛ بهجایش fingerprint سرورها را با ssh-keyscan از قبل در known_hosts بگذار. دوم، agent forwarding (-A) به سروری که مطمئن نیستی: هر کسی که آنجا root باشد از agent تو استفاده میکند — ProxyJump بهتر است چون کلید هرگز به سرور میانی نمیرود. سوم، ~/.ssh باید 700 و کلید خصوصی و authorized_keys باید 600 باشند؛ OpenSSH عمداً کلیدهای بازتر را رد میکند.
سختسازی سمت سرور (بهتر است در فایلی داخل /etc/ssh/sshd_config.d/):
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
AllowGroups sshusers
MaxAuthTries 3
قبل از reload کردن sshd یک نشست دوم باز نگه دار. اول sudo sshd -t برای اعتبارسنجی بزن، بعد systemctl reload sshd، و ورود را از یک ترمینال دیگر تست کن؛ تنها بعد از موفقیت نشست اول را ببند.
۱۴. متغیرهای محیطی و profile
export APP_ENV=production # برای این شل و فرزندانش
env | sort ; printenv JAVA_HOME ; which -a java
APP_ENV=staging ./run.sh # فقط برای همین یک اجرا
| فایل | چه زمانی خوانده میشود |
|---|---|
/etc/profile, /etc/profile.d/*.sh |
شل ورود، برای همهٔ کاربران |
~/.bash_profile یا ~/.profile |
شل ورود، برای آن کاربر |
~/.bashrc |
شل تعاملیِ غیرِورود (ترمینال جدید) |
/etc/environment |
سراسری از طریق PAM — بدون اسکریپت، فقط KEY=value |
| unit فایل systemd | Environment= و EnvironmentFile= |
نه cron و نه systemd، .bashrc را نمیخوانند — cron با PATH بسیار کوتاهی اجرا میشود و JAVA_HOME هم ندارد. در کارهای زمانبندیشده مسیر مطلق بنویس، متغیرها را صریح تعریف کن، یا در unit از EnvironmentFile= استفاده کن.
secret در متغیر محیطی امن نیست: مقدار متغیرهای یک پروسه در /proc/<pid>/environ خواندنی است، در systemctl show میآید، و اگر بهصورت آرگومان خط فرمان پاس داده شود در ps برای هر کاربری دیده میشود. هرگز رمز را آرگومان نده؛ EnvironmentFile باید 600 و متعلق به root باشد؛ و برای production از secret manager استفاده کن.
۱۵. زمانبندی: cron و systemd timer
قالب پنجستونی: دقیقه، ساعت، روز ماه، ماه، روز هفته (0 و 7 هر دو یکشنبه).
SHELL=/bin/bash
PATH=/usr/local/bin:/usr/bin:/bin
MAILTO=""
*/5 * * * * /usr/bin/flock -n /tmp/health.lock /opt/myapp/bin/healthcheck.sh >> /var/log/myapp/health.log 2>&1
30 2 * * * /usr/bin/find /var/log/myapp -name '*.log' -mtime +14 -delete
سه تلهٔ همیشگی cron: اول، % به newline تبدیل میشود پس date +%Y-%m-%d میشکند و باید \% بنویسی. دوم، بدون redirect خروجی به ایمیل میرود و روی سرور بدون MTA ناپدید میشود — همیشه >> logfile 2>&1. سوم، کاری که هر ۵ دقیقه اجرا میشود و گاهی ۷ دقیقه طول میکشد روی خودش سوار میشود؛ با flock -n قفل بگذار.
# /etc/systemd/system/myapp-cleanup.timer
[Unit]
Description=Run log cleanup daily
[Timer]
OnCalendar=*-*-* 02:30:00
RandomizedDelaySec=300
Persistent=true
Unit=myapp-cleanup.service
[Install]
WantedBy=timers.target
sudo systemctl enable --now myapp-cleanup.timer ; systemctl list-timers --all
systemd-analyze calendar '*-*-* 02:30:00' # اعتبارسنجی عبارت زمانی
sudo systemctl start myapp-cleanup.service # اجرای دستی برای تست
| معیار | cron | systemd timer |
|---|---|---|
| سادگی | یک خط، همهجا هست | دو فایل، فقط روی systemd |
| لاگ | خودت باید redirect کنی | خودکار در journald |
| اجرای ازدسترفته بعد از خاموشی | انجام نمیشود | با Persistent=true انجام میشود |
| جلوگیری از همپوشانی | دستی با flock |
خودکار |
| محدودیت منابع و jitter | ندارد | MemoryMax, CPUQuota, RandomizedDelaySec |
| تست دستی | باید دستور را کپی کنی | systemctl start <service> |
برای یک کار شخصی روی یک ماشین cron کافی است. برای هر کاری که در production اهمیت دارد — لاگ، جلوگیری از همپوشانی، سقف حافظه، جبران اجرای ازدسترفته — systemd timer انتخاب حرفهای است. روی Kubernetes پاسخ سوم CronJob است.
پاسخ: تقریباً همیشه محیط. cron شل ورود اجرا نمیکند، پس .bashrc و /etc/profile خوانده نمیشوند: PATH بسیار کوتاه است و java یا ابزارهای /usr/local/bin پیدا نمیشوند، JAVA_HOME نیست، پوشهٔ کاری خانهٔ کاربر است پس مسیرهای نسبی میشکنند، % به newline تبدیل میشود، و خروجی بدون redirect گم میشود.
روش تشخیص: در ابتدای اسکریپت env > /tmp/cron-env.txt بگذارم و با env دستی مقایسه کنم، یا با env -i /bin/sh -c '/path/script.sh' محیط تهی را شبیهسازی کنم. اصلاح: مسیرهای مطلق، تعریف صریح متغیرها، cd صریح، و >> /var/log/... 2>&1 — و برای هر کار مهمی systemd timer را ترجیح میدهم چون محیط و لاگش قابل بازرسی است.
۱۶. مدیریت بسته
| کار | Debian / Ubuntu | RHEL / Fedora / Rocky |
|---|---|---|
| بهروزرسانی فهرست | sudo apt update |
sudo dnf check-update |
| نصب / حذف | sudo apt install nginx · remove |
sudo dnf install nginx · remove |
| جزئیات و فایلهای بسته | apt show pkg · dpkg -L pkg |
dnf info pkg · rpm -ql pkg |
| این فایل از کدام بسته است؟ | dpkg -S /usr/bin/java |
rpm -qf /usr/bin/java |
| چه چیزی این دستور را میدهد؟ | apt-file search bin/jcmd |
dnf provides */jcmd |
| نسخههای موجود و قفل نسخه | apt policy pkg · apt-mark hold pkg |
dnf --showduplicates list pkg · dnf versionlock add pkg |
| فهرست نصبشدهها | apt list --installed |
rpm -qa |
sudo update-alternatives --config java # Debian/Ubuntu — انتخاب JDK فعال
sudo alternatives --config java # RHEL
readlink -f $(which java) # کدام JDK واقعاً اجرا میشود
apt upgrade را در ساعت کاری نزن: میتواند نسخهٔ JDK، کتابخانهٔ TLS یا کرنل را عوض کند و سرویسها را restart کند. نسخهها را قفل کن، فقط وصلهٔ امنیتی را خودکار کن، و ارتقای بزرگ را در پنجرهٔ تعمیرات انجام بده؛ needrestart روی Ubuntu میگوید کدام سرویس به restart نیاز دارد.
۱۷. اسکریپتنویسی bash
سه خط استاندارد شروع هر اسکریپت جدی:
#!/usr/bin/env bash
set -Eeuo pipefail
-E یعنی trap ERR در توابع هم ارث میرسد، -e یعنی با اولین خطا خارج شو، -u یعنی متغیر تعریفنشده خطاست (جلوی rm -rf $UNSET/ را میگیرد)، و pipefail یعنی شکست در هر جای pipeline، شکست کل است.
#!/usr/bin/env bash
set -Eeuo pipefail
readonly APP_NAME="myapp"
readonly APP_DIR="/opt/${APP_NAME}"
readonly HEALTH_URL="http://127.0.0.1:8080/actuator/health"
readonly TIMEOUT_SECONDS=90
log() { printf '%s [%s] %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$1" "${*:2}"; }
die() { log ERROR "$*"; exit 1; }
usage() { echo "Usage: deploy.sh -v <version>" >&2; exit 64; }
trap 'die "failed at line $LINENO"' ERR
wait_for_health() {
local deadline=$(( SECONDS + TIMEOUT_SECONDS )) code
while (( SECONDS < deadline )); do
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 3 "$HEALTH_URL" || echo 000)
[[ "$code" == "200" ]] && { log INFO "healthy after ${SECONDS}s"; return 0; }
log INFO "waiting, got HTTP $code"
sleep 3
done
return 1
}
main() {
local version=""
while getopts ":v:h" opt; do
case "$opt" in
v) version="$OPTARG" ;;
h) usage ;;
\?) die "unknown option -$OPTARG" ;;
:) die "option -$OPTARG requires an argument" ;;
esac
done
[[ -n "$version" ]] || usage
[[ -d "$APP_DIR/releases/$version" ]] || die "release $version not found"
ln -sfn "$APP_DIR/releases/$version" "$APP_DIR/current"
systemctl restart "$APP_NAME"
if wait_for_health; then
log INFO "deploy of $version succeeded"
else
log WARN "health check failed, rolling back"
ln -sfn "$APP_DIR/releases/previous" "$APP_DIR/current"
systemctl restart "$APP_NAME"
die "rolled back"
fi
}
main "$@"
نکتههای زبانی که باید بشناسی:
"$var" # همیشه در کوتیشن — جلوی word splitting را میگیرد
"${var:-default}" # مقدار پیشفرض | "${var:?msg}" خروج با خطا اگر تعریف نشده
"$@" # همهٔ آرگومانها، هرکدام یک کلمهٔ جدا (تقریباً همیشه درست)
$? $# $$ # exit code آخرین دستور · تعداد آرگومانها · PID اسکریپت
$(command) # جایگزینی فرمان $(( 3 + 4 )) محاسبهٔ عددی
[[ -f "$f" ]] ; [[ -z "$s" ]] ; [[ "$a" =~ ^[0-9]+$ ]]
نقلقول نگذاشتن، پرتکرارترین باگ bash است:
FILE="my report.log"
rm $FILE # ❌ تلاش برای حذف دو فایل: 'my' و 'report.log'
rm "$FILE" # ✅
اگر متغیر خالی باشد بدتر میشود: rm -rf $DIR/* وقتی DIR خالی است یعنی rm -rf /*. ترکیب set -u و همیشهکوتیشنگذاشتن این کلاس کامل از فاجعه را حذف میکند، و shellcheck در CI تقریباً همهٔ این موارد را خودکار میگیرد.
trap دو کار حیاتی میکند: trap cleanup EXIT تضمین میکند فایل موقت و lock حتی در صورت خطا پاک شوند؛ و اگر اسکریپت تو داخل کانتینر PID 1 است، trap 'kill -TERM $child' TERM INT باعث میشود سیگنال توقف واقعاً به پروسهٔ جاوا برسد — بدون آن هر docker stop بعد از timeout به kill -9 ختم میشود و graceful shutdown از دست میرود.
۱۸. عیبیابی JVM روی لینوکس
اینجا قلب فصل است — جایی که دانش لینوکس و جاوا به هم میرسند. (مبانی heap، GC و مدل حافظهٔ JVM در فصل jvm-internals آمده؛ اینجا فقط عملیات روی سرور.)
ابزارها
همهٔ اینها با خود JDK میآیند؛ اگر روی سرور فقط JRE داری هیچکدام نیستند.
jps -lvm # پروسههای جاوا + کلاس اصلی + آرگومانها
jcmd <pid> help # فهرست کامل دستورهای در دسترس همان JVM
jcmd <pid> VM.command_line # اپ واقعاً با چه فلگهایی بالا آمده؟
jcmd <pid> VM.flags -all | grep -i heap # مقادیر مؤثر واقعی | VM.uptime
سیاست رسمی از JDK 8 به بعد این است که jcmd جایگزین ابزارهای جداگانه شود: یک باینری و امکاناتی که بقیه ندارند (VM.native_memory، JFR.*، Thread.dump_to_file). ضمناً در JDK های جدید jmap -heap دیگر وجود ندارد؛ معادلش jcmd <pid> GC.heap_info یا jhsdb jmap --heap --pid <pid> است. روی هر سروری اول jcmd <pid> help بزن.
پیدا کردن نخِ داغ
top -H -b -n 1 -p 12345 | head -20 # نخهای پرمصرف
ps -L -o tid,pcpu,comm -p 12345 --sort=-pcpu | head -10 # جایگزین
printf '%x\n' 12401 # TID به هگز -> 3071
jcmd 12345 Thread.print -l > /tmp/td.txt ; grep -A 30 'nid=0x3071' /tmp/td.txt
flowchart TD
A["Alert: high CPU"] --> B["top -H -p PID"]
B --> C{"Hot threads named GC Thread#n?"}
C -- yes --> D["Memory problem: jstat -gcutil + GC log"]
C -- no --> E["printf '%x' TID -> hex nid"]
E --> F["jcmd PID Thread.print -l"]
F --> G["grep nid=0xHEX in three dumps"]
G --> H{"Same stack every time?"}
H -- yes --> I["Hot loop in application code"]
H -- no --> J["Just busy: profile with JFR"]
یک thread dump کافی نیست. یک عکس لحظهای نمیگوید چه چیزی «گیر» است و چه چیزی فقط مشغول کار عادی؛ سه تا بگیر با فاصلهٔ ۵ تا ۱۰ ثانیه. نخی که در هر سه در همان خط است واقعاً بلاک شده. و حتماً -l بزن، وگرنه قفلهای java.util.concurrent را نمیبینی و deadlock های مبتنی بر ReentrantLock پنهان میمانند (فصل sync-locks-jmm).
jcmd 12345 Thread.print -l > /tmp/td-$(date +%s).txt # روش ارجح
jcmd 12345 Thread.dump_to_file -format=json /tmp/td.json # شامل virtual thread ها
jstack -l 12345 > /tmp/td.txt # ابزار قدیمیتر
kill -3 12345 # SIGQUIT: dump روی stdout پروسه
kill -3 خروجی را کجا مینویسد؟ روی stdout خود پروسه، نه ترمینال تو. با systemd یعنی journalctl -u myapp؛ و اگر stdout به /dev/null رفته باشد dump کاملاً گم میشود. jcmd امنتر است چون خودش خروجی را به تو میدهد، ولی مزیت kill -3 این است که به هیچ ابزاری نیاز ندارد و در کانتینر بدون JDK هم کار میکند.
heap dump و GC log
jcmd 12345 GC.heap_dump -gz=1 /var/tmp/heap-$(date +%s).hprof.gz
jcmd 12345 GC.class_histogram | head -30 # سبکتر: فقط شمارش اشیا
jcmd 12345 GC.heap_info
سه واقعیت قبل از زدن Enter: اول، فایل تقریباً به بزرگی heap زنده است و اگر پارتیشن پر شود اپهای دیگر هم میمیرند. دوم، dump به یک safepoint نیاز دارد و چند ثانیه تا چند ده ثانیه کل JVM را فریز میکند؛ اول نود را از load balancer خارج کن. سوم، heap dump حاوی رمزها، توکنها و دادهٔ شخصی است؛ مثل یک دارایی محرمانه با آن رفتار کن. اگر فقط میخواهی بدانی «چه چیزی حافظه را پر کرده»، GC.class_histogram سبکتر و اغلب کافی است.
از JDK 9 لاگگیری یکپارچه (-Xlog) جایگزین فلگهای قدیمی مثل PrintGCDetails شده:
-Xlog:gc*:file=/var/log/myapp/gc.log:time,uptime,level,tags:filecount=5,filesize=20M
jstat -gcutil 12345 1000 ; jstat -gccause 12345 1000 # آمار زندهٔ GC هر ثانیه
jcmd 12345 VM.log output=/var/log/myapp/gc-live.log what=gc* # روشن کردن در حین اجرا
ستونهای jstat -gcutil: S0/S1 survivor، E eden، O old، M metaspace، YGC/YGCT تعداد و زمان GC جوان، FGC/FGCT همان برای full GC، و GCT زمان کل.
نشانهٔ واقعی «heap دارد تمام میشود» فقط بالا بودن O نیست؛ old generation قرار است پر شود. الگوی خطرناک این است که بعد از هر full GC هم O بالا میماند، کفش هر بار بالاتر میرود و FGC سریع زیاد میشود — یعنی GC چیزی برای آزاد کردن پیدا نمیکند (نشتی حافظه یا کش بیسقف). حالت بیصداتر «GC thrashing» است: اپ زنده است و OOM نمیدهد ولی بیشتر وقتش را در GC میگذراند و از بیرون فقط شبیه «سرویس کند شده» است.
OutOfMemoryError جاوا در برابر OOM killer لینوکس
java.lang.OutOfMemoryError |
OOM killer کرنل | OOMKilled کانتینر |
|
|---|---|---|---|
| چه کسی تصمیم میگیرد | خود JVM | کرنل لینوکس | کرنل، بر اساس محدودیت cgroup |
| علت | heap/metaspace/نخ به سقف JVM رسید | کل RAM ماشین تمام شد | مصرف کانتینر از memory.max رد شد |
| نشانه | استثنا در لاگ اپ + stack trace | پروسه بیصدا ناپدید شد | کانتینر restart شد |
| کد خروج | معمولاً غیرصفر با لاگ | 137 (128+9) |
137 |
| کجا ثبت میشود | لاگ اپلیکیشن | dmesg, journalctl -k |
رویدادهای orchestrator + dmesg نود |
| درمان | نشتی را پیدا کن یا -Xmx را تنظیم کن |
مصرف کل ماشین را کم کن | limit یا MaxRAMPercentage را تنظیم کن |
dmesg -T | grep -i -E 'out of memory|oom-kill|killed process'
journalctl -k --since "1 hour ago" | grep -i oom ; cat /proc/12345/oom_score_adj
یک خط نمونهٔ واقعی در dmesg:
Out of memory: Killed process 12345 (java) total-vm:9812344kB, anon-rss:4102300kB, UID:1001 oom_score_adj:0
anon-rss میگوید پروسه در لحظهٔ مرگ چقدر حافظهٔ فیزیکی خصوصی داشت — همان عددی که باید با -Xmx و بقیهٔ نواحی مقایسه کنی.
پیام بعد از دونقطه را بخوان:
Java heap space→ heap واقعاً پر است.GC overhead limit exceeded→ GC بیش از ۹۸٪ زمان را میگیرد و کمتر از ۲٪ heap آزاد میکند.Metaspace→ کلاسهای زیادی لود شده (نشتی classloader).unable to create native thread→ heap مشکل نیست؛ به سقف نخهای سیستمعامل خوردهای (ulimit -u،/proc/sys/kernel/threads-max،pids.maxکانتینر).Direct buffer memory→-XX:MaxDirectMemorySizeیا نشتی در NIO/Netty.
بالا بردن -Xmx برای موارد سوم به بعد اوضاع را بدتر میکند، چون حافظهٔ کمتری برای نواحی بومی میماند.
File descriptor ها
cat /proc/12345/limits | grep 'open files' # تنها منبع حقیقت برای این پروسه
ls /proc/12345/fd | wc -l # تعداد fd باز همین حالا
lsof -p 12345 | awk '{print $5}' | sort | uniq -c # تفکیک نوع
تلهٔ ulimit: ulimit -n در شل تو ربطی به سرویس ندارد — سرویس systemd سقفش را از LimitNOFILE= میگیرد نه از /etc/security/limits.conf که فقط برای نشستهای PAM است؛ مقدار واقعی را از /proc/<pid>/limits بخوان. و fd فقط فایل نیست: هر سوکت TCP، هر اتصال دیتابیس و هر epoll یک fd است.
حافظه در کانتینر
JVM های مدرن محدودیت cgroup را میفهمند و -XX:+UseContainerSupport پیشفرض روشن است. ولی مقدار پیشفرض MaxRAMPercentage برابر ۲۵ درصد است — یعنی در کانتینر یک گیگی، heap فقط ۲۵۶ مگابایت میشود.
java -XX:+PrintFlagsFinal -version | grep -E 'MaxHeapSize|MaxRAMPercentage|UseContainerSupport'
nproc # چند CPU برای این پروسه در دسترس است
cat /sys/fs/cgroup/memory.max # 'max' یعنی بدون محدودیت (cgroup v2)
cat /sys/fs/cgroup/memory.current # و memory.peak برای اوج مصرف
cat /sys/fs/cgroup/memory.events # شمارندهٔ oom و oom_kill
cat /sys/fs/cgroup/cpu.max
java -XX:MaxRAMPercentage=70 -XX:MaxMetaspaceSize=256m \
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/log/myapp \
-XX:+ExitOnOutOfMemoryError -jar app.jar
چون heap تنها بخشی از حافظهٔ پروسه است:
RSS ≈ heap + metaspace + compressed class space + code cache + ساختارهای GC + (تعداد نخ × ~1MB stack) + direct/mapped ByteBuffer + بافرهای native کتابخانهها + سرریز malloc
روی یک اپ Spring Boot معمولی مجموع غیرِheap بهراحتی ۳۰۰ تا ۷۰۰ مگابایت است؛ پس -Xmx2g در limit دو گیگی تقریباً همیشه یعنی کشته شدن. بهجای عدد ثابت -XX:MaxRAMPercentage=70..75 بده. و اگر کانتینر swap نداشته باشد (پیشفرض Kubernetes) حاشیهٔ امنی نیست — یک بایت رد شدن از limit یعنی SIGKILL فوری.
Native Memory Tracking و JFR
وقتی RES رشد میکند ولی heap ثابت است:
java -XX:NativeMemoryTracking=summary -jar app.jar # هزینه حدود ۵ تا ۱۰ درصد
jcmd 12345 VM.native_memory baseline
# ... چند ساعت صبر ...
jcmd 12345 VM.native_memory summary.diff scale=MB
خروجی حافظه را به دستههای Java Heap، Class، Thread، Code، GC و Internal تفکیک میکند: رشد در Thread یعنی نشتی نخ، در Class یعنی نشتی classloader، و در Internal/Other معمولاً direct buffer یا JNI.
jcmd 12345 JFR.start name=diag settings=profile duration=120s filename=/var/tmp/rec.jfr
JFR سربار بسیار کمی دارد و برای مشکلات متناوبی که در لحظهٔ حضور تو رخ نمیدهند ایدهآل است.
| نشانه | اولین دستور | چه چیزی را ثابت میکند |
|---|---|---|
| CPU بالا و پایدار | top -H -p <pid> + jcmd Thread.print |
کدام نخ و کدام خط کد |
| latency ناگهان بد شد | jstat -gcutil <pid> 1000 |
GC thrashing یا نه |
| مصرف حافظه مدام بالا میرود | jcmd GC.class_histogram سپس heap dump |
چه کلاسی انباشته میشود |
RES بالا ولی heap ثابت |
jcmd VM.native_memory summary.diff |
نشتی خارج از heap |
| پروسه بیصدا ناپدید شد | dmesg -T | grep -i oom |
OOM killer یا نه |
Too many open files |
ls /proc/<pid>/fd | wc -l + /proc/<pid>/limits |
نشتی fd یا سقف کم |
| هنگ کرده با CPU صفر / کندی نادر | سه Thread.print با فاصله · JFR.start settings=profile |
deadlock یا انتظار I/O |
پاسخ: ۸۰۰٪ یعنی حدود هشت هسته مداوم مشغولاند. (۱) top -H -p <pid> تا نخهای پرمصرف را ببینم. (۲) اگر نامشان GC Thread#n باشد مسئله CPU نیست بلکه حافظه است و میروم سراغ jstat -gcutil و GC log. (۳) اگر نخ اپلیکیشن است، TID را با printf '%x\n' به هگز میبرم و در سه thread dump متوالی دنبال nid=0x... میگردم. (۴) اگر هر سه همان stack را نشان دهند، یک حلقهٔ داغ یا regex فاجعهبار یا سریالسازی سنگین داریم. (۵) اگر ابزار بیشتری باشد ۳۰ ثانیه JFR میگیرم تا بهجای حدس، توزیع واقعی زمان CPU را ببینم.
برای مهار فوری نود را از load balancer خارج میکنم و اگر با یک deploy جدید همبسته است rollback. نکتهای که امتیاز میآورد: تفکیک «CPU بالا بهخاطر بار» از «CPU بالا بهخاطر GC» را همان اول انجام میدهم.
پاسخ: خیر، تقریباً همیشه برعکس. 137 = 128 + 9 یعنی پروسه با SIGKILL کشته شده، و JVM نمیتواند خودش را با SIGKILL بکشد؛ کرنل این کار را کرده، معمولاً بهخاطر رد شدن از محدودیت حافظهٔ cgroup.
تشخیص: dmesg -T | grep -i oom-kill روی خود نود، شمارندهٔ oom_kill در /sys/fs/cgroup/memory.events، و در Kubernetes دیدن Reason: OOMKilled در lastState. و مهمتر از همه، لاگ اپ: اگر java.lang.OutOfMemoryError در آن نیست، پس JVM سالم بوده و کرنل او را کشته.
علت غالب: -Xmx نزدیک به کل limit تنظیم شده و فضایی برای metaspace، code cache، پشتهٔ نخها و direct buffer نمانده. درمان: -XX:MaxRAMPercentage=70، سقف صریح برای MaxMetaspaceSize و MaxDirectMemorySize، و در صورت تکرار VM.native_memory.
پاسخ: چون -Xmx فقط سقف heap است در حالی که RES کل حافظهٔ فیزیکی پروسه است: metaspace، code cache، ساختارهای داخلی GC، پشتهٔ هر نخ (پیشفرض حدود یک مگابایت روی ۶۴ بیت)، direct و mapped ByteBuffer ها، حافظهٔ بومی کتابخانهها و سرریز allocator.
پس بزرگتر بودن RES طبیعی است. نگرانی وقتی درست است که RES مدام رشد کند در حالی که heap بعد از full GC ثابت است. آن وقت: jstat -gcutil تا مطمئن شوم heap پایدار است، بعد VM.native_memory baseline و کمی بعد summary.diff تا ببینم کدام دسته رشد میکند — Thread یعنی نشتی نخ، Class یعنی نشتی classloader، Internal/Other یعنی direct buffer یا JNI. اگر NMT فعال نبود، دستکم ls /proc/<pid>/task | wc -l و pmap -x <pid> | tail -1.
و برای ظرفیتسنجی سقف کانتینر را با فرض heap ≈ ۷۰٪ limit تعیین میکنم نه heap = limit.
پاسخ: jcmd <pid> Thread.print -l را با کاربر همان سرویس اجرا میکنم (sudo -u appuser)، چون ابزارهای attach فقط برای همان UID کار میکنند. سربارش یک safepoint کوتاه است پس downtime عملی ندارد؛ سه dump با فاصلهٔ ده ثانیه و با timestamp ذخیره میکنم.
در dump دنبال اینها میگردم: بخش Found one Java-level deadlock در انتها؛ توزیع وضعیتها (چند RUNNABLE، BLOCKED، WAITING)؛ نخهای BLOCKED و اینکه روی کدام monitor منتظرند (waiting to lock <0x...> در برابر locked <0x...>)؛ و الگوی تکرارشونده — اگر دویست نخ یک stack مشابه دارند، همانجا گلوگاه است.
اگر JDK روی کانتینر نصب نیست kill -3 <pid> میزنم و dump را از stdout سرویس برمیدارم؛ و در نسخههای جدیدتر Thread.dump_to_file -format=json کاملتر است چون نخهای مجازی را هم پوشش میدهد (فصل virtual-threads).
پاسخ: CPU صفر + بیپاسخی یعنی نخها منتظرند نه مشغول. سه سناریوی اصلی: deadlock، تمام شدن thread pool، یا انتظار روی یک منبع خارجی بدون timeout.
سه Thread.print -l با فاصلهٔ ده ثانیه میگیرم. اول دنبال Found one Java-level deadlock میگردم چون JVM خودش deadlock های مبتنی بر monitor را گزارش میکند. اگر نبود توزیع وضعیت نخها را میشمارم: دهها نخ http-nio-*-exec-* در حالت WAITING روی getConnection یعنی connection pool تمام شده؛ انبوه نخها روی socketRead0 یعنی منتظر سرویس بیرونی بدون timeout هستیم و با ss -tanp | grep <pid> میبینم به کجا وصلند.
درمان کوتاهمدت restart است؛ درمان واقعی timeout روی هر فراخوانی شبکهای و bulkhead/circuit breaker است (فصل resilience) — هر فراخوانی بدون timeout یک deadlock توزیعشدهٔ بالقوه است.
پاسخ: اول اندازهگیری: ls /proc/<pid>/fd | wc -l برای مصرف فعلی، cat /proc/<pid>/limits برای سقف واقعی (نه ulimit -n شل من)، و lsof -p <pid> | awk '{print $5}' | sort | uniq -c برای تفکیک نوع — انبوه IPv4 یعنی سوکت، REG یعنی فایل، pipe یعنی پروسهٔ فرزند.
اگر عدد یکنواخت بالا میرود نشتی است و بالا بردن سقف فقط زمان خرابی را عقب میاندازد؛ دنبال منبعی میگردم که close() نمیشود — response بدون بستن، استریم بدون try-with-resources، یا connection pool ای که اتصال را برنمیگرداند. اگر عدد پایدار ولی نزدیک سقف است، ظرفیت واقعاً کم است: LimitNOFILE=65535 در unit، بعد daemon-reload و restart، و تأیید با cat /proc/<pid>/limits. برای آینده متریک تعداد fd را با هشدار روی ۸۰٪ سقف اضافه میکنم.
پاسخ: kill <pid> یعنی SIGTERM (۱۵): JVM آن را میگیرد، shutdown hook ها را اجرا میکند، pool ها را میبندد و تمیز با کد ۱۴۳ خارج میشود. kill -9 یعنی SIGKILL که قابل گرفتن نیست؛ کرنل پروسه را فوراً حذف میکند و هیچ hook، flush یا rollback ی رخ نمیدهد — نتیجهاش تراکنش ناتمام، پیام ack نشده و لاگ ازدسترفته است. kill -3 یعنی SIGQUIT که پروسه را نمیکشد؛ JVM آن را درخواست thread dump تفسیر میکند. ترتیب درست: TERM ← صبر ← thread dump ← و فقط در نهایت KILL.
نکتهٔ عملیاتی: در systemd این رفتار را با KillSignal=، TimeoutStopSec= و SuccessExitStatus=143 مدیریت میکنم، و در کانتینر باید مطمئن شوم JVM واقعاً PID 1 است یا یک init درست وجود دارد، وگرنه SIGTERM هرگز به JVM نمیرسد.
۱۹. Cheat-sheet بقا
وقتی پیجر زنگ میزند ترتیب ثابت این است: سلامت ماشین (uptime, free -h, df -h, df -i) ← پرمصرفترین پروسه ← لاگ سرویس ← لاگ کرنل برای OOM ← شبکه ← وضعیت JVM.
restart تقریباً همیشه علائم را برطرف میکند و همیشه شواهد را نابود میکند. سی ثانیه وقت برای یک thread dump و یک class histogram، تفاوت بین «حل شد» و «جمعه شب دوباره اتفاق میافتد و باز هم نمیدانیم چرا» است. یک collect-diagnostics.sh بنویس — وسط حادثه کسی حوصلهٔ تایپ ندارد.
| میخواهم… | دستور |
|---|---|
| دنبال کردن لاگ زنده | tail -F app.log |
| جستجوی بازگشتی و فشرده | grep -rn 'p' /path --include='*.log' · zgrep -h -F 'p' app.log* |
| ده خطای پرتکرار | grep ERROR app.log | sort | uniq -c | sort -rn | head |
| فایل بزرگ و دسترسی | find /var -xdev -type f -size +500M · chown user:group f · namei -l /path |
| پروسه و نخ پرمصرف | ps -eo pid,%cpu,rss,cmd --sort=-%cpu | head · top -H -p PID |
| توقف مؤدبانه / اجباری | kill -15 PID · بعد از dump kill -9 PID |
| سرویس و لاگش | systemctl restart myapp · journalctl -u myapp -f -p err |
| فضای دیسک و inode | df -h · df -i · du -h --max-depth=1 /var | sort -h |
| فایل حذفشدهٔ باز و حافظه | lsof +L1 · free -h (ستون available) · vmstat 1 5 |
| پورتها و اتصالات | ss -ltnp · ss -tn state established '( dport = :5432 )' |
| تست HTTP، DNS و پورت | curl -s -o /dev/null -w '%{http_code} %{time_total}\n' URL · getent hosts name · nc -zv host port |
| کپی، آرشیو و تونل | rsync -avzP src/ user@host:/dst/ · tar -czf o.tgz dir/ · ssh -N -L 15432:127.0.0.1:5432 host |
| پروسهها و فلگهای جاوا | jps -lvm · jcmd PID VM.command_line · VM.flags -all |
| thread / heap dump | jcmd PID Thread.print -l · kill -3 PID · jcmd PID GC.heap_dump -gz=1 /var/tmp/h.hprof.gz |
| آمار GC و حافظهٔ بومی | jstat -gcutil PID 1000 · jcmd PID VM.native_memory summary scale=MB |
| سقف و مصرف fd، و OOM killer | cat /proc/PID/limits · ls /proc/PID/fd | wc -l · dmesg -T | grep -i oom |
| محدودیت cgroup | cat /sys/fs/cgroup/memory.max · memory.current |
لینوکس برای یک بکاند «دانش عمومی» نیست؛ ابزار تشخیص است. مدل ذهنی را در چهار جمله نگه دار: همهچیز یک فایل است (پس grep و awk ابزار مانیتورینگاند)، هر چیزی یک مالک و یک مجموعه بیت دسترسی دارد (پس جواب مشکل دسترسی chown است نه 777)، هر چیزی یک پروسه با یک والد و مجموعهای از سیگنالهاست (پس SIGTERM مؤدبانه است و SIGKILL شواهد را نابود میکند)، و هر منبعی یک سقف دارد — حافظه، fd، نخ، inode، پهنای باند — و خرابیهای production تقریباً همیشه یعنی رسیدن به یکی از این سقفها.
برای JVM سه تفکیک را هرگز اشتباه نکن: %CPU بالا بهخاطر کد در برابر بهخاطر GC؛ java.lang.OutOfMemoryError در برابر OOM killer کرنل (کد خروج ۱۳۷)؛ و RES در برابر heap. و همیشه قبل از restart شواهد را جمع کن — یک thread dump سیثانیهای ارزشش از یک هفته حدس زدن بیشتر است.
One morning the phone rings: "the service is down." Your IDE can't help, and neither can the debugger. All you have is a black terminal and a waiting prompt — and that is exactly where a mid-level developer differs from a senior: the senior knows what to type in the first thirty seconds.
This chapter teaches Linux not as "an operating system" but as a backend engineer's daily tool — from zero to working without fear on a box whose JVM is choking.
Filesystem and paths → files → permissions and ownership → users and sudo → processes and signals → systemd → pipes, redirection, exit codes → text tools and log hunting → archives → disk and memory → networking → SSH → environment variables → cron and timers → package managers → bash → JVM troubleshooting on Linux → cheat-sheet.
1. The mental model: everything is a file
Picture a large office building: one front door, floors with defined purposes, and to reach a room you give either the full address from the front door or "two doors down". Linux is that: one tree of directories, each branch with a conventional purpose, every resource — even the network card — behind a door shaped like a file.
On Windows you have several trees (C:\, D:\); on Linux there is only one tree rooted at /, and a second disk or network share gets mounted onto a branch of it.
"Everything is a file" means the kernel exposes nearly every resource behind one interface (open, read, write, close). That is why grep becomes a monitoring tool: grep VmRSS /proc/1234/status is "the physical memory used by process 1234". The Filesystem Hierarchy Standard then decides what lives where.
| Path | What lives there | What it means for a backend engineer |
|---|---|---|
/etc |
System configuration | /etc/hosts, /etc/systemd/system/myapp.service |
/var/log, /var/lib |
Logs and persistent service state | app logs, PostgreSQL data dir |
/opt |
Third-party software | where a Java app is usually installed |
/usr/bin, /usr/local/bin |
Executables | java, curl |
/proc |
Kernel virtual filesystem | /proc/<pid>/fd, /proc/meminfo |
/sys |
Devices and cgroups | /sys/fs/cgroup/memory.max |
An absolute path starts with / and always means one thing; a relative path resolves from the current directory. . is here, .. one up, ~ your home, cd - the previous directory.
Always use absolute paths in scripts. If cd fails in cd logs && rm -rf *, the delete runs in the current directory — possibly /. Write an absolute path, or cd logs || exit 1.
2. Navigating and looking around
pwd ; cd /var/log ; cd .. ; cd - # where am I / there and back
ls -lah # long + hidden + human sizes | ls -lt newest first
stat application.yml # inode, octal mode, times | file app.jar real type
readlink -f $(which java) # real path after resolving symlinks
Read ls -l column by column:
-rw-r--r-- 1 appuser appgroup 20480 Jul 30 11:02 application.yml
The first character is the type (- file, d directory, l symlink, s socket), the next nine are the permission bits, 1 is the hard-link count, then owner, group, size and mtime. Nobody memorises every flag; in an interview, "I'd check it with man, but the reasoning is this" beats guessing.
3. Reading and moving files
less app.log # pager: q quits, / searches, G jumps to the end
head -n 50 app.log ; tail -n 200 app.log ; tail -F app.log # F survives rotation
cp -a conf/ backup/ # archive: preserve mode, owner, times
mkdir -p /opt/myapp/logs
Never cat on production: on a multi-gigabyte log it dumps the whole file into your terminal and hammers server I/O. And for a rotated log write tail -F, not tail -f; with lowercase -f you stay attached to the deleted inode and conclude the service went quiet.
Hard links and symbolic links
ln /opt/app/v2.1.0/app.jar /opt/app/hard.jar # hard link: second name, same inode
ln -s /opt/app/v2.1.0 /opt/app/current # symlink: a file holding a path
| Hard link | Symlink | |
|---|---|---|
| Points at | the inode directly | a path, stored as text |
| Across filesystems | not possible | possible |
| To a directory | usually forbidden | allowed |
| If the original is deleted | data survives | link breaks (dangling) |
| Deployment use | incremental backups (rsync --link-dest) |
current -> releases/... |
Unpack the new version into releases/<timestamp>, run ln -sfn releases/<timestamp> current and restart; rollback is just pointing that symlink back. The -n flag is critical: without it, if current is already a symlink to a directory, the new link is created inside it — a bug that surfaces mid-deploy.
4. Permissions and ownership, in depth
Every file is a room with three groups: the owner, the owner's team (group) and everyone else (other). The kernel is the guard: "are you the owner?", if not "are you in the group?", if not "then you're other" — and it checks only that one class, then stops.
flowchart TD
A["Process opens a file"] --> B{"UID == file owner?"}
B -- yes --> C["Use OWNER bits only"]
B -- no --> D{"File GID in process groups?"}
D -- yes --> E["Use GROUP bits only"]
D -- no --> F["Use OTHER bits only"]
C --> G{"Access allowed?"}
E --> G
F --> G
G -- yes --> H["open() succeeds"]
G -- no --> I["EACCES: Permission denied"]
The subtle consequence: if you own the file and the owner bits deny read, being in its group does not save you — chmod 077 file leaves the owner with no access while everyone else has everything.
Each class has three bits: r (4), w (2), x (1) — so 7 = rwx, 6 = rw-, 5 = r-x, 4 = r--, and chmod 640 secrets.properties means owner reads and writes, group reads, everyone else nothing.
On a file, x means "executable". On a directory it means right of passage — the right to enter and reach its contents; r only means you may list names. So a directory with r-- lets you ls but every cat dir/file fails, and reading /a/b/c.txt needs x on /a and /a/b even if the file is 644.
chmod 750 start.sh ; chmod g-w,o-rwx config.yml
chmod -R u=rwX,g=rX,o= /opt/myapp # capital X: execute bit only on directories
chown -R appuser:appgroup /opt/myapp
namei -l /opt/myapp/conf/app.yml # which level of the path is blocking access?
chmod -R 755 /opt/myapp also makes every text file and jar executable — pure security noise. With u=rwX,g=rX,o= the execute bit reaches only directories and files that were already executable.
umask and the special bits
New files start from 666 and directories from 777, minus the umask bits; umask 022 gives 644 for files and 755 for directories.
| Bit | Octal | On a file | On a dir |
|---|---|---|---|
| setuid | 4000 | run as the file's owner | no effect on Linux |
| setgid | 2000 | run as the file's group | new files inherit the dir's group |
| sticky | 1000 | no effect | only the file's owner may delete it |
ls -ld /tmp # drwxrwxrwt <- the t is the sticky bit
chmod 2775 /srv/shared # setgid on a shared team directory
find / -perm -4000 -type f 2>/dev/null # audit: what is setuid here?
setfacl -m u:reportuser:r-x /srv/data # when three classes aren't enough
The umask trap: umask in ~/.bashrc is never seen by a systemd service. Put UMask=0027 in the unit, or your app logs may be created 644 and any user on the box can read them.
777 means any process on that machine can overwrite that file; if it is a jar, a start script or a config, you have built an arbitrary-code-execution path. The right move: sudo -u appuser stat /path and namei -l /path to find which level actually breaks, then chown it or add the user to the right group — the answer is almost always chown, not chmod.
Answer: 755 is rwx for the owner and r-x for group and other. On a file x means executable. On a directory the bits change meaning: r lets you list names, w lets you create or delete entries, x is right of passage into paths beneath it.
The two points they fish for: reading /a/b/c.txt needs x on every directory along the path, and deleting a file depends on write permission on the directory, not the file — which is why you can delete a file you cannot write to.
Answer: No, and I offer an alternative. First diagnose with sudo -u appuser stat /path and namei -l /path to find which level breaks — very often an intermediate directory, not the final file.
Then, by case: one service writes → chown -R appuser:appgroup and 750; several write → a shared group with chgrp -R shared and chmod -R 2770, where setgid guarantees new files get the right group; one user only reads → an ACL with setfacl. And I state the risk plainly: with 777 any process on the machine can replace the jar or the start script, turning a permission problem into a code-execution vulnerability.
5. Users, groups and sudo
id ; id appuser ; groups appuser
getent passwd appuser # reads passwd + LDAP + whatever is configured
/etc/passwd holds name, UID and shell (world-readable; no passwords there), /etc/shadow holds password hashes (root only), and /etc/group the groups.
sudo groupadd --system appgroup
sudo useradd --system --gid appgroup --home-dir /opt/myapp \
--no-create-home --shell /usr/sbin/nologin appuser
sudo usermod -aG docker deployer # the -a is vital
sudo -u appuser ls -l /opt # run one command as another user
sudo -l ; sudo visudo # my rights / edit sudoers safely
usermod -G without -a is dangerous: it drops the user from every other secondary group — including sudo or wheel. Always -aG. New membership also does not apply to open sessions; log in again.
Least privilege: instead of ALL=(ALL) NOPASSWD:ALL, allow only what deployer needs in /etc/sudoers.d/deployer:
deployer ALL=(root) NOPASSWD: /bin/systemctl restart myapp, /bin/systemctl status myapp
Files under /etc/sudoers.d/ must be 440 and validated with visudo -c -f <file>.
6. Processes and signals
Every process is a cook: it has a number (PID), was hired by another cook (PPID), consumes resources and takes orders (signals). The manager (the kernel) decides who works on which stove (CPU core).
ps -ef --forest # parent/child tree
ps -eo pid,ppid,user,%cpu,%mem,rss,cmd --sort=-%cpu | head -15
pgrep -af java # PID + full command line
Reading top for a JVM:
%CPUcan exceed 100; 100 is one full core, so760on an 8-core box is near saturation.RESis real physical memory and it is bigger than-Xmx, as it should be: heap + metaspace + code cache + thread stacks + direct buffers + the JVM itself.1intopbreaks CPU down per core;Hswitches from processes to threads.
stateDiagram-v2
[*] --> R: fork + exec
R: R (running / runnable)
S: S (interruptible sleep)
D: D (uninterruptible sleep, disk I/O)
Z: Z (zombie, exited, not reaped)
R --> S: waits for I/O or lock
S --> R: event arrives
R --> D: blocking disk or NFS I/O
D --> R: I/O completes
R --> Z: exit()
Z --> [*]: parent calls wait()
D state and zombies: a process in D will not die even to kill -9 because it waits on I/O (slow disk, hung NFS); fix the I/O, not the kill. A Z process (<defunct>) is already dead, waiting for its parent to read its exit code; it costs no memory, but a pile of them means a buggy parent. In a container, a JVM running as PID 1 that forks children accumulates zombies — hence --init or tini (see containers-jvm).
| Signal | No. | Meaning | Catchable? | For the JVM |
|---|---|---|---|---|
SIGTERM |
15 | "please finish" (kill default) |
yes | shutdown hooks run |
SIGINT |
2 | Ctrl+C | yes | like TERM |
SIGKILL |
9 | immediate, no negotiation | no | no hook runs at all |
SIGHUP |
1 | terminal hangup | yes | many services treat it as reload |
SIGQUIT |
3 | quit + core | yes | JVM: thread dump on stdout |
kill -TERM 1234 ; kill -9 1234 ; kill -3 1234 ; pkill -f 'myapp.jar'
./long-task.sh & ; jobs ; fg %1 ; disown -h %1 # job control
nohup ./run.sh > out.log 2>&1 & # survives SIGHUP
SIGKILL is applied by the kernel directly and the JVM never learns about it: shutdown hooks do not run, pools are not closed, in-flight transactions are not rolled back, log buffers are never flushed.
The correct order is kill -15 (or systemctl stop) → wait 30 seconds → take a thread dump so you learn why it is stuck → then kill -9. Going straight to -9 destroys the evidence.
nohup java -jar app.jar & keeps the service up until the first crash or reboot; after that nobody brings it back. No automatic restart, no resource limits, no startup ordering. Fine for a one-off migration; for a service, systemd (or a container with a restart policy) is the only professional answer.
7. systemd
systemd is the modern init system — PID 1, which starts and supervises everything else; its unit of work is a "unit".
# /etc/systemd/system/myapp.service
[Unit]
Description=My Backend Service
After=network-online.target
[Service]
Type=simple
User=appuser
Group=appgroup
WorkingDirectory=/opt/myapp
EnvironmentFile=-/etc/myapp/env
ExecStart=/usr/bin/java -XX:MaxRAMPercentage=70 \
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/log/myapp \
-Xlog:gc*:file=/var/log/myapp/gc.log:time,uptime,level,tags:filecount=5,filesize=20M \
-jar /opt/myapp/app.jar
SuccessExitStatus=143
Restart=on-failure
TimeoutStopSec=60
LimitNOFILE=65535
UMask=0027
NoNewPrivileges=true
[Install]
WantedBy=multi-user.target
Four professional details there: SuccessExitStatus=143, because after SIGTERM the JVM exits with 128+15=143 and without it systemd calls every normal stop a failure; TimeoutStopSec, the grace period before SIGKILL; LimitNOFILE, the fd ceiling; NoNewPrivileges, free hardening.
sequenceDiagram
participant Admin
participant systemd
participant JVM
Admin->>systemd: systemctl start myapp
systemd->>JVM: fork + exec ExecStart
Admin->>systemd: systemctl stop myapp
systemd->>JVM: SIGTERM
JVM->>JVM: run shutdown hooks
JVM-->>systemd: exit 143 (SIGKILL after TimeoutStopSec)
sudo systemctl daemon-reload # after every unit change — never forget
sudo systemctl enable --now myapp
systemctl cat myapp # effective unit + all drop-ins
systemctl list-units --type=service --state=failed
sudo systemctl edit myapp # drop-in override (prefer this)
journalctl -u myapp -f ; journalctl -u myapp --since "10 min ago" -p err
journalctl -k --since "1 hour ago" # kernel log — where the OOM killer shows up
Drop-ins instead of editing the shipped unit: systemctl edit myapp creates /etc/systemd/system/myapp.service.d/override.conf; changes survive package upgrades and systemctl cat shows what was overridden. To replace a list directive such as ExecStart you must first clear it (ExecStart=), then set the new value.
Log persistence: on some distributions journald writes only to memory and everything vanishes on reboot; if you need logs after a crash, set Storage=persistent in /etc/systemd/journald.conf.
8. Pipes, redirection and exit codes
Every process starts with three standard streams: 0 stdin, 1 stdout, 2 stderr.
cmd > out.txt # stdout to a file | cmd >> out.txt append
cmd > all.txt 2>&1 # both into one file (2> err.txt for stderr alone)
cmd | tee out.txt # screen and file | cmd 2>/dev/null discard errors
cmd > file 2>&1: stdout goes to the file, then stderr goes to "wherever stdout points right now" → both end up in the file. ✅
cmd 2>&1 > file: stderr first goes to stdout's current destination (the terminal), then stdout moves to the file → the errors stay on screen and never reach the log. ❌ That is a silent bug in deploy scripts: you think everything is logged, but the stack trace you need is nowhere.
Every command returns an exit code: 0 success, anything else an error. cmd1 && cmd2 runs the second only if the first succeeded; || is the inverse. Conventions: 126 not executable, 127 not found, 130 killed by Ctrl+C, and 128+N killed by signal N — so 137 means SIGKILL, the number that later gives away the OOM killer.
The pipeline exit-code trap: set -e stops the script after an error, but in a pipeline only the last command's code counts; false | true counts as success. The fix is set -o pipefail.
9. Text tools: real log hunting
grep -rn 'jdbc:postgresql' /opt/myapp # recursive + line numbers
grep -c ERROR app.log ; grep -v DEBUG app.log # count / invert
grep -A5 -B5 'NullPointerException' app.log # 5 lines of context each side
grep -E 'ERROR|FATAL|Exception' app.log # extended regex
grep -o 'traceId=[a-f0-9]*' app.log # print only the match
zgrep -h -F 'traceId=8f3ac91b' /var/log/myapp/app.log* # .gz too
For a fixed string (a UUID, an IP, a traceId) use grep -F: it is faster and . stops being regex — grep '10.0.0.1' would otherwise also match 10x0y0z1.
awk '{print $1, $7}' access.log # arbitrary columns
awk -F: '{print $1}' /etc/passwd # custom separator
awk '$9 >= 500' access.log # conditional column filter
sed -n '100,200p' app.log # only lines 100..200
sed 's/password=[^ ]*/password=***/g' app.log # masking
sed -i.bak 's/8080/9090/g' app.properties # in-place, with backup
sort -k3 -n -r data.txt ; sort -u list.txt ; uniq -c ; wc -l app.log
Two real examples
# 1) most frequent exceptions in the last hour
journalctl -u myapp --since "1 hour ago" -o cat \
| grep -oE '[A-Za-z.]+(Exception|Error)' | sort | uniq -c | sort -rn | head -10
# 2) top ten talkers
awk '{print $1}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -10
find /var/log -name '*.log' -mtime +7 -size +100M # old and large
find /var/log -name '*.gz' -mtime +30 -exec rm -f {} + # cheaper than \;
find /opt -name '*.jar' -print0 | xargs -0 ls -lh
find / -xdev -type f -size +500M 2>/dev/null | head # -xdev: one filesystem
\; versus +: \; forks a process per file, a disaster across a hundred thousand files; + passes all names at once. If a filename contains a space, plain xargs splits the line; always -print0 | xargs -0. Before any -delete, run it with -print first.
10. Archives and transfer
tar -czf logs.tar.gz /var/log/myapp/ # create (c) gzip (z) file (f)
tar -xzf logs.tar.gz -C /tmp/restore # extract | tar -tzf ... list only
zcat huge.log.gz | head # read compressed without unpacking
scp app.jar deployer@server:/opt/myapp/
rsync -avzP --delete --exclude '*.tmp' build/ deployer@server:/opt/myapp/
Why rsync beats scp: it transfers only differences, compresses, resumes after a drop, preserves permissions and offers --dry-run. Its classic trap is the trailing slash on the source: rsync -a src/ dst/ copies the contents of src into dst, while rsync -a src dst/ creates src inside dst — and --delete on the wrong path is a mass-deletion tool.
11. Disk and memory
df -h ; df -i # space and inodes — never skip the second
du -h --max-depth=1 /var | sort -h | tail -20
lsof +L1 ; lsof -nP | grep '(deleted)' # deleted-but-still-open files
free -h ; vmstat 1 5 ; iostat -xz 1
lsof -p 1234 ; lsof -i :8080
Reading free -h: used is what programs consume, buff/cache is page cache reclaimed the moment it is needed, and available is the only number you should look at. A small free is fine; Linux deliberately caches unused RAM.
Three classic causes:
1) A deleted file that is still open. If somebody runs rm app.log while the JVM holds it open, the name is gone but the blocks are not released; du cannot see it, df can. Detect with lsof +L1; fix by restarting the service or : > /proc/<pid>/fd/<n>.
2) Inode exhaustion. Millions of tiny files consume inodes while space remains; if IUse% in df -i is 100, the problem is file count, not size.
3) Files hidden under a mount point, found with mount --bind / /mnt.
12. Networking
ip -br a ; ip r
ss -ltnp # listening TCP sockets, numeric, with process
ss -tanp ; ss -s # all TCP connections | summary stats
ss -tn state established '( dport = :5432 )' # open connections to the DB
netstat belongs to the deprecated net-tools package and is often absent from minimal images. ss reads from netlink and has stronger filters; the equivalent of netstat -tulpn is ss -tulpn.
curl -I https://api.example.com/health # headers only | -v for the whole exchange
curl -X POST https://api.example.com/orders -H 'Content-Type: application/json' \
-H 'Authorization: Bearer TOKEN' -d '{"sku":"A-1","qty":2}'
curl --resolve api.example.com:443:10.0.0.7 https://api.example.com/health # bypass DNS
curl -f -sS --max-time 5 --retry 3 https://api.example.com/health # fail loudly, retry
curl -s -o /dev/null -w 'code=%{http_code} dns=%{time_namelookup} tcp=%{time_connect} tls=%{time_appconnect} ttfb=%{time_starttransfer} total=%{time_total}\n' https://api.example.com/health
That single line tells you whether the latency is DNS, TCP, the TLS handshake or the application itself — a complete diagnostic tool with nothing to install.
dig api.example.com +short # or dig @8.8.8.8 ... to ask a specific resolver
getent hosts api.example.com # the path your app takes (nsswitch)
nc -zv -w 3 db.internal 5432 # is the port open?
mtr -rwc 20 api.example.com # traceroute plus continuous ping
tcpdump -i any -nn port 5432 -c 20 # first 20 packets on the DB port
dig and your app do not take the same route: dig talks to DNS directly and ignores /etc/hosts, while a Java app uses the system resolver, which per /etc/nsswitch.conf consults hosts first; use getent hosts to reproduce the app's path. The JVM angle: networkaddress.cache.ttl in java.security controls DNS caching inside the JVM — in the cloud a long value means talking to a dead IP for hours.
A request has four failure-prone stages — DNS, TCP, TLS and HTTP — and this table says which one each error accuses.
| Symptom | What it means | Likely causes | First command |
|---|---|---|---|
Connection refused |
a TCP RST came back | nothing listening; wrong port | ss -ltnp | grep :8080 on the target |
Connection timed out |
no answer at all | firewall/security group; bad routing | nc -zv host port · mtr |
Name or service not known |
DNS did not answer | wrong name; broken resolver | getent hosts name · dig |
Connection reset by peer |
RST mid-conversation | peer crashed; LB idle timeout | peer's logs · ss -s |
For the database's own view, ask the engine about active connections:
SELECT state, count(*) AS conns, max(now() - state_change) AS oldest
FROM pg_stat_activity
WHERE datname = current_database()
GROUP BY state
ORDER BY conns DESC;-- Oracle: sessions from v$session
SELECT status, COUNT(*) AS conns, MAX(SYSDATE - logon_time) * 86400 AS oldest_sec
FROM v$session
WHERE type = 'USER'
GROUP BY status
ORDER BY conns DESC;In PostgreSQL every connection is an operating-system process; 500 connections means 500 processes with real memory cost. Oracle's default dedicated-server mode also uses one process per session, but shared server lets several sessions share one — so the safe pool ceiling differs between the two engines (see rdbms-tuning).
sudo ufw status numbered ; sudo ufw allow 22/tcp # Ubuntu/Debian
sudo ufw allow from 10.0.0.0/8 to any port 8080 proto tcp
sudo firewall-cmd --permanent --add-port=8080/tcp && sudo firewall-cmd --reload # RHEL
Always run ufw allow 22/tcp before ufw enable, or you lock yourself out of a remote box instantly with no way back except the console. For risky work leave a sleep 300 && ufw disable in the background as a safety rope.
Answer: Connection refused means the packet arrived and the peer answered with an RST; the network path is healthy and nothing is listening there. That differs completely from a timeout, where the packet never arrived at all — usually a firewall.
The order: (1) getent hosts db.internal to confirm the name resolves. (2) From the app server, nc -zv db.internal 5432. (3) On the database host, ss -ltnp | grep 5432; if nothing shows, the service is down. (4) If it listens only on 127.0.0.1, that is a bind-address problem, not a firewall. (5) Finally container networking — localhost inside a container means the container, not the host.
If the error is intermittent, I suspect pool exhaustion or the max_connections ceiling — capacity, not networking.
13. SSH
ssh-keygen -t ed25519 -C "deploy@laptop" -f ~/.ssh/id_ed25519_deploy
ssh-copy-id -i ~/.ssh/id_ed25519_deploy.pub deployer@server
ssh deployer@server 'systemctl status myapp'
Why ed25519: shorter, faster and cryptographically more modern. If forced onto RSA use at least -b 4096; DSA is obsolete and removed from modern OpenSSH.
# ~/.ssh/config — the biggest daily time-saver there is
Host bastion
HostName bastion.example.com
User deployer
IdentityFile ~/.ssh/id_ed25519_deploy
IdentitiesOnly yes
Host prod-*
User appuser
IdentityFile ~/.ssh/id_ed25519_prod
ProxyJump bastion
ServerAliveInterval 30
Host prod-db
HostName 10.0.3.11
LocalForward 15432 127.0.0.1:5432
Now ssh prod-db goes via the bastion, presents the right key and opens the database port on localhost:15432.
flowchart LR
L["Laptop 127.0.0.1:15432"] -->|encrypted SSH| B["Bastion host"]
B -->|ProxyJump| P["prod-db host"]
P -->|localhost:5432| D[("PostgreSQL")]
ssh -L 15432:127.0.0.1:5432 deployer@server # local: my port -> their service
ssh -R 9000:127.0.0.1:8080 deployer@server # remote: their port -> my service
ssh -N -f -L 15432:db.internal:5432 host # no shell, background
Three common security mistakes: StrictHostKeyChecking=no disables the exact protection against man-in-the-middle — pre-populate known_hosts with ssh-keyscan instead. Agent forwarding (-A) to a host you do not fully trust: anyone with root there can use your agent, so prefer ProxyJump, where the key never reaches the middle host. And ~/.ssh must be 700, the private key and authorized_keys 600; OpenSSH deliberately rejects anything looser.
Server-side hardening (preferably in a file under /etc/ssh/sshd_config.d/):
PermitRootLogin no
PasswordAuthentication no
AllowGroups sshusers
MaxAuthTries 3
Keep a second session open before reloading sshd. Run sudo sshd -t, then systemctl reload sshd, and test logging in from another terminal; close the first session only once that succeeds.
14. Environment variables and shell profiles
export APP_ENV=production # for this shell and its children
env | sort ; printenv JAVA_HOME ; which -a java
APP_ENV=staging ./run.sh # for one run only
| File | When it is read |
|---|---|
/etc/profile, /etc/profile.d/*.sh |
login shells, all users |
~/.bash_profile or ~/.profile |
login shells, that user |
~/.bashrc |
interactive non-login shells (a new terminal) |
/etc/environment |
system-wide via PAM — not a script, just KEY=value |
| a systemd unit file | Environment= and EnvironmentFile= |
Neither cron nor systemd reads .bashrc — cron runs with a very short PATH and no JAVA_HOME. In scheduled jobs use absolute paths, define variables explicitly, or EnvironmentFile=.
Secrets in environment variables are not safe: a process's variables are readable in /proc/<pid>/environ, show up in systemctl show, and as a command-line argument are visible in ps to every user. Never pass a password as an argument; EnvironmentFile must be 600 and root-owned; in production use a secret manager.
15. Scheduling: cron and systemd timers
The five fields are minute, hour, day of month, month, day of week (0 and 7 both mean Sunday).
SHELL=/bin/bash
PATH=/usr/local/bin:/usr/bin:/bin
MAILTO=""
*/5 * * * * /usr/bin/flock -n /tmp/health.lock /opt/myapp/bin/healthcheck.sh >> /var/log/myapp/health.log 2>&1
Three perennial cron traps: % becomes a newline, so date +%Y-%m-%d breaks and you must write \%; without a redirect the output is mailed and vanishes on a box with no MTA, so always >> logfile 2>&1; and a job running every 5 minutes that sometimes takes 7 overlaps itself, so lock it with flock -n.
# /etc/systemd/system/myapp-cleanup.timer
[Unit]
Description=Run log cleanup daily
[Timer]
OnCalendar=*-*-* 02:30:00
RandomizedDelaySec=300
Persistent=true
Unit=myapp-cleanup.service
[Install]
WantedBy=timers.target
sudo systemctl enable --now myapp-cleanup.timer ; systemctl list-timers --all
systemd-analyze calendar '*-*-* 02:30:00' # validate the expression
sudo systemctl start myapp-cleanup.service # run manually
| Criterion | cron | timer |
|---|---|---|
| Simplicity | one line, available everywhere | two files, systemd only |
| Logging | you redirect it yourself | automatic, in journald |
| Missed run after downtime | never happens | with Persistent=true, yes |
| Overlap prevention | manual, with flock |
automatic |
| Resource limits, jitter | none | MemoryMax, CPUQuota, RandomizedDelaySec |
| Manual test | copy the command by hand | systemctl start <service> |
For a personal job on one machine cron is enough. For anything that matters in production — logging, overlap prevention, memory caps, catching up on missed runs — a systemd timer is the professional choice. On Kubernetes the third answer is a CronJob.
Answer: Almost always the environment. cron does not run a login shell, so .bashrc and /etc/profile are never read: PATH is very short so java or anything in /usr/local/bin is not found, JAVA_HOME is missing, the working directory is the user's home so relative paths break, and unredirected output disappears.
How I diagnose: put env > /tmp/cron-env.txt at the top of the script and diff it against an interactive env, or simulate an empty environment with env -i /bin/sh -c '/path/script.sh'. The fix: absolute paths, explicit variables, an explicit cd, and >> /var/log/... 2>&1 — and for anything important a systemd timer, whose environment and logs are inspectable.
16. Package managers
| Task | Debian / Ubuntu | RHEL / Fedora |
|---|---|---|
| Refresh metadata, install/remove | sudo apt update · apt install nginx · remove |
sudo dnf check-update · dnf install nginx · remove |
| Details and files | apt show pkg · dpkg -L pkg |
dnf info pkg · rpm -ql pkg |
| Which package owns a file? | dpkg -S /usr/bin/java |
rpm -qf /usr/bin/java |
| What provides a command? | apt-file search bin/jcmd |
dnf provides */jcmd |
| Versions and pinning | apt policy pkg · apt-mark hold pkg |
dnf --showduplicates list pkg · versionlock add pkg |
| List installed | apt list --installed |
rpm -qa |
sudo update-alternatives --config java # Debian/Ubuntu (RHEL: alternatives)
readlink -f $(which java) # which JDK actually runs
Do not run apt upgrade during business hours: it can change the JDK, the TLS library or the kernel and restart services. Pin versions, automate only security patches, do big upgrades in a maintenance window; needrestart on Ubuntu shows which services need restarting.
17. Bash scripting
The standard opening of any serious script:
#!/usr/bin/env bash
set -Eeuo pipefail
-E makes trap ERR inherit into functions, -e exits on the first error, -u makes an undefined variable an error (preventing rm -rf $UNSET/), and pipefail makes a failure anywhere in a pipeline fatal.
#!/usr/bin/env bash
set -Eeuo pipefail
readonly APP_NAME="myapp"
readonly APP_DIR="/opt/${APP_NAME}"
readonly HEALTH_URL="http://127.0.0.1:8080/actuator/health"
readonly TIMEOUT_SECONDS=90
log() { printf '%s [%s] %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$1" "${*:2}"; }
die() { log ERROR "$*"; exit 1; }
usage() { echo "Usage: deploy.sh -v <version>" >&2; exit 64; }
trap 'die "failed at line $LINENO"' ERR
wait_for_health() {
local deadline=$(( SECONDS + TIMEOUT_SECONDS )) code
while (( SECONDS < deadline )); do
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 3 "$HEALTH_URL" || echo 000)
[[ "$code" == "200" ]] && { log INFO "healthy after ${SECONDS}s"; return 0; }
sleep 3
done
return 1
}
main() {
local version=""
while getopts ":v:h" opt; do
case "$opt" in
v) version="$OPTARG" ;;
h) usage ;;
\?) die "unknown option -$OPTARG" ;;
:) die "option -$OPTARG requires an argument" ;;
esac
done
[[ -n "$version" ]] || usage
[[ -d "$APP_DIR/releases/$version" ]] || die "release $version not found"
ln -sfn "$APP_DIR/releases/$version" "$APP_DIR/current"
systemctl restart "$APP_NAME"
if wait_for_health; then
log INFO "deploy of $version succeeded"
else
log WARN "health check failed, rolling back"
ln -sfn "$APP_DIR/releases/previous" "$APP_DIR/current"
systemctl restart "$APP_NAME"
die "rolled back"
fi
}
main "$@"
The language details you must know:
"$var" # always quoted — this is what prevents word splitting
"${var:-default}" # default value | "${var:?msg}" exit with an error if unset
"$@" # all arguments, each as its own word (usually what you want)
$? $# $$ # last exit code · argument count · script PID
$(command) # command substitution $(( 3 + 4 )) arithmetic
[[ -f "$f" ]] ; [[ -z "$s" ]] ; [[ "$a" =~ ^[0-9]+$ ]]
Forgetting to quote is the most common bash bug there is:
FILE="my report.log"
rm $FILE # ❌ tries to delete two files: 'my' and 'report.log'
rm "$FILE" # ✅
It gets worse with an empty variable: rm -rf $DIR/* with an empty DIR means rm -rf /*. set -u plus always quoting kills that whole class of disaster, and shellcheck in CI catches nearly all of it.
trap does two vital jobs: trap cleanup EXIT removes temp files and locks even on failure; and if your script is PID 1 inside a container, trap 'kill -TERM $child' TERM INT makes the stop signal actually reach the Java process — without it every docker stop ends in a kill -9.
18. JVM troubleshooting on Linux
This is the heart of the chapter — where Linux and Java knowledge meet. (Heap internals, GC and the JVM memory model are covered in jvm-internals; here we only do operations on the server.)
The tools
All of these ship with the JDK; with only a JRE, none of them exist.
jps -lvm # Java processes + main class + arguments
jcmd <pid> help # every command that JVM supports
jcmd <pid> VM.command_line # what flags did the app start with?
jcmd <pid> VM.flags -all | grep -i heap # real effective values | VM.uptime
The official direction since JDK 8 is for jcmd to replace the individual tools: one binary, plus capabilities the others lack (VM.native_memory, JFR.*, Thread.dump_to_file). Note also that jmap -heap no longer exists on modern JDKs; use jcmd <pid> GC.heap_info or jhsdb jmap --heap --pid <pid>. Run jcmd <pid> help first on any server.
Finding the hot thread
top -H -b -n 1 -p 12345 | head -20 # busiest threads (or ps -L -o tid,pcpu -p PID)
printf '%x\n' 12401 # TID in hex -> 3071
jcmd 12345 Thread.print -l > /tmp/td.txt ; grep -A 30 'nid=0x3071' /tmp/td.txt
flowchart TD
A["Alert: high CPU"] --> B["top -H -p PID"]
B --> C{"Hot threads named GC Thread#n?"}
C -- yes --> D["Memory problem: jstat -gcutil + GC log"]
C -- no --> E["printf '%x' TID -> hex nid"]
E --> F["jcmd PID Thread.print -l"]
F --> G["grep nid=0xHEX in three dumps"]
G --> H{"Same stack every time?"}
H -- yes --> I["Hot loop in application code"]
H -- no --> J["Just busy: profile with JFR"]
One thread dump is not enough. A single snapshot cannot separate what is stuck from what is merely busy; take three, five to ten seconds apart. A thread on the same line in all three is genuinely blocked. Always pass -l, or you never see java.util.concurrent locks and ReentrantLock deadlocks stay hidden (see sync-locks-jmm).
jcmd 12345 Thread.print -l > /tmp/td-$(date +%s).txt # preferred
jcmd 12345 Thread.dump_to_file -format=json /tmp/td.json # incl. virtual threads
kill -3 12345 # SIGQUIT: dump on process stdout
Where does kill -3 write? To the process's own stdout, not your terminal. Under systemd that means journalctl -u myapp; if stdout went to /dev/null the dump is lost. jcmd is safer because it hands you the output directly, but kill -3 needs no tooling and works in a JDK-less container.
Heap dumps and GC logs
jcmd 12345 GC.heap_dump -gz=1 /var/tmp/heap-$(date +%s).hprof.gz
jcmd 12345 GC.class_histogram | head -30 # lighter: just object counts
Three facts before you press Enter. First, the file is roughly the size of the live heap, and if the partition fills up other apps die too. Second, the dump needs a safepoint and freezes the whole JVM for seconds to tens of seconds; pull the node out of the load balancer first. Third, it holds passwords, tokens and personal data. If you only need to know what is filling memory, GC.class_histogram is lighter and usually enough.
Since JDK 9, unified logging (-Xlog) replaces flags such as PrintGCDetails:
-Xlog:gc*:file=/var/log/myapp/gc.log:time,uptime,level,tags:filecount=5,filesize=20M
jstat -gcutil 12345 1000 # live GC stats every second (also -gccause)
jcmd 12345 VM.log output=/var/log/myapp/gc.log what=gc* # enable at runtime
jstat -gcutil columns: S0/S1 survivors, E eden, O old, M metaspace, YGC/YGCT young-GC count and time, FGC/FGCT full GC, GCT total.
The real sign that "the heap is running out" is not a high O; the old generation is supposed to fill up. The dangerous pattern is that O stays high after every full GC, its floor creeps upward, and FGC climbs fast — GC can no longer find anything to free. The quieter variant is GC thrashing: the app is alive and never throws OOM but spends most of its time in GC, and from outside just looks like "the service got slow".
Java's OutOfMemoryError versus the Linux OOM killer
java.lang.OutOfMemoryError |
Kernel OOM killer | Container OOMKilled |
|
|---|---|---|---|
| Who decides | the JVM itself | the Linux kernel | the kernel, from the cgroup limit |
| Cause | heap/metaspace/threads hit the JVM ceiling | the machine ran out of RAM | the container passed memory.max |
| Symptom | an exception with a stack trace | the process vanished silently | the container restarted |
| Exit code | usually non-zero, with a log | 137 (128+9) |
137 |
| Recorded in | application log | dmesg, journalctl -k |
orchestrator events + node dmesg |
| Fix | find the leak or tune -Xmx |
reduce total machine usage | tune the limit or MaxRAMPercentage |
dmesg -T | grep -i -E 'out of memory|oom-kill|killed process'
journalctl -k --since "1 hour ago" | grep -i oom ; cat /proc/12345/oom_score_adj
A real line from dmesg:
Out of memory: Killed process 12345 (java) total-vm:9812344kB, anon-rss:4102300kB, UID:1001 oom_score_adj:0
anon-rss is the private physical memory held at the moment of death — compare it against -Xmx plus the other regions.
Read the message after the colon:
Java heap space→ the heap really is full.GC overhead limit exceeded→ GC takes over 98% of the time and frees under 2% of the heap.Metaspace→ too many loaded classes (a classloader leak).unable to create native thread→ the heap is not the problem; you hit an OS thread ceiling (ulimit -u,/proc/sys/kernel/threads-max, the container'spids.max).Direct buffer memory→-XX:MaxDirectMemorySizeor a leak in NIO/Netty.
Raising -Xmx from the third case onwards makes things worse: it leaves even less room for the native regions.
File descriptors
cat /proc/12345/limits | grep 'open files' # the only source of truth here
ls /proc/12345/fd | wc -l # how many fds are open now
lsof -p 12345 | awk '{print $5}' | sort | uniq -c # broken down by type
The ulimit trap: ulimit -n in your shell has nothing to do with the service — systemd takes the ceiling from LimitNOFILE=, not /etc/security/limits.conf, which only applies to PAM sessions; read the real value from /proc/<pid>/limits. And an fd is not only a file: every TCP socket, DB connection and epoll is one.
Memory inside a container
Modern JVMs understand cgroup limits and -XX:+UseContainerSupport is on by default. But the default MaxRAMPercentage is 25 percent — in a 1 GiB container the heap is 256 MiB.
java -XX:+PrintFlagsFinal -version | grep -E 'MaxHeapSize|MaxRAMPercentage|UseContainerSupport'
nproc # CPUs visible to this process
cat /sys/fs/cgroup/memory.max # 'max' means unlimited (cgroup v2)
cat /sys/fs/cgroup/memory.current # and memory.peak for the high-water mark
cat /sys/fs/cgroup/memory.events # oom and oom_kill counters | cpu.max for CPU
java -XX:MaxRAMPercentage=70 -XX:MaxMetaspaceSize=256m \
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/log/myapp \
-XX:+ExitOnOutOfMemoryError -jar app.jar
Because the heap is only one part of the process's memory:
RSS ≈ heap + metaspace + compressed class space + code cache + GC structures + (thread count × ~1MB stack) + direct/mapped ByteBuffers + libraries' native buffers + malloc overhead
On a typical Spring Boot app the non-heap total is easily 300–700 MiB, so -Xmx2g under a 2 GiB limit almost always means getting killed. Give -XX:MaxRAMPercentage=70..75 instead of a fixed number. With no swap (the Kubernetes default) there is no margin — one byte over the limit is an immediate SIGKILL.
Native Memory Tracking and JFR
When RES grows while the heap stays flat:
java -XX:NativeMemoryTracking=summary -jar app.jar # costs roughly 5–10%
jcmd 12345 VM.native_memory baseline # ... wait a few hours ...
jcmd 12345 VM.native_memory summary.diff scale=MB
The output splits memory into Java Heap, Class, Thread, Code, GC and Internal: growth in Thread is a thread leak, Class a classloader leak, Internal usually direct buffers or JNI.
jcmd 12345 JFR.start name=diag settings=profile duration=120s filename=/var/tmp/rec.jfr
JFR has very low overhead and is ideal for intermittent problems that never happen while you watch.
| Symptom | First command | What it proves |
|---|---|---|
| High, sustained CPU | top -H -p <pid> + jcmd Thread.print |
which thread, which line |
| Latency suddenly bad | jstat -gcutil <pid> 1000 |
GC thrashing or not |
| Memory keeps climbing | jcmd GC.class_histogram, then a heap dump |
which class accumulates |
High RES, flat heap |
jcmd VM.native_memory summary.diff |
a leak outside the heap |
| Process silently vanished | dmesg -T | grep -i oom |
OOM killer or not |
Too many open files |
ls /proc/<pid>/fd | wc -l + /proc/<pid>/limits |
fd leak or low ceiling |
| Hung at zero CPU / rare slowness | three spaced Thread.prints · JFR.start settings=profile |
deadlock or I/O wait |
Answer: 800% means roughly eight cores continuously busy. (1) top -H -p <pid> to see the busiest threads. (2) If they are named GC Thread#n, this is a memory problem, not a CPU one, and I go to jstat -gcutil and the GC log. (3) If they are application threads, I convert the TID with printf '%x\n' and look for nid=0x... across three consecutive dumps. (4) If all three show the same stack, we have a hot loop, a catastrophic regex or heavy serialisation. (5) With more tooling I take 30 seconds of JFR to see the real CPU distribution instead of guessing.
For containment I pull the node out of the load balancer and roll back if it correlates with a deploy. The point that scores: I separate "high CPU from load" from "high CPU from GC" at the start.
Answer: No, almost always the opposite. 137 = 128 + 9 means the process was killed with SIGKILL, and a JVM cannot SIGKILL itself; the kernel did it, usually because the cgroup memory limit was exceeded.
How I confirm: dmesg -T | grep -i oom-kill on the node, the oom_kill counter in /sys/fs/cgroup/memory.events, and on Kubernetes Reason: OOMKilled in the pod's lastState. Most importantly, the app log: if there is no java.lang.OutOfMemoryError in it, the JVM was healthy and the kernel killed it.
The dominant cause: -Xmx set close to the whole limit, leaving no room for metaspace, code cache, thread stacks and direct buffers. The fix: -XX:MaxRAMPercentage=70, explicit ceilings for MaxMetaspaceSize and MaxDirectMemorySize.
Answer: Because -Xmx caps only the heap, whereas RES is the whole process's physical memory: metaspace, the JIT code cache, GC structures, one stack per thread (about 1 MiB on 64-bit), direct and mapped ByteBuffers, libraries' native memory and allocator overhead.
So a larger RES is normal. Worry when RES keeps growing while the heap stays flat after full GCs. Then jstat -gcutil to confirm the heap is stable, then VM.native_memory baseline and a later summary.diff to see which category grows — Thread a thread leak, Class a classloader leak, Internal/Other direct buffers or JNI. Without NMT, at least ls /proc/<pid>/task | wc -l and pmap -x <pid> | tail -1. For capacity I size the container as heap ≈ 70% of the limit, not heap = limit.
Answer: I run jcmd <pid> Thread.print -l as the service's own user (sudo -u appuser), because attach tooling only works for the same UID. The overhead is one short safepoint, so no practical downtime; I take three dumps ten seconds apart, each with a timestamp.
In the dump I look for: the Found one Java-level deadlock section; the distribution of states (RUNNABLE, BLOCKED, WAITING); which monitor the BLOCKED threads wait on (waiting to lock <0x...> versus locked <0x...>); and any repeating pattern — if two hundred threads share one stack, that is the bottleneck.
With no JDK in the container I use kill -3 <pid> and take the dump from the service's stdout; on newer releases Thread.dump_to_file -format=json also covers virtual threads.
Answer: Zero CPU plus no responses means threads are waiting, not working. Three scenarios: a deadlock, thread-pool exhaustion, or waiting on an external resource with no timeout.
I take three Thread.print -l dumps ten seconds apart and first look for Found one Java-level deadlock, since the JVM reports monitor deadlocks itself. If there is none I count the state distribution: dozens of http-nio-*-exec-* threads WAITING on getConnection means the pool is exhausted; a pile on socketRead0 means we wait on an external service with no timeout, and ss -tanp | grep <pid> shows where.
The short-term fix is a restart; the real fix is a timeout on every network call plus bulkheads and circuit breakers (see resilience) — every call without a timeout is a potential distributed deadlock.
Answer: Measure first: ls /proc/<pid>/fd | wc -l for usage, cat /proc/<pid>/limits for the real ceiling (not my shell's ulimit -n), and lsof -p <pid> | awk '{print $5}' | sort | uniq -c for the breakdown — lots of IPv4 means sockets, REG files, pipe child processes.
If the number climbs steadily it is a leak, and raising the ceiling only delays the outage; I look for something never close()d — an unclosed response, a stream without try-with-resources, or a pool that does not return connections. If it is stable but near the ceiling, capacity is short: LimitNOFILE=65535 in the unit, daemon-reload, restart, verified with cat /proc/<pid>/limits. Then I add an open-fd metric alerting at 80%.
Answer: kill <pid> is SIGTERM (15): the JVM catches it, runs shutdown hooks, closes pools and exits cleanly with code 143. kill -9 is SIGKILL, which cannot be caught; the kernel removes the process instantly and no hook, flush or rollback happens — unfinished transactions, unacknowledged messages, lost logs. kill -3 is SIGQUIT, which does not kill the process; the JVM reads it as a thread-dump request. The right order is TERM → wait → thread dump → only then KILL.
Operationally I control this with KillSignal=, TimeoutStopSec= and SuccessExitStatus=143, and in a container I make sure the JVM is PID 1 or a proper init exists — otherwise SIGTERM never reaches it.
19. Survival cheat-sheet
Order when the pager goes off: machine health (uptime, free -h, df -h, df -i) → heaviest process → service log → kernel log for OOM → networking → the JVM.
A restart clears the symptom and destroys the evidence. Thirty seconds on one thread dump and one class histogram is the difference between "fixed" and "it happens again on Friday and we still don't know why". Write a collect-diagnostics.sh — nobody wants to type mid-incident.
| I want to… | Command |
|---|---|
| Follow and search logs | tail -F app.log · grep -rn 'p' /path · zgrep -h -F 'p' app.log* |
| Top ten errors | grep ERROR app.log | sort | uniq -c | sort -rn | head |
| Big files, access | find /var -xdev -size +500M · chown user:group f · namei -l /path |
| Heaviest process / thread | ps -eo pid,%cpu,rss,cmd --sort=-%cpu | head · top -H -p PID |
| Stop politely / forcibly, service | kill -15 PID then kill -9 · systemctl restart myapp · journalctl -u myapp -f -p err |
| Disk, inodes, deleted-but-open | df -h · df -i · du -h --max-depth=1 /var · lsof +L1 |
| Memory | free -h (available) · vmstat 1 5 |
| Ports and connections | ss -ltnp · ss -tn state established '( dport = :5432 )' |
| HTTP, DNS, port checks | curl -s -o /dev/null -w '%{http_code} %{time_total}\n' URL · getent hosts name · nc -zv host port |
| Copy, archive, tunnel | rsync -avzP src/ host:/dst/ · tar -czf o.tgz dir/ · ssh -N -L 15432:127.0.0.1:5432 host |
| Java processes and flags | jps -lvm · jcmd PID VM.command_line · VM.flags -all |
| Thread / heap dump | jcmd PID Thread.print -l · kill -3 PID · jcmd PID GC.heap_dump -gz=1 h.hprof.gz |
| GC stats, native memory | jstat -gcutil PID 1000 · jcmd PID VM.native_memory summary scale=MB |
| fd usage, OOM killer, cgroup | cat /proc/PID/limits · dmesg -T | grep -i oom · cat /sys/fs/cgroup/memory.max |
For a backend engineer Linux is not "general knowledge"; it is a diagnostic instrument. Keep the mental model in four sentences: everything is a file (so grep and awk are monitoring tools), everything has an owner and permission bits (so the fix for a permission problem is chown, not 777), everything is a process with a parent and signals (so SIGTERM is polite and SIGKILL destroys evidence), and every resource has a ceiling — memory, fds, threads, inodes, bandwidth — and production failures almost always mean hitting one.
For the JVM, never confuse three distinctions: high %CPU from code versus from GC; java.lang.OutOfMemoryError versus the kernel OOM killer (exit 137); and RES versus heap. Always collect evidence before restarting — a thirty-second thread dump beats a week of guessing.