DevOps

Построение внутреннего Developer CLI на Go для управления Kubernetes-окружениями: от идеи до дистрибуции

Ruslan Ismailov Опубликовано 18 мин чтения
П

Зачем команде нужен собственный CLI вместо набора скриптов

Каждая растущая платформенная команда рано или поздно оказывается в одной и той же ситуации: репозиторий scripts/ превращается в хаос из Bash-файлов, Makefile-целей и Python-однострочников. Новый разработчик тратит день только на то, чтобы понять, какой скрипт запускать в каком порядке. CI/CD-пайплайны дублируют логику, которая уже есть локально. Один инженер обновляет скрипт и ломает окружение коллеги.

Типичные боли, которые знакомы DevOps-инженерам и платформенным командам:

  • Нет единой точки входа для операций с Kubernetes-окружениями
  • Скрипты не документированы, флаги передаются переменными окружения в произвольном порядке
  • Невозможно протестировать скрипты без реального кластера
  • Версионирование отсутствует — непонятно, какая версия скрипта используется в production
  • Автодополнение в терминале недоступно, команды приходится помнить наизусть

Решение — собственный внутренний CLI-инструмент на Go. Один бинарник, строгий интерфейс, встроенная документация, тестируемость и простая дистрибуция. Именно этот путь прошли команды в Spotify, Shopify и Netflix, создав внутренние PaaS CLI поверх Kubernetes. В 2026 году Go остаётся лучшим выбором для подобных инструментов благодаря скорости компиляции, статической типизации и нативной поддержке кросс-компиляции.

Выбор стека: Cobra, client-go и kubeconfig

Для построения Go CLI Kubernetes-инструмента используется проверенный временем стек:

  • Cobra — фреймворк для построения CLI с вложенными командами, флагами и автодополнением. Именно на Cobra построен сам kubectl.
  • client-go — официальный Go-клиент Kubernetes API. Позволяет программно создавать, читать и удалять любые ресурсы кластера.
  • viper — библиотека для управления конфигурацией, совместимая с Cobra. Читает файлы YAML, переменные окружения и флаги CLI.
  • kubeconfig — стандартный механизм аутентификации, который поддерживает несколько контекстов и кластеров.

Инициализация проекта:

mkdir devctl && cd devctl
go mod init github.com/yourorg/devctl
go get github.com/spf13/cobra@latest
go get github.com/spf13/viper@latest
go get k8s.io/client-go@latest
go get k8s.io/api@latest
go get k8s.io/apimachinery@latest

Базовая структура проекта:

devctl/
├── cmd/
│   ├── root.go        # корневая команда, инициализация Cobra
│   ├── namespace.go   # команды для работы с namespace
│   ├── deploy.go      # деплой приложений
│   ├── logs.go        # просмотр логов
│   └── version.go     # версия инструмента
├── internal/
│   ├── k8s/
│   │   └── client.go  # инициализация client-go
│   └── config/
│       └── config.go  # загрузка конфигурации
├── main.go
└── go.mod

Корневая команда в cmd/root.go:

package cmd

import (
    "fmt"
    "os"

    "github.com/spf13/cobra"
    "github.com/spf13/viper"
)

var cfgFile string

var rootCmd = &cobra.Command{
    Use:   "devctl",
    Short: "Внутренний CLI для управления Kubernetes-окружениями",
    Long: `devctl — инструмент платформенной команды для создания namespace,
деплоя приложений и работы с логами в Kubernetes-кластерах.`,
}

func Execute() {
    if err := rootCmd.Execute(); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}

func init() {
    cobra.OnInitialize(initConfig)
    rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "конфиг (по умолчанию $HOME/.devctl.yaml)")
    rootCmd.PersistentFlags().String("context", "", "kubeconfig контекст")
    viper.BindPFlag("context", rootCmd.PersistentFlags().Lookup("context"))
}

func initConfig() {
    if cfgFile != "" {
        viper.SetConfigFile(cfgFile)
    } else {
        home, _ := os.UserHomeDir()
        viper.AddConfigPath(home)
        viper.SetConfigName(".devctl")
    }
    viper.AutomaticEnv()
    viper.ReadInConfig()
}

Реализация команд: namespace, деплой, логи

Инициализация client-go с поддержкой kubeconfig

package k8s

import (
    "k8s.io/client-go/kubernetes"
    "k8s.io/client-go/tools/clientcmd"
)

func NewClient(kubeContext string) (*kubernetes.Clientset, error) {
    loadingRules := clientcmd.NewDefaultClientConfigLoadingRules()
    configOverrides := &clientcmd.ConfigOverrides{}
    if kubeContext != "" {
        configOverrides.CurrentContext = kubeContext
    }
    config, err := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(
        loadingRules,
        configOverrides,
    ).ClientConfig()
    if err != nil {
        return nil, err
    }
    return kubernetes.NewForConfig(config)
}

Команда создания namespace

Команда devctl namespace create <name> создаёт изолированное окружение разработчика:

package cmd

import (
    "context"
    "fmt"

    "github.com/spf13/cobra"
    "github.com/spf13/viper"
    corev1 "k8s.io/api/core/v1"
    metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    "github.com/yourorg/devctl/internal/k8s"
)

var namespaceCmd = &cobra.Command{
    Use:   "namespace",
    Short: "Операции с namespace",
}

var namespaceCreateCmd = &cobra.Command{
    Use:   "create [name]",
    Short: "Создать namespace для окружения разработчика",
    Args:  cobra.ExactArgs(1),
    RunE: func(cmd *cobra.Command, args []string) error {
        name := args[0]
        kubeCtx := viper.GetString("context")

        client, err := k8s.NewClient(kubeCtx)
        if err != nil {
            return fmt.Errorf("не удалось подключиться к кластеру: %w", err)
        }

        ns := &corev1.Namespace{
            ObjectMeta: metav1.ObjectMeta{
                Name: name,
                Labels: map[string]string{
                    "managed-by": "devctl",
                    "environment": "dev",
                },
            },
        }

        _, err = client.CoreV1().Namespaces().Create(
            context.Background(), ns, metav1.CreateOptions{},
        )
        if err != nil {
            return fmt.Errorf("ошибка создания namespace: %w", err)
        }

        fmt.Printf("✓ Namespace '%s' создан\n", name)
        return nil
    },
}

func init() {
    namespaceCmd.AddCommand(namespaceCreateCmd)
    rootCmd.AddCommand(namespaceCmd)
}

Команда деплоя приложения

Команда devctl deploy --image=myapp:v1.2.3 --namespace=dev-alice создаёт или обновляет Deployment:

var deployCmd = &cobra.Command{
    Use:   "deploy",
    Short: "Задеплоить приложение в Kubernetes",
    RunE: func(cmd *cobra.Command, args []string) error {
        image, _ := cmd.Flags().GetString("image")
        ns, _ := cmd.Flags().GetString("namespace")
        replicas, _ := cmd.Flags().GetInt32("replicas")
        kubeCtx := viper.GetString("context")

        client, err := k8s.NewClient(kubeCtx)
        if err != nil {
            return err
        }

        dep := buildDeployment(image, ns, replicas)
        _, err = client.AppsV1().Deployments(ns).Apply(
            context.Background(), dep, metav1.ApplyOptions{FieldManager: "devctl"},
        )
        if err != nil {
            return fmt.Errorf("деплой не удался: %w", err)
        }

        fmt.Printf("✓ Приложение %s задеплоено в %s\n", image, ns)
        return nil
    },
}

func init() {
    deployCmd.Flags().String("image", "", "Docker-образ для деплоя (обязательно)")
    deployCmd.Flags().String("namespace", "default", "Целевой namespace")
    deployCmd.Flags().Int32("replicas", 1, "Количество реплик")
    deployCmd.MarkFlagRequired("image")
    rootCmd.AddCommand(deployCmd)
}

Просмотр логов с стримингом

Команда devctl logs --namespace=dev-alice --app=myapp --follow реализует стриминг логов через client-go:

var logsCmd = &cobra.Command{
    Use:   "logs",
    Short: "Просмотр логов приложения",
    RunE: func(cmd *cobra.Command, args []string) error {
        ns, _ := cmd.Flags().GetString("namespace")
        app, _ := cmd.Flags().GetString("app")
        follow, _ := cmd.Flags().GetBool("follow")
        kubeCtx := viper.GetString("context")

        client, err := k8s.NewClient(kubeCtx)
        if err != nil {
            return err
        }

        pods, err := client.CoreV1().Pods(ns).List(context.Background(), metav1.ListOptions{
            LabelSelector: fmt.Sprintf("app=%s", app),
        })
        if err != nil || len(pods.Items) == 0 {
            return fmt.Errorf("pods для приложения '%s' не найдены", app)
        }

        podName := pods.Items[0].Name
        req := client.CoreV1().Pods(ns).GetLogs(podName, &corev1.PodLogOptions{
            Follow: follow,
        })
        stream, err := req.Stream(context.Background())
        if err != nil {
            return err
        }
        defer stream.Close()
        _, err = io.Copy(os.Stdout, stream)
        return err
    },
}

Интеграция с CI/CD: использование CLI в GitHub Actions

Один из главных аргументов в пользу Go CLI Kubernetes-инструмента — переиспользование одного бинарника и локально, и в CI/CD-пайплайне. Это исключает класс ошибок «у меня работает».

Пример GitHub Actions workflow с использованием devctl:

name: Deploy to Dev

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Download devctl
        run: |
          curl -sSL https://github.com/yourorg/devctl/releases/latest/download/devctl-linux-amd64 \
            -o /usr/local/bin/devctl
          chmod +x /usr/local/bin/devctl

      - name: Configure kubeconfig
        run: |
          echo "${{ secrets.KUBECONFIG_BASE64 }}" | base64 -d > $HOME/.kube/config

      - name: Create namespace if not exists
        run: devctl namespace create dev-${{ github.actor }} --context=dev-cluster

      - name: Deploy application
        run: |
          devctl deploy \
            --image=ghcr.io/yourorg/myapp:${{ github.sha }} \
            --namespace=dev-${{ github.actor }} \
            --context=dev-cluster

      - name: Verify deployment
        run: devctl status --namespace=dev-${{ github.actor }} --wait=60s

Такая интеграция CI/CD обеспечивает идемпотентность операций и единую точку изменений: чтобы изменить логику деплоя, достаточно обновить CLI, а не все пайплайны одновременно.

Работа с секретами и конфигурацией окружений

Для production-grade внутреннего PaaS CLI необходимо безопасно управлять секретами. Рекомендуемый подход — читать секреты из переменных окружения или Kubernetes Secrets, никогда не сохраняя их в файлах конфигурации.

Пример интеграции с Kubernetes Secrets через client-go:

func GetSecret(client *kubernetes.Clientset, ns, name string) (map[string][]byte, error) {
    secret, err := client.CoreV1().Secrets(ns).Get(
        context.Background(), name, metav1.GetOptions{},
    )
    if err != nil {
        return nil, fmt.Errorf("секрет '%s' не найден в namespace '%s': %w", name, ns, err)
    }
    return secret.Data, nil
}

Для конфигурации окружений используйте файл ~/.devctl.yaml с профилями:

default_context: dev-cluster
environments:
  dev:
    context: dev-cluster
    registry: ghcr.io/yourorg
    default_namespace: dev
  staging:
    context: staging-cluster
    registry: ghcr.io/yourorg
    default_namespace: staging

Viper автоматически подхватывает переменные окружения с префиксом DEVCTL_, например DEVCTL_CONTEXT=staging-cluster, что удобно для CI/CD без изменения файла конфигурации.

Сборка и дистрибуция через GitHub Releases и Docker-образ

Дистрибуция — ключевой аспект developer tooling. Разработчик должен установить инструмент одной командой. Рекомендуется два канала: бинарники через GitHub Releases и Docker-образ для CI/CD без установки.

GoReleaser для автоматической сборки

Файл .goreleaser.yaml:

project_name: devctl

builds:
  - env:
      - CGO_ENABLED=0
    goos:
      - linux
      - darwin
      - windows
    goarch:
      - amd64
      - arm64
    ldflags:
      - -s -w
      - -X github.com/yourorg/devctl/cmd.Version={{.Version}}
      - -X github.com/yourorg/devctl/cmd.CommitHash={{.Commit}}
      - -X github.com/yourorg/devctl/cmd.BuildDate={{.Date}}

archives:
  - format: tar.gz
    name_template: "{{ .ProjectName }}-{{ .Os }}-{{ .Arch }}"

checksum:
  name_template: checksums.txt

dockers:
  - image_templates:
      - ghcr.io/yourorg/devctl:{{ .Tag }}
      - ghcr.io/yourorg/devctl:latest
    dockerfile: Dockerfile.goreleaser

Минималистичный Dockerfile.goreleaser для образа на основе scratch:

FROM scratch
COPY devctl /devctl
ENTRYPOINT ["/devctl"]

GitHub Actions для релиза:

name: Release
on:
  push:
    tags: ['v*']
jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: actions/setup-go@v5
        with:
          go-version: '1.22'
      - uses: goreleaser/goreleaser-action@v5
        with:
          version: latest
          args: release --clean
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

После настройки разработчик устанавливает инструмент одной командой:

curl -sSL https://github.com/yourorg/devctl/releases/latest/download/devctl-darwin-arm64.tar.gz | tar -xz
mv devctl /usr/local/bin/

Тестирование CLI-команд: моки Kubernetes API

Тестирование — слабое место большинства внутренних инструментов. client-go предоставляет пакет k8s.io/client-go/kubernetes/fake для создания фейкового клиента без реального кластера:

package cmd_test

import (
    "testing"
    "context"

    corev1 "k8s.io/api/core/v1"
    metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    "k8s.io/client-go/kubernetes/fake"
)

func TestNamespaceCreate(t *testing.T) {
    fakeClient := fake.NewSimpleClientset()

    ns := &corev1.Namespace{
        ObjectMeta: metav1.ObjectMeta{
            Name: "dev-testuser",
            Labels: map[string]string{
                "managed-by": "devctl",
            },
        },
    }
    _, err := fakeClient.CoreV1().Namespaces().Create(
        context.Background(), ns, metav1.CreateOptions{},
    )
    if err != nil {
        t.Fatalf("ожидалось успешное создание, получена ошибка: %v", err)
    }

    got, err := fakeClient.CoreV1().Namespaces().Get(
        context.Background(), "dev-testuser", metav1.GetOptions{},
    )
    if err != nil {
        t.Fatalf("namespace не найден после создания: %v", err)
    }
    if got.Labels["managed-by"] != "devctl" {
        t.Errorf("неверный лейбл: %s", got.Labels["managed-by"])
    }
}

Для тестирования самих Cobra-команд используйте паттерн инъекции зависимостей: принимайте kubernetes.Interface вместо конкретного типа, что позволяет подставлять фейковый клиент в тестах. Запускайте тесты командой:

go test ./... -v -race -count=1

Best practices: обратная совместимость, версионирование, документация

Версионирование и встроенная документация

Каждый релиз должен иметь семантическую версию, доступную через devctl version:

var versionCmd = &cobra.Command{
    Use:   "version",
    Short: "Версия devctl",
    Run: func(cmd *cobra.Command, args []string) {
        fmt.Printf("devctl версия %s (commit: %s, собран: %s)\n",
            Version, CommitHash, BuildDate)
    },
}

Ключевые принципы

  • Обратная совместимость флагов: никогда не удаляйте существующие флаги, только помечайте их как deprecated через cmd.Flags().MarkDeprecated(). Это критично, когда CLI используется в десятках CI/CD-пайплайнов.
  • Явные коды выхода: используйте os.Exit(1) только в main.go, во всех командах возвращайте ошибку через RunE. Это упрощает тестирование и интеграцию.
  • Автодополнение: Cobra генерирует скрипты автодополнения для bash, zsh и fish командой devctl completion zsh > ~/.zsh/_devctl.
  • Structured logging: используйте --output=json флаг для машиночитаемого вывода в CI/CD и стандартный текстовый формат для разработчика.
  • Changelog и миграции: документируйте breaking changes в CHANGELOG.md при каждом минорном релизе. Рассмотрите команду devctl migrate для автоматической миграции конфигурационных файлов.
  • Мониторинг использования: добавьте анонимную телеметрию с явным opt-out, чтобы понимать, какие команды используются чаще всего и где пользователи сталкиваются с ошибками.

Итог

Построение внутреннего Developer CLI на Go — это инвестиция, которая окупается быстро. Один бинарник заменяет хаос скриптов, ускоряет онбординг новых разработчиков, делает CI/CD-пайплайны воспроизводимыми и тестируемыми. Связка Cobra + client-go + viper покрывает 90% потребностей платформенной команды при работе с Kubernetes-окружениями.

Начните с трёх команд — namespace create, deploy и logs — и итеративно добавляйте функциональность по запросам команды. Используйте GoReleaser для автоматизации дистрибуции и фейковый client-go в тестах, чтобы не зависеть от реального кластера в CI/CD. Следуйте принципам обратной совместимости с первого дня, и ваш внутренний PaaS CLI станет инструментом, которым команда будет гордиться.

Технологии

Теги

Руслан Исмаилов

Senior Web / Backend разработчик. Senior web/backend разработчик с 9-летним опытом. Стек: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, микросервисы, CI/CD. Подробнее обо мне →