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

Kế hoạch release và deploy: kiểm gì trước, dừng ở đâu sau?

Câu hỏi: build xanh rồi, làm sao biết đang đưa đúng bản lên đúng môi trường và có đường lui khi một bước hỏng?

Cần biết trước: commit/tag Git, artifact, migration và khóa DDL, mã thoát của job. Ví dụ dưới đây là dịch vụ đơn hàng giả; lab chỉ kiểm một repo tạm bằng Python và Git, không kết nối production.

Version, artifact và trạng thái triển khai

Release xác định bản phần mềm được đưa ra dùng; deploy đưa artifact vào môi trường. Một release có thể được deploy nhiều lần. Version trong changelog, tag trong Git và digest của artifact cần trỏ về cùng nội dung; tag đúng tên chưa chứng minh điều đó.

SRE Release Engineering mô tả kiểm soát build, đóng gói và triển khai theo mức rủi ro. DORA khuyến khích dùng cùng package và quy trình qua các môi trường, rồi kiểm hoạt động sau deploy. Cách làm cụ thể còn tùy hệ thống; một trang tĩnh và một migration dữ liệu không có cùng đường phục hồi.

SemVer 2.0.0 diễn đạt tương thích của public API bằng MAJOR.MINOR.PATCH. Tên v1.2.0 là quy ước tag của bài, chữ v không thuộc version SemVer. Script sau chỉ chấp nhận stable version, không xử lý prerelease/build metadata. Keep a Changelog giúp mô tả thay đổi cho người dùng; git log dài không tự giải thích ảnh hưởng của bản mới.

Một kế hoạch điền được trước khi chạy

Với mỗi dòng sau, ghi giá trị cụ thể và bằng chứng, thay vì chỉ đánh dấu “đã có”.

Cần chốtVí dụ cho release giả 1.2.0
Nội dung và nơi nhậnThêm lý do hủy đơn; staging trước, production sau; chưa mở cho mọi người dùng
Danh tính bảnTag v1.2.0 ở commit đã review; ghi digest package đã thử trên staging vào nhật ký
Người quyết và người chạyNgười vận hành chạy; chủ dịch vụ quyết mở rộng hoặc dừng; ghi xác nhận theo đúng artifact
Cửa sổ và thời hạnCửa sổ giả 20:00–20:30 +07; nếu chưa qua kiểm ở 20:20 thì dừng mở rộng
Dữ liệu và tương thíchThêm cột nullable trước; app cũ vẫn đọc được; chưa xóa cột trong cùng lượt
Kiểm sau mỗi bướcĐọc một đơn, hủy một đơn thử và hủy lặp; kiểm trạng thái/event; ghi expected/actual
Điều kiện dừngSmoke test sai hoặc migration vượt lock timeout đã chọn: dừng bước tiếp theo, giữ log
Cách phục hồiTắt tính năng, đưa package cũ đã thử trở lại; giữ cột mới. Down migration chỉ sau review dữ liệu
Quan sát sau mởTheo dõi lỗi hủy đơn và sự kiện trả tồn trùng; chốt khoảng quan sát và người tiếp nhận

Giờ và thời hạn trên là lựa chọn minh họa, chưa đo trên hệ thống thật. Ngưỡng lỗi/latency phải đến từ baseline, SLO và rủi ro của dịch vụ; đừng chép một tỷ lệ từ checklist khác.

Thứ tự của ví dụ: kiểm bản → thêm schema tương thích → deploy app với tính năng tắt → smoke test → mở cho nhóm nhỏ → quan sát → quyết mở rộng. Mỗi bước ghi lệnh, người chạy, kết quả mong đợi, kết quả thật và hành động khi thất bại. Schema đã thay đổi có thể vẫn tồn tại sau rollback app; phục hồi dữ liệu là quyết định riêng.

Preflight chỉ kiểm điều máy đo được

Script kiểm worktree sạch, stable tag đúng commit HEAD, mục changelog không rỗng và mỗi migration *.up.sql có file *.down.sql không rỗng. Nó chỉ đọc repo được chỉ định. Trường hợp không có migration vẫn hợp lệ. Không chạy SQL, không tải artifact, không kiểm phê duyệt, chữ ký, quyền production hay khả năng khôi phục.

Lưu preflight.py tại thư mục trống:

from __future__ import annotations

import re
import subprocess
import sys
from pathlib import Path

STABLE_TAG = re.compile(r"v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)")


class PreflightError(ValueError):
    pass


def git(repo: Path, *args: str) -> str:
    result = subprocess.run(
        ["git", "-C", str(repo), *args],
        capture_output=True, text=True, timeout=10, check=False,
    )
    if result.returncode:
        raise PreflightError("git: không đọc được snapshot yêu cầu")
    return result.stdout


def check(repo: Path, tag: str) -> None:
    if STABLE_TAG.fullmatch(tag) is None:
        raise PreflightError("tag: cần stable vX.Y.Z, không có số 0 ở đầu")
    if git(repo, "status", "--porcelain=v1", "--untracked-files=all"):
        raise PreflightError("worktree: có thay đổi tracked hoặc untracked")
    tagged = git(repo, "rev-parse", "--verify", f"refs/tags/{tag}^{{commit}}").strip()
    if tagged != git(repo, "rev-parse", "HEAD").strip():
        raise PreflightError("tag: không trỏ về HEAD")
    changelog = git(repo, "show", f"{tag}:CHANGELOG.md")
    sections = re.split(r"(?m)^## ", changelog)
    matching = [s for s in sections[1:] if s.splitlines()[0].startswith(f"[{tag[1:]}] ")]
    if len(matching) != 1 or not "\n" in matching[0] or not matching[0].split("\n", 1)[1].strip():
        raise PreflightError("changelog: thiếu mục duy nhất có nội dung cho version")
    paths = git(repo, "ls-tree", "-r", "--name-only", tag, "--", "migrations").splitlines()
    up = {p for p in paths if p.endswith(".up.sql")}
    down = {p for p in paths if p.endswith(".down.sql")}
    if {p.removesuffix(".up.sql") for p in up} != {p.removesuffix(".down.sql") for p in down}:
        raise PreflightError("migration: thiếu cặp up/down")
    for path in sorted(up | down):
        if not git(repo, "show", f"{tag}:{path}").strip():
            raise PreflightError("migration: file up/down rỗng")


def main() -> int:
    if len(sys.argv) != 3:
        print("Cách dùng: python3 preflight.py REPO TAG", file=sys.stderr)
        return 2
    try:
        check(Path(sys.argv[1]).resolve(), sys.argv[2])
    except (PreflightError, OSError, subprocess.TimeoutExpired) as error:
        print(f"PREFLIGHT_FAIL: {error}", file=sys.stderr)
        return 1
    print("PREFLIGHT_OK: snapshot hợp lệ; còn cần quyết định phát hành")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

--porcelain=v1 được Git dành cho script đọc trạng thái. refs/tags/...^{commit} trong rev-parse lấy commit của đúng tag, kể cả annotated tag; nó không xác minh chữ ký người tạo tag. File bị ignore không nằm trong phép kiểm worktree này. Build dựa vào file ignored, submodule hoặc dependency mạng cần cơ chế khóa và kiểm riêng.

Thử cả bản đạt và bản bị từ chối

Lưu exercise.py cùng thư mục. Script tạo repo Git tạm, không thêm/commit vào repo đang làm việc; TemporaryDirectory dọn fixture khi thoát. Dữ liệu và tác giả đều giả.

from __future__ import annotations

import os
import subprocess
from pathlib import Path
from tempfile import TemporaryDirectory

from preflight import PreflightError, check

ENV = os.environ | {
    "GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_GLOBAL": os.devnull,
    "GIT_AUTHOR_NAME": "Dev", "GIT_AUTHOR_EMAIL": "dev@example.org",
    "GIT_COMMITTER_NAME": "Dev", "GIT_COMMITTER_EMAIL": "dev@example.org",
}


def run(repo: Path, *args: str) -> None:
    subprocess.run(["git", "-C", str(repo), *args], env=ENV, check=True,
                   capture_output=True, text=True, timeout=10)


def fixture(root: Path, name: str, changelog: str, rollback: str | None) -> Path:
    repo = root / name
    repo.mkdir()
    run(repo, "init", "-q", "--template=", "--initial-branch=main")
    (repo / "CHANGELOG.md").write_text(changelog)
    (repo / "migrations").mkdir()
    (repo / "migrations/reason.up.sql").write_text("ALTER TABLE orders ADD COLUMN reason text;\n")
    if rollback is not None:
        (repo / "migrations/reason.down.sql").write_text(rollback)
    run(repo, "add", ".")
    run(repo, "-c", "commit.gpgsign=false", "commit", "-qm", "fixture")
    run(repo, "tag", "v1.2.0")
    return repo


def rejected(repo: Path, tag: str, reason: str) -> None:
    try:
        check(repo, tag)
    except PreflightError as error:
        assert reason in str(error), (reason, str(error))
        print("từ chối:", reason)
    else:
        raise AssertionError("preflight nhận fixture phải bị từ chối")


with TemporaryDirectory(prefix="release-lab-") as directory:
    root = Path(directory)
    notes = "# Changelog\n\n## [1.2.0] - 2026-10-13\n\n### Added\n- Lý do hủy đơn.\n"
    rollback = "ALTER TABLE orders DROP COLUMN reason;\n"
    good = fixture(root, "good", notes, rollback)
    before = subprocess.check_output(["git", "-C", str(good), "status", "--porcelain=v1"])
    check(good, "v1.2.0")
    assert before == subprocess.check_output(["git", "-C", str(good), "status", "--porcelain=v1"])
    print("đạt: stable tag, snapshot, changelog, cặp migration")
    rejected(good, "v01.2.0", "tag:")
    rejected(good, "v1.2.0-rc.1", "tag:")
    rejected(good, "v1.2.1", "git:")
    (good / "new.txt").write_text("untracked")
    rejected(good, "v1.2.0", "worktree:")
    (good / "new.txt").unlink()
    (good / "CHANGELOG.md").write_text(notes + "changed\n")
    rejected(good, "v1.2.0", "worktree:")
    run(good, "add", ".")
    run(good, "-c", "commit.gpgsign=false", "commit", "-qm", "next")
    rejected(good, "v1.2.0", "tag:")
    rejected(fixture(root, "notes", "# Changelog\n", rollback), "v1.2.0", "changelog:")
    rejected(fixture(root, "empty-notes", "## [1.2.0] - 2026-10-13\n", rollback), "v1.2.0", "changelog:")
    rejected(fixture(root, "missing-down", notes, None), "v1.2.0", "migration:")
    rejected(fixture(root, "empty-down", notes, ""), "v1.2.0", "migration:")
    print("1 ca đạt, 10 ca từ chối đúng nguyên nhân; repo tạm đã được kiểm")
python3 exercise.py

Dòng cuối mong đợi:

1 ca đạt, 10 ca từ chối đúng nguyên nhân; repo tạm đã được kiểm

Chạy với repo của bạn: python3 preflight.py /path/to/repo v1.2.0. Exit 1 phải dừng bước tiếp theo; không nối || true. Lab đã kiểm trên Ubuntu x86_64, Python 3.14 và Git; chưa kiểm riêng Windows/macOS. Nó chứng minh các guard của ví dụ, không chứng minh build reproducible hay deployment an toàn.

Xanh ở preflight, quyết định gì còn lại?

Người review cần xác nhận artifact thực sự được tạo từ tag và đã thử, quyền thay đổi đúng môi trường, cửa sổ triển khai, người nhận sự cố và bằng chứng phục hồi. Changelog chỉ có heading là chưa đủ; file down chứa comment vẫn có thể qua phép kiểm hiện diện. Down script xóa cột có thể làm mất giá trị: lab không chạy nó và không coi đó là đường lui phù hợp cho release thật.

Sau deploy, đo đúng đầu mút: đường dẫn người dùng mở, CSS/JS, login hoặc thao tác nghiệp vụ cần thiết, dữ liệu sau migration và version đang chạy. HTTP 200 từ một health endpoint không chứng minh cả UI dùng được. Khi kiểm sai, ghi bước đã chạy và trạng thái thật; deploy thất bại ở bước cuối có thể đã thay đổi production ở bước trước.

Học tiếp: migration có khóa, exit code và retry, luồng Git. Nguồn công khai trong bài được đọc ngày 2026-10-13; mẫu và lab là thiết kế minh họa.