Bài 03

Tương tác với hệ thống: redirect, pipe, signal, process, trap

06 tháng 5, 2026 · 120 phút đọc

Bài 03 — Tương tác với hệ thống: redirect, pipe, signal, process, trap

Script đơn giản print ra terminal là xong. Production script cần: log ra file, lọc output bằng pipe, xử lý Ctrl+C khi đang chạy, biết khi nào service crash, graceful shutdown trong container.

Bài này là phần "thấu hiểu OS" — không chỉ học cú pháp, mà hiểu shell tương tác với kernel ra sao: file descriptor là gì ở mức process table, fork/exec/wait là chuỗi syscall gì, daemon được tạo bằng cách nào, signal là cơ chế IPC như thế nào.


Shell Redirection

Shell Redirection là một tính năng mạnh mẽ trong các shell của Unix/Linux. Nó cho phép kiểm soát cách dữ liệu được gửi vào hoặc ra khỏi chương trình, file, hoặc thiết bị. Đặc biệt hữu ích khi muốn lưu output, xử lý dữ liệu, hoặc tương tác với file mà không cần giao diện trực tiếp.

3 stream chuẩn (file descriptor / FD):

0 - Đầu vào - Bàn phím (stdin)
1 - Đầu ra  - Màn hình (stdout)
2 - Lỗi     - Màn hình (stderr)

Đây là số POSIX chuẩn, còn được gọi là mô tả tệp (FD). Mỗi lệnh Linux ít nhất mở các stream trên để giao tiếp với user hoặc chương trình hệ thống khác.

Input — chuyển hướng đầu vào

Cấu trúc:

command < filename

Còn được gọi là stdin. Đầu vào chuẩn mặc định là bàn phím. < là ký hiệu chuyển hướng đầu vào.

Output — chuyển hướng đầu ra

Ghi dữ liệu output của chương trình vào file thay vì hiển thị trên màn hình (stdout):

command > output.file        # ghi đè
command >> output.file       # append (ghi tiếp vào cuối)

echo "Hello" > out.txt       # tạo file out.txt với "Hello"
echo "World" >> out.txt      # thêm "World" vào cuối

Error — chuyển hướng lỗi

Chuyển đầu ra lỗi (stderr) vào file:

command 2> errors.txt        # ghi đè
command 2>> errors.txt       # append

Tạo file rỗng

Cấu trúc:

> filename

Redirect không có command → tạo file rỗng (hoặc truncate file đang có). Hữu ích để clear log file đang được app ghi tiếp (không phải xoá rồi tạo lại — file inode khác, app vẫn ghi vào inode cũ).

/dev/null — loại bỏ output không mong muốn

/dev/null là một thiết bị đặc biệt trong Unix/Linux. Hoạt động như một "hố đen" — mọi dữ liệu gửi vào đều bị loại bỏ mà không để lại dấu vết. Rất hữu ích khi muốn loại bỏ output không mong muốn.

Cấu trúc:

command > /dev/null              # bỏ stdout
command 2> /dev/null             # bỏ stderr
command > /dev/null 2>&1         # bỏ cả stdout và stderr
command &> /dev/null             # shortcut, tương đương trên

Dùng khi chỉ quan tâm exit status:

if grep -q "ERROR" /var/log/app.log > /dev/null 2>&1; then
    alert
fi

Stdout vs stderr — phân biệt khi viết script

Trong script, viết stderr cho error message:

echo "Backup OK"          # info → stdout
echo "ERROR: ..." >&2     # error → stderr

Habit này quan trọng khi script bị pipe — error vẫn ra terminal, stdout đi vào pipe sạch.


File descriptor — góc nhìn OS

Tài liệu hay lướt qua "FD là số 0/1/2". Thực tế ở mức kernel, FD là integer index vào bảng file mở của process.

Mỗi process có một file descriptor table — array nhỏ map FD number → kernel file object. Khi process gọi open("/tmp/log"), kernel:

  1. Tìm slot trống nhỏ nhất trong bảng FD (thường 3, vì 0/1/2 đã dùng).
  2. Tạo kernel file object.
  3. Trỏ slot 3 → file object đó.
  4. Trả về số 3 cho process.

Process sau đó dùng số 3 cho read(), write(), close().

Kiểm tra FD đang mở của process:

ls -l /proc/<PID>/fd/
# 0 -> /dev/pts/0
# 1 -> /dev/pts/0
# 2 -> /dev/pts/0
# 3 -> /var/log/myapp.log

/proc/<PID>/fd/ là window xem file descriptor table thật của process — debug "process này đang mở file/socket nào".


Here Document (Heredoc)

Here Document là phương pháp trong shell scripting của Unix/Linux để truyền nhiều dòng văn bản làm đầu vào cho một lệnh hoặc chương trình. Đây là cách thay thế hiệu quả cho việc nhập liệu trực tiếp hoặc sử dụng file tạm.

Chủ yếu dùng EOF làm marker. Cấu trúc:

command <<EOF
nội_dung
EOF

Ví dụ tạo config file:

cat <<EOF > config.conf
[server]
address=127.0.0.1
port=8080
EOF

Variable trong heredoc được expand. Không muốn expand → quote EOF:

cat <<'EOF' > script.sh
echo "$VAR"     # giữ nguyên $VAR, không expand
EOF

Use case kinh điển:

mysql -uroot -p mydb <<EOF
CREATE TABLE users (id INT, name VARCHAR(50));
INSERT INTO users VALUES (1, 'admin');
EOF

ssh server <<'EOF'
cd /var/www
git pull origin main
systemctl reload nginx
EOF

Here Strings

Here Strings là tính năng trong shell (như Bash) dùng để cung cấp một chuỗi văn bản ngắn gọn làm đầu vào cho một lệnh. Cách thay thế ngắn gọn hơn so với Here Document khi chỉ cần truyền một dòng.

Cấu trúc:

command <<< "chuỗi_văn_bản"

Ví dụ:

cat <<< "Hello, World!"
# đọc dữ liệu đầu vào và in ra màn hình

grep "admin" <<< "admin user added"
# Tương đương: echo "admin user added" | grep "admin"

File descriptor tuỳ ý với exec

Bash hỗ trợ mở FD số bất kỳ (≥ 3) cho file:

exec fd> output.txt
# fd >= 3

Ví dụ:

#!/bin/bash
# Gán fd 3 cho output file
exec 3> /tmp/output.txt

# Ghi vào fd 3
echo "This is a test" >&3

# Ghi date output vào fd 3
date >&3

# Đóng fd 3
exec 3<&-

3 mode: read, write, read+write

Bash hỗ trợ cú pháp sau để mở file đọc/ghi vào file descriptor:

exec fd<>fileName

Giải thích:

  • exec fd< filename — mở file ở chế độ ĐỌC.
  • exec fd> filename — mở file ở chế độ GHI.
  • exec fd<>fileName — mở file ở chế độ ĐỌC và GHI.

Ví dụ:

#!/bin/bash
FILENAME="/tmp/out.txt"
# Mở fd 3 cho /tmp/out.txt — đọc và ghi
exec 3<>$FILENAME

# Ghi
echo "Today is $(date)" >&3
echo "Fear is the path to the dark side." >&3
echo "--- Yoda" >&3

# Đóng fd 3
exec 3>&-

read -u — đọc từ FD tuỳ ý

Sử dụng read -u cho phép đọc dữ liệu từ một file descriptor (FD) tùy chỉnh thay vì từ stdin. Hữu ích khi cần đọc từ file hoặc luồng dữ liệu khác ngoài bàn phím hoặc đầu vào mặc định.

Cấu trúc:

read -u fd var1 var2 ... varN

Ví dụ:

#!/bin/bash
# Mở fd 3 ở chế độ đọc
exec 3< /etc/resolv.conf

# Mở fd 4 ở chế độ ghi
exec 4> /tmp/output.txt

# Đọc dữ liệu từ fd 3, gán vào biến a, b
read -u 3 a b

# Hiển thị
echo "Data read from fd 3:"
echo "$a $b"

# Ghi cùng dữ liệu vào fd 4
echo "Field #1 - $a" >&4
echo "Field #2 - $b" >&4

# Đóng fd 3 và 4
exec 3<&-
exec 4<&-

Pipes và Filters

Trong lập trình shell, pipes và filters là 2 khái niệm cơ bản giúp kết nối các lệnh với nhau, cho phép xử lý dữ liệu theo chuỗi mà không cần lưu trữ tạm vào file. Đây là cách tiếp cận liên kết lệnh (command chaining) mạnh mẽ trong shell.

Pipe |

Pipes trong shell được sử dụng để chuyển dữ liệu từ lệnh này sang lệnh khác. Dữ liệu được chuyển qua pipe giữa các lệnh dưới dạng dòng (stream) mà không cần lưu vào file.

Cú pháp:

command1 | command2
  • command1: lệnh đầu tiên, xuất ra dữ liệu (stdout).
  • command2: lệnh thứ hai, nhận dữ liệu từ stdin của lệnh đầu.

Ví dụ:

ps aux | grep nginx | wc -l      # đếm process nginx
cat /var/log/nginx/access.log | awk '{print $1}' | sort | uniq -c | sort -rn | head -10

Pipe ở mức kernel: shell tạo pipe (cặp FD đọc + ghi), fork 2 subprocess, child A redirect stdout vào đầu ghi, child B redirect stdin vào đầu đọc. Hai child chạy song song.

⚠️ Pipe chỉ pipe stdout. Stderr không qua pipe:

make 2>&1 | grep error    # cần 2>&1 để stderr cũng vào pipe

Filters

Filters là các lệnh hoặc chương trình có chức năng xử lý, chuyển đổi hoặc lọc dữ liệu đầu vào và xuất ra kết quả đã xử lý. Filters thường làm việc với dữ liệu từ stdin và gửi kết quả ra stdout.

Các lệnh phổ biến dùng làm filters:

  • grep: tìm kiếm chuỗi trong dữ liệu.
  • awk: xử lý và phân tích dữ liệu văn bản theo cột.
  • sed: thay thế hoặc chỉnh sửa văn bản.
  • sort: sắp xếp dữ liệu.
  • cut: cắt bỏ hoặc lựa chọn cột từ dữ liệu.

Ví dụ phổ biến:

grep "pattern" file              # tìm dòng match
grep -v "pattern"                # invert (không match)
grep -i "PATTERN"                # case insensitive
grep -E "p1|p2"                  # regex extended

awk '{print $1, $3}' file        # cột 1 và 3
awk -F: '{print $1}' /etc/passwd # delimiter là :

sed 's/old/new/g' file           # thay thế (in stdout)
sed -i 's/old/new/g' file        # in-place

cut -d: -f1 /etc/passwd          # cột 1, delimiter :
sort | uniq -c | sort -rn        # đếm + sắp xếp ngược

head -n 10                       # 10 dòng đầu
tail -f log.txt                  # follow file (real-time)
wc -l                            # đếm dòng

Sử dụng pipes với multiple commands

Có thể kết hợp nhiều lệnh với pipe để tạo chuỗi xử lý phức tạp hơn:

cat file.txt | grep "pattern" | sort | uniq

Pipe cho stderr (2>&1)

Dùng để kết hợp đầu ra lỗi (stderr) với đầu ra chuẩn (stdout):

ls /nonexistent_directory 2>&1 | grep "No such file or directory"
# 2>&1: chuyển stderr sang stdout
# | grep: lọc thông báo lỗi

Pipe với process substitution <() >()

  • <(command): tạo "file ảo" chứa output của command, lệnh khác đọc được như file.
  • >(command): tạo "file ảo" mà lệnh khác có thể ghi vào.

Ví dụ:

diff <(echo "apple") <(echo "orange")
# So sánh output 2 lệnh

# Xem khác biệt config 2 server
diff <(ssh server1 cat /etc/nginx/nginx.conf) \
     <(ssh server2 cat /etc/nginx/nginx.conf)

Ở mức OS, bash tạo named pipe (FIFO) dưới /dev/fd/<n>, command đọc/ghi pipe đó như file thường.

Ví dụ thực tế

# Tìm các dòng lỗi trong file log
cat /var/log/syslog | grep "error" | sort | uniq

Signals

Linux hỗ trợ cả tín hiệu đáng tin cậy POSIX ("tín hiệu chuẩn") và tín hiệu thời gian thực POSIX. Tín hiệu được gửi đến một tiến trình hoặc lệnh để thông báo sự kiện đã xảy ra.

Đây là một dạng inter-process communication (IPC) — process gửi signal, kernel deliver, process nhận xử lý.

Khi process nhận signal, kernel pause execution, gọi handler tương ứng (mặc định hoặc custom set bằng signal()/sigaction() syscall), sau đó tiếp tục.

Kiểm tra các tín hiệu được hỗ trợ:

kill -l

Hoặc đọc header file kernel:

more /usr/include/linux/signal.h

(Tuỳ distro path có thể là /usr/include/asm/signal.h hoặc /usr/include/asm-generic/signal.h.)


Process

Linux là hệ điều hành đa người dùng (nhiều người dùng có thể đăng nhập và chia sẻ tài nguyên của nó) và đa nhiệm. Nghĩa là bạn có thể chạy nhiều lệnh và thực hiện nhiều tác vụ cùng lúc.

Trong Linux, khi bạn bắt đầu một quy trình, nó được cấp một số duy nhất gọi là PID hoặc process-id.

  • PID range thường từ 1 đến 65535 (modern Linux có thể cao hơn, xem /proc/sys/kernel/pid_max).
  • PID 1 luôn được gán cho quy trình init, đây là quy trình đầu tiên được bắt đầu khi khởi động (trên distro modern là systemd).

Mỗi process có:

  • PID — số định danh duy nhất.
  • PPID — PID của parent process.
  • State — Running (R), Sleeping (S), Stopped (T), Zombie (Z).
  • UID/GID — quyền sở hữu.
  • Memory map, open files (FD table), signal handlers, priority.

Parent — Child

Tiến trình cha là tiến trình Linux đã tạo ra một hoặc nhiều tiến trình con. Tiến trình có thể phân nhánh tiến trình con, tức là tạo một tiến trình con.

Hàm fork()

Hàm fork() tạo một bản sao của tiến trình hiện tại. Tiến trình cha tiếp tục chạy, và tiến trình con được tạo ra từ cùng một điểm gọi fork().

  • Trong tiến trình cha, fork() trả về PID của tiến trình con.
  • Trong tiến trình con, fork() trả về giá trị 0.
pid_t pid = fork();
if (pid == 0) {
    // Đây là CHILD
    printf("I am child, PID = %d\n", getpid());
} else if (pid > 0) {
    // Đây là PARENT
    printf("I am parent, child PID = %d\n", pid);
}

Sau khi gọi fork(), cả tiến trình cha và con chạy song song.

getpid() và getppid()

  • getpid(): trả về PID của tiến trình hiện tại.
  • getppid(): trả về PID của tiến trình cha.

Trong bash:

echo $$           # PID của shell hiện tại — bash dùng getpid()
echo $PPID        # PID của parent — bash dùng getppid()

exec() — thay process

Sau fork(), child thường gọi exec() để load chương trình mới vào chính process đó. Memory cũ bị thay, nhưng PID và FD table giữ nguyên.

Đó là cách shell chạy command:

shell (PID 1234)
  ↓ fork()
shell (1234) + child clone (1235)
                  ↓ exec("/bin/ls")
                ls (1235) — vẫn PID 1235, nhưng giờ chạy code của ls

wait() — parent thu dọn

Khi child kết thúc, kernel giữ entry trong process table cho đến khi parent gọi wait() để thu thập. Sau wait(), entry mới được xoá hoàn toàn. wait() là chức năng để tiến trình cha chờ và thu thập thông tin về tiến trình con của mình.

Bash tự gọi wait() cho mọi command foreground. Background command (cmd &) cần explicit wait:

sleep 100 &
PID=$!
wait $PID

Kiểm tra process khi chương trình chạy

ps -ef | grep <tên chương trình>

Tiến trình mồ côi (Orphan) và Tiến trình zombie (Zombie)

Orphan Process

Nếu tiến trình cha thoát trước khi tiến trình con hoàn thành, tiến trình con sẽ trở thành mồ côi.

Hệ thống sẽ gán tiến trình con cho init (PID 1) làm cha mới. Init nhận trách nhiệm wait() cho orphan khi nó xong.

Bình thường không phải vấn đề. Daemon thực ra dùng cơ chế này.

Zombie Process

Khi tiến trình con hoàn thành nhưng tiến trình cha chưa gọi wait() để thu dọn tài nguyên, tiến trình con sẽ chuyển thành zombie.

Trạng thái zombie giữ lại thông tin của tiến trình con trong bảng tiến trình.

Một zombie không hại. Nhưng nếu app cha có bug không handle SIGCHLD đúng → zombie tích tụ → process table đầy → kernel không fork được nữa → toàn hệ thống "hết chỗ tạo process".

Xem zombie:

ps aux | awk '$8=="Z"'
ps -ef | grep defunct

DevOps thấy zombie tích tụ → không kill được zombie (đã chết rồi). Phải:

  1. Tìm parent: ps -o ppid <zombie-pid>.
  2. Kill parent → init adopt zombie → init wait() → zombie biến mất.
  3. Hoặc fix bug ở app cha (handle SIGCHLD).

Hệ thống xử lý tiến trình cơ bản

Mọi chương trình chạy trên Linux đều là một process. Process có thể là:

  • Tiến trình hệ thống (daemon): chạy nền cung cấp dịch vụ như sshd, cron, nginx.
  • Tiến trình người dùng: ứng dụng do user chạy như vim, bash.

Quản lý tiến trình cha-con

Khi chương trình mới chạy, Linux tạo tiến trình con từ tiến trình cha hiện có:

  • Tiến trình cha gọi fork() để tạo con.
  • Tiến trình con thực hiện công việc hoặc thay thế bằng chương trình khác qua exec().

Ví dụ thực tế: tiến trình init hoặc systemd (tiến trình đầu tiên trên hệ thống) là cha của hầu hết các tiến trình khác.

Daemon process — quản lý trên máy chủ Linux

Các dịch vụ như web server (nginx), DB (mysql), SSH (sshd) hoạt động trong nền dưới dạng tiến trình daemon. Daemon được quản lý bởi systemd hoặc init.

Hoạt động thực tế:

  1. Khi máy chủ khởi động, systemd khởi chạy các tiến trình nền cần thiết.
  2. Mỗi dịch vụ chạy dưới dạng một tiến trình độc lập.
  3. Các tiến trình nền sinh thêm tiến trình con để xử lý công việc cụ thể.

Ví dụ: nginx tạo tiến trình con để xử lý các request HTTP từ client.

systemd không cần gọi fork() để tạo nginx; thay vào đó, systemd dùng tệp binary (như /usr/sbin/nginx) để khởi chạy nginx. Sau khi nginx được khởi động, nginx sẽ dùng fork() để tạo các tiến trình con (worker processes) xử lý request HTTP.

Khi tiến trình con hoàn thành, tiến trình con gửi tín hiệu để tiến trình cha gọi wait() và thu dọn.

Cách daemon truyền thống được tạo (double-fork)

Daemon C truyền thống dùng pattern double-fork để tách khỏi terminal:

  1. Fork lần 1. Parent thoát ngay. Child mồ côi → init nhận làm cha (PPID=1).
  2. setsid() — child tách khỏi controlling terminal, trở thành leader của session mới.
  3. Fork lần 2. Để đảm bảo daemon không thể tự reclaim terminal.
  4. Đổi working directory về /.
  5. umask(0) — reset umask.
  6. Đóng FD 0, 1, 2 hoặc redirect vào /dev/null.
  7. Bắt đầu logic daemon.

Modern Linux thường KHÔNG dùng pattern này — systemd quản lý daemon thay (Type=simple, forking, notify trong unit file). Systemd exec() trực tiếp binary, daemon không cần tự fork tách terminal.

Trạng thái tiến trình

Tiến trình trong Linux có nhiều trạng thái:

  • Running: đang thực thi.
  • Sleeping: chờ tài nguyên hoặc sự kiện.
  • Stopped: bị dừng tạm thời.
  • Zombie: đã kết thúc nhưng chưa được dọn dẹp bởi cha.

Công cụ quản lý process

Hiển thị tất cả tiến trình:

ps aux
ps
ps aux | less
ps aux | grep "process-name"
ps aux | grep "httpd"
ps alx | grep "mysqld"

Theo dõi real-time:

top
htop

Quan hệ cha-con:

pstree
pstree -p     # kèm PID

Quản lý tiến trình:

kill <PID>            # Kết thúc một tiến trình cụ thể (gửi SIGTERM)
nice / renice         # Điều chỉnh độ ưu tiên của tiến trình

nice / renice — process priority

Linux scheduler dùng giá trị nice (-20 đến +19) quyết định ưu tiên CPU. Nice thấp = ưu tiên cao. Mặc định nice = 0.

# Chạy command với nice +10 (priority thấp hơn)
nice -n 10 ./heavy-job.sh

# Đổi nice của process đang chạy
renice -n 5 -p <PID>

# Chỉ root mới đặt nice âm
sudo nice -n -10 ./critical-task

Use case DevOps: backup job đêm nên nice -n 19 để không tranh CPU với app:

0 2 * * * nice -n 19 /scripts/backup.sh

Danh sách các tín hiệu thường dùng

Signal Ý nghĩa Ứng dụng Catch được?
SIGHUP (1) Yêu cầu reload config hoặc khởi động lại Thay đổi cấu hình mà không tắt service (nginx, apache)
SIGINT (2) Tín hiệu ngắt — Ctrl+C trong terminal Ngừng tiến trình đang chạy
SIGQUIT (3) Yêu cầu dừng + tạo core dump Lỗi nghiêm trọng cần debug
SIGKILL (9) Buộc tiến trình dừng ngay lập tức Khi tiến trình không phản hồi Không
SIGSEGV (11) Báo lỗi phân vùng bộ nhớ (segfault) Truy cập bộ nhớ không hợp lệ Có (hạn chế)
SIGTERM (15) Yêu cầu kết thúc lịch sự (default kill) Yêu cầu kết thúc bình thường
SIGCHLD (17) Tiến trình con kết thúc hoặc đổi trạng thái Cha thu dọn tài nguyên con
SIGCONT (18) Tiếp tục tiến trình đã tạm dừng Sau SIGSTOP
SIGSTOP (19) Yêu cầu dừng ngay lập tức Dừng không hủy bỏ Không
SIGTSTP (20) Tạm dừng (Ctrl+Z trong terminal) Dừng tiến trình mà không kết thúc
SIGUSR1, SIGUSR2 Tín hiệu user-defined App tự define ý nghĩa

Tín hiệu đặc biệt:

  • SIGKILLSIGTERM là 2 tín hiệu quan trọng nhất để dừng tiến trình. SIGKILL mạnh nhất và không thể từ chối.
  • SIGHUP, SIGUSR1, SIGUSR2 thường được dùng để thông báo và thực hiện hành động tùy chỉnh.

(Số signal có thể khác trên architecture khác — ARM, PowerPC. Kiểm tra cụ thể bằng kill -l.)

Gửi tín hiệu

kill -<số tín hiệu> <PID>
kill -9 <PID>            # SIGKILL
kill -HUP <PID>          # SIGHUP (reload)
kill -TERM <PID>         # SIGTERM rõ ràng

killall nginx
pkill -f "python app.py"

Quy tắc dùng signal

  1. SIGTERM trước, SIGKILL sau. SIGTERM cho app cleanup (đóng connection, flush buffer, lưu state). Sau vài giây không respond, mới SIGKILL.
  2. kill -9 không phải reflex. App handle SIGTERM đúng → graceful shutdown. SIGKILL = data corruption nguy cơ.
  3. SIGHUP để reload config thay restart. Không downtime:
    kill -HUP $(pidof nginx)
  4. SIGKILL và SIGSTOP không catch được — đặc tính kernel, đảm bảo admin luôn có cách kill/freeze process.

trap

Lệnh trap trong Linux được sử dụng để xử lý các tín hiệu (signals) trong shell script. Cho phép chỉ định các hành động mà shell sẽ thực hiện khi nhận một tín hiệu nhất định, giúp kiểm soát và xử lý các sự kiện bất ngờ hoặc tín hiệu như SIGINT, SIGTERM, SIGKILL, v.v.

Cú pháp:

trap 'command' signal

Ví dụ đơn giản:

#!/bin/bash
# Bắt tín hiệu EXIT (0)
trap 'echo "Exit signal detected..."' 0

echo "This is a test"
exit 0

Pattern production — script chạy lâu với cleanup:

#!/bin/bash
set -euo pipefail

LOCK="/tmp/myscript.lock"

cleanup() {
    echo "Cleaning up..."
    rm -f "$LOCK"
}

# Bắt EXIT (chạy cleanup khi script kết thúc, dù OK hay error)
# Bắt SIGINT (Ctrl+C) và SIGTERM
trap cleanup EXIT INT TERM

if [[ -f "$LOCK" ]]; then
    echo "Already running" >&2
    exit 1
fi
touch "$LOCK"

echo "Working..."
sleep 100

trap cleanup EXIT ở đầu script = cleanup chắc chắn chạy. Defensive programming pattern. Đưa vào template script production.


Subshell

Subshell (tiến trình con) là một phiên bản con của shell, được tạo ra khi một lệnh hoặc một tập lệnh được thực thi trong một shell khác biệt. Subshell cho phép thực thi các lệnh mà không ảnh hưởng đến môi trường của shell cha (shell gốc).

Tạo subshell bằng dấu ngoặc đơn ( ... ):

(echo "This is a subshell"; ls)

Đặc điểm subshell

  • Tách biệt môi trường — biến, working directory không leak ra shell cha.
  • Các lệnh trong subshell độc lập.
  • Subshell là tiến trình con thực sự, có thể tạo signal hoặc thay đổi thông tin tiến trình (qua kill, wait, v.v.).

Ví dụ:

( cd /tmp && echo "Current directory is $(pwd)" )
echo "Current directory is $(pwd)"

Lệnh cd vào /tmpecho chạy trong subshell. Môi trường trong subshell thay đổi nhưng KHÔNG ảnh hưởng shell cha. Sau khi subshell kết thúc, shell cha vẫn ở thư mục ban đầu.

Subshell có PID riêng:

echo $$              # PID parent shell
( echo $BASHPID )    # PID subshell — khác $$

Use case: thay đổi env tạm thời, set trap riêng, tránh leak biến:

(
    export DEBUG=1
    export LOG_LEVEL=verbose
    ./run-test.sh
)
# Sau ngoặc, env quay về như cũ

Compound command — lệnh hợp thành

Compound command là cách kết hợp nhiều lệnh lại với nhau để thực thi chúng trong một cách có tổ chức.

Dùng dấu chấm phẩy ;

Các lệnh được thực thi tuần tự, bất kể lệnh trước có thành công hay không:

command1; command2; command3
echo "Hello"; echo "World"; ls

Dùng && (AND), || (OR)

cmd1 && cmd2           # cmd2 chạy NẾU cmd1 OK
cmd1 || cmd2           # cmd2 chạy NẾU cmd1 fail

Dùng & cho parallel

cmd1 &                 # chạy nền, không đợi
cmd1 & cmd2 & wait     # parallel rồi wait tất cả

Pattern parallel:

backup_db &
backup_files &
backup_logs &
wait                    # đợi cả 3 xong
echo "All backups done"

Lưu PID để kill sau:

long_task &
TASK_PID=$!
echo "Started: $TASK_PID"
kill "$TASK_PID"

exec

Trong Linux và Unix, exec là một lệnh trong shell có tác dụng thay thế tiến trình hiện tại bằng một tiến trình mới. Khi exec được sử dụng, tiến trình hiện tại (chạy lệnh exec) sẽ bị thay thế hoàn toàn bằng tiến trình mới, và tiến trình này sẽ chạy trong cùng một không gian bộ nhớ và PID như tiến trình gốc.

Điều này có nghĩa là khi exec được gọi, các lệnh hoặc chương trình mới sẽ được thực thi mà KHÔNG tạo ra tiến trình con.

Cú pháp:

exec <command> [arguments...]

Ví dụ:

exec /usr/bin/python /app/main.py
# Lệnh sau exec không bao giờ chạy (process đã bị thay)

Use case quan trọng: wrapper script trong Docker entrypoint.

#!/bin/bash
# entrypoint.sh
export DB_HOST="prod-db"
export DB_PORT=5432
exec python /app/main.py "$@"
# python thay bash, giữ PID 1, signal forward đúng

Câu chuyện thực tế — signal forwarding trong Docker

Service Python deploy production trong Docker. Container chạy bình thường, nhưng docker stop mất 10 giây chờ rồi force kill. Log không có "graceful shutdown".

Entrypoint script:

#!/bin/bash
export DB_URL="..."
python /app/main.py

Vấn đề ở mức OS:

  1. Container start → bash chạy → bash fork() + exec() python.
  2. Bash giờ là PID 1 trong namespace container, python là PID 2.
  3. docker stop gửi SIGTERM tới PID 1 = bash.
  4. Bash đang ở wait() chờ python — không có handler SIGTERM nên không forward.
  5. Python không nhận SIGTERM → không cleanup.
  6. 10 giây sau Docker gửi SIGKILL tới PID 1 — bash chết, kernel cũng kill PID 2 (python).
  7. Connection DB bị reset, request đang xử lý dở mất data.

Fix dùng exec:

#!/bin/bash
export DB_URL="..."
exec python /app/main.py    # exec → python THAY bash, trở thành PID 1

Bây giờ:

  1. Container start → bash chạy.
  2. exec python → kernel giữ PID 1 nhưng load code python — bash biến mất, python là PID 1.
  3. docker stop gửi SIGTERM tới PID 1 = python trực tiếp.
  4. Python handle SIGTERM, graceful shutdown trong 1-2 giây.

Bài học:

  1. Trong container, luôn exec lệnh cuối trong entrypoint script.
  2. Hiểu process model (fork/exec/PID) để debug "tại sao app không nhận SIGTERM".
  3. Hoặc dùng tini / dumb-init làm PID 1:
    ENTRYPOINT ["tini", "--", "/app/entrypoint.sh"]

Cùng nguyên lý: K8s gửi SIGTERM → pod's PID 1 phải forward → app graceful shutdown trong terminationGracePeriodSeconds.


Lab

mkdir /tmp/sys-lab && cd /tmp/sys-lab

# Test redirect & pipe
cat > analyze.sh <<'EOF'
#!/bin/bash
set -euo pipefail
echo "=== Top 5 CPU ==="
ps aux --sort=-%cpu | head -6
echo
echo "=== Disk usage (>50%) ==="
df -h | awk 'NR==1 || $5+0 > 50' | grep -v tmpfs
EOF
chmod +x analyze.sh
./analyze.sh > report.txt 2> err.log
cat report.txt

# Tạo file rỗng
> /tmp/empty.log
ls -la /tmp/empty.log

# File descriptor tuỳ ý
exec 3> /tmp/myfd.log
echo "Line 1" >&3
echo "Line 2" >&3
exec 3>&-
cat /tmp/myfd.log

# read từ FD tuỳ ý
exec 4< /etc/hostname
read -u 4 host
echo "Hostname từ FD 4: $host"
exec 4<&-

# Trap signal
cat > robust.sh <<'EOF'
#!/bin/bash
set -euo pipefail
LOCK="/tmp/robust.lock"

cleanup() {
    echo "Cleaning up..."
    rm -f "$LOCK"
}
trap cleanup EXIT INT TERM

[[ -f "$LOCK" ]] && { echo "Already running"; exit 1; }
touch "$LOCK"

echo "Working... (Ctrl+C để test trap)"
for i in {1..20}; do
    echo "Step $i"
    sleep 1
done
EOF
chmod +x robust.sh
./robust.sh
# Nhấn Ctrl+C → "Cleaning up..." chạy → lock xoá

# Khám phá process với /proc
echo "Bash hiện tại PID: $$"
ls -l /proc/$$/fd/

# Tìm zombie (nếu có)
ps aux | awk '$8=="Z"'

# Nice + renice
sleep 1000 &
PID=$!
ps -p $PID -o pid,ni,cmd       # nice = 0
renice -n 10 -p $PID
ps -p $PID -o pid,ni,cmd       # nice = 10
kill $PID

# Subshell — biến không leak
VAR_OUT="parent"
( VAR_OUT="subshell"; echo "Inside: $VAR_OUT" )
echo "Outside: $VAR_OUT"        # vẫn "parent"

# Demo exec — script bị thay
cat > exec-demo.sh <<'EOF'
#!/bin/bash
echo "Trước exec, PID = $$"
exec sleep 5
echo "Dòng này không bao giờ chạy"
EOF
chmod +x exec-demo.sh
./exec-demo.sh

Pitfalls

  1. > ghi đè file, lỡ output > input thay output > newfile → mất data.
  2. Pipe chỉ pipe stdout. Cần stderr → 2>&1 trước |.
  3. kill -9 reflex → app không cleanup, data corruption. SIGTERM trước, SIGKILL chỉ khi cần.
  4. Bash PID 1 trong container không forward signal → app không nhận SIGTERM. Dùng exec hoặc tini.
  5. Heredoc <<EOF (không quote) expand variable. Quote <<'EOF' để giữ nguyên.
  6. Zombie tích tụ = bug app cha không wait child. Không kill được — kill parent hoặc fix code.
  7. ./script & không lưu $! → khó kill sau. ./script & PID=$!.
  8. Trap không bắt SIGKILL và SIGSTOP — đặc tính kernel.
  9. renice priority cao (âm) cần root. User thường chỉ làm priority thấp hơn.

Tóm tắt

  • 3 stream stdin/stdout/stderr (FD 0/1/2). FD là index vào file table của process/proc/<PID>/fd/ xem được.
  • Pipe | chỉ pipe stdout. Stderr cần 2>&1.
  • exec FD<>file cho FD tuỳ ý. read -u FD đọc từ đó.
  • Process tạo bằng fork() (clone parent), exec() (load code mới), wait() (parent thu dọn). PID 1 = init/systemd.
  • Zombie = child chết, parent chưa wait. Orphan = child sống, parent chết → init nhận.
  • Daemon truyền thống: double-fork + setsid. Modern dùng systemd.
  • SIGTERM trước, SIGKILL sau. SIGKILL/SIGSTOP không catch được.
  • trap cleanup EXIT ở đầu script production. Cleanup luôn chạy.
  • exec trong entrypoint container để app nhận signal đúng (PID 1).
  • nice/renice priority — backup job nên nice cao (19) để không tranh CPU.

🎓 Tổng kết — 3 bài Bash

Đi qua:

  • Bài 01: Shell cơ bản — Linux/shell/bash, builtins vs external (ranh giới shell-OS), shell script là gì, script đầu tiên, biến, login vs non-login shell, customize bashrc.
  • Bài 02: Logic — exit status, test command, if/elif/else, case, AND/OR/NOT, positional parameters, loops (for/while/until/select/break/continue), function với local/global var, set -euo pipefail, ShellCheck.
  • Bài 03: Hệ thống — redirect, /dev/null, heredoc/here string, file descriptor tuỳ ý, pipes/filters, signals, process internals (fork/exec/wait/getpid), orphan/zombie, daemon lifecycle, trap, subshell, compound command, exec.

Đủ viết script production thật + hiểu OS từ phía shell.

Lúc chưa rõ thì đọc thêm:

  • Array (declare -a, ${arr[@]}) — quản lý list.
  • Associative array (declare -A) — Bash 4+, key-value.
  • Regex sâu: PCRE, sed, awk regex.
  • coproc, named pipe (mkfifo) — IPC giữa script.
  • Bash debug: bash -x script.sh, set -x, PS4.
  • The Linux Programming Interface (Michael Kerrisk) — định nghĩa kernel của syscall, fd, signal, process. Đọc khi muốn hiểu kernel side.

Lời khuyên cuối:

  1. Mọi script production:

    #!/bin/bash
    set -euo pipefail
    trap cleanup EXIT
  2. Quote biến luôn. ShellCheck báo chỗ thiếu.

  3. Hiểu OS phía dưới = debug nhanh gấp 10 lần. Khi gặp "app không respond SIGTERM", "process zombie tích tụ", "FD leak" — bạn không panic mà biết đào đâu.

  4. Bash quá rối/dài (>500 dòng) → cân nhắc Python/Go. Bash giỏi nhất ở thao tác file/process.

  5. Khi rối, đọc kỹ error message của bash. Đa phần nói rõ dòng nào, lý do gì.

Minh Hưng

← Bài trước
Bài 02
Zalo tư vấn