Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Mã thoát của job: để bộ lập lịch biết khi nào dừng và khi nào thử lại

Câu hỏi bài này trả lời: một job chạy dưới Kubernetes hay AWS Batch chỉ báo kết quả cho bộ lập lịch bằng một con số; những cách viết script nào khiến con số đó thành 0 dù việc đã hỏng; shell tự đặt những mã nào; và một hợp đồng mã thoát tối thiểu trông ra sao để lỗi tạm thời được thử lại có giới hạn còn lỗi xác thực thì dừng ngay?

Cần biết trước: shell cơ bản ($?, pipeline, ||), mã trạng thái HTTP, đọc Python ngắn. Lab dùng bash 3.2.57 (bản đi kèm macOS), curl 8.7.1 và Python 3.14.4 (thư viện chuẩn) trên macOS arm64, với một API thử chạy cục bộ ở cổng do hệ điều hành cấp; không cần mạng ngoài. Lab chưa chạy trên Linux, trên bash hay curl bản khác, trên Kubernetes hay AWS Batch: phần nền tảng chỉ trích tài liệu đọc ngày 2026-10-04 và ghi rõ là chưa chạy.

Bộ lập lịch chỉ đọc một con số

Khi tiến trình của job kết thúc, thứ bộ lập lịch nhận được là mã thoát. Ví dụ podFailurePolicy trong tài liệu Job của Kubernetes viết: “an exit code of 0 means that the container succeeded”, còn “any other exit code represents that the container failed, and hence the entire Pod. The Pod will be re-created if the total number of restarts is below backoffLimit”. AWS Batch liệt kê trong các tình huống thất bại có thể thử lại: “Any non-zero exit code from a container job”.

Có hai hướng sai, và cả hai đều tốn kém:

  • Báo 0 khi việc đã hỏng. Không tầng nào phía sau thấy lỗi: không thử lại, không cảnh báo, và job kế tiếp đọc dữ liệu hỏng như dữ liệu tốt.
  • Báo cùng một mã khác 0 cho mọi lỗi. Bộ lập lịch không phân biệt được lỗi nên thử lại (mạng chập chờn) với lỗi thử lại vô ích (thiếu token), nên chỉ còn hai lựa chọn: thử lại tất cả hoặc không thử lại gì.

Bài đi từ hướng sai thứ nhất sang cách giải hướng thứ hai bằng một hợp đồng mã thoát.

Ba cách làm mã thoát thành 0 dù việc đã hỏng

Mỗi cách có một bản sai và một bản sửa trong lab, số liệu ở phần kết quả bên dưới:

Cách viếtVì sao thoát 0Dấu vết sai còn lạiBản sửa
lệnh || truelệnh lỗi nằm trong danh sách ||; trạng thái cả danh sách là truedòng “xong” vẫn in, bước sau chạy tiếpbỏ || true; lab thoát 1 (mã của cp)
pipeline không pipefailtrạng thái pipeline là của lệnh cuốidữ liệu đầu vào cụt, bước sau nhận thiếuset -o pipefail; lab thoát 3 (mã lệnh đầu)
curl không --failHTTP 401 vẫn là một phản hồi nhận được nên curl thoát 0tệp đích chứa JSON báo lỗi như thể là dữ liệu--fail: thoát 22 và không tạo tệp

Căn cứ trong tài liệu: “The exit status of a pipeline is the exit status of the last command in the pipeline, unless the pipefail option is enabled”; với pipefail, trạng thái là mã của “the last (rightmost) command to exit with a non-zero status”. Về set -e, tài liệu Bash nói shell không thoát khi lệnh lỗi là một phần của any command executed in a && or || list except the command following the final && or || hoặc của any command in a pipeline but the last (subject to the state of the pipefail shell option). Với curl, mã 22 là “HTTP page not retrieved” và “This return code only appears if –fail is used”.

--fail chưa đủ để phân loại

Hai ca sau cần cách xử lý khác hẳn nhau: HTTP 401 là sai cấu hình, thử lại vô ích; HTTP 503 là sự cố tạm thời, thử lại có thể qua. Nhưng --fail cho cả hai cùng mã 22 (dòng curl --fail: HTTP 401 thoát 22, HTTP 503 thoát 22 trong kết quả), nên bộ lập lịch không có gì để phân biệt. Tài liệu curl còn ghi --fail “is not fail-safe and there are occasions where non-successful response codes slip through, especially when authentication is involved (response codes 401 and 407)”. Vì hai lý do đó, fetch.sh trong lab không dựa vào --fail: nó đọc %{http_code} (theo tài liệu curl là “The numerical response code that was found in the last retrieved HTTP(S) or FTP(s) transfer”) rồi tự đổi sang mã thoát của hợp đồng. Lab không tái hiện được trường hợp “slip through” vì nó cần cơ chế xác thực nhiều bước; câu đó chỉ là trích tài liệu.

Bỏ qua lỗi: đúng một mã đã biết

Có lúc một lệnh thất bại là chấp nhận được, ví dụ một bước trả mã 4 để nói “không có gì để làm”. Cách an toàn là bỏ qua đúng mã đó và để mọi mã khác đi qua: step "$1" || { rc=$?; [ "$rc" -eq 4 ] || exit "$rc"; }. Trong lab, mã 4 cho chạy tiếp (thoát 0) còn mã 9 thoát 9 chứ không bị nuốt. || true là bản không phân biệt mã nào.

Mã do shell đặt

Một số con số đã có nghĩa cố định trước khi job của bạn viết dòng nào:

MãKhi nàoCa trong labCăn cứ
126tìm thấy lệnh nhưng không thực thi được./noexec.sh thiếu quyền thực thiBash: “If a command is found but is not executable, the return status is 126”; POSIX: “the exit status shall be 126”
127không tìm thấy lệnhkhong-co-lenhBash: “a status of 127”; POSIX: “the exit status shall be 127”
128+Ntiến trình chết vì tín hiệu số NSIGTERM (N là 15) cho 143Bash: “Bash uses the value 128+N as the exit status”; POSIX: “an exit status greater than 128” và “in an implementation-defined manner, which signal”

Mã của ứng dụng nên tránh 126, 127 và từ 128 trở lên, để mỗi con số trong log chỉ có một nghĩa. Mục POSIX nói trên là “2.8.2 Exit Status for Commands” trong bản POSIX.1-2024.

Hợp đồng mã thoát của bài

MãNghĩaBộ lập lịch nênCa trong lab
0xong, tệp đích đã ghiđi tiếpHTTP 200
10lỗi tạm thờithử lại có giới hạnHTTP 408, 429, 500, 503; quá thời gian (curl 28); cổng đóng (curl 7)
11xác thực hoặc ủy quyềndừng và báo người sửa cấu hìnhHTTP 401, 403
1lỗi khácdừng và để người đọc logHTTP 404; HTTP 302 vì fetch.sh không tự theo chuyển hướng

Mã 10 và 11 là quy ước của bài, không phải chuẩn: chúng nhỏ hơn 126 và khác 1, mã chung chung nhất. Việc xếp HTTP 408, 429 và 5xx vào “tạm thời” cũng là lựa chọn của bài; 500 có thể là lỗi lặp lại mãi, nên thử lại luôn phải có giới hạn. Theo tài liệu curl, mã 7 là “Failed to connect to host” và mã 28 là “Operation timeout”; --max-time đặt thời gian tối đa cho mỗi lần truyền và “Prevents your batch jobs from hanging for hours due to slow networks or links going down”, nên fetch.sh luôn đặt nó.

Lab: tải có phân loại và thử lại có giới hạn

Tạo thư mục trống rồi lưu bốn tệp:

  • api.py là API thử bằng thư viện chuẩn: /seed đòi header Authorization đúng, /flaky trả 503 hai lần rồi 200, /slow chậm 3 giây, /status/NNN trả đúng mã NNN.
  • fetch.sh tải một URL và đổi kết quả thành mã thoát của hợp đồng. Nó không dùng set -e vì cần đọc $? của curl rồi tự quyết; đọc %{http_code} thay vì dựa vào --fail; ghi vào tệp tạm rồi mv để chỉ lần thành công mới tạo hoặc thay tệp đích; có trap dọn tệp tạm.
  • jobwrap.py chạy một lệnh tối đa ba lần, chỉ thử lại khi mã nằm trong RETRYABLE (chỉ có 10), nghỉ tăng dần giữa các lần, và trả mã của lần cuối. Python báo cái chết vì tín hiệu bằng mã âm (“A negative value -N indicates that the child was terminated by signal N”), nên wrapper đổi sang 128+N như shell để một con số trong log có cùng nghĩa ở mọi chỗ.
  • cases.py chạy mọi ca và assert từng kết quả: lệch một mã là script thoát khác 0.
import json
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

TOKEN = "demo-token"
hits = {"flaky": 0}


class Handler(BaseHTTPRequestHandler):
    def log_message(self, *args):
        pass  # giữ stderr sạch cho phần đo

    def reply(self, status, body):
        payload = json.dumps(body).encode()
        try:
            self.send_response(status)
            self.send_header("Content-Type", "application/json")
            self.send_header("Content-Length", str(len(payload)))
            self.end_headers()
            self.wfile.write(payload)
        except (BrokenPipeError, ConnectionResetError):
            pass  # client đã bỏ đi, như ca quá thời gian

    def do_GET(self):
        if self.path == "/seed":
            if self.headers.get("Authorization") == f"Bearer {TOKEN}":
                return self.reply(200, {"rows": 3})
            return self.reply(401, {"error": "unauthorized"})
        if self.path == "/flaky":
            hits["flaky"] += 1
            return self.reply(503 if hits["flaky"] <= 2 else 200, {"attempt": hits["flaky"]})
        if self.path == "/slow":
            time.sleep(3)
            return self.reply(200, {"slow": True})
        if self.path.startswith("/status/"):
            return self.reply(int(self.path.rsplit("/", 1)[1]), {"path": self.path})
        return self.reply(404, {"error": "not found"})


def start():
    """Mở server ở cổng do hệ điều hành cấp; trả về (server, địa chỉ gốc)."""
    server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
    threading.Thread(target=server.serve_forever, daemon=True).start()
    port = server.server_address[1]
    return server, f"http://127.0.0.1:{port}"
#!/usr/bin/env bash
# Tải $1 về tệp $2 và đổi kết quả thành mã thoát theo hợp đồng của job:
#   0 thành công | 10 lỗi tạm thời, thử lại được | 11 xác thực hoặc ủy quyền, không thử lại | 1 lỗi khác
# Không dùng `set -e`: script tự đọc mã của curl rồi tự quyết mã thoát.
set -uo pipefail
url=$1
out=$2
tmp=$(mktemp "$out.XXXXXX") || exit 1
trap 'rm -f "$tmp"' EXIT
status=$(curl --silent --show-error --max-time "${MAX_TIME:-30}" --output "$tmp" \
  --write-out '%{http_code}' --header "Authorization: Bearer ${TOKEN:-}" "$url")
rc=$?
if [ "$rc" -ne 0 ]; then
  case $rc in
    7 | 28) exit 10 ;; # không kết nối được hoặc quá thời gian: thường là tạm thời
    *) exit 1 ;;
  esac
fi
case $status in
  2??) mv "$tmp" "$out" ;; # chỉ thành công mới tạo hoặc thay tệp đích
  401 | 403) exit 11 ;;
  408 | 429 | 5??) exit 10 ;;
  *) exit 1 ;;
esac
import argparse
import subprocess
import sys
import time

RETRYABLE = {10}  # chỉ mã "lỗi tạm thời" của hợp đồng mới được thử lại


def run(command, attempts, backoff):
    code = 1
    for attempt in range(1, attempts + 1):
        code = subprocess.run(command).returncode
        if code < 0:
            code = 128 - code  # bị tín hiệu N giết: báo 128+N như shell
        print(f"lần {attempt}: mã {code}", file=sys.stderr, flush=True)
        if code not in RETRYABLE or attempt == attempts:
            return code
        time.sleep(backoff * attempt)
    return code


parser = argparse.ArgumentParser()
parser.add_argument("--attempts", type=int, default=3)
parser.add_argument("--backoff", type=float, default=0.01)
parser.add_argument("command", nargs=argparse.REMAINDER)
args = parser.parse_args()
command = args.command[1:] if args.command[:1] == ["--"] else args.command
sys.exit(run(command, args.attempts, args.backoff))
import os
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path

import api

LAB = Path.cwd()
server, base = api.start()
work = Path(tempfile.mkdtemp(prefix="joblab-"))


def run(command, token, extra):
    env = {**os.environ, "API": base, **extra}
    env.pop("TOKEN", None)
    if token:
        env["TOKEN"] = api.TOKEN
    return subprocess.run(command, cwd=work, env=env, capture_output=True, text=True, timeout=60)


def sh(script, *args, token=False, **extra):
    return run(["bash", "-c", script, "_", *args], token, extra)


def fetch_once(url, out="probe.json", token=False, **extra):
    return run(["bash", str(LAB / "fetch.sh"), url, out], token, extra).returncode


def wrapped(*command, token=False):
    """Chạy lệnh qua jobwrap.py; trả về (mã thoát, mã của từng lần thử)."""
    wrapper = [sys.executable, "-B", str(LAB / "jobwrap.py"), "--attempts", "3", "--backoff", "0.01", "--"]
    done = run([*wrapper, *command], token, {})
    attempts = [int(line.rsplit(" ", 1)[1]) for line in done.stderr.splitlines() if line.startswith("lần ")]
    return done.returncode, attempts


def fetch(url, out="seed.json", token=False):
    return wrapped("bash", str(LAB / "fetch.sh"), url, out, token=token)


def seed_text():
    path = work / "seed.json"
    return path.read_text() if path.exists() else "(không có tệp)"


try:
    curl_version = subprocess.run(["curl", "--version"], capture_output=True, text=True).stdout.split()[1]
    bash_version = sh("echo $BASH_VERSION").stdout.strip()
    print(f"công cụ: curl {curl_version}, bash {bash_version}, python {sys.version.split()[0]}")

    print("== Ba cách nuốt lỗi và bản sửa")
    bad = sh("cp missing.txt out.txt || true; echo xong")
    good = sh("set -e; cp missing.txt out.txt; echo xong")
    print(
        f"`|| true` sau lệnh lỗi: bản sai thoát {bad.returncode} và vẫn in {bad.stdout.strip()!r}; "
        f"bản sửa thoát {good.returncode}"
    )
    assert (bad.returncode, good.returncode) == (0, 1) and "xong" not in good.stdout

    producer = "producer() { echo dong1; return 3; }; "
    bad = sh(producer + "producer | wc -l > /dev/null")
    good = sh("set -o pipefail; " + producer + "producer | wc -l > /dev/null")
    print(f"pipeline không pipefail: bản sai thoát {bad.returncode}; bản sửa thoát {good.returncode}")
    assert (bad.returncode, good.returncode) == (0, 3)

    bad = sh('curl -s "$API/seed" -o seed.json')
    body = seed_text()
    (work / "seed.json").unlink()
    good = sh('curl -s --fail "$API/seed" -o seed.json')
    print(
        f"curl không --fail gặp HTTP 401: bản sai thoát {bad.returncode} và để lại {body}; "
        f"bản sửa thoát {good.returncode}, tệp: {seed_text()}"
    )
    assert bad.returncode == 0 and "unauthorized" in body
    assert good.returncode == 22 and not (work / "seed.json").exists()

    codes = [sh(f'curl -s --fail "$API/status/{n}" -o /dev/null').returncode for n in (401, 503)]
    print(f"curl --fail: HTTP 401 thoát {codes[0]}, HTTP 503 thoát {codes[1]}: cùng một mã")
    assert codes == [22, 22]

    rule = 'step() { return "$1"; }; step "$1" || { rc=$?; [ "$rc" -eq 4 ] || exit "$rc"; }; echo tiếp'
    known, unknown = sh(rule, "4"), sh(rule, "9")
    print(f"bỏ qua đúng một mã đã biết (4): mã 4 thoát {known.returncode}, mã 9 thoát {unknown.returncode}")
    assert (known.returncode, unknown.returncode) == (0, 9)

    print("== Mã do shell đặt")
    (work / "noexec.sh").write_text("echo hi\n")
    for label, script, expected in (
        ("thành công", "true", 0),
        ("lệnh không tồn tại", "khong-co-lenh", 127),
        ("tệp không có quyền thực thi", "./noexec.sh", 126),
        ("tiến trình con bị SIGTERM", "sleep 30 & kill -TERM $!; wait $!", 143),
    ):
        code = sh(script).returncode
        print(f"{label} -> {code}")
        assert code == expected

    print("== Hợp đồng: từ kết quả HTTP hoặc mạng sang mã thoát")
    for label, path, extra, expected in (
        ("HTTP 200", "/status/200", {}, 0),
        ("HTTP 302 (không theo chuyển hướng)", "/status/302", {}, 1),
        ("HTTP 401", "/status/401", {}, 11),
        ("HTTP 403", "/status/403", {}, 11),
        ("HTTP 404", "/status/404", {}, 1),
        ("HTTP 408", "/status/408", {}, 10),
        ("HTTP 429", "/status/429", {}, 10),
        ("HTTP 500", "/status/500", {}, 10),
        ("HTTP 503", "/status/503", {}, 10),
        ("quá thời gian (curl 28)", "/slow", {"MAX_TIME": "1"}, 10),
    ):
        code = fetch_once(base + path, **extra)
        print(f"{label} -> {code}")
        assert code == expected
    dead, dead_base = api.start()
    dead.shutdown()
    dead.server_close()
    code = fetch_once(dead_base + "/seed")
    print(f"cổng đóng (curl 7) -> {code}")
    assert code == 10
    leftovers = sorted(p.name for p in work.glob("probe.json.*"))
    print(f"tệp tạm còn sót lại sau các lần gọi ở trên: {leftovers}")
    assert leftovers == []

    print("== Khởi tạo gọi API có xác thực")
    naive = sh('curl -s "$API/seed" -o seed.json; echo "khởi tạo xong"')
    print(f"init cũ, thiếu token: thoát {naive.returncode}, seed.json = {seed_text()}")
    assert naive.returncode == 0 and "unauthorized" in seed_text()
    (work / "seed.json").unlink()
    code, attempts = fetch(base + "/seed")
    print(f"fetch.sh thiếu token: các lần {attempts} -> thoát {code}, tệp: {seed_text()}")
    assert (code, attempts) == (11, [11]) and not (work / "seed.json").exists()
    code, attempts = fetch(base + "/seed", token=True)
    print(f"fetch.sh có token: các lần {attempts} -> thoát {code}, seed.json = {seed_text()}")
    assert (code, attempts) == (0, [0]) and seed_text() == '{"rows": 3}'
    code, attempts = fetch(base + "/seed")
    print(f"lần sau thiếu token: các lần {attempts} -> thoát {code}, seed.json vẫn là {seed_text()}")
    assert (code, attempts) == (11, [11]) and seed_text() == '{"rows": 3}'

    print("== Wrapper thử lại có giới hạn")
    for label, path, expected in (
        ("/flaky trả 503 hai lần rồi 200", "/flaky", (0, [10, 10, 0])),
        ("503 mãi", "/status/503", (10, [10, 10, 10])),
        ("HTTP 404 (lỗi khác)", "/status/404", (1, [1])),
    ):
        result = fetch(base + path, out="out.json", token=True)
        print(f"{label}: các lần {result[1]} -> thoát {result[0]}")
        assert result == expected
    result = wrapped("bash", "-c", "kill -TERM $$")
    print(f"job bị SIGTERM: các lần {result[1]} -> thoát {result[0]}")
    assert result == (143, [143])
finally:
    server.shutdown()
    shutil.rmtree(work, ignore_errors=True)
python3 -B cases.py
công cụ: curl 8.7.1, bash 3.2.57(1)-release, python 3.14.4
== Ba cách nuốt lỗi và bản sửa
`|| true` sau lệnh lỗi: bản sai thoát 0 và vẫn in 'xong'; bản sửa thoát 1
pipeline không pipefail: bản sai thoát 0; bản sửa thoát 3
curl không --fail gặp HTTP 401: bản sai thoát 0 và để lại {"error": "unauthorized"}; bản sửa thoát 22, tệp: (không có tệp)
curl --fail: HTTP 401 thoát 22, HTTP 503 thoát 22: cùng một mã
bỏ qua đúng một mã đã biết (4): mã 4 thoát 0, mã 9 thoát 9
== Mã do shell đặt
thành công -> 0
lệnh không tồn tại -> 127
tệp không có quyền thực thi -> 126
tiến trình con bị SIGTERM -> 143
== Hợp đồng: từ kết quả HTTP hoặc mạng sang mã thoát
HTTP 200 -> 0
HTTP 302 (không theo chuyển hướng) -> 1
HTTP 401 -> 11
HTTP 403 -> 11
HTTP 404 -> 1
HTTP 408 -> 10
HTTP 429 -> 10
HTTP 500 -> 10
HTTP 503 -> 10
quá thời gian (curl 28) -> 10
cổng đóng (curl 7) -> 10
tệp tạm còn sót lại sau các lần gọi ở trên: []
== Khởi tạo gọi API có xác thực
init cũ, thiếu token: thoát 0, seed.json = {"error": "unauthorized"}
fetch.sh thiếu token: các lần [11] -> thoát 11, tệp: (không có tệp)
fetch.sh có token: các lần [0] -> thoát 0, seed.json = {"rows": 3}
lần sau thiếu token: các lần [11] -> thoát 11, seed.json vẫn là {"rows": 3}
== Wrapper thử lại có giới hạn
/flaky trả 503 hai lần rồi 200: các lần [10, 10, 0] -> thoát 0
503 mãi: các lần [10, 10, 10] -> thoát 10
HTTP 404 (lỗi khác): các lần [1] -> thoát 1
job bị SIGTERM: các lần [143] -> thoát 143

Đọc kết quả:

  • Ba cách nuốt lỗi đều thoát 0 và để lại dấu vết sai. || true vẫn in “xong”; pipeline không pipefail thoát 0 dù lệnh đầu trả 3; curl không --fail ghi JSON báo lỗi vào seed.json. Bản sửa thoát 1, 3 và 22 và không để lại tệp.
  • --fail cho mã 22 với cả 401 lẫn 503. Một mã không đủ để bộ lập lịch chọn giữa dừng và thử lại, nên hợp đồng phải do script tự đặt.
  • Shell tự đặt 127, 126 và 143. Lệnh không tồn tại, tệp không thực thi được và SIGTERM (128 + 15) cho ba con số khác nhau, không cần job làm gì.
  • Bảng hợp đồng ra đúng ở cả hai nhánh mạng. Cổng đóng (curl 7) và quá thời gian (curl 28) đều thành 10, 401 và 403 thành 11, 404 và 302 thành 1. Sau chuỗi ca lỗi, thư mục không còn tệp tạm.
  • Khởi tạo thiếu token. Bản cũ thoát 0 và để seed.json chứa JSON báo lỗi, bước sau sẽ đọc nó như dữ liệu. fetch.sh thoát 11, không tạo tệp, và wrapper chỉ chạy một lần. Sau một lần thành công, lần chạy thiếu token tiếp theo vẫn thoát 11 và giữ nguyên {"rows": 3} vì tệp đích chỉ bị thay khi nhận 2xx.
  • Wrapper chỉ thử lại mã 10. /flaky ra các lần 10, 10, 0 rồi thoát 0; 503 mãi ra 10, 10, 10 và thoát 10 (hết lượt vẫn báo lỗi, không đổi thành 0); 404 (mã 1) và SIGTERM (mã 143) chỉ chạy một lần.

Tác giả đã phá thử 13 chỗ của fetch.sh và jobwrap.py trong bản sao ngoài lab (đổi 401 hoặc 403 thành lỗi chung, bỏ 5xx hay 408 khỏi “tạm thời”, bỏ mã 7 hoặc 28, bỏ trap, ghi đè tệp đích khi 401, coi 3xx là thành công, thử lại cả mã 11 hoặc mã 1, bỏ đổi tín hiệu thành 128+N, chỉ thử một lần) và cases.py đỏ ở cả 13 lần. Hai chỗ đầu tiên chưa bị bắt (3xx và mã 1) dẫn tới hai ca HTTP 302 và HTTP 404 trong lab.

Thử lại có giới hạn: wrapper làm và không làm gì

  • Chỉ mã 10 mới thử lại. Lỗi xác thực (11), lỗi khác (1) và cái chết vì tín hiệu (143) đều dừng ngay. Việc không thử lại tín hiệu là lựa chọn của bài: job bị dừng từ ngoài thường là quyết định của bộ lập lịch hay người vận hành.
  • Hết lượt vẫn là thất bại. Sau ba lần thoát 10, wrapper trả 10: bộ lập lịch thấy lỗi và có thể cảnh báo. Wrapper không bao giờ đổi lỗi thành 0.
  • Khoảng nghỉ của lab chỉ để chạy nhanh. 0,01 giây nhân số lần là quá ngắn cho dịch vụ thật; thực tế cần khoảng nghỉ dài hơn, có jitter và tổng thời hạn, như bài Batch job: chạy lại an toàn sau lỗi giữa chừng đã nêu.
  • Chạy lại chỉ an toàn khi job không gây hiệu ứng kép. Wrapper không biết job có idempotent hay không; bài batch job nói cách chọn identity và checkpoint để chạy lại không nhân đôi kết quả.
  • Hai tầng thử lại nhân nhau. Wrapper trong container và số lần thử lại của nền tảng (backoffLimit, attempts) cho số lần chạy thật bằng tích của hai tầng. Hãy để một tầng quyết định, hoặc tính tích đó vào ngân sách.

Ánh xạ sang Kubernetes và AWS Batch (chưa chạy)

Cả hai nền tảng đều có cách đọc mã thoát, và cả hai mặc định thử lại khi không có luật nào khớp:

Nền tảngMã không khớp luật nàoCăn cứ
Kubernetes podFailurePolicyxử lý mặc định: Pod được tạo lại nếu số lần khởi động lại còn dưới backoffLimit“When no rule matches the Pod failure, the default handling applies”
AWS Batch evaluateOnExitjob được thử lại“If evaluateOnExit is specified, but none of the retry strategies match, then the job is retried”

Vì vậy “chỉ thử lại mã 10” cần một luật dừng cho phần còn lại.

Kubernetes. Tài liệu Job nói các luật trong spec.podFailurePolicy.rules “are evaluated in order. Once a rule matches a Pod failure, the remaining rules are ignored”. Hành động FailJob đánh dấu Job thất bại và dừng mọi Pod đang chạy; Count xử lý theo cách mặc định và tăng bộ đếm backoffLimit. Ví dụ của tài liệu dùng onExitCodes với operator: In và values: [42] cho FailJob, trong Pod có restartPolicy: Never, và đây là điều kiện bắt buộc: “you must also define that Job’s pod template with .spec.restartPolicy set to Never”. Với hợp đồng của bài, một luật NotIn là đủ:

spec:
  backoffLimit: 6
  podFailurePolicy:
    rules:
      - action: FailJob
        onExitCodes:
          containerName: main
          operator: NotIn
          values: [10]

Tài liệu tham chiếu API Job nói mã thoát 0 “are excluded from the requirement check” nên luật chỉ xét container đã thất bại, và NotIn thỏa khi “at least one container exit code (…) is not in the set of specified values”. Mã 10 không khớp luật nào nên rơi vào xử lý mặc định (thử lại đến backoffLimit); mọi mã khác làm Job thất bại ngay.

AWS Batch. Tài liệu cho phép attempts “between 1 and 10”, và khi dùng evaluateOnExit thì phải đặt cả attempts. Mẫu tài liệu đề nghị là các mục RETRY riêng rồi một mục cuối thoát với mọi lý do (“add a final entry that exits for any reason”):

"retryStrategy": {
  "attempts": 3,
  "evaluateOnExit": [
    { "action": "RETRY", "onExitCode": "10" },
    { "action": "EXIT", "onReason": "*" }
  ]
}

onExitCode là “a glob pattern to match against the decimal representation of the ExitCode”, chỉ gồm chữ số và có thể kết thúc bằng * “so that only the start of the string needs to be an exact match”. Hệ quả cho hợp đồng này: 10 khớp đúng mã 10, còn 1* khớp cả 1, 10, 11 và 143, một cái bẫy khi chọn mã gần nhau. Các trang đã đọc không nêu thứ tự đánh giá các mục; ví dụ của tài liệu đặt mục EXIT ở cuối cùng.

Cả hai đoạn cấu hình chỉ theo tài liệu, chưa chạy. Trước khi tin, hãy chạy một job cố ý thoát 10, một job thoát 11 và một job thoát 1 trên cụm hay tài khoản của bạn, rồi đếm số lần Pod hay attempt thật.

Giới hạn

  • Lab chỉ chạy với bash 3.2.57, curl 8.7.1 và Python 3.14.4 trên macOS arm64. Chưa chạy trên Linux, bash 5, dash, zsh hay BusyBox, và chưa thử curl bản khác; tài liệu curl đọc là bản hiện hành, không phải riêng bản 8.7.1.
  • Kubernetes và AWS Batch chỉ được trích tài liệu, không chạy. Tài liệu AWS tự nhắc tối đa 6 mục evaluateOnExit ở trang hướng dẫn nhưng 5 điều kiện ở trang API, nên bài không nêu con số nào.
  • Mã 10 và 11, danh sách mã HTTP “tạm thời” và việc không thử lại tín hiệu đều là quy ước của bài, không phải chuẩn. Một dịch vụ khác có thể cần danh sách khác.
  • API thử là giả lập: không TLS, không chuyển hướng, không Retry-After, không giới hạn tốc độ, không xác thực nhiều bước. Trường hợp --fail để lọt 401 hoặc 407 mà tài liệu curl nêu không được tái hiện.
  • Ba cách nuốt lỗi là ba cách hay gặp trong lab, không phải danh sách đầy đủ; bài không xét trap, exit trong subshell hay các cách khác.
  • Lab truyền token qua tham số --header cho ngắn; bài không bàn cách giữ bí mật token trong job.
  • Lab không đo thời gian thật của thử lại (khoảng nghỉ 0,01 giây) và không mô phỏng hiệu ứng ngoài của job nên không chứng minh chạy lại an toàn.
  • Lab không ghi tệp ngoài thư mục bạn đã tạo và thư mục tạm; mọi server tự tắt khi cases.py kết thúc, kể cả khi assert hỏng.

Học tiếp và nguồn

Nguồn trực tuyến đọc ngày 2026-10-04; số đo thuộc bash 3.2.57, curl 8.7.1 và Python 3.14.4 trên macOS arm64.