Kubernetes × Actions Runner Controller：EphemeralRunner の自己回復を実現する PR #4059 徹底解説

# Kubernetes × Actions Runner Controller：EphemeralRunner の自己回復を実現する PR #4059 徹底解説
## 1. 背景 ― なぜ本 PR が必要だったのか？

| 症状                                                                    | これまでの実装                                                |
| --------------------------------------------------------------------- | ------------------------------------------------------ |
| **Spot インスタンス** は時間が来たら終了し、EphemeralRunnerはそれを障害判定してしまう。 | EphemeralRunner の `status.failures` に **1 回ごと**に失敗が蓄積。 |
| Spotインスタンスを利用している状況下で `minRunners ≥ 1` にしていると Spotインスタンスの中断に必ず遭遇してしまう。  | **5 回** 失敗(中断)すると `Phase=Failed` で **Runner CR が残留**。      |
| Workflow 側からは「Runner が見つからない」状態になりジョブが詰まる。                            | Issue [#2721](https://github.com/actions/actions-runner-controller/issues/2721) で約2年前から報告              |

---

## 2. これまでの制限と課題

* **失敗上限 5 回** という定数自体は *以前から* 存在
* 上限到達後は **`markAsFailed()` で Phase を変えるだけ** だったため、

  * EphemeralRunnerSet が「Desired=1 だけど既にオブジェクトがある」と判断
  * **新しい EphemeralRunner を生成できず** “ゾンビ化” していた

---

## 3. PR #4059 で何が変わったのか？

| 変更点                                                                          | 役割                                                                               |
| ---------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
| `status.failures` の **型変更** <br>`map[string]bool` → `map[string]metav1.Time` | 失敗時刻を記録し **最新失敗を時系列で把握**                                                         |
| `failedRunnerBackoff` 配列導入                                                   | 0 s → 5 s → 10 s → 20 s → 40 s → 80 s の **指数バックオフ** ([GitHub](https://github.com/actions/actions-runner-controller/pull/4059/files#diff-5c7193f43d9efbfc1fce789d32897a294678f0f551d5cde01b837496d519bdf3R56-R63))                |
| `LastFailure()` ヘルパー追加                                                       | Status から **直近失敗時刻** を取得 ([GitHub](https://github.com/actions/actions-runner-controller/pull/4059/files#diff-66f5adca3ea1470f589b19e084eaba79d1ff4eb5542ebacaf84ed3332d290e8eR140-R153))                                                         |
| **再キュー制御**                                                                   | 失敗回数 n ≤ 5 なら `RequeueAfter(backoff[n])` 相当の処理 ([GitHub](https://github.com/actions/actions-runner-controller/pull/4059/files#diff-5c7193f43d9efbfc1fce789d32897a294678f0f551d5cde01b837496d519bdf3R202-R211))                           |
| **上限超えの対処を変更**                                                               | *Phase 変更* ではなく **EphemeralRunner CR を即 `Delete()`**。<br>EphemeralRunnerSet が新品を再生成可 ([GitHub](https://github.com/actions/actions-runner-controller/pull/4059/files#diff-5c7193f43d9efbfc1fce789d32897a294678f0f551d5cde01b837496d519bdf3R190-R192)) |

> 💡 **ポイント**
> *上限 5 回* という定数そのものは変わっていません
> **「上限到達後にどうするか」** が *Phase=Failed → オブジェクト削除* に変わり、
> EphemeralRunner の **自己回復** が可能になりました

---

## 4. 実際の Spot × minRunners シナリオでの動作

```mermaid
sequenceDiagram
    autonumber
    participant ARC as ARC Controller
    participant ER as EphemeralRunner CR
    participant Set as EphemeralRunnerSet
    participant Node as Spot Node

    Node-->>ER: 中断予告 (2 分前)
    Node--x ER: Pod Terminated
    ARC->>ER: failures+=1 / RequeueAfter(backoff[1])
    loop 〜5回繰り返し〜
        Node--x ER: 再び Terminated
        ARC->>ER: failures+=1 / Backoff
    end
    ARC->>ER: failures=6 (>5)\nDelete(ER)
    Set->>ARC: Desired=1 だが CR なし
    Set-->>ARC: **新規 EphemeralRunner 作成**
```

* 6 回目で **CR 削除 → 再生成** するため “ゾンビ” は残らない

---

## 5. コード解説

### 1. 失敗を記録する Status フィールド

```go
type EphemeralRunnerStatus struct {
    // …既存フィールド
    Failures map[string]metav1.Time `json:"failures,omitempty"`
}

func (s *EphemeralRunnerStatus) LastFailure() *metav1.Time {
    // map を走査して最新タイムスタンプを返すヘルパー
}
```

* **キー**: Podの.metadata.uid
* **値**: その失敗が起きた時刻

### 2. 指数的バックオフ & 再キュー

```go
var failedRunnerBackoff = []time.Duration{
    0, 5 * time.Second, 10 * time.Second,
    20 * time.Second, 40 * time.Second, 80 * time.Second,
}
const maxFailures = 5
```

```go
if len(er.Status.Failures) <= maxFailures {
    delay := failedRunnerBackoff[len(er.Status.Failures)]
    return ctrl.Result{RequeueAfter: delay}, nil
}
```

* 0 秒 → 5 s → 10 s … と再試行
* 6 回目（`len > maxFailures`）に到達すると次のセクションへ

### 3. 6 回目で即削除 → EphemeralRunnerSet が再作成

```go
// 最終フェーズ
if len(er.Status.Failures) > maxFailures {
    log.Info("Maximum failures reached – deleting EphemeralRunner")
    if err := r.Delete(ctx, er); err != nil { ... }
    return ctrl.Result{}, nil
}
```

* **オブジェクト自体を Delete** するので Controller の再試行は終了
* 上位リソース **EphemeralRunnerSet** が Desired 数を満たすために新しい EphemeralRunner を生成 → Pod → ジョブ再開

---

## 動作フローまとめ

```mermaid
graph TD
    A[Pod Failed] --> B{Failures <= 5?}
    B -- Yes --> C[Record failure & requeue after backoff]
    B -- No --> D[Delete EphemeralRunner CR]
    D --> E[EphemeralRunnerSet creates new EphemeralRunner]
```

---

## 6. まとめ

PR #4059 は Spot × minRunners 環境だと特に頻繁に遭遇する **「5 回中断すると詰む」** という長年の課題（Issue [#2721](https://github.com/actions/actions-runner-controller/issues/2721)）を解決しました

* 失敗を時系列で管理し、指数バックオフで再試行
* 上限超過時は**EphemeralRunner CR を削除**して EphemeralRunnerSet に再作成させる
* これにより **フルマネージドに近い自己回復** が実現
* とはいえ **ジョブが実行中にSpotの中断に遭遇して実行が阻害されるリスク** は残っているので、Spotを利用する場合はその点にも注意する必要があります

[1]: https://github.com/actions/actions-runner-controller/issues/2721 "I want to disable \"Pod has failed to start more than 5 times\" #2721"