Có những việc Python không cần tự làm: nén file bằng zip, lấy thông tin từ git, chuyển đổi video bằng ffmpeg. Module subprocess cho phép gọi các lệnh đó và nhận lại kết quả. Nhưng cũng chính chỗ này là nơi nhiều lỗ hổng bảo mật ra đời, nên cần làm cho đúng ngay từ đầu.
subprocess.run với danh sách tham số
Quy tắc số một: truyền lệnh dưới dạng danh sách, mỗi tham số một phần tử. Không ghép thành một chuỗi dài.
import subprocess
kq = subprocess.run(["ls", "-l", "/tmp"])
print(kq.returncode) # 0 nếu thành công
Ở dạng danh sách, Python gọi thẳng chương trình và đưa từng tham số vào đúng chỗ. Không có shell nào đứng giữa để diễn giải dấu cách, dấu nháy hay ký tự đặc biệt. Nhờ vậy tên file có dấu cách cũng không cần xử lý gì thêm:
# Chạy đúng, dù tên file có dấu cách
subprocess.run(["cp", "bao cao thang 9.pdf", "/backup/"])
Lấy đầu ra với capture_output và text
Mặc định đầu ra của lệnh con đi thẳng ra màn hình. Muốn giữ lại để xử lý, bật capture_output=True.
kq = subprocess.run(["git", "rev-parse", "--short", "HEAD"],
capture_output=True, text=True)
print(kq.stdout.strip()) # ví dụ: 2109b0f
print(kq.stderr) # thông báo lỗi, nếu có
Tham số text=True rất đáng nhớ: không có nó, stdout trả về kiểu bytes và bạn phải tự .decode(). Có nó, bạn nhận được chuỗi bình thường. Đầu ra gần như luôn kèm ký tự xuống dòng ở cuối, nên .strip() là thói quen tốt. Cần tách nhiều dòng thì dùng .splitlines().
Đọc mã thoát cho đúng
Theo quy ước, 0 là thành công, khác 0 là có chuyện. Nhưng “khác 0” không phải lúc nào cũng là lỗi — nhiều lệnh dùng mã thoát để trả lời câu hỏi.
kq = subprocess.run(["grep", "localhost", "/etc/hosts"],
capture_output=True, text=True)
if kq.returncode == 0:
print("Tìm thấy:", kq.stdout.strip())
elif kq.returncode == 1:
print("Không tìm thấy — đây không phải lỗi")
else:
print("Lỗi thật:", kq.stderr)
Với grep, mã 1 nghĩa là không khớp dòng nào. Nếu bạn coi mọi mã khác 0 là thất bại, chương trình sẽ báo lỗi giả. Hãy đọc tài liệu của lệnh bạn gọi trước khi quyết định.
check=True để lỗi không bị bỏ qua
Khi bạn chắc chắn lệnh phải thành công, check=True sẽ ném ngoại lệ thay vì để chương trình chạy tiếp với dữ liệu sai.
try:
kq = subprocess.run(["tar", "-czf", "backup.tar.gz", "du_lieu"],
capture_output=True, text=True, check=True)
except subprocess.CalledProcessError as e:
print("Lệnh thất bại, mã thoát:", e.returncode)
print("Chi tiết:", e.stderr)
Đối tượng CalledProcessError giữ đủ returncode, stdout và stderr, nên bạn ghi log được nguyên nhân thật. Một ngoại lệ khác cần bắt riêng là FileNotFoundError — nó xảy ra khi chính chương trình bạn gọi không có trên máy, và check=True không giúp gì trong trường hợp đó.
timeout để không treo mãi
Một lệnh chờ nhập liệu hoặc kẹt mạng có thể làm script của bạn đứng im vô hạn. Luôn đặt hạn cho các lệnh gọi ra ngoài.
try:
kq = subprocess.run(["curl", "-s", "https://example.com"],
capture_output=True, text=True, timeout=10)
except subprocess.TimeoutExpired:
print("Quá 10 giây, đã bỏ qua")
Hết giờ, Python giết tiến trình con rồi mới ném TimeoutExpired, nên không để lại tiến trình mồ côi.
Vì sao shell=True với dữ liệu người dùng là lỗ hổng
Tham số shell=True đưa chuỗi lệnh cho shell diễn giải. Shell hiểu dấu chấm phẩy là “hết lệnh này, chạy tiếp lệnh sau” — và đó là toàn bộ vấn đề.
# Giả sử tên file lấy từ form người dùng nhập
ten = "a.txt; echo DA_BI_CHEN"
r = subprocess.run(f"echo {ten}", shell=True, capture_output=True, text=True)
print(r.stdout)
# a.txt
# DA_BI_CHEN <-- lệnh thứ hai đã chạy!
Ở đây chỉ là echo vô hại. Nhưng người dùng gõ a.txt; rm -rf /du_lieu thì shell cũng chạy y như vậy, với đúng quyền của tiến trình Python. Cùng đầu vào đó, dạng danh sách hoàn toàn vô hại:
r = subprocess.run(["echo", ten], capture_output=True, text=True)
print(r.stdout)
# a.txt; echo DA_BI_CHEN <-- chỉ là một chuỗi, không có lệnh nào chạy
Toàn bộ chuỗi được coi là một tham số, dấu chấm phẩy chỉ là ký tự bình thường. Nếu thật sự cần tính năng của shell (đường ống, chuyển hướng, mở rộng *), hãy tự làm phần đó trong Python: đọc file bằng glob, nối dữ liệu bằng input=. Còn nếu bắt buộc phải dùng shell, mọi giá trị chèn vào phải đi qua shlex.quote():
import shlex
print(shlex.quote("a.txt; echo DA_BI_CHEN"))
# 'a.txt; echo DA_BI_CHEN' — đã được bọc nháy an toàn
Nhưng cách chắc chắn nhất vẫn là không bật shell=True. Nếu bạn có sẵn một chuỗi lệnh và muốn đổi sang danh sách, shlex.split() làm việc đó đúng cách, kể cả với phần trong dấu nháy.
Thư mục, biến môi trường và dữ liệu đầu vào
import os
# Chạy trong một thư mục khác, không cần đổi thư mục của cả script
subprocess.run(["git", "status"], cwd="/Users/sysadmin/du_an")
# Thêm biến môi trường, vẫn giữ các biến sẵn có
subprocess.run(["./deploy.sh"], env={**os.environ, "MOI_TRUONG": "production"})
# Đẩy dữ liệu vào stdin của lệnh con
kq = subprocess.run(["wc", "-l"], input="a\nb\nc\n",
capture_output=True, text=True)
print(kq.stdout.strip()) # 3
Chú ý env={**os.environ, ...}: nếu truyền một dict trống, lệnh con mất sạch biến môi trường, kể cả PATH.
Một cái bẫy khi gộp stderr vào stdout
Nhiều người viết capture_output=True kèm stderr=subprocess.STDOUT và nhận ngay ValueError. Hai cách này loại trừ nhau — muốn gộp, hãy khai báo thủ công:
kq = subprocess.run(["./script.sh"],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True)
print(kq.stdout) # cả hai luồng trộn chung ở đây
print(kq.stderr) # None
# Muốn vứt bỏ đầu ra thì dùng DEVNULL
subprocess.run(["ping", "-c", "1", "8.8.8.8"], stdout=subprocess.DEVNULL)
Vài nguyên tắc nên nhớ
- Luôn truyền lệnh dạng danh sách.
shell=Truevới dữ liệu người dùng là lỗ hổng chèn lệnh. - Đặt
timeoutcho mọi lệnh có thể chờ mạng hoặc chờ nhập liệu. - Dùng
check=Truekhi lệnh bắt buộc phải thành công, và bắtCalledProcessErrorđể lấystderr. - Đọc tài liệu về mã thoát trước khi coi “khác 0” là lỗi.
- Nhớ
text=True, nếu không bạn sẽ nhận vềbytes. - Việc nào Python làm được bằng thư viện chuẩn thì đừng gọi lệnh ngoài — ít phụ thuộc, dễ chạy trên mọi máy.
subprocess là cây cầu nối Python với phần còn lại của hệ thống. Đi đúng làn — danh sách tham số, có timeout, có kiểm tra lỗi — thì cây cầu đó rất chắc.
