GitLab IIS 部署最危險的順序,不是少一條啟動命令,而是先停止服務、清空目前目錄,之後才執行可能失敗的前端與 .NET 建置。只要建置中斷,原本可運作的版本已經消失,後續 pipeline 又可能被「已停止」狀態擋住,讓最新錯誤掩蓋真正的第一個失敗點。
本文說明一個可稽核的 GitLab IIS 部署 邊界:新版先在獨立 staging 目錄完整產出並驗收,成功後才進入短暫 cutover;IIS stop/start 採狀態感知與冪等處理;建置失敗不動目前版本;切換失敗則保留可判讀的目錄與狀態,依明確條件嘗試回復,不保證所有檔案系統錯誤都能自動復原。範例只操作本機 sandbox 目錄與記憶體狀態,不呼叫真實 IIS、不接觸共用站台,也不執行不可逆資料操作。
來源筆記提供問題背景,並不證明修正已完成正式部署。本文的 manifest、健康檢查與清理閘門屬延伸設計建議。既有 GitLab Runner PowerShell fail-fast 教學 已涵蓋先建候選與錯誤傳遞;本篇新增重點是 IIS 狀態冪等、兩次目錄移動的中斷狀態及有限回復決策。
為什麼 GitLab IIS 部署不能先刪除目前版本?
先刪再建會把「建置失敗」直接升級成「服務版本消失」;安全順序應先證明新版輸出完整,再停止服務與切換目錄,讓失敗侷限在尚未曝光的 staging 區。
部署工作通常同時包含套件還原、前端編譯、.NET publish、設定檔整理與靜態資產產生。這些步驟任何一項都可能因工具版本、磁碟空間、暫時性執行錯誤或程式碼問題而中止。若 pipeline 在它們之前就刪除目前目錄,服務可用性便被尚未通過驗收的工作綁架。正確的風險分界是:建置階段只建立新目錄,不改目前版本;切換階段只接收已完成的輸出。
「已有備份」也不等於安全。備份如果沒有在失敗路徑自動回復、沒有檢查內容完整性,或還原說明被放在會因 fail-fast 而永遠執行不到的下一行,就只是一份被動檔案。真正可用的回復設計要明確知道目前目錄、staging 目錄與 backup 目錄各自代表哪一版,並在每個中斷點保留可判讀狀態。
這個原則也能改善事故調查。當連續數條 pipeline 失敗時,最新一條常只呈現前次事故留下的狀態,例如站台已停止、目錄缺失或備份仍佔用名稱。調查應沿時間往回找到第一條由成功轉為失敗的執行,分開記錄「原始失敗」與「殘留狀態造成的次生失敗」。否則團隊可能一直修改 stop 指令,卻沒有處理最早的建置中斷。
Build-to-staging 應建立哪些發布閘門?
Build-to-staging 至少要驗證命令結束碼、預期輸出檔、版本識別與目錄邊界;只有全部通過,pipeline 才能把新版標記為可切換候選。
第一個閘門是建置成功。每個套件還原、前端 build、測試與 publish 命令都必須保留原始非零結束碼,不能用後續訊息覆蓋。GitLab Runner 會把產生的 job script 交給設定的 shell 執行,因此 YAML 中的多行結構、PowerShell 語法邊界與錯誤偏好都屬於部署程式的一部分,而不是排版細節。應在與 runner 相同的 shell 模式下驗證,而不是只在互動式終端確認片段可跑。
第二個閘門是輸出完整性。僅看到 publish 命令回傳零仍不足以證明站台可切換;至少要檢查入口檔、主要組件、靜態資產目錄與版本標記是否存在。若應用程式有前後端兩組輸出,兩者必須屬於同一次 pipeline,不應把舊前端與新後端混放。檢查只需針對必要契約,不要把暫存檔數量當作固定值,以免工具小改版造成無意義失敗。
第三個閘門是可追溯性。staging 目錄名稱可以包含 pipeline 識別,並寫入不含敏感值的 release manifest,例如提交識別、建置時間、目標環境類別與輸出摘要。GitLab artifacts 可保存 job 產物供後續 job 使用或稽核;但 artifacts 是否上傳、保留多久,以及失敗時是否收集,都要在 pipeline 中明確設定,不能假設所有失敗輸出會自動存在。
第四個閘門是同磁碟與路徑限制。若設計依賴目錄改名來縮短切換時間,current、stage 與 backup 應位於相同檔案系統邊界,並且 pipeline 只能操作預先核准的父目錄。不要把跨磁碟檔案移動的行為套用到目錄;本文只使用同磁碟目錄移動,跨磁碟發布需另外設計複製、驗收與切換流程。本文不把目錄移動宣稱為資料庫交易式原子性;它是縮短空窗、保留回復點的部署技巧,仍需針對實際檔案系統測試。
IIS stop 與 start 為什麼必須冪等?
IIS 生命週期命令應先查目前狀態,再決定是否 stop 或 start;「已停止」與「已啟動」是可接受狀態,不應自動被解讀成部署失敗。
Microsoft 的 AppCmd 文件提供列出、啟動與停止 site、app pool 的命令。部署腳本應利用查詢結果建立小型狀態機:Started 才執行 stop,Stopped 則直接進入下一步,未知或查詢失敗才中止。如此一來,重跑 pipeline、人工先停止、前次失敗停在中間等情況,都不會因重複 stop 而製造新的紅燈。
冪等不代表忽略所有錯誤。若名稱不存在、查詢無法執行、狀態格式不符合預期,腳本應 fail closed,因為它已無法證明操作目標正確。只應把明確且已知的「目標已在期望狀態」視為成功。用 || true 或廣泛 catch 吞掉所有結果,會同時掩蓋拼字錯誤、權限問題與錯誤站台名稱,反而使部署不可稽核。
start 也要有對稱規則。切換成功後先 start,再查詢是否進入 Started;若啟動失敗,應留下 current、backup 與日誌,不要立刻刪除可回復證據。若 cutover 前 app pool 原本就是停止狀態,是否自動啟動必須由發布契約決定。本文 sandbox 採「成功切換後進入 Started」作為固定期望,真實專案則應把預期狀態寫入設定並接受審查。
PowerShell 的 try/catch/finally 可用來集中回復與清理,但在 GitLab Windows shell executor 中仍要驗證 runner 實際產生的腳本。不要假設互動式貼上成功就等同 YAML 逐行展開成功。最穩妥的方式是把複雜部署邏輯放入受版本控制的 .ps1,CI job 只傳入明確參數並呼叫檔案,減少 YAML quoting 與逐行解析造成的差異。
目錄切換與 rollback 要如何設計?
安全 cutover 應把 current 改成 backup、把已驗收的 stage 改成 current,失敗時優先把 backup 放回,並延後刪除上一版直到健康檢查完成。
切換前先再次確認 stage 的 release manifest 與必要檔案,避免建置 job 與部署 job 之間的傳遞不完整。接著停止需要停止的 app pool,將舊 current 改名為唯一 backup,再將 stage 改名為 current。PowerShell Move-Item 能移動目錄,但腳本仍需自行處理目標已存在、來源缺失與跨磁碟等條件。命名不可只用固定 _bak 而沒有清理策略,否則前次殘留可能讓下一次 cutover 在一半失敗。
如果第二次移動失敗,而 current 已不存在、backup 仍存在,回復邏輯應把 backup 改回 current,之後再恢復服務狀態並以非零結束。若 stage 已部分移入 current,不能盲目覆蓋;應停止自動處理、保留三方路徑與日誌,交由明確 runbook 判讀。回復規則的目的不是保證任何檔案系統錯誤都能自動修復,而是讓常見中斷點具備有限、可測、可觀察的處理方式。
健康檢查通過前不要刪除 backup。健康檢查至少確認程序可啟動、首頁或健康端點回應、必要靜態檔可讀,以及 release manifest 與預期版本一致。若系統需要資料結構變更,部署與回復策略還要另外處理向前、向後相容;單純回復網站目錄不能撤銷已套用的資料變更,因此本文不把檔案切換描述成完整系統 rollback。
同樣重要的是清理。staging 建置失敗可以刪除自己的未曝光目錄,但不得碰 current;成功發布的舊 backup 應依保留政策延後移除;過期 stage 與 backup 的清理應限定父目錄、名稱格式與最長保留數。任何廣泛遞迴刪除都應在執行前驗證 resolved path,避免空變數或錯誤路徑把操作範圍擴大。
驗證與重現:Windows PowerShell 5.1 目錄切換 sandbox
這個 sandbox 使用 Windows PowerShell 5.1,每個測試情境都在腳本同層建立新的 deployment_swap_lab_ 目錄,不刪除既有資料,測試建置失敗、成功切換與 app pool 已停止三種狀態;修訂版已在排程環境實際執行。
- 在空白測試目錄將下列內容存為
deployment_swap_lab.ps1,每個測試情境會建立不同的 GUID 子目錄,測試輸出保留供檢查。 - 執行
powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File .\deployment_swap_lab.ps1;腳本只建立新的 sandbox 子目錄,不執行遞迴刪除。 - 檢查失敗建置情境是否保留
old-release且狀態仍為 Started,任何 current 缺失都算失敗。 - 檢查成功情境是否讓 current 變成
new-release、backup 保留old-release,並確認原本已停止的狀態不會讓 cutover 失敗。 - 只有十個
PASS、最後ALL PASS與程序結束碼零同時成立才算通過;這不代表真實 IIS、共享檔案系統或完整應用程式已驗收。
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$LabRoot = $null
function Reset-Lab {
$script:LabRoot = Join-Path $PSScriptRoot ('deployment_swap_lab_' + [guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Path $script:LabRoot | Out-Null
}
function New-ReleaseDirectory([string]$Path, [string]$Marker) {
New-Item -ItemType Directory -Path $Path | Out-Null
Set-Content -LiteralPath (Join-Path $Path 'release.txt') -Value $Marker -NoNewline
}
function Stop-IfRunning([hashtable]$RuntimeState) {
if ($RuntimeState.AppPool -eq 'Started') {
$RuntimeState.AppPool = 'Stopped'
return 'StoppedNow'
}
if ($RuntimeState.AppPool -eq 'Stopped') {
return 'AlreadyStopped'
}
throw "Unexpected application-pool state: $($RuntimeState.AppPool)"
}
function Invoke-StagedCutover(
[string]$CurrentPath,
[string]$StagePath,
[string]$BackupPath,
[hashtable]$RuntimeState,
[bool]$BuildSucceeded
) {
if (-not $BuildSucceeded) {
return 'BuildFailedBeforeCutover'
}
if (-not (Test-Path -LiteralPath (Join-Path $StagePath 'release.txt'))) {
throw 'Staged release is incomplete.'
}
if (-not (Test-Path -LiteralPath (Join-Path $CurrentPath 'release.txt'))) {
throw 'Current release is missing or incomplete.'
}
if (Test-Path -LiteralPath $BackupPath) {
throw 'Backup destination already exists; preserve it and stop.'
}
$previousState = $RuntimeState.AppPool
[void](Stop-IfRunning $RuntimeState)
$oldMoved = $false
try {
Move-Item -LiteralPath $CurrentPath -Destination $BackupPath
$oldMoved = $true
Move-Item -LiteralPath $StagePath -Destination $CurrentPath
$RuntimeState.AppPool = 'Started'
return 'CutoverComplete'
}
catch {
if ($oldMoved -and (-not (Test-Path -LiteralPath $CurrentPath)) -and (Test-Path -LiteralPath $BackupPath)) {
Move-Item -LiteralPath $BackupPath -Destination $CurrentPath
$RuntimeState.AppPool = $previousState
}
throw
}
}
function Assert-Equal([string]$Name, $Actual, $Expected) {
if ($Actual -ne $Expected) {
throw "$Name failed. Expected [$Expected], actual [$Actual]."
}
Write-Output "PASS: $Name"
}
Reset-Lab
$current = Join-Path $LabRoot 'site-current'
$stage = Join-Path $LabRoot 'site-stage'
$backup = Join-Path $LabRoot 'site-backup'
$state = @{ AppPool = 'Started' }
New-ReleaseDirectory $current 'old-release'
New-ReleaseDirectory $stage 'incomplete-release'
$result = Invoke-StagedCutover $current $stage $backup $state $false
Assert-Equal 'failed build leaves current release intact' (Get-Content (Join-Path $current 'release.txt') -Raw) 'old-release'
Assert-Equal 'failed build does not stop the application pool' $state.AppPool 'Started'
Assert-Equal 'failed build exits before cutover' $result 'BuildFailedBeforeCutover'
Reset-Lab
$current = Join-Path $LabRoot 'site-current'
$stage = Join-Path $LabRoot 'site-stage'
$backup = Join-Path $LabRoot 'site-backup'
$state = @{ AppPool = 'Started' }
New-ReleaseDirectory $current 'old-release'
New-ReleaseDirectory $stage 'new-release'
$result = Invoke-StagedCutover $current $stage $backup $state $true
Assert-Equal 'successful cutover promotes staged release' (Get-Content (Join-Path $current 'release.txt') -Raw) 'new-release'
Assert-Equal 'successful cutover keeps rollback copy' (Get-Content (Join-Path $backup 'release.txt') -Raw) 'old-release'
Assert-Equal 'successful cutover restarts the application pool' $state.AppPool 'Started'
Assert-Equal 'successful cutover reports completion' $result 'CutoverComplete'
Reset-Lab
$current = Join-Path $LabRoot 'site-current'
$stage = Join-Path $LabRoot 'site-stage'
$backup = Join-Path $LabRoot 'site-backup'
$state = @{ AppPool = 'Stopped' }
New-ReleaseDirectory $current 'old-release'
New-ReleaseDirectory $stage 'new-release'
$result = Invoke-StagedCutover $current $stage $backup $state $true
Assert-Equal 'already-stopped state is idempotent' $result 'CutoverComplete'
Assert-Equal 'already-stopped cutover promotes staged release' (Get-Content (Join-Path $current 'release.txt') -Raw) 'new-release'
Assert-Equal 'already-stopped cutover finishes started' $state.AppPool 'Started'
Write-Output 'ALL PASS'
**測試邊界:**十項斷言未注入晉級失敗、還原失敗或啟動失敗,因此不代表 rollback 已驗收。範例遇到既有 backup 會中止並保留它;無法證明安全還原時維持停止狀態,交由人工判讀。此為單程序狀態示範,真實部署仍需鎖定以避免並行切換。
預期輸出(修訂版實際重跑亦符合):
PASS: failed build leaves current release intact
PASS: failed build does not stop the application pool
PASS: failed build exits before cutover
PASS: successful cutover promotes staged release
PASS: successful cutover keeps rollback copy
PASS: successful cutover restarts the application pool
PASS: successful cutover reports completion
PASS: already-stopped state is idempotent
PASS: already-stopped cutover promotes staged release
PASS: already-stopped cutover finishes started
ALL PASS
修訂版實際以 powershell.exe -File 執行,十項斷言全部輸出 PASS,最後輸出 ALL PASS,程序結束碼為零。結果證明 sandbox 狀態機符合三個有限主張:建置失敗不碰 current、成功切換保留 backup、已停止狀態可冪等處理。它沒有呼叫 AppCmd,也沒有驗證真實 IIS 權限、檔案鎖定、跨磁碟行為、應用程式啟動時間或效能。
讀者若要加入 IIS 層驗收,應在可拋棄的本機或隔離測試環境,用合成站台名稱建立 site 與 app pool,先驗證 appcmd list 的狀態解析,再測試重複 stop、重複 start 與啟動失敗回復。這些屬於讀者可重現步驟,排程環境未執行。不得直接拿共用站台做第一次破壞性演練,也不得從 sandbox 結果推論正式流量安全性。
發布檢查清單與結論
可靠的 GitLab IIS 部署把 build、驗收、cutover、健康檢查與清理拆成獨立閘門;任何閘門失敗,都不能把未完成版本暴露成 current。
發布前應確認:新版輸出位於獨立 stage;所有建置命令保留非零結束碼;必要檔案與版本識別完成檢查;current、stage、backup 位於預期父目錄;stop/start 只接受明確已知狀態;backup 在健康檢查前不刪除;失敗路徑會保留可判讀目錄;清理只處理符合命名規則的過期項目;artifacts 保留政策與調查需求一致。
監控與稽核也要對齊狀態機。每次部署至少記錄 build gate、stage verification、pre-cutover state、directory promotion、start result、health result 與 cleanup result,但不記錄敏感設定內容。連續失敗時,先找第一個從綠轉紅的 pipeline,再區分原始錯誤與殘留狀態;這比只閱讀最新 job 的最後一行更能縮短定位時間。
最後的設計原則是「先建立可用候選,再改變服務狀態」。停止站台與移動 current 都是高影響步驟,必須延後到新版已完整產出之後。透過 staging、狀態感知 IIS 控制、可回復目錄切換與 sandbox 驗證,部署風險會從不可控的先刪再建,收斂成範圍明確、可以重跑、可以稽核的發布流程。