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
    

快速統計 Action storage 用量的 Bash 腳本

我寫了一支 Bash 腳本,可以快速分析個人帳號下所有的 private repo 佔用了多少 Action storage 儲存空間,方便你加總並騰出空間來用!👍

#!/bin/bash
if ! gh auth status > /dev/null 2>&1; then
  echo "Please authenticate with the GitHub CLI using 'gh auth login'."
  exit 1
fi

# Function to show usage
usage() {
  echo "Usage: $0 [repo_type] [--debug]"
  echo "repo_type can be one of: public, private, all. Default is private."
  echo "Example: $0 private --debug"
  exit 1
}

# Default values
REPO_TYPE="private"
DEBUG=false
POSITIONAL_ARGS=()

# Parse arguments
while [[ $# -gt 0 ]]; do
  case $1 in
    --debug)
      DEBUG=true
      shift # past argument
      ;;
    *)
      POSITIONAL_ARGS+=("$1") # save positional arg
      shift # past argument
      ;;
  esac
done

# Restore positional arguments
set -- "${POSITIONAL_ARGS[@]}"

# repo_type is the first positional argument, if present
if [ -n "$1" ]; then
    REPO_TYPE=$1
fi

if [[ "$REPO_TYPE" != "public" && "$REPO_TYPE" != "private" && "$REPO_TYPE" != "all" ]]; then
  echo "Invalid repo_type. Please choose 'public', 'private', or 'all'."
  usage
fi

echo "Fetching your repositories..."

PAGE=1
TOTAL_ARTIFACT_SIZE=0
TOTAL_CACHE_SIZE=0

while true; do
  API_URL="/user/repos?affiliation=owner&visibility=$REPO_TYPE&per_page=100&page=$PAGE"

  if [ "$DEBUG" = true ]; then
    echo "DEBUG: gh api -H \"Accept: application/vnd.github+json\" \"$API_URL\""
    echo "DEBUG: Repositories on page $PAGE:"
  fi
  REPOS_RESPONSE=$(gh api -H "Accept: application/vnd.github+json" "$API_URL")


  REPOS=$(echo "$REPOS_RESPONSE" | jq -r '.[].full_name')
  if [ -z "$REPOS" ]; then
    break
  fi

  REPOS_COUNT=$(printf '%s\n' "$REPOS" | grep -c .)
  echo "🟢 Repositories on page $PAGE: $REPOS_COUNT items"

  if [ "$DEBUG" = true ]; then
    echo "DEBUG: Repositories on page $PAGE:"
    while IFS= read -r repo; do
      [ -n "$repo" ] && echo "DEBUG:   $repo"
    done <<< "$REPOS"
  fi
  #echo "$REPOS"

  for REPO in $REPOS; do
    echo "⚪ Processing repository: $REPO"
    REPO_PAGE=1
    REPO_ARTIFACT_SIZE=0

    while true; do
      API_URL="/repos/$REPO/actions/artifacts?per_page=100&page=$REPO_PAGE"
      if [ "$DEBUG" = true ]; then
        echo "DEBUG: gh api -H \"Accept: application/vnd.github+json\" \"$API_URL\""
      fi
      RESPONSE=$(gh api -H "Accept: application/vnd.github+json" "$API_URL")
      SIZES=$(echo "$RESPONSE" | jq '.artifacts[].size_in_bytes' 2>/dev/null)
      if [ -z "$SIZES" ]; then
        break
      fi

      for SIZE in $SIZES;
      do
        REPO_ARTIFACT_SIZE=$((REPO_ARTIFACT_SIZE + SIZE))
      done
      HAS_NEXT_PAGE=$(echo "$RESPONSE" | jq '.artifacts | length == 100')
      if [ "$HAS_NEXT_PAGE" != "true" ]; then
        break
      fi

      REPO_PAGE=$((REPO_PAGE + 1))
    done
    API_URL="/repos/$REPO/actions/cache/usage"
    if [ "$DEBUG" = true ]; then
      echo "DEBUG: gh api -H \"Accept: application/vnd.github+json\" \"$API_URL\""
    fi
    CACHE_RESPONSE=$(gh api -H "Accept: application/vnd.github+json" "$API_URL")

    REPO_CACHE_SIZE=$(echo "$CACHE_RESPONSE" | jq '.active_caches_size_in_bytes // 0')
    TOTAL_ARTIFACT_SIZE=$((TOTAL_ARTIFACT_SIZE + REPO_ARTIFACT_SIZE))
    TOTAL_CACHE_SIZE=$((TOTAL_CACHE_SIZE + REPO_CACHE_SIZE))

    if [ "$REPO_ARTIFACT_SIZE" -ne 0 ] || [ "$REPO_CACHE_SIZE" -ne 0 ]; then
      REPO_ARTIFACT_SIZE_MB=$(echo "scale=2; $REPO_ARTIFACT_SIZE / 1024 / 1024" | bc)
      REPO_CACHE_SIZE_MB=$(echo "scale=2; $REPO_CACHE_SIZE / 1024 / 1024" | bc)
      echo "ℹ Total artifact size for $REPO: $REPO_ARTIFACT_SIZE_MB MB"
      echo "ℹ Total cache size for $REPO: $REPO_CACHE_SIZE_MB MB"
    fi
  done
  HAS_NEXT_PAGE=$(echo "$REPOS_RESPONSE" | jq 'length == 100')
  if [ "$HAS_NEXT_PAGE" != "true" ]; then
    break
  fi

  PAGE=$((PAGE + 1))
done
TOTAL_ARTIFACT_SIZE_MB=$(echo "scale=2; $TOTAL_ARTIFACT_SIZE / 1024 / 1024" | bc)
TOTAL_CACHE_SIZE_MB=$(echo "scale=2; $TOTAL_CACHE_SIZE / 1024 / 1024" | bc)

echo "========================================"
echo "Total artifact size across all repositories: $TOTAL_ARTIFACT_SIZE_MB MB"
echo "Total cache size across all repositories: $TOTAL_CACHE_SIZE_MB MB"

使用方式如下:

  1. 統計 private repos 的空間用量

    check-artifacts-usage.sh private
    

    預設值就是 private,所以直接執行也可以:

    check-artifacts-usage.sh
    
  2. 統計 public repos 的空間用量

    check-artifacts-usage.sh public
    
  3. 統計 all repos 的空間用量

    check-artifacts-usage.sh all
    

相關連結

留言評論