The Will Will Web

記載著 Will 在網路世界的學習心得與技術分享

如何解決 GitHub Actions 的 Artifact storage 不夠用的問題

最近這幾個月因為寫了不少新的 GitHub Actions workflows,CI 頻繁執行的狀況下,導致這幾天開始出現了 Artifact storage quota has been hit 的錯誤訊息。經查詢後瞭解到,原來我的 GitHub Pro 訂閱,除了 GitHub Actions 每月 3,000 分鐘的執行時間額度外,還有每月 2 GB 的用量限制。但仔細查看 GitHub Actions billing 文件之後,發現還真的非常複雜,這篇文章我打算來順一下計費的脈絡。

Image

以下是我遇到的錯誤訊息:

Error: Failed to CreateArtifact: Artifact storage quota has been hit. Unable to upload any new artifacts. Usage is recalculated every 6-12 hours.
More info on storage limits: https://docs.github.com/en/billing/managing-billing-for-github-actions/about-billing-for-github-actions#calculating-minute-and-storage-spending

GitHub Actions 的計費方式

基本上,在使用 GitHub Actions 的時候,以下這兩種情境是完全免費的:

  1. 使用自我託管的執行器 (self-hosted runners)
  2. 你的專案是個公開儲存庫 (public repositories),且使用標準 GitHub 託管的執行器 (GitHub-hosted runners)

對於私人儲存庫 (private repositories),每個 GitHub 帳戶將根據其購買的方案,可以獲得一定額度的免費分鐘數成品儲存空間,用於 GitHub 託管的執行器,用量的計算大致分成以下兩類:

  1. 執行分鐘數 (每月重置)

    分鐘數是根據當月在每種 執行器 (runner) 類型上使用的總處理時間計算。

  2. 使用儲存空間用量 (每月不會重置)

    儲存空間是根據當月成品(artifact)的小時使用量計費。

    儲存空間用量的計算方式非常複雜,而且文件也講的很難理解,更沒有後台可以讓你快速查看用量,其實不好懂。GitHub 並不是給你一個容量的上限,而是所謂 GB-Hours 的概念,再除以每月 744 小時。

預設的情況下,超出額度限制的任何使用量,會直接不能使用,不會直接向用戶收費,但你可以到 Budgets and alerts 調整預算限制與警示通知。

分鐘數的計算方式

任何時候 CI / CD 要執行 Pipeline 時,都需要 Runner (執行器) 才能執行程式,當你使用了標準 GitHub 託管的執行器 (GitHub-hosted runners) 在「私有專案」(private repo),就會記錄你每次的「執行分鐘數」。

不過,執行器 (Runner) 執行 1 分鐘,不代表會扣除你的每月免費額度 3,000 分鐘的 1 分鐘。有時候執行 1 分鐘,會直接扣除 2 分鐘額度。有時候執行 1 分鐘,會直接扣除 10 分鐘額度。這樣的機制我們稱為「分鐘數乘數」(minute multipliers)。

  • 若選用 Linux runner,乘數為 1 倍

    如果用 Linux 的 Runner 跑 10 分鐘,就會以 10 分鐘來計算。

  • 若選用 Windows runner,乘數為 2 倍

    如果用 Windows 的 Runner 跑 10 分鐘,就會以 20 分鐘來計算。

  • 若選用 macOS runner,乘數為 10 倍

    如果用 macOS 的 Runner 跑 10 分鐘,就會以 100 分鐘來計算。

如需 GitHub 託管執行器的分鐘數乘數的完整詳細資訊,請參閱 Actions 分鐘數乘數參考

儲存空間用量的計算方式

我用以下例子來說明儲存空間的成本計算方式。

假設你在這個月使用 2 GB 的儲存空間 20 天,並使用 1 GB 的儲存空間 10 天,則您的儲存空間使用量將為:

  • 2GB x 20 天 x (每天 24 小時) = 960 GB-Hours
  • 3GB x 10 天 x (每天 24 小時) = 720 GB-Hours

當月總 GB-Hours 為 960 + 720 = 1680 GB-Hours

當月總 GB-Month 為 1680 GB-Hours / 744 Hours-per-month = 2.25 GB

如果你不小心還沒到月底就用超過限制了,要等下個月初才會重新開始計算,我覺得非常不便!

以我的帳號的 Action storage 上限為 2GB 為例,不用到月底,你的 Action storage 就無法再寫入了:

image

如果你有允許超量使用的話,月底時,GitHub 會將您的儲存空間四捨五入到最接近的 MB,並自動扣款。

這裡必須要注意的是,GitHub 會在每 6 到 12 小時的視窗內更新您的儲存空間用量,但你並不知道何時會計算你當前的用量,這部分是個大黑箱。

假設你的計算用量的時間是晚上 00:00 的話,你在 01:00 寫入了 1GB 的檔案到 artifact storage,但是在 03:00 的時候把 artifact storage 刪除,那麼到 06:00 的時候,就不會計算到你的 GB-Hours 之內。

如何刪除 Action storage 佔用的空間

很可惜的,在 GitHub 上面完全沒有任何 UI 可以看到 Action storage 佔用的空間,而且你的 Action storage 用量每個 Private Repo 都有可能會用到,所以只能透過 GitHub REST API 取得用量資訊,然後逐一刪除過期的 workflow runs 或 artifacts 才有機會釋出空間。

因此,我用 PowerShell 寫了一份腳本,可以快速刪除 GitHub Actions 的 artifacts 與 workflow runs!

只要建立 $HOME\Documents\PowerShell\Scripts\Cleanup-GitHubActions.ps1 檔案:

<#
.SYNOPSIS
  清除指定 GitHub Repo 中早於指定天數的 GitHub Actions artifacts 與 workflow runs。

.DESCRIPTION
  以 GitHub CLI gh 調用 REST API。
  先刪 artifacts 再刪 workflow runs。支援 -WhatIf 與 -Confirm。

.PARAMETER Owner
  擁有者帳號。例如: doggy8088

.PARAMETER Repo
  倉庫名稱。例如: playwright.tw

.PARAMETER DaysToKeep
  保留天數。早於此天數的資料會被刪除。預設 14。

.PARAMETER PerPage
  單頁抓取數量。預設 100。

.PARAMETER ArtifactsOnly
  只處理 artifacts。

.PARAMETER RunsOnly
  只處理 workflow runs。

.EXAMPLE
  .\Cleanup-GHA.ps1 -Owner doggy8088 -Repo playwright.tw -DaysToKeep 14 -WhatIf

.EXAMPLE
  .\Cleanup-GHA.ps1 -Owner org -Repo repo -Confirm
#>

[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')]
param(
  [Parameter(Mandatory = $true)][string]$Owner,
  [Parameter(Mandatory = $true)][string]$Repo,
  [int]$DaysToKeep = 14,
  [int]$PerPage    = 100,
  [switch]$ArtifactsOnly,
  [switch]$RunsOnly
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

function Test-Gh {
  if (-not (Get-Command gh -ErrorAction SilentlyContinue)) {
    throw "找不到 GitHub CLI gh。請安裝後重試。"
  }
  try {
    gh auth status | Out-Null
  } catch {
    throw "gh 尚未登入或權限不足。請執行 gh auth login 後重試。"
  }
}

function Get-Cutoff {
  param([int]$KeepDays)
  return [DateTimeOffset]::UtcNow.AddDays(-$KeepDays)
}

function Remove-OldArtifacts {
  param(
    [string]$BasePath,
    [DateTimeOffset]$Cutoff,
    [int]$PerPage
  )
  $page = 1
  $deleted = 0
  do {
    try {
      $resp = gh api "$BasePath/artifacts?per_page=$PerPage&page=$page" | ConvertFrom-Json
    } catch {
      Write-Warning "取得 artifacts 清單失敗,page=$page。錯誤: $($_.Exception.Message)"
      break
    }

    $items = @()
    if ($resp -and $resp.artifacts) { $items = @($resp.artifacts) }

    foreach ($a in $items) {
      $created = [DateTimeOffset]::Parse($a.created_at)
      if ($created -lt $Cutoff) {
        $target = "artifact id=$($a.id) name=$($a.name) created_at=$($a.created_at) run_id=$($a.workflow_run.id)"
        if ($PSCmdlet.ShouldProcess($target, "Delete")) {
          try {
            gh api --method DELETE "$BasePath/artifacts/$($a.id)" | Out-Null
            $deleted++
            Write-Host "Deleted $target"
          } catch {
            Write-Warning "刪除 artifact 失敗 id=$($a.id) name=$($a.name) 錯誤: $($_.Exception.Message)"
          }
        } else {
          Write-Host "Would delete $target"
        }
      }
    }
    $page++
  } while ($items.Count -gt 0)

  return $deleted
}

function Remove-OldRuns {
  param(
    [string]$BasePath,
    [DateTimeOffset]$Cutoff,
    [int]$PerPage
  )
  $page = 1
  $deleted = 0
  do {
    try {
      $resp = gh api "$BasePath/runs?per_page=$PerPage&page=$page" | ConvertFrom-Json
    } catch {
      Write-Warning "取得 workflow runs 清單失敗,page=$page。錯誤: $($_.Exception.Message)"
      break
    }

    $items = @()
    if ($resp -and $resp.workflow_runs) { $items = @($resp.workflow_runs) }

    foreach ($r in $items) {
      $created = [DateTimeOffset]::Parse($r.created_at)
      if ($created -lt $Cutoff) {
        $target = "run id=$($r.id) name=$($r.name) created_at=$($r.created_at)"
        if ($PSCmdlet.ShouldProcess($target, "Delete")) {
          try {
            gh api --method DELETE "$BasePath/runs/$($r.id)" | Out-Null
            $deleted++
            Write-Host "Deleted $target"
          } catch {
            Write-Warning "刪除 workflow run 失敗 id=$($r.id) name=$($r.name) 錯誤: $($_.Exception.Message)"
          }
        } else {
          Write-Host "Would delete $target"
        }
      }
    }
    $page++
  } while ($items.Count -gt 0)

  return $deleted
}

# 主流程
if ($ArtifactsOnly -and $RunsOnly) {
  throw "-ArtifactsOnly 與 -RunsOnly 不可同時使用。"
}

Test-Gh

$base   = "repos/$Owner/$Repo/actions"
$cutoff = Get-Cutoff -KeepDays $DaysToKeep

Write-Host "將刪除建立於 $DaysToKeep 天之前的 artifacts 與 workflow runs"
Write-Host "時間門檻 UTC: $($cutoff.UtcDateTime.ToString('u'))"
Write-Host ""

$totalArtifacts = 0
$totalRuns      = 0

if (-not $RunsOnly) {
  $totalArtifacts = Remove-OldArtifacts -BasePath $base -Cutoff $cutoff -PerPage $PerPage
  Write-Host ""
  Write-Host "共刪除 artifacts: $totalArtifacts"
  Write-Host ""
}

if (-not $ArtifactsOnly) {
  $totalRuns = Remove-OldRuns -BasePath $base -Cutoff $cutoff -PerPage $PerPage
  Write-Host ""
  Write-Host "共刪除 workflow runs: $totalRuns"
}

# 結束碼: 有刪除則 0,否則 0(不視為錯誤)
exit 0

使用方式如下,記得將 USERREPO 換成你自己的 GitHub 帳號與 Repo 名稱:

  1. 刪除超過 14 天的所有 artifacts 與 workflow runs

    Cleanup-GitHubActions -Owner USER -Repo REPO
    

    你也可以明確加入 -DaysToKeep 指定保留的天數 (預設為 14 天)

    Cleanup-GitHubActions -Owner USER -Repo REPO -DaysToKeep 14
    
  2. 刪除所有 artifacts 與 workflow runs

    Cleanup-GitHubActions -Owner USER -Repo REPO -DaysToKeep 0
    
  3. 僅刪除 artifacts 且保留 workflow runs 所有的 Summary 與 Logs

    Cleanup-GitHubActions -Owner USER -Repo REPO -DaysToKeep 0 -ArtifactsOnly
    
  4. 僅刪除 workflow runs (此舉將會連同 artifacts 一起全數刪除)

    Cleanup-GitHubActions -Owner USER -Repo REPO -DaysToKeep 0 -RunsOnly
    

相關連結

留言評論