jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

Bash & Shell Scripting · Part 8 — Text Processing: grep, sed & awk

grep, sed, and awk for shell text processing: flags, substitution, field parsing, cut/sort/uniq/tr/wc pipelines — bilingual, with real log pipelines and exercises.

This is Part 8 of a 10-part series on writing robust shell scripts {Đây là Phần 8 của series 10 bài về viết shell script chắc chắn}. You already know redirection and pipes from Part 7 {Bạn đã biết redirection và pipe từ Phần 7}. Now we put those pipes to work with the text-processing trinitygrep, sed, and awk — plus the small tools that complete every real pipeline {Giờ ta dùng các pipe đó với bộ ba xử lý textgrep, sed, và awk — cùng các công cụ nhỏ hoàn thiện mọi pipeline thực tế}.

Logs, CSV exports, config files, and API responses are all lines of text {Log, export CSV, file config, và response API đều là dòng text}. The shell does not parse JSON natively, but it excels at slicing, filtering, and transforming line-oriented data at speed {Shell không parse JSON sẵn, nhưng rất giỏi cắt, lọc, và biến đổi dữ liệu theo dòng với tốc độ cao}. Master these tools and you can answer “which IPs hit us most?”, “what is the total spend?”, and “strip this field from every line?” without leaving the terminal {Nắm các công cụ này là bạn trả lời được “IP nào truy cập nhiều nhất?”, “tổng chi tiêu là bao nhiêu?”, và “bỏ field này khỏi mọi dòng?” mà không rời terminal}.


The text-processing model {Mô hình xử lý text}

Think of a pipeline as a conveyor belt of lines {Hãy tưởng pipeline như băng chuyền các dòng}. Each tool does one job on every line (or on the whole stream) and passes the result to the next tool {Mỗi công cụ làm một việc trên mỗi dòng (hoặc trên cả luồng) rồi chuyển kết quả sang công cụ tiếp theo}:

Tool {Công cụ}Job {Nhiệm vụ}Typical input {Input điển hình}
grepfilter — keep lines that match a pattern {lọc — giữ dòng khớp pattern}log files, config grep
sedtransform — substitute, delete, print ranges {biến đổi — thay thế, xóa, in khoảng dòng}bulk find-replace, strip comments
awkparse & compute — split fields, sum columns, report {phân tích & tính — tách field, cộng cột, báo cáo}CSV, access logs, tabular data
cut / sort / uniq / tr / wcsupport — extract columns, order, count, translate, measure {hỗ trợ — lấy cột, sắp xếp, đếm, chuyển đổi, đo}prep before awk or after grep
cat log.txt whole file | grep ERROR keep matches | sort order them | uniq -c count dupes each | sends the left command's stdout into the right command's stdin
A multi-stage pipeline: each stage reads lines from stdin, processes them, and writes to stdout for the next command

The diagram shows the classic pattern: read a file, grep to narrow, sort to group, uniq -c to count, then head for the top N {Sơ đồ thể hiện mẫu kinh điển: đọc file, grep để thu hẹp, sort để nhóm, uniq -c để đếm, rồi head lấy top N}. That single chain answers dozens of ops questions on real servers {Một chuỗi đó trả lời hàng chục câu hỏi vận hành trên server thật}.


grep — filter lines by pattern {grep — lọc dòng theo pattern}

grep prints lines that match a regular expression (or a fixed string) {grep in các dòng khớp biểu thức chính quy (hoặc chuỗi cố định)}. It is almost always the first stage when you need to narrow a large file {Gần như luôn là giai đoạn đầu khi bạn cần thu hẹp file lớn}.

Basic match {Khớp cơ bản}

# Keep lines containing "ERROR" (case-sensitive)
grep ERROR app.log

# Search multiple files
grep "connection refused" /var/log/syslog app.log

# No match → exit code 1 (useful in scripts with set -e if you expect hits)
grep "TODO" src/**/*.sh

Common flags you will use daily {Các flag dùng hàng ngày}

# -i  case-insensitive
grep -i error app.log

# -v  invert — print lines that do NOT match
grep -v '^#' nginx.conf          # drop comment lines
grep -v DEBUG app.log            # hide DEBUG noise

# -n  show line numbers
grep -n "OutOfMemory" app.log

# -c  count matching lines (not the matches inside a line)
grep -c ERROR app.log

# -o  only the matching part (great with -E for extracting tokens)
grep -oE '[0-9]{1,3}(\.[0-9]{1,3}){3}' access.log   # pull IPv4 addresses

# -r  recursive through directories
grep -r "API_KEY" ./config/

# -E  extended regex (|, +, ?, groups) — same as egrep
grep -E 'ERROR|WARN|FATAL' app.log
grep -E '^[0-9]{4}-[0-9]{2}-[0-9]{2}' events.log    # lines starting with a date

Tip {Mẹo}: combine -r with --include to avoid searching binaries {kết hợp -r với --include để tránh tìm trong file nhị phân}.

grep -r --include='*.sh' 'set -euo pipefail' .

Always quote your pattern so the shell does not expand *, ?, or $ before grep sees them {Luôn quote pattern để shell không expand *, ?, hay $ trước khi grep nhận}:

grep 'user=$' config.env    # correct — $ is literal end-of-line in regex
grep user=$ config.env      # wrong — shell expands $ to empty or a variable

sed — stream editor for line transforms {sed — biên tập luồng để biến đổi dòng}

sed reads input line by line, applies editing commands, and writes to stdout {sed đọc input từng dòng, áp lệnh chỉnh sửa, rồi ghi ra stdout}. It is the go-to for bulk substitution and light line surgery {Đây là công cụ hàng đầu cho thay thế hàng loạt và phẫu thuật dòng nhẹ}.

Substitution: s/old/new/ {Thay thế: s/old/new/}

# Replace first occurrence per line
echo 'foo bar foo' | sed 's/foo/baz/'

# Global — all occurrences on each line
echo 'foo bar foo' | sed 's/foo/baz/g'

# Use a different delimiter when the pattern contains /
echo 'path /usr/local/bin' | sed 's|/usr/local|/opt|g'

# Capture groups (extended regex with -E / -r)
echo 'user=alice' | sed -E 's/user=([a-z]+)/name=\1/'

In-place editing: -i {Chỉnh sửa tại chỗ: -i}

# GNU/Linux (most distros) — edits the file directly
sed -i 's/debug=false/debug=true/' config.properties

# macOS / BSD — REQUIRES a backup extension (even empty '')
sed -i '' 's/debug=false/debug=true/' config.properties

# Portable pattern: never use -i in scripts that must run everywhere;
# write to a temp file instead
sed 's/old/new/g' input.txt > input.txt.tmp && mv input.txt.tmp input.txt

Warning {Cảnh báo}: sed -i differs between GNU sed (Linux) and BSD sed (macOS) {sed -i khác nhau giữa GNU sed (Linux) và BSD sed (macOS)}. On macOS, sed -i 's/x/y/' file creates a backup named file with a literal suffix — almost never what you want {Trên macOS, sed -i 's/x/y/' file tạo backup tên file với suffix literal — gần như không bao giờ là điều bạn muốn}. Test on your target OS or avoid -i in portable scripts {Test trên OS đích hoặc tránh -i trong script portable}.

Deleting and printing ranges {Xóa và in khoảng dòng}

# Delete lines matching a pattern
sed '/^#/d' nginx.conf              # remove comment lines
sed '/^$/d' file.txt                # remove blank lines

# Delete a line range (lines 2 through 5)
sed '2,5d' report.txt

# Print only lines 10–20 (suppress default print with -n)
sed -n '10,20p' huge.log

# Print only lines matching a pattern
grep -n ERROR app.log | sed -n '1,5p'   # first 5 ERROR hits with numbers from grep

sed shines in pipelines when you need a quick transform without writing a script {sed tỏa sáng trong pipeline khi bạn cần biến đổi nhanh mà không viết script}:

# Normalize log level to uppercase in flight
grep ERROR app.log | sed -E 's/(ERROR|WARN|INFO)/\U\1/'
# Note: \U is GNU sed; on BSD use a different approach or perl

awk — pattern, fields, and small programs {awk — pattern, field, và chương trình nhỏ}

awk is a mini language for line-oriented data {awkngôn ngữ nhỏ cho dữ liệu theo dòng}. Every awk program is built from pattern \{ action \} blocks: when a line matches the pattern, awk runs the action {Mọi chương trình awk gồm khối pattern \{ action \}: khi dòng khớp pattern, awk chạy action}. An empty pattern means “every line” {Pattern rỗng nghĩa là “mọi dòng”}.

Fields: $1, $2, $NF {Field: $1, $2, $NF}

By default awk splits each line on whitespace into fields $1, $2, … $NF (last field) {Mặc định awk tách mỗi dòng theo khoảng trắng thành field $1, $2, … $NF (field cuối)}:

# access.log style: IP method path status
# 192.168.1.5 GET /api/users 200

awk '{ print $1 }' access.log           # first field — client IP
awk '{ print $NF }' access.log          # last field — status code
awk '{ print $(NF-1) }' access.log      # second-to-last — path segment trick

Built-in variables: NR, NF {Biến sẵn: NR, NF}

# NR = record (line) number, NF = number of fields on this line
awk '{ print NR, NF, $0 }' data.txt

# Print line 50 only
awk 'NR == 50' data.txt

# Skip the header row
awk 'NR > 1' report.csv

Custom field separator: -F {Dấu phân cách field tùy chỉnh: -F}

# CSV with comma separator
awk -F',' '{ print $1, $3 }' sales.csv

# /etc/passwd uses colon
awk -F: '{ print $1, $6 }' /etc/passwd   # username and home dir

# Multiple characters as FS (GNU awk)
awk -F'[ ,]+' '{ print $2 }' messy.txt

Sum and aggregate a column {Cộng và gộp một cột}

# Sum the third column (amount) in a simple CSV: id,name,amount
awk -F',' 'NR > 1 { sum += $3 } END { print sum }' sales.csv

# Count rows and average
awk '{ total += $2; count++ } END { printf "avg=%.2f (n=%d)\n", total/count, count }' numbers.txt

# Count HTTP status codes from column 9 in combined log format
awk '{ codes[$9]++ } END { for (c in codes) print codes[c], c }' access.log
# Aligned columns from ps output
ps aux | awk '{ printf "%-8s %5s %s\n", $1, $3, $11 }'

# Extract date and IP from a structured log line
awk '/ERROR/ { print $1, $4 }' app.log

BEGIN and END blocks {Khối BEGIN và END}

# BEGIN runs before any input; END runs after all input
awk 'BEGIN { print "ip,hits" }
     { ips[$1]++ }
     END { for (ip in ips) print ip, ips[ip] }' access.log

awk is often clearer than chaining cut when fields are variable-width or you need per-line logic {awk thường rõ ràng hơn nối cut khi field độ rộng thay đổi hoặc bạn cần logic theo dòng}.


The supporting toolkit {Bộ công cụ hỗ trợ}

These commands are small, fast, and appear in almost every real pipeline {Các lệnh này nhỏ, nhanh, và xuất hiện trong gần như mọi pipeline thật}:

Command {Lệnh}Purpose {Mục đích}Key flags {Flag chính}
cutextract columns by character position or delimiter {lấy cột theo vị trí ký tự hoặc delimiter}-d',' -f1,3 · -c1-10
sortorder lines {sắp xếp dòng}-n numeric · -r reverse · -k2 key field
uniqcollapse adjacent duplicate lines {gộp dòng trùng liền kề}-c count · requires sorted input
trtranslate or delete characters {chuyển hoặc xóa ký tự}tr 'a-z' 'A-Z' · tr -d '\r'
wcword/line/byte counts {đếm từ/dòng/byte}-l lines · -w words · -c bytes
head / tailfirst / last N lines {N dòng đầu / cuối}-n 20 · tail -f follow
findlocate files by name, type, mtime {tìm file theo tên, loại, mtime}-name '*.log' · -mtime -1
xargsbuild commands from stdin lines {ghép lệnh từ dòng stdin}-0 with find -print0 · -I replace token
# cut — fixed delimiter (breaks if fields contain the delimiter)
cut -d',' -f1,3 report.csv

# sort — numeric, reverse, by second field
sort -t',' -k2 -n sales.csv
sort -rn amounts.txt          # -n numeric, -r reverse

# uniq — MUST sort first
sort names.txt | uniq
sort names.txt | uniq -c      # count occurrences
sort names.txt | uniq -c | sort -rn   # most common first

# tr — strip Windows CRLF, uppercase
tr -d '\r' < windows.txt > unix.txt
echo 'hello' | tr 'a-z' 'A-Z'

# wc
wc -l app.log                 # line count
wc -w essay.txt               # word count
wc -c archive.tar.gz          # byte size

# head / tail
head -n 5 access.log
tail -n 20 error.log
tail -f /var/log/syslog       # follow new lines (Ctrl-C to stop)

# find + xargs — safe with spaces via -print0
find . -name '*.log' -mtime -1 -print0 | xargs -0 wc -l

Real pipelines {Pipeline thực tế}

Top client IPs from an access log {Top IP client từ access log}

# Combined log format — IP is field 1
# 192.168.1.5 - - [24/Feb/2025:10:00:01 +0000] "GET /api HTTP/1.1" 200 1234

awk '{ print $1 }' access.log \
  | sort \
  | uniq -c \
  | sort -rn \
  | head -10

Only HTTP 5xx errors, then top paths {Chỉ lỗi HTTP 5xx, rồi top path}:

awk '$9 ~ /^5/ { print $7 }' access.log \
  | sort \
  | uniq -c \
  | sort -rn \
  | head -5

Sum a CSV column and filter with grep {Cộng cột CSV và lọc bằng grep}

# sales.csv:
# id,region,amount
# 1,APAC,120.50
# 2,EMEA,89.00
# 3,APAC,200.00

# Total amount for APAC rows only
grep APAC sales.csv \
  | awk -F',' 'NR > 1 { sum += $3 } END { printf "APAC total: %.2f\n", sum }'

# Full pipeline: strip header, sum column 3, format result
awk -F',' 'NR > 1 { sum += $3 } END { print sum }' sales.csv

Multi-tool maintenance sweep {Quét bảo trì đa công cụ}

# Find recent .sh files, count lines, show the largest
find ./scripts -name '*.sh' -mtime -7 -print0 \
  | xargs -0 wc -l \
  | sort -rn \
  | head -5

grep vs sed vs awk — when to reach for which {grep vs sed vs awk — khi nào dùng cái nào}

Question {Câu hỏi}Reach for {Dùng}Why {Vì sao}
“Does this file contain X?” {File có chứa X không?}grepFast filter, exit code for scripts
”Replace X with Y on every line” {Thay X bằng Y trên mọi dòng}sedOne-liner substitution
”Sum column 3 / group by field 1” {Cộng cột 3 / nhóm theo field 1}awkField math and aggregation
”Delete lines matching a comment pattern” {Xóa dòng khớp pattern comment}sed or grep -vsed for in-place; grep -v in a pipe
”Extract fixed columns from clean CSV” {Lấy cột cố định từ CSV sạch}cut or awk -F','cut is simpler; awk handles messy rows
”Count unique values” {Đếm giá trị duy nhất}sort | uniq -cuniq only works on sorted adjacent lines

Mistakes beginners make {Lỗi người mới hay mắc}

  • ❌ Running uniq without sort firstuniq only merges consecutive duplicates, so scattered duplicates stay {Chạy uniq không sort trướcuniq chỉ gộp duplicate liền kề, duplicate rải rác vẫn còn}.
  • ❌ Using cut on CSV that contains commas inside quoted fields — fields shift and numbers go wrong; use awk with proper parsing or a real CSV tool {Dùng cut trên CSV có dấu phẩy trong field được quote — field lệch và số sai; dùng awk parse đúng hoặc công cụ CSV thật}.
  • ❌ Assuming sed -i works the same on Linux and macOS — GNU sed takes -i alone; BSD sed requires -i '' {Tưởng sed -i giống nhau trên Linux và macOS — GNU sed dùng -i một mình; BSD sed cần -i ''}.
  • ❌ Forgetting to quote regex patterns — the shell eats $, *, and ? before grep/sed/awk see them {Quên quote pattern regex — shell nuốt $, *, và ? trước khi grep/sed/awk nhận}.
  • ❌ Using grep -c expecting match count per line — it counts lines with at least one match, not total occurrences {Dùng grep -c mong đợi số lần khớp trên dòng — nó đếm dòng có ít nhất một khớp, không phải tổng lần xuất hiện}.
  • ❌ Piping to awk without setting -F on delimited data — everything lands in $1 {Pipe vào awk không đặt -F trên dữ liệu có delimiter — mọi thứ dồn vào $1}.

Exercises {Bài tập}

Try each before opening the solution {Thử từng bài trước khi mở lời giải}.

  1. From app.log, print only lines containing ERROR or FATAL (case-insensitive), with line numbers, and count how many such lines exist {Từ app.log, in chỉ dòng chứa ERROR hoặc FATAL (không phân biệt hoa thường), kèm số dòng, và đếm có bao nhiêu dòng như vậy}.
  2. Use sed to remove all lines that are blank or start with # from nginx.conf, writing the result to nginx-clean.conf without sed -i (portable) {Dùng sed xóa mọi dòng trống hoặc bắt đầu bằng # từ nginx.conf, ghi kết quả vào nginx-clean.conf không dùng sed -i (portable)}.
  3. Given access.log (IP in field 1, HTTP status in field 9), write a pipeline that prints the top 3 IPs among requests that returned status 500 {Cho access.log (IP ở field 1, HTTP status ở field 9), viết pipeline in top 3 IP trong các request trả status 500}.
Solution {Lời giải}
# Exercise 1 — filter, number, and count
grep -inE 'ERROR|FATAL' app.log
grep -icE 'ERROR|FATAL' app.log    # -c gives the count; combine with -n in two steps

# Or one pipeline for numbered lines + separate count:
grep -inE 'ERROR|FATAL' app.log
echo "total: $(grep -icE 'ERROR|FATAL' app.log)"
# Exercise 2 — portable sed to a new file
sed -e '/^#/d' -e '/^$/d' nginx.conf > nginx-clean.conf
# Exercise 3 — filter 500s, count IPs, top 3
awk '$9 == 500 { print $1 }' access.log \
  | sort \
  | uniq -c \
  | sort -rn \
  | head -3

Exercise 1 uses -E for alternation and -i for case folding {Bài 1 dùng -E cho alternation và -i cho không phân biệt hoa thường}. grep -c cannot combine with line numbers in one invocation — run twice or use a pipeline {grep -c không kết hợp với số dòng trong một lần gọi — chạy hai lần hoặc dùng pipeline}. Exercise 2 chains two sed expressions with -e and redirects to a new file, avoiding OS-specific -i {Bài 2 nối hai biểu thức sed bằng -e và redirect sang file mới, tránh -i phụ thuộc OS}. Exercise 3 is the canonical awk | sort | uniq -c | sort -rn | head pattern from this lesson {Bài 3 là mẫu awk | sort | uniq -c | sort -rn | head kinh điển trong bài này}.


Takeaway {Điều cốt lõi}

grep filters, sed transforms, awk parses and computes {grep lọc, sed biến đổi, awk phân tích và tính toán}. Chain them with sort, uniq, cut, tr, and wc to answer real questions on logs and CSVs in one line {Nối chúng với sort, uniq, cut, tr, và wc để trả lời câu hỏi thật trên log và CSV trong một dòng}. Quote your patterns, sort before uniq, and know your sed -i dialect before touching production files {Quote pattern, sort trước uniq, và biết dialect sed -i của bạn trước khi sửa file production}. Next up: make scripts fail safely with set -euo pipefail, traps, and structured error handling {Tiếp theo: làm script fail an toàn với set -euo pipefail, trap, và xử lý lỗi có cấu trúc}.