0

Cập nhật WordPress không sợ vỡ site: tự sao lưu, kiểm tra và rollback

Cập nhật WordPress không sợ vỡ site: tự sao lưu, kiểm tra và rollback

Ai giữ site WordPress lâu chắc đều có ít nhất một lần như này: bấm "Cập nhật tất cả" plugin, vài giây sau F5 thì trang chủ hiện "There has been a critical error on this website". Lúc đó phải SSH vào, tìm bản backup gần nhất, khôi phục bằng tay, rồi đoán xem plugin nào gây lỗi.

Mình gặp chuyện này đủ nhiều nên khi viết Lares Panel (một control panel mã nguồn mở cho VPS), mình làm tính năng cập nhật WordPress theo một quy trình cố định: sao lưu trước, cập nhật, tự kiểm tra lại site, lỗi thì tự khôi phục. Bài này chia sẻ cách mình làm phần "tự kiểm tra" và "tự khôi phục", vì nó khó hơn mình tưởng lúc đầu. Code là TypeScript (Node.js), nhưng ý tưởng áp dụng được cho bất kỳ script nào bạn đang dùng để cập nhật WordPress.

WordPress đã có sẵn gì?

Từ bản 5.2, WordPress có cơ chế bảo vệ khi gặp lỗi fatal: hiện thông báo "critical error" cho khách và gửi email cho admin một link vào recovery mode để tắt plugin gây lỗi. Cơ chế này hữu ích, nhưng:

  • Khách vẫn thấy trang lỗi cho tới khi có người vào sửa.
  • Nó không quay lại phiên bản cũ của plugin, chỉ giúp bạn tắt plugin đi.
  • Nó chỉ bắt lỗi PHP fatal. Trang trắng, lỗi kết nối database, site kẹt ở chế độ bảo trì thì không.

Thứ mình muốn là: nếu bản cập nhật làm site tệ đi, site phải tự quay về đúng trạng thái trước khi cập nhật, kể cả database.

Quy trình 5 bước

1. Ghi nhận tình trạng site (trang chủ, wp-login.php, error log)
2. Sao lưu file + database        → lỗi thì huỷ, site không bị đụng tới
3. wp core update, update-db, plugin update, theme update
4. Kiểm tra lại site, so với bước 1
5. Tệ đi → khôi phục bản sao lưu, kiểm tra lại, chỉ ra mục gây lỗi

2-cap-nhat-wordpress.png Cả quy trình chạy thành một tác vụ nền, giữ chung một khoá với sao lưu và khôi phục của site đó. Như vậy sẽ không có chuyện lịch sao lưu hằng ngày chạy chen vào giữa lúc đang cập nhật.

Bước 1: "chụp" tình trạng site trước khi cập nhật

Phần quan trọng nhất là so sánh trước và sau, chứ không phải kiểm tra tuyệt đối. Rất nhiều site vốn đã có vấn đề: trang wp-login.php bị plugin bảo mật giấu đi (404), error log đầy cảnh báo cũ, tiêu đề trang có chữ "404" vì đặt tên dở... Nếu kiểm tra tuyệt đối thì site nào cũng bị báo lỗi và bị rollback oan.

Mỗi lần "chụp", Lares lấy hai trang: trang chủ và /wp-login.php. Với mỗi trang, nó ghi lại mã HTTP, tiêu đề <title>, độ dài nội dung và các "dấu hiệu lỗi" tìm thấy trong HTML.

Gọi thẳng vào nginx trên máy, bỏ qua DNS và CDN

Nếu site đứng sau Cloudflare, request tới tên miền có thể nhận về bản cache từ trước khi cập nhật, và thế là không phát hiện được lỗi gì. Mình dùng --connect-to của curl để mọi tên miền đều trỏ về nginx trên chính máy đó, trong khi vẫn giữ đúng header Host và SNI:

const r = await host.exec(
  `curl -sS -k -m 20 --connect-to ::127.0.0.1: -A 'Lares-HealthCheck/1.0' ` +
    `-H 'Cache-Control: no-cache' -o - -w '\\n__PROBE__ %{http_code} %{redirect_url}' ${shq(url)}`,
);

Trang chủ được gọi kèm một query ngẫu nhiên (/?lares_health=<timestamp>) để không trúng cache của plugin. Redirect chỉ được đi theo khi vẫn nằm trên site đó (http sang https, có www hay không có www), tối đa 5 lần.

Những dấu hiệu lỗi cần tìm

Mã 200 chưa có nghĩa là site ổn. Trang "critical error" của WordPress trả về 500, nhưng nhiều trang lỗi khác vẫn trả 200. Lares tìm các chuỗi sau trong HTML:

export const WP_MARKERS = [
  // trình xử lý lỗi fatal của WordPress >= 5.2 (câu chữ của 5.2-5.4 và 5.5+)
  { id: 'critical', re: /There has been a critical error on (?:this|your) website|The site is experiencing technical difficulties/i },
  // trang wp_die(), bất kể ngôn ngữ
  { id: 'wp-die', re: /<body[^>]*\bid=["']error-page["']/i },
  { id: 'fatal', re: /(?:<b>)?Fatal error(?:<\/b>)?:\s/ },
  { id: 'parse', re: /(?:<b>)?Parse error(?:<\/b>)?:\s/ },
  { id: 'database', re: /Error establishing a database connection/i },
  { id: 'maintenance', re: /Briefly unavailable for scheduled maintenance/i },
];

Mẹo nhỏ: trang wp_die() luôn có <body id="error-page"> dù site dùng tiếng Việt hay tiếng Anh, nên bắt theo id này chắc ăn hơn bắt theo câu chữ.

Đánh dấu error log thay vì đọc cả file

Lỗi PHP fatal của site nằm trong error log của nginx (PHP-FPM ghi vào dưới dạng FastCGI sent in stderr) và trong wp-content/debug.log nếu site bật WP_DEBUG_LOG. Thay vì đọc cả file, trước khi kiểm tra mình chỉ ghi lại kích thước hiện tại của từng file. Sau khi cập nhật thì chỉ đọc phần được ghi thêm từ vị trí đó. Nếu file bị logrotate làm nhỏ đi thì đọc lại từ đầu.

Có hai chi tiết nhỏ nhưng quan trọng:

  • debug.log nằm trong thư mục của site, tức là chỗ mà code của site ghi được. Panel chạy quyền root, nên chỉ đọc khi đó là file thường (lstat), không đọc symlink. Nếu không, một site có thể tạo symlink trỏ tới một file bất kỳ trên máy.
  • Cùng một lỗi fatal được ghi nhiều lần với timestamp, IP và request id khác nhau. Mình chuẩn hoá mỗi dòng về phần thông điệp (PHP Fatal error: ... cho tới hết dòng đầu tiên) rồi mới so sánh, để lỗi cũ không bị tính thành lỗi mới.

Bước 2: sao lưu, và huỷ nếu sao lưu lỗi

Bước này đơn giản nhưng có một nguyên tắc: không sao lưu được thì không cập nhật. Đầy ổ đĩa hay mysqldump lỗi thì tác vụ dừng ngay, kèm thông báo "site không bị thay đổi". Bản sao lưu trước cập nhật được gắn nhãn riêng và chỉ giữ 3 bản gần nhất, để không đè lên lịch sao lưu hằng ngày.

Bước 3: cập nhật bằng wp-cli, không chạy bằng root

wp-cli chạy bằng user web của site, không chạy bằng root, vì đây là code của site (plugin, theme). Thứ tự là core trước, sau đó wp core update-db (thêm --network nếu là multisite), rồi tới plugin và theme.

Bài học ở bước này: đừng tin hoàn toàn vào output của wp-cli. Khi lỗi giữa chừng, phần tóm tắt JSON có thể không có. Sau khi cập nhật, Lares đọc lại phiên bản thực tế của từng plugin và đối chiếu: phiên bản đã đổi thì là đã cập nhật, phiên bản vẫn như cũ thì là thất bại, bất kể wp-cli báo gì.

Cũng ở bước này, Lares kiểm tra có plugin nào đang bật trước đó mà giờ bị tắt không. WordPress có thể tự tắt plugin khi bản cập nhật lỗi, và theme đang dùng cũng có thể bị đổi. Site vẫn chạy nhưng mất chức năng, kiểm tra bằng HTTP sẽ không thấy.

Bước 4: so sánh, chỉ tính những gì tệ đi

Trước khi kiểm tra, Lares reload PHP-FPM một cách nhẹ nhàng để bỏ opcache của các file vừa thay. Nếu không, có thể vẫn đang chạy code cũ trong cache. Đợi 3 giây rồi mới "chụp" lại.

Hàm so sánh là một hàm thuần, không đụng tới I/O, nên viết unit test rất dễ (đã rút gọn, bản thật còn giới hạn số dòng lỗi fatal được báo):

/** 0 = ổn (2xx/3xx), 1 = 4xx và mã khác, 2 = 5xx, 3 = không phản hồi */
const rank = (s: number | null) => (s === null ? 3 : s >= 200 && s < 400 ? 0 : s >= 500 ? 2 : 1);

export function compareHealth(before: HealthSnapshot, after: HealthSnapshot): WpHealthProblem[] {
  const out: WpHealthProblem[] = [];
  for (const page of ['home', 'login'] as const) {
    const b = before[page];
    const a = after[page];
    // mã HTTP tệ đi: 200 → 500, 404 → không phản hồi...
    if (rank(a.status) > rank(b.status)) out.push({ code: 'status', page, before: b.status, after: a.status });
    // dấu hiệu lỗi mới xuất hiện
    for (const marker of a.markers) if (!b.markers.includes(marker)) out.push({ code: 'marker', page, marker });
    // tiêu đề chuyển thành kiểu trang lỗi
    if (a.title !== b.title && ERROR_TITLE_RE.test(a.title) && !ERROR_TITLE_RE.test(b.title)) out.push({ code: 'title', page, before: b.title, after: a.title });
    // trang trắng: vẫn 200 nhưng gần như không có nội dung
    if (statusOk(a.status) && statusOk(b.status) && b.bodyBytes >= 512 && a.bodyBytes < 64) out.push({ code: 'blank', page });
  }
  // lỗi PHP fatal mới, không có trong log trước đó
  const seen = new Set(before.recentFatals);
  for (const line of after.newFatals) if (!seen.has(line)) out.push({ code: 'fatal', line });
  return out;
}

Để ý quy tắc "chỉ tính cái tệ đi": wp-login.php trả 404 cả trước lẫn sau thì không sao (plugin bảo mật giấu nó). Còn từ 200 chuyển sang 404 thì có chuyện.

Trường hợp trang trắng là thứ dễ bỏ sót nhất: PHP chết sau khi đã gửi header, trình duyệt nhận 200 với nội dung rỗng. Điều kiện "trước đó trên 512 byte, giờ dưới 64 byte" bắt được trường hợp này mà không báo nhầm với trang vốn ngắn.

Kiểm tra lại một lần nữa trước khi kết luận. Nếu phát hiện bất thường, Lares đợi 5 giây rồi chụp lại. PHP-FPM vừa reload hoặc database đang bận có thể làm request đầu bị lỗi thoáng qua, và rollback chỉ vì một lỗi như vậy thì còn phiền hơn.

Bước 5: khôi phục và chỉ ra "thủ phạm"

Khi site tệ đi, Lares khôi phục bản sao lưu ở bước 2 (cả file lẫn database), xoá file .maintenance nếu còn sót, rồi kiểm tra lại lần nữa xem site đã thật sự quay về như trước chưa. Nếu khôi phục rồi mà vẫn lỗi, nhiều khả năng lỗi không do bản cập nhật, và panel báo đúng như vậy thay vì nói "đã khôi phục thành công".

Phần cuối là đoán plugin nào gây lỗi, vì người dùng sẽ hỏi ngay câu đó:

  • Chỉ cập nhật một mục: chính là nó.
  • Cập nhật nhiều mục: tìm trong các dòng lỗi fatal mới đường dẫn kiểu wp-content/plugins/<slug>/.... Dòng lỗi nằm trong wp-includes hay wp-admin thì nghi cho core. Plugin bị WordPress tự tắt cũng bị liệt vào danh sách nghi vấn.
  • Không xác định được: báo thẳng là không biết, và khuyên cập nhật từng mục một.

Ngoài ra còn hai chuyện nhỏ: file .maintenance luôn được xoá ở mọi nhánh, kể cả khi lỗi. Và nếu panel bị restart giữa lúc đang cập nhật, lúc khởi động lại nó đánh dấu lần chạy đó là bị gián đoạn và đưa site ra khỏi chế độ bảo trì, để site không kẹt mãi ở trang "Briefly unavailable".

Giới hạn mình biết

Cách làm này không bắt được mọi lỗi:

  • Chỉ kiểm tra trang chủ và trang đăng nhập. Trang thanh toán WooCommerce lỗi mà trang chủ vẫn ổn thì không phát hiện được.
  • Không chạy JavaScript. Lỗi JS làm hỏng giao diện hay form vẫn lọt qua.
  • Khôi phục database nghĩa là mất dữ liệu ghi vào trong lúc cập nhật, ví dụ một đơn hàng hay bình luận đến đúng trong vài phút đó. Với site bán hàng đông khách, nên cập nhật lúc vắng người.

Dù vậy, những ca hay gặp nhất là trắng trang, critical error, lỗi database và kẹt bảo trì, và mấy ca đó đều đã được xử lý tự động.

Lời kết

Toàn bộ code nằm trong repo tampham92/lares. Phần kiểm tra ở apps/server/src/services/wpHealth.ts, các quy tắc so sánh ở wpUpdatePolicy.ts, quy trình ở wpUpdates.ts. Panel mình làm một mình, có AI hỗ trợ viết code và test. Các quy tắc so sánh có unit test riêng, và mỗi lần cập nhật CI đều cài thử thật trên Ubuntu.

Nếu bạn muốn thử, Lares cài bằng một lệnh trên Ubuntu 22.04/24.04 hoặc Debian 12. Lares đang beta, nên hãy thử trên một VPS không quan trọng trước:

curl -sSL https://lares.thocode.dev/install | sudo bash

Bạn đang cập nhật WordPress cho nhiều site theo cách nào? Có trường hợp lỗi nào mình chưa tính tới không? Comment cho mình biết với, mình rất muốn nghe. Nếu muốn trao đổi sâu hơn hoặc báo lỗi, bạn có thể mở thảo luận ở GitHub Discussions hoặc gửi mail về hello@thocode.dev.


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí