DEV Community

Cover image for Build own Kubernetes - Node commands
Jonatan Ezron
Jonatan Ezron

Posted on • Edited on

Build own Kubernetes - Node commands

In the previous post, we built the node image and wrote the agent, all done by executing commands on the shell with docker build and run, but the whole process should be automated by our program, this is what this article will focus on, on creating node on command, listing them and deleting them.


Before we begin I had some minor fixes in the code: rename the command variables in cmd/pod.go because we are going to use the same name in node, change the agent pods routes to pods and not pod as BP in REST, and moved the GenerateNewID function to pkg/util.go. Some big change was in the Dockerfile, I am relying on the environment that is running the commands to have locally the node image built, so I made a Makefile to build the main program, the agent, and the node image.
Makefile:

default: build

build: build-agent build-node-image
    go build -o bin/main main.go

build-node-image: 
    sudo docker build -t own-kube-node .

build-agent: 
    go build -o bin/agent pkg/agent/agent.go
Enter fullscreen mode Exit fullscreen mode

As a result, we can rely on the binary for the agent is already built when we build out the image so I removed the build from the Dockerfile:

# Dockerfile for node image
FROM ubuntu

WORKDIR /agent

RUN apt-get update && apt-get install -y wget containerd

COPY bin/agent .

EXPOSE 10250

ENTRYPOINT [ "./agent" ]
Enter fullscreen mode Exit fullscreen mode

Let's start with our node functionality, Like the pod, we have related files to node functionality in pkg/node.
We start on pkg/node/constraints.go to define some constraints for our work:

package node

import "github.com/jonatan5524/own-kubernetes/pkg/agent/api"

const (
    NODE_NAME          = "node"
    NODE_IMAGE         = "own-kube-node"
    NODE_PORT          = api.PORT + "/tcp"
    NODE_PORT_HOST_IP  = "0.0.0.0"
    MEMORY_LIMIT       = 2.9e+9 // 2900MB
    CPU_LIMIT          = 2
    NODE_HOST_MIN_PORT = 10250
    NODE_HOST_MAX_PORT = 10300
)
Enter fullscreen mode Exit fullscreen mode

All the constraints will be used in the following files and all the values are taken from the last article when we ran the docker container node.
In the pkg/node.go we define a Node struct:

type Node struct {
    Id   string
    Port string
}
Enter fullscreen mode Exit fullscreen mode

Id - generated id for the node
Port - generate unused port for the node
In this file we also define function for creating a new node using the docker sdk:

func NewNode(cli *client.Client, ctx context.Context) (*Node, error) {
    exists, err := isNodeImageExists(cli, ctx)
    if err != nil {
        return nil, err
    } else if exists == false {
        return nil, fmt.Errorf("node image: %s not exists locally, need to build the image", NODE_IMAGE)
    }

    id := pkg.GenerateNewID(NODE_NAME)
    config := &container.Config{
        Image: NODE_IMAGE,
    }

    hostConfig := &container.HostConfig{
        PortBindings: nat.PortMap{
            nat.Port(fmt.Sprintf("%s/tcp", api.PORT)): []nat.PortBinding{
                {
                    HostIP:   NODE_PORT_HOST_IP,
                    HostPort: "0",
                },
            },
        },
        Resources: container.Resources{
            Memory:    MEMORY_LIMIT,
            CPUShares: CPU_LIMIT,
        },
        Privileged: true,
    }

    _, err = cli.ContainerCreate(ctx, config, hostConfig, nil, nil, id)
    if err != nil {
        return nil, err
    }

    return &Node{Id: id}, nil
}
Enter fullscreen mode Exit fullscreen mode

We first check if the node image exists, then we generate an id, and create some config for the container:

  • node image
  • port forwarding from the port inside the container (the agent API) to outside of the container. The HostPort is 0 because Linux assigns a random open port when a service assigns a 0 port.
  • memory limit
  • CPU limit
  • privileged All the configurations are equivalent to the command we ran in the previous article:
sudo docker run -it --memory="2900MB" --cpus="2" -p 10250:10250 --privileged --name test --rm containerd_test
Enter fullscreen mode Exit fullscreen mode

Then we create the container with the config and generated Id and return a new Node struct.
The isNodeImageExists function implementation:

func isNodeImageExists(cli *client.Client, ctx context.Context) (bool, error) {
    images, err := cli.ImageList(ctx, types.ImageListOptions{})
    if err != nil {
        return false, err
    }

    for _, image := range images {
        if strings.Contains(image.RepoTags[0], NODE_IMAGE) {
            return true, nil
        }
    }

    return false, nil
}
Enter fullscreen mode Exit fullscreen mode

The function List the existing images, go through each one, and check if its RepoTag contains an own-kube-node

Let's move to pkg/node/service.go, the first function we will go through will be the initDockerConnection which initialized the docker client and context:

func initDockerConnection() (*client.Client, context.Context, error) {
    ctx := context.Background()
    cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
    if err != nil {
        return nil, nil, err
    }

    return cli, ctx, nil
}
Enter fullscreen mode Exit fullscreen mode

Next, is the NewNodeAndRun function which creates new node and starts the container:

func NewNodeAndRun() (*Node, error) {
    cli, ctx, err := initDockerConnection()
    if err != nil {
        return nil, err
    }
    defer cli.Close()

    node, err := NewNode(cli, ctx)
    if err != nil {
        return nil, err
    }

    if err := cli.ContainerStart(ctx, node.Id, types.ContainerStartOptions{}); err != nil {
        return nil, err
    }

    log.Printf("node created: %s\n", node.Id)
    log.Printf("starting node\n")

    container, err := cli.ContainerInspect(ctx, node.Id)
    if err != nil {
        return nil, err
    }

    // get linux generated port
    node.Port = container.NetworkSettings.Ports[nat.Port(fmt.Sprintf("%s/tcp", api.PORT))][0].HostPort

    log.Printf("node assign port: %s\n", node.Port)

    return node, nil
}
Enter fullscreen mode Exit fullscreen mode

As you can see in this function, we are initializing the connection to the docker daemon, creating a new node and container, starting the container, and then because we assign port 0 we need to retrieve the assigned port by the system.

The next function is ListRunningNodes:

func ListRunningNodes() ([]string, error) {
    cli, ctx, err := initDockerConnection()
    if err != nil {
        return []string{}, err
    }
    defer cli.Close()

    runningNodes := []string{}

    filter := filters.NewArgs(filters.KeyValuePair{Key: "ancestor", Value: NODE_IMAGE})
    containers, err := cli.ContainerList(ctx, types.ContainerListOptions{Filters: filter})
    if err != nil {
        return runningNodes, err
    }

    for _, container := range containers {
        runningNodes = append(runningNodes, container.Names[0])
    }

    return runningNodes, nil
}
Enter fullscreen mode Exit fullscreen mode

The function initializes the connection, calls the ContainerList method with the ancestor filter which means the image we want to filter, and then runs over each container to create a list of names.

The last function is KillNode function which simply stops the container and removes it:

func KillNode(name string) (string, error) {
    cli, ctx, err := initDockerConnection()
    if err != nil {
        return "", err
    }
    defer cli.Close()

    if err := cli.ContainerStop(ctx, name, nil); err != nil {
        return "", err
    }

    removeOptions := types.ContainerRemoveOptions{
        RemoveVolumes: true,
        Force:         true,
    }

    if err := cli.ContainerRemove(ctx, name, removeOptions); err != nil {
        return "", err
    }

    return name, nil
}
Enter fullscreen mode Exit fullscreen mode

The last part we need to complete is cmd/node.go its structure is the same as cmd/pod.go, I'm not going to add this file here as this article is too long, you can see it in the source code.

we can see that everything works on the terminal:

❯ make
go build -o bin/agent pkg/agent/agent.go
sudo docker build -t own-kube-node .
...
❯ sudo ./bin/main node create
2022/10/12 19:18:26 node created: node-7daf80ec-0a46-4977-952c-66deb81e32f7
2022/10/12 19:18:26 starting node
2022/10/12 19:18:26 node assign port: 49167
❯ sudo docker ps
CONTAINER ID   IMAGE           COMMAND     CREATED         STATUS         PORTS                      NAMES
2066e8e0b933   own-kube-node   "./agent"   5 seconds ago   Up 4 seconds   0.0.0.0:49167->10250/tcp   node-7daf80ec-0a46-4977-952c-66deb81e32f
❯ curl -X POST localhost:49167/pods -H 'Content-Type: application/json' -d '{"name": "redis", "image registry": "docker.io/library/redis:alpine"}'
{"image registry":"docker.io/library/redis:alpine","name":"redis-a1cfce57-5db9-4894-a8cf-c277444e1b0c"}
Enter fullscreen mode Exit fullscreen mode

And that's it, we have a node created by command using an API call that can also create for us a pod inside the node!

In the next article, we will cover some Linux network and get working communication with our pods and nodes.

As always, the source code can be found here, the changes were in pkg/node, Dockerfile, Makefile, cmd/node.

Top comments (0)