Skip to content

Commit eccb2da

Browse files
Merge pull request #1 from mattermost/node-rotator
Rotator tool to accelerate k8s cluster upgrades and node rotations.
2 parents c8add55 + e08ffc4 commit eccb2da

27 files changed

Lines changed: 2757 additions & 1 deletion

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
coverage.out
2+
.vscode
3+
build/_output/

Makefile

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
2+
# See LICENSE.txt for license information.
3+
4+
## Docker Build Versions
5+
DOCKER_BUILD_IMAGE = golang:1.15.8
6+
DOCKER_BASE_IMAGE = alpine:3.13
7+
8+
# Variables
9+
GO = go
10+
APP := rotator
11+
APPNAME := node-rotator
12+
ROTATOR_IMAGE ?= mattermost/node-rotator:test
13+
14+
################################################################################
15+
16+
export GO111MODULE=on
17+
18+
all: check-style unittest fmt
19+
20+
.PHONY: check-style
21+
check-style: govet
22+
@echo Checking for style guide compliance
23+
24+
.PHONY: vet
25+
govet:
26+
@echo Running govet
27+
$(GO) vet ./...
28+
@echo Govet success
29+
30+
.PHONY: fmt
31+
fmt: ## Run go fmt against code
32+
go fmt ./...
33+
34+
.PHONY: unittest
35+
unittest:
36+
$(GO) test ./... -v -covermode=count -coverprofile=coverage.out
37+
38+
# Build for distribution
39+
.PHONY: build
40+
build:
41+
@echo Building Mattermost Rotator
42+
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 $(GO) build -gcflags all=-trimpath=$(PWD) -asmflags all=-trimpath=$(PWD) -a -installsuffix cgo -o build/_output/bin/$(APP) ./cmd/$(APP)
43+
44+
45+
# Builds the docker image
46+
.PHONY: build-image
47+
build-image:
48+
@echo Building Rotator Docker Image
49+
docker build \
50+
--build-arg DOCKER_BUILD_IMAGE=$(DOCKER_BUILD_IMAGE) \
51+
--build-arg DOCKER_BASE_IMAGE=$(DOCKER_BASE_IMAGE) \
52+
. -f build/Dockerfile \
53+
-t $(ROTATOR_IMAGE) \
54+
--no-cache

README.md

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,93 @@
1-
# node-rotator
1+
# Rotator
2+
3+
Rotator is a tool meant to smooth and accelerate k8s cluster upgrades and node rotations. It offers automation on autoscaling group recognition and flexility on options such as, how fast to rotate nodes, drain retries, waiting time between rotations and drains as well as mater/worker node separation.
4+
5+
## How to use
6+
7+
The rotator tool can be imported either as a go package and used in any app that needs to rotate/detach/drain k8s nodes or used as a cli tool, where the rotator server can accept rotation requests.
8+
9+
### Import as Go package
10+
11+
To import as a Go package both the [rotator]("github.com/mattermost/rotator/rotator") and the [model]("github.com/mattermost/rotator/model") should be imported.
12+
13+
The rotator should be called with a cluster object like the one bellow:
14+
15+
```golang
16+
clusterRotator := rotatorModel.Cluster{
17+
ClusterID: <The id of the cluster to rotate nodes>, (string)
18+
MaxScaling: <Maximum number of nodes to rotate in each rotation>, (int)
19+
RotateMasters: <if master nodes should be rotated>, (bool)
20+
RotateWorkers: <if worker nodes should be rotated>, (bool)
21+
MaxDrainRetries: <max number of retries when a node drain fails>, (int)
22+
EvictGracePeriod: <pod evict grace period>, (int)
23+
WaitBetweenRotations: <wait between each rotation of groups of nodes defined by MaxScaling in seconds>, (int)
24+
WaitBetweenDrains: <wait between each node drain in a group of nodes>, (int)
25+
ClientSet: <k8s clientset>, (*kubernetes.Clientset)
26+
}
27+
```
28+
29+
Calling the `InitRotateCluster` function of the rotator package with the defined clusterRotator object is all is needed to rotate a cluster. Example can be seen bellow:
30+
31+
```golang
32+
rotatorMetadata, err = rotator.InitRotateCluster(&clusterRotator, rotatorMetadata, logger)
33+
if err != nil {
34+
cluster.ProvisionerMetadataKops.RotatorRequest.Status = rotatorMetadata
35+
return err
36+
}
37+
```
38+
39+
where
40+
41+
```golang
42+
rotatorMetadata = &rotator.RotatorMetadata{}
43+
```
44+
45+
The rotator returns metadata that in case of rotation failure include information of ASGs pending rotation. This metadata can be passed back to the InitRotateCluster and the rotator will resume from where it left.
46+
47+
48+
### Use Rotator as CLI tool
49+
50+
The Rotator can be used as a docker image or as a local server.
51+
52+
#### Building
53+
54+
Simply run the following:
55+
56+
```bash
57+
go install ./cmd/rotator
58+
alias cloud='$HOME/go/bin/rotator'
59+
```
60+
61+
#### Running
62+
63+
Run the server with:
64+
65+
```bash
66+
rotator server
67+
```
68+
69+
In a different terminal/window, to rotate a cluster:
70+
```bash
71+
rotator cluster rotate --cluster <cluster_id> --rotate-workers --rotate-masters --wait-between-rotations 30 --wait-between-drains 60 --max-scaling 4 --evict-grace-period 30
72+
```
73+
74+
You will get a response like this one:
75+
```bash
76+
[{
77+
"ClusterID": "<cluster_id>",
78+
"MaxScaling": 4,
79+
"RotateMasters": true,
80+
"RotateWorkers": true,
81+
"MaxDrainRetries": 10,
82+
"EvictGracePeriod": 30,
83+
"WaitBetweenRotations": 30,
84+
"WaitBetweenDrains": 30,
85+
"ClientSet": null
86+
}
87+
```
88+
89+
### Other Setup
90+
91+
For the rotator to run access to both the AWS account and the K8s cluster is required to be able to do actions such as, `DescribeInstances`, `DetachInstances`, `TerminateInstances`, `DescribeAutoScalingGroups`, as well as `drain`, `kill`, `evict` pods, etc.
92+
93+
The relevant AWS Access and Secret key pair should be exported and k8s access should be provided via a passed clientset or a locally exported k8s config.

api/api.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package api
2+
3+
import (
4+
"net/http"
5+
6+
"github.com/gorilla/mux"
7+
"github.com/mattermost/rotator/model"
8+
rotator "github.com/mattermost/rotator/rotator"
9+
)
10+
11+
// Register registers the API endpoints on the given router.
12+
func Register(rootRouter *mux.Router, context *Context) {
13+
apiRouter := rootRouter.PathPrefix("/api").Subrouter()
14+
15+
initCluster(apiRouter, context)
16+
}
17+
18+
// initCluster registers RDS cluster endpoints on the given router.
19+
func initCluster(apiRouter *mux.Router, context *Context) {
20+
addContext := func(handler contextHandlerFunc) *contextHandler {
21+
return newContextHandler(context, handler)
22+
}
23+
24+
clustersRouter := apiRouter.PathPrefix("/rotate").Subrouter()
25+
clustersRouter.Handle("", addContext(handleRotateCluster)).Methods("POST")
26+
}
27+
28+
// handleRotateCluster responds to POST /api/rotate, beginning the process of rotating a k8s cluster.
29+
// sample body:
30+
// {
31+
// "clusterID": "12345678",
32+
// "maxScaling": 2,
33+
// "rotateMasters": true,
34+
// "rotateWorkers": true,
35+
// "maxDrainRetries": 10,
36+
// "EvictGracePeriod": 60,
37+
// "WaitBetweenRotations": 60,
38+
// "WaitBetweenDrains": 60,
39+
// }
40+
func handleRotateCluster(c *Context, w http.ResponseWriter, r *http.Request) {
41+
42+
rotateClusterRequest, err := model.NewRotateClusterRequestFromReader(r.Body)
43+
if err != nil {
44+
c.Logger.WithError(err).Error("failed to decode request")
45+
w.WriteHeader(http.StatusBadRequest)
46+
return
47+
}
48+
49+
cluster := model.Cluster{
50+
ClusterID: rotateClusterRequest.ClusterID,
51+
MaxScaling: rotateClusterRequest.MaxScaling,
52+
RotateMasters: rotateClusterRequest.RotateMasters,
53+
RotateWorkers: rotateClusterRequest.RotateWorkers,
54+
MaxDrainRetries: rotateClusterRequest.MaxDrainRetries,
55+
EvictGracePeriod: rotateClusterRequest.EvictGracePeriod,
56+
WaitBetweenRotations: rotateClusterRequest.WaitBetweenRotations,
57+
WaitBetweenDrains: rotateClusterRequest.WaitBetweenDrains,
58+
}
59+
60+
rotatorMetada := rotator.RotatorMetadata{}
61+
62+
go rotator.InitRotateCluster(&cluster, &rotatorMetada, c.Logger.WithField("cluster", cluster.ClusterID))
63+
64+
w.Header().Set("Content-Type", "application/json")
65+
w.WriteHeader(http.StatusAccepted)
66+
outputJSON(c, w, cluster)
67+
}

api/common.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package api
2+
3+
import (
4+
"encoding/json"
5+
"io"
6+
)
7+
8+
// outputJSON is a helper method to write the given data as JSON to the given writer.
9+
//
10+
// It only logs an error if one occurs, rather than returning, since there is no point in trying
11+
// to send a new status code back to the client once the body has started sending.
12+
func outputJSON(c *Context, w io.Writer, data interface{}) {
13+
encoder := json.NewEncoder(w)
14+
err := encoder.Encode(data)
15+
if err != nil {
16+
c.Logger.WithError(err).Error("failed to encode result")
17+
}
18+
}

api/context.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package api
2+
3+
import "github.com/sirupsen/logrus"
4+
5+
// Context provides the API with all necessary data and interfaces for responding to requests.
6+
//
7+
// It is cloned before each request, allowing per-request changes such as logger annotations.
8+
type Context struct {
9+
RequestID string
10+
Logger logrus.FieldLogger
11+
}
12+
13+
// Clone creates a shallow copy of context, allowing clones to apply per-request changes.
14+
func (c *Context) Clone() *Context {
15+
return &Context{
16+
Logger: c.Logger,
17+
}
18+
}

api/handler.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package api
2+
3+
import (
4+
"net/http"
5+
6+
"github.com/mattermost/rotator/model"
7+
log "github.com/sirupsen/logrus"
8+
)
9+
10+
type contextHandlerFunc func(c *Context, w http.ResponseWriter, r *http.Request)
11+
12+
type contextHandler struct {
13+
context *Context
14+
handler contextHandlerFunc
15+
}
16+
17+
// ServeHTTP gets the http Request
18+
func (h contextHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
19+
context := h.context.Clone()
20+
context.RequestID = model.NewID()
21+
context.Logger = context.Logger.WithFields(log.Fields{
22+
"path": r.URL.Path,
23+
"request": context.RequestID,
24+
})
25+
26+
h.handler(context, w, r)
27+
}
28+
29+
func newContextHandler(context *Context, handler contextHandlerFunc) *contextHandler {
30+
return &contextHandler{
31+
context: context,
32+
handler: handler,
33+
}
34+
}

0 commit comments

Comments
 (0)