Построение внутреннего Developer CLI на Go для управления Kubernetes-окружениями: от идеи до дистрибуции
Зачем команде нужен собственный 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. Подробнее обо мне →