🔥 🎁 QUÀ TẶNG khi đăng ký: Phần mềm DNT Digital SaaS FindMe VPS Guardian 12 tháng (~240 USD) và 24 tháng (~480 USD)
Chạy backend bằng Windows Scheduled Task: 6 cái bẫy làm task chết im lặng
ĐTĐược viết bởi Đặng Trí Thanhvào ngày 01/10/2026 lúc 16:45|… lượt xem
Hướng dẫn01/10/2026 · 22 phút đọc

Chạy backend như Windows Scheduled Task: 6 cái bẫy làm task chết im lặng

Trên Windows, cách phổ biến nhất để giữ một backend chạy nền là Windows Service. Nhưng với rất nhiều dự án nhỏ — đặc biệt là backend .NET chạy trên VPS của khách — Windows Scheduled Task lại là lựa chọn thực dụng hơn: không cần cài đặt, không cần quyền admin để gỡ, dễ nhìn thấy trong Task Scheduler, và khởi động nhanh. Đổi lại, nó có sáu cái bẫy mà tôi đã lần lượt sập vào trong hơn một năm vận hành hệ thống thật.


1. Hai giờ sáng, và một dashboard không có dữ liệu

Đồng hồ báo 2 giờ 14 phút sáng. Dashboard giám sát của khách báo không có dữ liệu mới trong 3 giờ 40 phút.

Tôi mở RDP vào VPS, gõ Get-ScheduledTask và thấy dòng này:

TaskName    State
--------    -----
FindMe-BE   Ready

Trạng thái Ready. Không phải Running. Không phải Disabled. Không có lỗi. Chỉ đơn giản là task đã dừng, và không một thông báo nào được gửi đi.

Điều đáng nói: backend này đã chạy ổn định 11 tuần trước đó. Không ai sửa gì. Không có deploy. Vậy tại sao nó chết?

Câu trả lời nằm ở chỗ mà không ai để ý: một bản cập nhật Windows cài lúc 1 giờ 50 sáng đã khởi động lại máy, và task đã không tự chạy lại — vì trigger duy nhất của nó là "At startup", nhưng bản cập nhật đó làm máy chuyển sang trạng thái "hibernate rồi resume" chứ không phải cold boot. Trigger At startup không kích hoạt khi máy resume từ hibernate. Task vẫn "Ready", chờ một sự kiện không bao giờ tới.

Bài này viết lại sáu cái bẫy cùng loại — mỗi cái đều để lại hậu quả âm thầm, và mỗi cái đều có cách chặn rẻ hơn nhiều so với việc ngồi chờ khách phát hiện.

2. Vì sao chọn Scheduled Task thay vì Windows Service

Trước khi nói về bẫy, cần nói vì sao lại chọn công cụ dễ sinh bẫy này.

Windows Service là lựa chọn "đúng bài" cho backend chạy nền. Nó có vòng đời rõ ràng, tự khởi động lại, và có cơ chế recovery chính thức. Nhưng nó có ba nhược điểm rất thực tế với dự án nhỏ:

Thứ nhất, cài đặt cần quyền admin. Với VPS của khách hoặc máy trong nhà, mỗi lần cài lại là một lần xin quyền.

Thứ hai, gỡ lỗi khó hơn. Service chạy trong Session 0, không có giao diện. Muốn xem nó nói gì, bạn phải đọc Windows Event Log hoặc tự viết logging ra file. Không có cách nào "mở lên xem thử".

Thứ ba, và quan trọng nhất với tôi: một service là một khối cứng. Nó chạy một tiến trình, và nếu tiến trình đó treo, bạn phải có cơ chế riêng để phát hiện và restart.

Scheduled Task cho tôi thứ tôi thực sự cần: một tiến trình chạy nền có thể khởi động lại bằng một dòng lệnh, dễ nhìn thấy trạng thái, và không cần quyền admin để thao tác trong lúc vận hành. Đổi lại, nó không tự lo vòng đời cho tôi. Tôi phải tự lo.

Và đó chính là nguồn của cả sáu cái bẫy.

Hình 1 – Sáu cái bẫy của Scheduled Task: cái nào cũng để lại hậu quả âm thầm, không có thông báo lỗi
Hình 1 – Sáu cái bẫy của Scheduled Task: cái nào cũng để lại hậu quả âm thầm, không có thông báo lỗi

3. Bẫy 1 — Trigger không bao giờ kích hoạt

Đây là bẫy đã kết liễu 11 tuần chạy ổn định ở trên.

Khi tạo task, Windows cho bạn chọn rất nhiều loại trigger:

TriggerKích hoạt khi
At startupMáy khởi động lạnh
At log onNgười dùng đăng nhập
On an eventMột sự kiện cụ thể xuất hiện trong Event Log
Daily / Weekly / MonthlyTheo lịch
At task creation/modificationTask được tạo hoặc sửa

Cái bẫy: `At startup` không chạy khi máy resume từ hibernate hoặc sleep, và cũng không chạy khi máy khởi động lại theo kiểu "fast startup" — tính năng mà Windows 10/11 bật mặc định và làm cho thao tác "Shut down" thực chất là hibernate.

Nói cách khác: trên nhiều máy Windows hiện đại, nút "Shut down" không tắt máy thật. Nó lưu trạng thái kernel và phiên người dùng vào đĩa. Lần bật sau là một lần resume. Và At startup không thấy gì cả.

Hình 2 – Vì sao trigger At startup bị bỏ qua: khởi động lạnh thì chạy, còn fast startup và hibernate thì không
Hình 2 – Vì sao trigger At startup bị bỏ qua: khởi động lạnh thì chạy, còn fast startup và hibernate thì không

Cách chặn rẻ nhất là dùng nhiều trigger cùng lúc, không dựa vào một cái duy nhất:

$taskName = "FindMe-BE"

# Trigger 1: khi máy khởi động
$t1 = New-ScheduledTaskTrigger -AtStartup

# Trigger 2: tự chạy lại mỗi 5 phút, vô hạn
$t2 = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) `
        -RepetitionInterval (New-TimeSpan -Minutes 5) `
        -RepetitionDuration ([TimeSpan]::MaxValue)

# Trigger 3: khi có sự kiện resume từ sleep/hibernate
$t3 = New-ScheduledTaskTrigger -AtLogOn -User "SYSTEM"

Register-ScheduledTask -TaskName $taskName -Trigger @($t1, $t2, $t2, $t3) `
  -Action $action -Settings $settings -Force

Trigger thứ hai là quan trọng nhất và cũng ít người dùng nhất: task tự chạy lại mỗi 5 phút, nhưng có cờ "không chạy nếu đang chạy". Nhờ đó, nếu task đã chết vì bất kỳ lý do gì — kể cả lý do bạn chưa từng nghĩ tới — nó sẽ tự sống lại trong vòng 5 phút.

Đây là điểm khác biệt giữa "task chạy đúng lúc" và "task tự phục hồi". Bạn muốn cái thứ hai.

Cờ đó nằm trong phần Settings:

$settings = New-ScheduledTaskSettingsSet `
  -MultipleInstances IgnoreNew `
  -AllowStartIfOnBatteries `
  -DontStopIfGoingOnBatteries `
  -StartWhenAvailable `
  -RestartCount 3 `
  -RestartInterval (New-TimeSpan -Minutes 1)

StartWhenAvailable là một cờ đáng giá: nếu máy đang tắt đúng thời điểm trigger lẽ ra chạy, Windows sẽ chạy bù ngay khi máy bật lại. Không có nó, một trigger bị lỡ là mất luôn.

4. Bẫy 2 — Working directory không phải nơi bạn nghĩ

Đây là bẫy tốn thời gian nhất của tôi, vì nó biểu hiện rất khó hiểu.

Task chạy. Process có trong danh sách. Nhưng backend báo lỗi:

Unhandled exception. System.IO.FileNotFoundException:
Could not find file 'D:\Windows\system32\appsettings.Production.json'.

Đường dẫn D:\Windows\system32\ — không phải thư mục dự án. Backend đang tìm file cấu hình ở đó.

Nguyên nhân: Scheduled Task mặc định chạy với working directory là `%SystemRoot%\System32`, không phải thư mục chứa file thực thi. Nếu code của bạn dùng đường dẫn tương đối — và rất nhiều đoạn khởi tạo cấu hình dùng — nó sẽ trỏ sai chỗ.

Có hai cách sửa, và tôi khuyên làm cả hai:

Cách một — chỉ định working directory trong action:

$action = New-ScheduledTaskAction `
  -Execute "C:\FindMe\BackendCloud\FindMe.Cloud.exe" `
  -WorkingDirectory "C:\FindMe\BackendCloud"

Cách hai — đừng bao giờ dùng đường dẫn tương đối trong code backend. Neo mọi đường dẫn vào thư mục của chính file thực thi:

// SAI — phụ thuộc working directory
var path = "appsettings.Production.json";

// ĐÚNG — neo theo thư mục chứa file exe
var baseDir = AppContext.BaseDirectory;
var path = Path.Combine(baseDir, "appsettings.Production.json");

Ghi nhớ điểm này: Directory.GetCurrentDirectory() và AppContext.BaseDirectory không giống nhau khi chạy dưới Scheduled Task. Trong lúc phát triển bằng dotnet run, hai giá trị này tình cờ trùng nhau nên lỗi không lộ. Chỉ khi lên máy thật mới bung.

Đây là dạng lỗi tệ nhất trong nhóm: nó không tái hiện ở môi trường phát triển. Bạn không thể sửa nó bằng cách "chạy thử lại trên máy mình".

5. Bẫy 3 — Cửa sổ nháy lên rồi tắt, không kịp đọc lỗi

Task chạy và thoát ngay. Bạn biết nó lỗi, nhưng không biết lỗi gì, vì cửa sổ console chỉ nháy một cái trong khoảng một phần ba giây.

Cách chữa nhanh để chẩn đoán là tạm thời chuyển output ra file. Nhưng cần hiểu đúng cách làm, vì có hai sai lầm ở đây.

Sai lầm thứ nhất: dùng `>` để ghi log.

# SAI — mỗi lần chạy ghi đè, mất sạch log cũ
-Extract "C:\app.exe > C:\log.txt"

Sai lầm thứ hai: chờ process cha.

Nếu bạn gọi trực tiếp file .exe, Windows sẽ chạy nó. Nhưng nếu bạn cần chuyển hướng output, bạn phải bọc trong cmd.exe hoặc powershell.exe, và điều đó đưa bạn tới bẫy số 5.

Cách làm đúng — để ứng dụng tự ghi log, với cơ chế xoay file, thay vì dựa vào chuyển hướng của shell:

using Serilog;

Log.Logger = new LoggerConfiguration()
    .MinimumLevel.Information()
    .WriteTo.File(
        path: Path.Combine(AppContext.BaseDirectory, "logs", "app-.log"),
        rollingInterval: RollingInterval.Day,   // file mới mỗi ngày
        retainedFileCountLimit: 14,             // giữ 14 ngày
        shared: false,
        outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff} [{Level:u3}] {Message:lj}{NewLine}{Exception}")
    .CreateLogger();

Ba tham số quan trọng và lý do của chúng:

  • rollingInterval: Day — không bao giờ để một file log phình tới hàng gigabyte. File lớn là file bạn không đọc.
  • retainedFileCountLimit: 14 — tự xoá log cũ. Không có nó, đĩa của VPS sẽ đầy trong vài tháng. Tôi từng gặp đúng cảnh này: đĩa đầy, và vì đĩa đầy nên ứng dụng không ghi được log nữa, và vì không ghi được log nên không ai biết tại sao nó chết. Một vòng lặp chết.
  • outputTemplate có timestamp tới mili-giây — khi phải đối chiếu log backend với log của sàn hoặc của cổng thanh toán, độ phân giải giây là không đủ.

Với cách này, bạn không cần cửa sổ console nữa. Và bạn có thể chạy task hoàn toàn ẩn.

6. Bẫy 4 — Chạy dưới account sai nên không thấy biến môi trường

Task chạy dưới tài khoản SYSTEM, nhưng backend cần đọc biến môi trường DB_PASSWORD mà bạn đã đặt trong phiên đăng nhập của chính mình.

Biến môi trường trên Windows có ba phạm vi, và chúng không nhìn thấy nhau:

Phạm viAi đọc đượcCách đặt
ProcessChỉ tiến trình hiện tại$env:X = "..." trong phiên PowerShell
UserMọi tiến trình chạy dưới user đó[Environment]::SetEnvironmentVariable("X","v","User")
MachineMọi tiến trình trên máy[Environment]::SetEnvironmentVariable("X","v","Machine")

Khi bạn gõ $env:DB_PASSWORD = "..." trong một cửa sổ PowerShell, bạn chỉ đặt được ở phạm vi Process. Đóng cửa sổ đó, biến mất. Và Scheduled Task — dù chạy dưới cùng user — là một tiến trình mới, không thừa hưởng gì.

Bài học tôi rút ra, và nó ngược với trực giác: đừng dựa vào biến môi trường để chứa bí mật trên máy Windows dùng Scheduled Task. Có hai lý do.

Lý do thứ nhất là kỹ thuật: biến ở phạm vi Machine được lưu trong registry và hiển thị cho mọi tiến trình trên máy, kể cả tiến trình của user khác. Nó không phải là nơi chứa bí mật an toàn.

Lý do thứ hai là vận hành: biến môi trường rất khó kiểm toán. Không ai biết ai đã đặt gì, khi nào, và còn cái nào đã lỗi thời.

Cách tôi làm bây giờ — thứ tự đọc có ưu tiên rõ ràng, và bí mật nằm trong file có quyền truy cập hạn chế:

static string DocBaoMat(string ten)
{
    // 1. Biến môi trường (dùng cho CI/CD hoặc container)
    var tuEnv = Environment.GetEnvironmentVariable(ten);
    if (!string.IsNullOrWhiteSpace(tuEnv)) return tuEnv;

    // 2. File .env riêng, chỉ account chạy task đọc được
    var duongDan = Path.Combine(AppContext.BaseDirectory, ".env");
    if (File.Exists(duongDan))
    {
        foreach (var dong in File.ReadAllLines(duongDan))
        {
            var i = dong.IndexOf('=');
            if (i <= 0) continue;
            if (dong[..i].Trim() == ten) return dong[(i + 1)..].Trim();
        }
    }

    // 3. Không có thì DỪNG HẲN, không dùng giá trị mặc định
    throw new InvalidOperationException(
        $"Thiếu cấu hình bắt buộc: {ten}. Kiểm tra .env hoặc biến môi trường.");
}

Điểm quan trọng nhất trong đoạn này là dòng cuối: ném lỗi thay vì trả giá trị mặc định. Ứng dụng phải từ chối khởi động khi thiếu bí mật. Nếu bạn để mặc định, ứng dụng vẫn chạy — với một khoá rỗng hoặc một khoá mẫu — và trông hoàn toàn bình thường.

Tôi đã từng để mặc định khoá ký token ngay trong mã nguồn. Hệ quả là bất kỳ ai đọc được mã nguồn đều tự ký được token quản trị và đọc dữ liệu của khách. Chi tiết câu chuyện đó nằm ở bài Bảo mật REST API: 14 lỗ hổng thật và cách sửa.

7. Bẫy 5 — Task báo "đang chạy" nhưng process đã chết

Đây là bẫy nguy hiểm nhất, vì nó nói dối bạn.

Trong Task Scheduler, task hiển thị Running. Nhưng khi bạn mở Task Manager, không có tiến trình nào tên FindMe.Cloud.exe.

Nguyên nhân nằm ở cách bạn bọc lệnh. Nếu action là:

-Execute "powershell.exe" -ArgumentList "-Command", "C:\FindMe\app.exe"

thì tiến trình mà Task Scheduler theo dõi là powershell.exe, không phải app.exe. Nếu app.exe crash, powershell.exe vẫn sống — vì nó chỉ đơn giản là đã gọi xong lệnh và giờ đang chờ. Task vẫn Running. Backend đã chết từ lâu.

Điều tương tự xảy ra với cmd.exe /c. Và với Start-Process — tệ hơn, vì Start-Process không chờ.

Cách chữa có hai tầng.

Tầng một — chỉ định đúng file thực thi, không bọc qua shell:

$action = New-ScheduledTaskAction `
  -Execute "C:\FindMe\BackendCloud\FindMe.Cloud.exe" `
  -WorkingDirectory "C:\FindMe\BackendCloud"

Khi đó Task Scheduler theo dõi đúng tiến trình backend. Task hiển thị Running có nghĩa là backend đang chạy. Task chuyển về Ready có nghĩa là backend đã thoát.

Tầng hai — đừng tin Task Scheduler, hãy hỏi chính backend. Đây là nguyên tắc tôi áp dụng cho mọi hệ thống chạy nền: nguồn sự thật duy nhất phải là health endpoint của ứng dụng, không phải trạng thái của công cụ quản lý tiến trình.

// Endpoint health phải kiểm tra cả phụ thuộc, không chỉ "tôi còn sống"
app.MapGet("/api/health", async (NpgsqlDataSource db) =>
{
    try
    {
        await using var cmd = db.CreateCommand("SELECT 1");
        await cmd.ExecuteScalarAsync();
        return Results.Ok(new
        {
            status = "ok",
            db = "connected",
            uptime = Environment.TickCount64 / 1000,
            version = typeof(Program).Assembly.GetName().Version?.ToString()
        });
    }
    catch (Exception ex)
    {
        return Results.Json(
            new { status = "error", db = "disconnected", detail = ex.Message },
            statusCode: 503);
    }
});

Hai điểm cần chú ý ở endpoint này.

Nó trả 503 khi database chết, không phải 200. Một backend trả 200 OK trong lúc database đã chết là một backend đang nói dối. Bất kỳ công cụ giám sát nào đọc mã 200 rồi kết luận "hệ thống ổn" đều bị lừa.

Nó trả cả version. Nhờ đó bạn phân biệt được "backend còn sống" với "backend còn sống và đang chạy đúng bản vừa deploy". Không có trường này, bạn sẽ có lần deploy xong, health trả 200, và ba ngày sau phát hiện bản cũ vẫn đang chạy vì tiến trình cũ chưa bị dừng.

8. Bẫy 6 — Cổng bị tiến trình cũ giữ, và lỗi báo sai chỗ

Bẫy này đã làm tôi mất gần một buổi và suýt khiến tôi kết luận sai rằng bản build mới bị hỏng.

Sự việc: tôi deploy bản mới, task báo chạy thành công, /api/health trả 200. Nhưng mọi endpoint khác đều trả 404. Routing như bị xoá sạch.

Sau khi kiểm tra từng lớp, sự thật là: một ứng dụng khác đang giữ cổng đó.

TCP    127.0.0.1:5199     LISTENING    29660      <- FindMe.Cloud.exe (bản cũ, chưa tắt)
TCP    0.0.0.0:5199       LISTENING    48212      <- Node.js (bản mới)

Hai tiến trình cùng bind cổng 5199. Windows cho phép điều này vì một cái bind 127.0.0.1 (địa chỉ cụ thể) và một cái bind 0.0.0.0 (mọi địa chỉ). Windows ưu tiên bind cụ thể hơn. Nên request tới localhost:5199 đi vào tiến trình cũ — và tiến trình cũ là một ứng dụng hoàn toàn khác, không có các route đó, nên trả 404.

Bài học: `bind()` thành công không có nghĩa là bạn đã chiếm được cổng. Với 0.0.0.0, bạn chiếm mọi địa chỉ trừ những địa chỉ đã bị bind riêng.

Cách kiểm tra phải là đếm số listener, và đúng bằng 1:

# Phải trả về ĐÚNG MỘT dòng
(Get-NetTCPConnection -LocalPort 5199 -State Listen).Count

Và trong quy trình deploy, phải đợi cổng thực sự được giải phóng trước khi khởi động bản mới. Dừng task không có nghĩa là tiến trình đã chết ngay:

Stop-ScheduledTask -TaskName $taskName

# Đợi cổng được nhả — tối đa 30 giây
$hetHan = (Get-Date).AddSeconds(30)
while ((Get-NetTCPConnection -LocalPort $port -State Listen -ErrorAction SilentlyContinue) `
       -and (Get-Date) -lt $hetHan) {
    Start-Sleep -Milliseconds 500
}

if (Get-NetTCPConnection -LocalPort $port -State Listen -ErrorAction SilentlyContinue) {
    throw "Cổng $port vẫn bị giữ. Dừng deploy — không khởi động bản mới."
}

Dòng throw cuối cùng quan trọng: thà deploy thất bại rõ ràng còn hơn deploy thành công giả. Nếu bạn cứ khởi động bản mới trong khi cổng còn bị giữ, bạn sẽ có một hệ thống chạy nửa cũ nửa mới — và tệ nhất là health endpoint có thể trả 200 từ tiến trình cũ, khiến bạn tưởng mọi thứ ổn.

9. Quy trình deploy 7 bước tôi dùng cho mọi backend Scheduled Task

Đây là quy trình đã được rút ra từ tất cả các bẫy ở trên. Nó có bảy bước và không bước nào là tuỳ chọn.

Hình 3 – Quy trình deploy 7 bước: build, đồng bộ, dừng task, đợi cổng, khởi động, poll health, xác nhận
Hình 3 – Quy trình deploy 7 bước: build, đồng bộ, dừng task, đợi cổng, khởi động, poll health, xác nhận
$taskName = "FindMe-BE"
$port     = 5199
$appDir   = "C:\FindMe\BackendCloud"
$health   = "http://localhost:$port/api/health"

# 1. Build trên máy phát triển (không build trên VPS)
dotnet publish -c Release -o .\dist

# 2. Copy artifact, đồng bộ chính xác (xoá file lạ còn sót)
robocopy .\dist $appDir /MIR /NFL /NDL /NJH /NJS

# 3. Dừng task
Stop-ScheduledTask -TaskName $taskName

# 4. ĐỢI cổng được nhả (tối đa 30 giây) — bỏ bước này là bẫy 6
$hetHan = (Get-Date).AddSeconds(30)
while ((Get-NetTCPConnection -LocalPort $port -State Listen -ErrorAction SilentlyContinue) `
       -and (Get-Date) -lt $hetHan) { Start-Sleep -Milliseconds 500 }

# 5. Khởi động lại
Start-ScheduledTask -TaskName $taskName

# 6. ĐỢI ứng dụng sẵn sàng — poll health, đừng sleep mù
$hetHan = (Get-Date).AddSeconds(60)
$ok = $false
while ((Get-Date) -lt $hetHan) {
    try {
        $r = Invoke-RestMethod $health -TimeoutSec 3
        if ($r.status -eq "ok") { $ok = $true; break }
    } catch { Start-Sleep -Seconds 2 }
}

# 7. Xác nhận, và THẤT BẠI RÕ RÀNG nếu không đạt
if (-not $ok) { throw "Deploy thất bại: health check không xanh sau 60 giây" }
Write-Host "Deploy OK — version $($r.version), db $($r.db)"

Hai điểm khác biệt so với cách làm ngây thơ.

Bước 6 dùng poll thay vì `Start-Sleep 20`. Tôi từng dùng Start-Sleep 20 trong một thời gian dài. Nó "hoạt động" — nghĩa là phần lớn lần deploy đều ổn. Nhưng nó có hai vấn đề. Nó chậm không cần thiết khi ứng dụng khởi động trong 3 giây, và nó không đủ khi ứng dụng cần 25 giây vì database đang chậm. Poll loại bỏ cả hai vấn đề: nhanh khi có thể, và kiên nhẫn tới giới hạn bạn đặt ra.

Bước 7 ném lỗi. Rất nhiều script deploy tôi thấy chỉ Write-Host "Xong" ở cuối. Nếu health check đỏ, script vẫn kết thúc với mã thoát 0, và hệ thống CI của bạn báo xanh. Deploy thất bại phải ồn ào.

Và tất nhiên, script này phải chạy được không cần người ngồi nhìn — nên nó không được có bất kỳ dòng nào chờ input.

10. Watchdog: thứ thực sự giữ hệ thống sống

Sáu bẫy trên có thể chặn trước. Nhưng không có danh sách bẫy nào đầy đủ, vì môi trường sản xuất luôn sinh ra tình huống mới. Thứ thực sự khiến hệ thống sống sót qua nhiều năm không phải là phòng ngừa hoàn hảo, mà là phát hiện nhanh.

Watchdog tối thiểu cần làm ba việc:

Hình 4 – Watchdog: kiểm tra mỗi 60 giây, báo sau 3 lần lỗi liên tiếp, và gửi cả tin nhắn hồi phục
Hình 4 – Watchdog: kiểm tra mỗi 60 giây, báo sau 3 lần lỗi liên tiếp, và gửi cả tin nhắn hồi phục

Kiểm tra định kỳ, ngắn. Mỗi 60 giây là hợp lý cho hầu hết backend nhỏ. Nhanh hơn thì tốn tài nguyên vô ích; chậm hơn thì bạn biết sự cố muộn.

Kiểm tra đúng thứ. Gọi /api/health và kiểm tra cả mã trạng thái HTTP và trường status trong body. Một tiến trình còn sống nhưng database mất kết nối phải bị coi là "không khoẻ".

Cảnh báo tới nơi người đọc được. Ghi log là chưa đủ — log chỉ hữu ích khi bạn đã biết cần tìm gì. Cảnh báo phải tới điện thoại:

$soLanLoiLienTiep = 0

while ($true) {
    $khoe = $false
    try {
        $r = Invoke-RestMethod $health -TimeoutSec 5
        $khoe = ($r.status -eq "ok")
    } catch { $khoe = $false }

    if ($khoe) {
        if ($soLanLoiLienTiep -gt 1) {
            Gui-Telegram "✅ Backend $env:COMPUTERNAME đã hồi phục sau $soLanLoiLienTiep lần lỗi"
        }
        $soLanLoiLienTiep = 0
    } else {
        $soLanLoiLienTiep++
        # Chỉ báo sau 3 lần lỗi liên tiếp (3 phút) — tránh báo động giả
        if ($soLanLoiLienTiep -eq 3) {
            Gui-Telegram "🔴 Backend $env:COMPUTERNAME không phản hồi 3 phút liên tiếp"
        }
    }
    Start-Sleep -Seconds 60
}

Ngưỡng 3 lần liên tiếp là quyết định thiết kế, không phải chi tiết vặt. Nếu bạn báo ngay từ lần lỗi đầu tiên, mỗi lần mạng chớp một giây bạn sẽ nhận một tin nhắn. Sau hai tuần, bạn sẽ tắt thông báo — và thế là mất luôn khả năng phát hiện sự cố thật. Hiện tượng này gọi là alert fatigue (mệt mỏi vì cảnh báo), và nó là nguyên nhân phổ biến nhất khiến hệ thống giám sát trở nên vô dụng.

Tin nhắn hồi phục cũng quan trọng không kém tin nhắn sự cố. Không có nó, bạn không biết hệ thống đã tự khỏi hay vẫn đang chết.

Về gửi Telegram, hãy dùng bot qua HTTP API — nó ổn định và không cần thư viện:

function Gui-Telegram($noiDung) {
    $token  = $env:TELEGRAM_BOT_TOKEN
    $chatId = $env:TELEGRAM_CHAT_ID
    if (-not $token -or -not $chatId) { return }
    $body = @{ chat_id = $chatId; text = $noiDung } | ConvertTo-Json
    Invoke-RestMethod "https://api.telegram.org/bot$token/sendMessage" `
      -Method Post -ContentType "application/json; charset=utf-8" `
      -Body ([Text.Encoding]::UTF8.GetBytes($body)) | Out-Null
}

Chi tiết nhỏ nhưng hay làm sai: phải gửi body dạng UTF-8 bytes, không phải chuỗi. Nếu không, tiếng Việt trong tin nhắn sẽ thành ký tự lạ.

11. Ba lỗi tôi đã tự làm sai và cách sửa

Phần này là phần tôi thấy cần thiết nhất trong mọi bài viết kỹ thuật, vì danh sách "cách làm đúng" không dạy được bằng cảm giác của người đã làm sai.

Lỗi thứ nhất: tôi dùng `Start-Sleep` thay vì chờ thật.

Script deploy của tôi có Start-Sleep -Seconds 20 sau khi khởi động task. Nó chạy đúng trong nhiều tháng. Rồi một ngày, VPS bận vì Windows Update đang chạy nền, ứng dụng cần 26 giây để sẵn sàng. Health check thất bại. Script báo lỗi. Tôi hoảng, tưởng bản build hỏng, và deploy lại — lần này thành công, vì lần trước ứng dụng thực ra đã khởi động xong sau khi script kết thúc.

Bài học không phải "tăng lên 30 giây". Bài học là không được đoán thời gian khởi động; phải hỏi ứng dụng. Từ đó tôi chuyển hẳn sang poll health.

Lỗi thứ hai: tôi để log không có giới hạn.

Ứng dụng ghi log mỗi request. Sau bốn tháng, thư mục log chiếm 41 GB trên VPS 80 GB. Đĩa đầy. Và vì đĩa đầy, ứng dụng không ghi được nữa — bao gồm cả log lỗi. Nên khi nó crash, không có gì để đọc. Tôi phải mở Event Viewer, tìm ra lỗi ghi đĩa, rồi mới lần ngược được nguyên nhân gốc.

Từ đó, mọi cấu hình logging của tôi đều có retainedFileCountLimit. Và tôi thêm một cảnh báo riêng cho dung lượng đĩa vượt 85% — cảnh báo trước khi sự cố xảy ra, chứ không phải sau.

Lỗi thứ ba: tôi đặt tên task trùng với một task cũ.

Tôi tạo task tên FindMe-BE. Đã tồn tại một task cùng tên từ lần thử trước. Register-ScheduledTask không có -Force sẽ báo lỗi — nhưng trong script có -ErrorAction SilentlyContinue mà tôi thêm vào để "cho gọn output". Nên lỗi bị nuốt. Task cũ vẫn chạy, trỏ vào thư mục cũ, dùng cổng cũ.

Suốt hai tuần, tôi tưởng mình đã deploy bản mới. Thực tế tôi đang deploy vào một thư mục không ai chạy. Đây chính là lý do trường version trong health endpoint tồn tại: nếu tôi kiểm tra version ngay từ lần deploy đầu, tôi đã phát hiện ra trong 10 giây thay vì 14 ngày.

12. Checklist 14 điểm trước khi giao một Scheduled Task cho khách

Danh sách này để tự chấm trước khi bàn giao:

[ ] 1.  Task có ÍT NHẤT 2 trigger: At startup + chạy lại mỗi 5 phút
[ ] 2.  Đã bật StartWhenAvailable (chạy bù khi lỡ lịch)
[ ] 3.  MultipleInstances = IgnoreNew (không chồng tiến trình)
[ ] 4.  Action trỏ thẳng file .exe, không bọc qua cmd/powershell
[ ] 5.  WorkingDirectory đã đặt đúng thư mục ứng dụng
[ ] 6.  Code KHÔNG dùng đường dẫn tương đối (dùng AppContext.BaseDirectory)
[ ] 7.  Bí mật đọc từ file .env, không phải biến môi trường phạm vi User
[ ] 8.  Ứng dụng TỪ CHỐI khởi động nếu thiếu cấu hình bắt buộc
[ ] 9.  Có /api/health trả 503 khi database chết (không phải 200)
[ ] 10. /api/health trả trường version để xác nhận đúng bản deploy
[ ] 11. Log ghi ra file, có rolling theo ngày và retainedFileCountLimit
[ ] 12. Script deploy đợi cổng được nhả trước khi khởi động bản mới
[ ] 13. Script deploy poll health (không dùng sleep mù) và THẤT BẠI rõ ràng
[ ] 14. Có watchdog báo Telegram sau 3 lần lỗi liên tiếp + tin nhắn hồi phục

Nếu phải chọn ba điểm quan trọng nhất để làm trước, tôi chọn 1, 9 và 14. Điểm 1 giữ task luôn sống. Điểm 9 đảm bảo bạn biết sự thật về trạng thái hệ thống. Điểm 14 đảm bảo bạn biết sự thật đó trong vòng ba phút thay vì ba ngày.

13. Khi nào không nên dùng Scheduled Task

Để công bằng: có ba tình huống mà Windows Service là lựa chọn tốt hơn.

Khi ứng dụng cần chạy liên tục và không được phép có khoảng trống. Với trigger chạy lại mỗi 5 phút, một task chết sẽ có tối đa 5 phút gián đoạn. Với hệ thống nhận webhook thanh toán, 5 phút có thể là mất đơn. Windows Service khởi động lại gần như tức thời.

Khi có nhiều tiến trình phụ thuộc nhau. Với Scheduled Task, thứ tự khởi động giữa các task là do bạn tự quản. Với Service, bạn khai báo được quan hệ phụ thuộc.

Khi tổ chức đã có quy trình vận hành Service. Đừng đổi công cụ chỉ vì công cụ mới thú vị hơn. Nhất quán trong vận hành có giá trị riêng.

Với hai trường hợp còn lại — backend nhỏ, một tiến trình, không cần zero-downtime — Scheduled Task là lựa chọn tốt, miễn là bạn chặn đủ sáu cái bẫy ở trên.

Câu hỏi thường gặp

Scheduled Task có chạy được khi không có ai đăng nhập vào máy không?

Có, với hai điều kiện: chọn "Run whether user is logged on or not" trong phần cấu hình task, và chọn đúng tài khoản. Nếu bạn để mặc định "Run only when user is logged on", task sẽ dừng ngay khi bạn đóng RDP. Đây là bẫy rất phổ biến: task chạy hoàn hảo trong lúc bạn đang kiểm tra, rồi chết khi bạn thoát phiên.

Chọn tài khoản nào để chạy task?

Nếu ứng dụng cần truy cập tài nguyên mạng (file share, database dùng xác thực Windows), hãy dùng một tài khoản dịch vụ chuyên dụng có mật khẩu không hết hạn và quyền tối thiểu. Nếu không cần gì đặc biệt, SYSTEM là lựa chọn đơn giản — nhưng nhớ rằng SYSTEM không có profile người dùng, nên mọi thứ liên quan tới %APPDATA% sẽ trỏ vào thư mục khác.

Làm sao chạy task mà không nháy cửa sổ console?

Chọn "Run whether user is logged on or not". Khi chọn mục này, Windows chạy task trong phiên không tương tác và không hiện cửa sổ. Nếu vẫn thấy cửa sổ nháy, kiểm tra lại xem action có đang gọi qua cmd.exe hoặc powershell.exe hay không — đó là nguyên nhân phổ biến.

Task chạy được bằng tay nhưng không chạy theo trigger, tại sao?

Ba nguyên nhân theo thứ tự phổ biến: (1) trigger không khớp điều kiện — ví dụ At startup không kích hoạt khi resume từ hibernate; (2) task đang ở trạng thái Disabled; (3) tài khoản chạy task không có quyền "Log on as a batch job". Kiểm tra bằng (Get-ScheduledTask -TaskName "X").State và Event Viewer, mục Task Scheduler.

Có cần dừng task trước khi copy file mới không?

Có, luôn luôn. Trên Windows, bạn thường không thể ghi đè một file .exe đang chạy vì file bị khoá. Nếu robocopy báo lỗi truy cập, đó là dấu hiệu bạn quên dừng task. Và ngay cả khi copy thành công, bản cũ vẫn đang giữ cổng — đây chính là bẫy số 6.

Làm sao biết task đang chạy bản build nào?

Đây là lý do tôi đưa trường version vào health endpoint. Cách khác là ghi version vào tên file (ví dụ FindMe.Cloud.2026.10.01.exe) và kiểm tra đường dẫn trong action. Cách thứ hai có nhược điểm là bạn phải sửa task mỗi lần deploy. Cách thứ nhất — hỏi chính ứng dụng — không có nhược điểm đó.

Có nên dùng NSSM hoặc WinSW để bọc thành service không?

Có, và đó là lựa chọn tốt nếu bạn muốn vòng đời của Service nhưng không muốn viết code Service. NSSM và WinSW là hai công cụ trưởng thành, biến một file .exe bình thường thành Service với cơ chế restart tự động. Đánh đổi: thêm một phụ thuộc bên ngoài, và cấu hình nằm ở một chỗ khác nữa ngoài ứng dụng.

Nếu VPS bị khởi động lại giữa lúc đang deploy thì sao?

Script deploy sẽ thất bại ở bước health check, vì task không khởi động được hoặc ứng dụng chưa sẵn sàng. Điều quan trọng là script phải thất bại rõ ràng thay vì báo thành công. Sau khi máy khởi động lại, trigger chạy lại mỗi 5 phút sẽ tự khởi động backend — miễn là bạn đã chặn bẫy số 1.

Kết luận

Scheduled Task là một công cụ tốt cho backend nhỏ trên Windows. Nó không tự lo vòng đời cho bạn, và đó vừa là nhược điểm vừa là ưu điểm: bạn kiểm soát hoàn toàn, đổi lại bạn phải tự chặn sáu cái bẫy.

Ba nguyên tắc tóm gọn cả bài:

Đừng dựa vào một trigger. Task phải tự chạy lại định kỳ, để nó tự phục hồi từ những thất bại bạn chưa từng nghĩ tới.

Đừng tin công cụ quản lý tiến trình. Nguồn sự thật duy nhất là health endpoint của chính ứng dụng — và endpoint đó phải kiểm tra cả phụ thuộc, phải trả 503 khi hỏng, và phải trả version.

Đừng để lỗi im lặng. Script deploy thất bại phải ồn ào. Watchdog phải báo tới điện thoại. Và log phải tự xoay, vì log không giới hạn sẽ đầy đĩa, và đĩa đầy sẽ làm bạn mất chính khả năng đọc log.

Mười một tuần chạy tốt không chứng minh hệ thống của bạn ổn định. Nó chỉ chứng minh bạn chưa gặp tình huống mà hệ thống không xử lý được. Việc của bạn là chuẩn bị cho tình huống đó trước khi nó tới.


Đọc tiếp trong loạt bài backend vận hành thật:

  • Tự host backend .NET trên VPS Windows: Kestrel, biến môi trường và 5 lỗi deploy đầu tiên
  • VPS vận hành cBot 24/7: những gì cần chuẩn bị trước khi chạy tiền thật
  • Checklist 25 điểm trước khi chạy bot bằng tiền thật

Cần người dựng lại phần vận hành cho hệ thống của bạn?

Nếu bạn đang chạy backend hoặc bot trên VPS và không dám chắc nó có tự sống lại sau khi máy khởi động lại, đó là dấu hiệu bạn đang thiếu đúng những thứ trong bài này: trigger dự phòng, health check thật, log có xoay, và watchdog báo tới điện thoại.

Tại DNT Digital, các khoá học về bot auto trading và backend được dạy trực tiếp trên hệ thống đang chạy thật — cùng loại hệ thống bạn vừa đọc ở trên. Bạn sẽ tự tay dựng Scheduled Task, tự cố tình làm nó chết, và tự chặn từng cái bẫy.

Hoặc xem danh sách bài viết kỹ thuật để đọc tiếp.

Đọc xong rồi? Hãy thử ngay — miễn phí 7 ngày.
📬

Weekly Digest — Nhận Bản Tin Hàng Tuần

Nhận các bài viết phân tích kỹ thuật chuyên sâu, thuật toán giao dịch tự động (Trading Bot) và các giải pháp công nghệ mới nhất từ Hướng Nghiệp Dữ Liệu.