Skip to content

Commit 9e28d68

Browse files
authored
Merge pull request #1 from van-sprundel/heartbeat
feat: add heartbeat to node, update in kubelet
2 parents 9b5d0cf + 290d678 commit 9e28d68

9 files changed

Lines changed: 238 additions & 63 deletions

File tree

.github/workflows/ci.yml

Lines changed: 2 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -30,48 +30,8 @@ jobs:
3030
benchmark:
3131
name: run benchmarks
3232
runs-on: ubuntu-latest
33+
if: github.event_name == 'push'
3334
steps:
3435
- uses: actions/checkout@v6
3536
- uses: actions/setup-go@v6
36-
37-
- name: Install benchstat
38-
run: go install golang.org/x/perf/cmd/benchstat@latest
39-
40-
- name: Run benchmarks on PR branch
41-
if: github.event_name == 'pull_request'
42-
run: go test -run=^$ -bench=. -benchmem -benchtime=100ms -count=3 ./... 2>&1 | tee benchmark-pr.txt
43-
44-
- name: Run benchmarks on master
45-
if: github.event_name == 'push'
46-
run: go test -run=^$ -bench=. -benchmem -benchtime=100ms -count=3 ./...
47-
48-
- name: Checkout base branch
49-
if: github.event_name == 'pull_request'
50-
uses: actions/checkout@v4
51-
with:
52-
ref: ${{ github.base_ref }}
53-
clean: false
54-
55-
- name: Run benchmarks on base branch
56-
if: github.event_name == 'pull_request'
57-
run: go test -run=^$ -bench=. -benchmem -benchtime=100ms -count=3 ./... 2>&1 | tee benchmark-base.txt
58-
59-
- name: Compare benchmarks
60-
if: github.event_name == 'pull_request'
61-
run: |
62-
echo "## 📊 Benchmark Results" > benchmark_results.md
63-
echo "" >> benchmark_results.md
64-
echo "Comparing \`${{ github.head_ref }}\` (PR) vs \`${{ github.base_ref }}\` (base)" >> benchmark_results.md
65-
echo "" >> benchmark_results.md
66-
echo '```' >> benchmark_results.md
67-
benchstat benchmark-base.txt benchmark-pr.txt >> benchmark_results.md 2>&1 || echo "No comparable benchmarks found" >> benchmark_results.md
68-
echo '```' >> benchmark_results.md
69-
echo "" >> benchmark_results.md
70-
echo "<sub>📏 Using Go's benchstat for statistical comparison. Lower is better for time/op and B/op.</sub>" >> benchmark_results.md
71-
72-
- name: Comment PR with results
73-
if: github.event_name == 'pull_request'
74-
uses: thollander/actions-comment-pull-request@v2
75-
with:
76-
filePath: benchmark_results.md
77-
comment_tag: benchmark_results
37+
- run: go test -run=^$ -bench=. -benchmem ./...

.pre-commit-config.yaml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
repos:
2+
- repo: local
3+
hooks:
4+
- id: golangci-lint
5+
name: golangci-lint
6+
entry: golangci-lint run --enable errcheck
7+
types: [go]
8+
language: system
9+
pass_filenames: false

README.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,6 @@ Very nice!
4949

5050
# TODOS
5151

52-
- [ ] Mark nodes as `NotReady` if kubelet stops responding (will require healthchecks)
5352
- [ ] Round-robin for scheduler
5453
- [ ] Pod deletion cleanup (stop containers if the pods are deleted via API)
5554
- [ ] etcd (k/v store for cluster state so restarts persist)
@@ -70,6 +69,17 @@ TODO
7069
| Running | exited | Update pod to Failed |
7170
| Running | doesn't exist | Weird state - maybe re-create or mark Failed |
7271

72+
```
73+
(heartbeat)
74+
Kubelet ─────────────> NodeStore
75+
|
76+
V
77+
NodeController ("is heartbeat stale?")
78+
|
79+
V
80+
NotReady if stale
81+
```
82+
7383
# Disclaimer
7484

7585
This project is purely for my own education. That means **no** LLM's, which also means it's not going to be production-ready code. ~~The Pod spec is purposefully simple (name, img, state) because I do not need anything else for my goals.~~ Turns out that was a lie

cmd/miniku/main.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,15 +27,19 @@ func main() {
2727

2828
// reconcile pods -> containers (one per node)
2929
rt := &runtime.DockerCLIRuntime{}
30-
kubelet1 := kubelet.New(podStore, rt, "node-1")
31-
kubelet2 := kubelet.New(podStore, rt, "node-2")
30+
kubelet1 := kubelet.New(podStore, nodeStore, rt, "node-1")
31+
kubelet2 := kubelet.New(podStore, nodeStore, rt, "node-2")
3232
go kubelet1.Run()
3333
go kubelet2.Run()
3434

3535
// reconcile replicasets -> pods
3636
rsController := controller.New(podStore, rsStore)
3737
go rsController.Run()
3838

39+
// mark nodes NotReady if heartbeat is stale
40+
nodeController := controller.NewNodeController(nodeStore)
41+
go nodeController.Run()
42+
3943
// API Server
4044
srv := &api.Server{
4145
PodStore: podStore,

pkg/controller/node.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package controller
2+
3+
import (
4+
"miniku/pkg/store"
5+
"miniku/pkg/types"
6+
"time"
7+
)
8+
9+
const NODE_HEARTBEAT_THRESHOLD = 15 * time.Second
10+
11+
type NodeController struct {
12+
nodeStore store.NodeStore
13+
}
14+
15+
func NewNodeController(nodeStore store.NodeStore) *NodeController {
16+
return &NodeController{nodeStore}
17+
}
18+
19+
func (c *NodeController) Run() {
20+
for {
21+
for _, node := range c.nodeStore.List() {
22+
c.reconcile(node)
23+
}
24+
time.Sleep(POLL_INTERVAL)
25+
}
26+
}
27+
28+
func (c *NodeController) reconcile(node types.Node) {
29+
if time.Since(node.LastHeartbeat) > NODE_HEARTBEAT_THRESHOLD {
30+
node.Status = types.NodeStateNotReady
31+
} else {
32+
node.Status = types.NodeStateReady
33+
}
34+
c.nodeStore.Put(node.Name, node)
35+
}

pkg/controller/node_test.go

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
package controller
2+
3+
import (
4+
"miniku/pkg/store"
5+
"miniku/pkg/types"
6+
"testing"
7+
"time"
8+
)
9+
10+
func TestNodeControllerReconcile(t *testing.T) {
11+
tests := []struct {
12+
name string
13+
node types.Node
14+
expectedStatus types.NodeState
15+
}{
16+
{
17+
name: "fresh heartbeat stays ready",
18+
node: types.Node{
19+
Name: "node-1",
20+
Status: types.NodeStateReady,
21+
LastHeartbeat: time.Now(),
22+
},
23+
expectedStatus: types.NodeStateReady,
24+
},
25+
{
26+
name: "stale heartbeat marked notready",
27+
node: types.Node{
28+
Name: "node-1",
29+
Status: types.NodeStateReady,
30+
LastHeartbeat: time.Now().Add(-30 * time.Second),
31+
},
32+
expectedStatus: types.NodeStateNotReady,
33+
},
34+
{
35+
name: "zero heartbeat marked notready",
36+
node: types.Node{
37+
Name: "node-1",
38+
Status: types.NodeStateReady,
39+
},
40+
expectedStatus: types.NodeStateNotReady,
41+
},
42+
{
43+
name: "recovered node marked ready",
44+
node: types.Node{
45+
Name: "node-1",
46+
Status: types.NodeStateNotReady,
47+
LastHeartbeat: time.Now(),
48+
},
49+
expectedStatus: types.NodeStateReady,
50+
},
51+
{
52+
name: "heartbeat exactly at threshold",
53+
node: types.Node{
54+
Name: "node-1",
55+
Status: types.NodeStateReady,
56+
LastHeartbeat: time.Now().Add(-NODE_HEARTBEAT_THRESHOLD),
57+
},
58+
expectedStatus: types.NodeStateNotReady,
59+
},
60+
{
61+
name: "heartbeat just before threshold",
62+
node: types.Node{
63+
Name: "node-1",
64+
Status: types.NodeStateReady,
65+
LastHeartbeat: time.Now().Add(-NODE_HEARTBEAT_THRESHOLD + time.Second),
66+
},
67+
expectedStatus: types.NodeStateReady,
68+
},
69+
}
70+
71+
for _, tt := range tests {
72+
t.Run(tt.name, func(t *testing.T) {
73+
nodeStore := store.NewMemStore[types.Node]()
74+
nodeStore.Put(tt.node.Name, tt.node)
75+
76+
controller := NewNodeController(nodeStore)
77+
controller.reconcile(tt.node)
78+
79+
updatedNode, found := nodeStore.Get(tt.node.Name)
80+
if !found {
81+
t.Fatal("node not found in store")
82+
}
83+
84+
if updatedNode.Status != tt.expectedStatus {
85+
t.Errorf("expected status %s, got %s", tt.expectedStatus, updatedNode.Status)
86+
}
87+
})
88+
}
89+
}

pkg/kubelet/kubelet.go

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,17 @@ const BASE_DELAY time.Duration = 1000
1717
const MAX_DELAY time.Duration = 60_000
1818

1919
type Kubelet struct {
20-
name string
21-
store store.PodStore
22-
runtime runtime.Runtime
20+
name string
21+
podStore store.PodStore
22+
nodeStore store.NodeStore
23+
runtime runtime.Runtime
2324
}
2425

25-
func New(store store.PodStore, runtime runtime.Runtime, name string) Kubelet {
26+
func New(podStore store.PodStore, nodeStore store.NodeStore, runtime runtime.Runtime, name string) Kubelet {
2627
return Kubelet{
2728
name,
28-
store,
29+
podStore,
30+
nodeStore,
2931
runtime,
3032
}
3133
}
@@ -42,7 +44,7 @@ func (k *Kubelet) Sync() {
4244
}
4345

4446
for _, container := range containers {
45-
pod, exists := k.store.Get(container.Name)
47+
pod, exists := k.podStore.Get(container.Name)
4648
if !exists {
4749
log.Printf("sync: removing orphan container %s (%s)", container.Name, container.ID)
4850
if err := k.runtime.Stop(container.ID); err != nil {
@@ -63,7 +65,7 @@ func (k *Kubelet) Sync() {
6365
log.Printf("sync: linking container %s to pod %s", container.ID, pod.Spec.Name)
6466
pod.ContainerID = container.ID
6567
pod.Status = types.PodStatusRunning
66-
k.store.Put(pod.Spec.Name, pod)
68+
k.podStore.Put(pod.Spec.Name, pod)
6769
}
6870
}
6971
}
@@ -72,7 +74,7 @@ func (k *Kubelet) Run() {
7274
k.Sync()
7375

7476
for {
75-
for _, pod := range k.store.List() {
77+
for _, pod := range k.podStore.List() {
7678
// only reconcile pods assigned to this node
7779
if pod.Spec.NodeName != k.name {
7880
continue
@@ -83,6 +85,7 @@ func (k *Kubelet) Run() {
8385
}
8486

8587
// polling
88+
k.updateHeartbeat()
8689
time.Sleep(POLL_INTERVAL)
8790
}
8891
}
@@ -134,7 +137,7 @@ func (k *Kubelet) reconcilePod(pod types.Pod) error {
134137
return fmt.Errorf("unhandled state")
135138
}
136139

137-
k.store.Put(updatedPod.Spec.Name, updatedPod)
140+
k.podStore.Put(updatedPod.Spec.Name, updatedPod)
138141
return nil
139142
}
140143

@@ -178,3 +181,11 @@ func calculateNextRetry(pod types.Pod) time.Time {
178181
delay := min(MAX_DELAY, BASE_DELAY*time.Duration(1<<pod.RetryCount))
179182
return time.Now().Add(delay)
180183
}
184+
185+
func (k *Kubelet) updateHeartbeat() {
186+
node, ok := k.nodeStore.Get(k.name)
187+
if ok {
188+
node.LastHeartbeat = time.Now()
189+
k.nodeStore.Put(k.name, node)
190+
}
191+
}

0 commit comments

Comments
 (0)