Usando Docker sin salir de Go/Sudo Null IT News

¡Hola Habr!

Mi nombre es Egor, programo en Go y en este artículo quiero compartir información sobre Docker y Golang.

Diré de inmediato, si vino por el SDK de Docker, desplácese un poco hacia abajo, todo estará allí.

¿Quién es Docker? Como afirma la propia empresa, Docker es el programa de contenedorización número uno para desarrolladores de software. En este artículo no explicaré qué es, por qué y por qué está ahí como funcionario. documentación, así como buenos artículos sobre Habré. En resumen, Docker es una herramienta que le permite ejecutar programas en un sandbox (contenedor) con un sistema operativo de destino, generalmente Linux. La ventaja más importante de Docker es que incluye todo lo que necesita para su programa (por ejemplo: dependencias) en un solo módulo. Y esto gasta muchos menos recursos que la misma máquina virtual.

Pero quiero señalar sobre Go. Para nuestro querido Go, no es necesario conservar todos los paquetes de idiomas en la máquina de destino. Sólo se necesitan dependencias del sistema.

Además, al construir utilizamos dos contenedores: construir y desplegar. Creo que los nombres hablan por sí solos, pero aún así: compilación: ensamblamos nuestra aplicación: el compilador Goshka, sus bibliotecas, dependencias, etc. implementación: contiene lo mínimo e implementa nuestra aplicación, ensamblada en compilación: literalmente la inicia, lanza ejecutables aplicaciones. De lo contrario, lanza artefactos.

Espero que sepas cómo hacer un Dockerfile para una aplicación en Goshka, si no, léelo, repito, este artículo no trata de eso.

Bueno, bueno, pasemos al SDK de Docker.

SDK de Docker

¿Por qué necesitas Docker SDK? La razón más importante son las pruebas. Puede recopilar métricas, redirigir el tráfico, generar contenedores automáticamente, analizar registros en tiempo real, crear imágenes y muchas otras cosas que simplifican las pruebas. Intentaré cubrir estos puntos en este artículo.

El SDK de Docker funciona con la API de Docker. De hecho, puedes intentar trabajar con él, pero no me detendré en este punto. Si te interesa aquí lo tienes enlace.

Docker SDK v Go en sí: github.com/docker/docker/client.

Descargando imágenes

Intentemos descargar una imagen de Docker Hub sin salir de Go. Sugiero descargar hello-world, pero puedes descargar cualquier otro, por ejemplo: docker/welcome-to-docker. Creemos un programa como este:

package main

import (
	"context"
	"io"
	"log"
	"os"

	"github.com/docker/docker/api/types/image"
	"github.com/docker/docker/client"
)

func main() {
	// Создаем клиента
	cli, err := client.NewClientWithOpts(client.FromEnv)
	// проверяем на ошибку
	if err != nil {
		log.Fatal(err)
	}
	// в конце программы закрываем клиента
	defer cli.Close()

	// получаем образ hello-world. Аналог консольной команды docker pull hello-world
	res, err := cli.ImagePull(context.Background(), "hello-world", image.PullOptions{})
	// проверяем на ошибку
	if err != nil {
		log.Fatal(err)
	}
	// в конце программы закрываем данные, которые получили. Не сам образ, а именно сообщение о его удачном получении
	defer res.Close()

	// из-за того, что полученные данные храняться в io.ReadCloser, их можно вывести в консоль таким образом
	io.Copy(os.Stdout, res)
}

Importante: al iniciar el programa, escriba sudo: sudo go run main.go. Esto es necesario porque la extracción de imágenes solo se puede realizar con derechos de supergopher.

Después de eso, ejecútelo. Debería mostrarse un mensaje similar a este:

{"status":"Pull complete","progressDetail":{},"id":"c1ec31eb5944"}
{"status":"Digest: sha256:91fb4b041da273d5a3273b6d587d62d518300a6ad268b28628f74997b93171b2"}
{"status":"Status: Downloaded newer image for hello-world:latest"}

Si tiene el estado “{“status”:”Estado: La imagen está actualizada para hello-world:latest”}”, entonces esto significa que la imagen de hello-world ya está instalada en su computadora. Para comprobar el programa hay que eliminarlo con el comando sudo docker rmi -f hello-world.

Después de la instalación, intente ejecutar esta imagen. Escribe el comando en la consola. sudo docker run hello-world. Allí también debería aparecer una línea con “hola desde Docker”.

Descargar imágenes privadas súper secretas

Quizás quieras compartir tu imagen privada. Para hacer esto necesitas cambiar un poco el programa:

package main

import (
	"context"
	"encoding/base64"
	"encoding/json"
	"io"
	"log"
	"os"

	"github.com/docker/docker/api/types/image"
	"github.com/docker/docker/api/types/registry"
	"github.com/docker/docker/client"
)

func main() {
	// Создаем клиента
	cli, err := client.NewClientWithOpts(client.FromEnv)
	// проверяем на ошибку
	if err != nil {
		log.Fatal(err)
	}
	// в конце программы закрываем клиента
	defer cli.Close()

	authCfg := registry.AuthConfig{
		Username: "твой username",
		Password: "твой пароль",
	}
	encodedAuth, err := json.Marshal(authCfg)
	// проверяем на ошибку
	if err != nil {
		log.Fatal(err)
	}
	authStr := base64.URLEncoding.EncodeToString(encodedAuth)

	// получаем образ. Аналог консольной команды docker pull hello-world
	res, err := cli.ImagePull(
		context.Background(),
		"твой приватный образ",
		image.PullOptions{RegistryAuth: authStr},
	)
	// проверяем на ошибку
	if err != nil {
		log.Fatal(err)
	}
	// в конце программы закрываем данные, которые получили. Не сам образ, а именно сообщение о его удачном получении
	defer res.Close()

	// из-за того, что полученные данные храняться в io.ReadCloser, их можно вывести в консоль таким образом
	io.Copy(os.Stdout, res)
}

En este caso, iniciamos sesión en la cuenta para extraer una imagen privada de la misma cuenta.

Lanzamos mi imagen

Una vez que reciba la imagen, definitivamente querrá ejecutarla. Y no sólo ejecutarlo, sino directamente desde Goshka. Hagamos esto. Subí específicamente mi imagen a Docker Hub para que también puedas usarla para practicar la separación de registros en registros de errores y salida estándar:

package main

import (
	"context"
	"fmt"
	"io"
	"log"
	"log/slog"
	"os"

	"github.com/docker/docker/api/types/container"
	"github.com/docker/docker/api/types/image"
	"github.com/docker/docker/client"
	"github.com/docker/docker/pkg/stdcopy"
)

func main() {
	// Создаем клиента
	cli, err := client.NewClientWithOpts(client.FromEnv)
	// проверяем на ошибку
	if err != nil {
		log.Fatal(err)
	}
	// в конце программы закрываем клиента
	defer cli.Close()

	logger := slog.New(
		slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}),
	)

	// создаем контекст
	ctx := context.Background()

	if err = pullImage("egorklimenko/test-logs", cli, &ctx, logger); err != nil {
		log.Fatal(err)
	}

	if err = buildImage("egorklimenko/test-logs", cli, &ctx, logger); err != nil {
		log.Fatal(err)
	}
}

func pullImage(
	imageName string,
	cli *client.Client,
	ctx *context.Context,
	logger *slog.Logger,
) error {
	// объявляем константу op, где будет храниться название действия для легкого определения источника ошибки
	const op = "pullImage"

	logger = logger.With(slog.String("op", op))

	// получаем мой образ. Аналог консольной команды docker pull egorklimenko/test-logs
	res, err := cli.ImagePull(*ctx, imageName, image.PullOptions{})
	// проверяем на ошибку
	if err != nil {
		return fmt.Errorf("%s: %w", op, err)
	}
	// в конце программы закрываем данные, которые получили. Не сам образ, а именно сообщение о его удачном получении
	defer res.Close()

	// из-за того, что полученные данные храняться в io.ReadCloser, их можно вывести в консоль таким образом
	io.Copy(os.Stdout, res)
	return nil
}

func buildImage(
	imageName string,
	cli *client.Client,
	ctx *context.Context,
	logger *slog.Logger,
) error {
	// объявляем константу op, где будет храниться название действия для легкого определения источника ошибки
	const op = "buildImage"

	logger = logger.With(slog.String("op", op))

	// создаем контейнер с названием SuperContainer
	resp, err := cli.ContainerCreate(*ctx, &container.Config{
		Image: imageName,
		// tty - выключаем использование интерактивной консоли внутри контейнера
		Tty: false,
	}, nil, nil, nil, "SuperContainer")
	// проверяем на ошибку
	if err != nil {
		return fmt.Errorf("%s: %w", op, err)
	}

	// стартуем наш контейнер
	if err = cli.ContainerStart(*ctx, resp.ID, container.StartOptions{}); err != nil {
		return fmt.Errorf("%s: %w", op, err)
	}

	// важная строка. Эта функция ассинхронная. Здесь мы ждем окончания работы контейнера. Если точнее - то ждем состояния контейнера, когда он НЕ работает. Есть также и другие состояния, но об этом под кодом
	statusCh, errCh := cli.ContainerWait(*ctx, resp.ID, container.WaitConditionNotRunning)

	// если хочешь, можешь почитать про select - у меня есть статья на тему параллелизма в Go: 
	select {
	// как только появится ошибка - мы ее поймаем
	case err := <-errCh:
		if err != nil {
			return fmt.Errorf("%s: %w", op, err)
		}
	case status := <-statusCh:
		// Если контейнер стартанет без ошибки - выведем статус код
		fmt.Printf("Статус код: %d\n", status.StatusCode)
	}

	// получаем логи. Я специально использую свой образ, чтобы получить логи. Мой образ пишет в Stdout сообщение в виде json "this is a message", а в Stderr пишет "this is an error"
	out, err := cli.ContainerLogs(*ctx, resp.ID, container.LogsOptions{
		ShowStdout: true, // включаем получение стандартного вывода
		ShowStderr: true, // включаем получение ошибок
	})
	// проверяем на ошибку
	if err != nil {
		return fmt.Errorf("%s: %w", op, err)
	}
	// в конце функции закрываем логи
	defer out.Close()

	// Создаем файлы для логов
	stdoutLogFile, err := os.Create("logs.json")
	if err != nil {
		return fmt.Errorf("%s: %w", op, err)
	}
	defer stdoutLogFile.Close()

	errorLogFile, err := os.Create("error_logs.json")
	if err != nil {
		return fmt.Errorf("%s: %w", op, err)
	}
	defer errorLogFile.Close()

	// Разделяем стандартный вывод и вывод ошибок
	stdcopy.StdCopy(stdoutLogFile, errorLogFile, out)

	return nil
}

Después de iniciar el programa, debería tener dos archivos: logs.json y error_logs.json. Si tiene “{“error”: “esto es un error”}” escrito en error_logs.json y “{“message”: “esto es un mensaje”}” escrito en logs.json, entonces todo funcionó correctamente.

Estados del contenedor

Ahora sobre los estados del contenedor. Hay tres estados:

const (
	WaitConditionNotRunning WaitCondition = "not-running"
	WaitConditionNextExit   WaitCondition = "next-exit"
	WaitConditionRemoved    WaitCondition = "removed"
)

El primero, que utilizamos, es esperar a que se complete el programa, es decir, esperar un estado en el que el contenedor NO se esté ejecutando.

El segundo es la “próxima salida”, el estado en el que el contenedor está a punto de salir.
El tercero es “eliminado”: el estado en el que se retira el contenedor.

Recibimos felicitaciones ejecutándolas en segundo plano.

El contenedor también se puede iniciar en segundo plano; para hacer esto, debe cambiar ligeramente el código eliminando la espera del contenedor. En teoría, así es como se lanzan la mayoría de los contenedores. Pero, en este caso, no será posible controlar los registros. Hay otras formas de hacer esto y creo que son más razonables que la que usamos. Aunque si tu aplicación funciona por un corto período de tiempo, puedes usar el método que te mostré. Por cierto, en Habré hay artículos sobre la tala, por ejemplo.

También quiero señalar el lanzamiento en un puerto específico. Esto es análogo al parámetro “-p puerto de enlace: puerto inicial”. Vamos a ver:

package main

import (
	"context"
	"fmt"
	"io"
	"log"
	"log/slog"
	"os"

	"github.com/docker/docker/api/types/container"
	"github.com/docker/docker/api/types/image"
	"github.com/docker/docker/client"
	"github.com/docker/go-connections/nat"
)

func main() {
	// Создаем клиента
	cli, err := client.NewClientWithOpts(client.FromEnv)
	if err != nil {
		// проверяем на ошибку
		log.Fatal(err)
	}
	// в конце программы закрываем клиента
	defer cli.Close()

	logger := slog.New(
		slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}),
	)

	ctx := context.Background()

	if err = pullImage("docker/welcome-to-docker", cli, &ctx, logger); err != nil {
		log.Fatal(err)
	}

	if err = buildImage("docker/welcome-to-docker", cli, &ctx, logger); err != nil {
		log.Fatal(err)
	}
}

func pullImage(
	imageName string,
	cli *client.Client,
	ctx *context.Context,
	logger *slog.Logger,
) error {
	// объявляем константу op, где будет храниться название действия для легкого определения источника ошибки
	const op = "pullImage"

	logger = logger.With(slog.String("op", op))

	// получаем мой образ. Аналог консольной команды docker pull egorklimenko/test-logs
	res, err := cli.ImagePull(*ctx, imageName, image.PullOptions{})
	// проверяем на ошибку
	if err != nil {
		return fmt.Errorf("%s: %w", op, err)
	}
	// в конце программы закрываем данные, которые получили. Не сам образ, а именно сообщение о его удачном получении
	defer res.Close()

	// из-за того, что полученные данные храняться в io.ReadCloser, их можно вывести в консоль таким образом
	io.Copy(os.Stdout, res)
	return nil
}

func buildImage(
	imageName string,
	cli *client.Client,
	ctx *context.Context,
	logger *slog.Logger,
) error {
	// объявляем константу op, где будет храниться название действия для легкого определения источника ошибки
	const op = "buildImage"

	logger = logger.With(slog.String("op", op))

	// создаем hostConfig, где будем привязывать порт 9990 к 0.0.0.0
	hostConfig := &container.HostConfig{
		PortBindings: nat.PortMap{
			// 80 - изначальный порт welcome-to-docker
			"80/tcp": ()nat.PortBinding{
				{
					HostIP:   "0.0.0.0",
					HostPort: "9990", // новый порт
				},
			},
		},
	}

	// создаем контейнер с названием SuperContainer
	resp, err := cli.ContainerCreate(*ctx, &container.Config{
		Image: imageName,
		// tty - выключаем использование интерактивной консоли внутри контейнера
		Tty: false,
		// Указываем hostConfig
	}, hostConfig,
		nil, nil, "SuperContainer")
	// проверяем на ошибку
	if err != nil {
		return fmt.Errorf("%s: %w", op, err)
	}

	// стартуем наш контейнер
	if err = cli.ContainerStart(*ctx, resp.ID, container.StartOptions{}); err != nil {
		return fmt.Errorf("%s: %w", op, err)
	}

	return nil
}

Después del lanzamiento, intente ir a “localhost:9990”. Allí verás las felicitaciones de Docker.

basta de felicitaciones

Para detener el contenedor que se ejecuta en segundo plano, debes escribir el comando “sudo docker kill SuperContainer”, pero hagámoslo en Goshka:

package main

import (
	"context"
	"fmt"
	"log"

	"github.com/docker/docker/api/types/container"
	"github.com/docker/docker/client"
)

func main() {
	// Создаем клиента
	cli, err := client.NewClientWithOpts(client.FromEnv)
	if err != nil {
		// проверяем на ошибку
		log.Fatal(err)
	}
	// в конце программы закрываем клиента
	defer cli.Close()

	// получаем containerID
	var containerID string
	fmt.Scan(&containerID)

	// Останавливаем контейнер. Есть одно интересное поле конфигурации остановки(StopOptions) - timeout. Про него расскажу под кодом
	if err = cli.ContainerStop(context.Background(), containerID, container.StopOptions{}); err != nil {
		log.Fatal(err)
	}
}

Ejecutemos un contenedor en segundo plano, como “docker/welcome-to-docker”: sudo docker run -d -p 9990:80 docker/welcome-to-docker. Vayamos a localhost:9990 y recibamos felicitaciones periódicas (por qué no). Leemos la lista de contenedores en ejecución con el comando docker ps -abusque “welcome-to-docker” allí y copie su “id de contenedor”. Ahora lanzamos nuestro programa en Goshka: sudo go run main.go e ingrese el ID del contenedor. Volvemos a localhost:9990 y estamos tristes porque las felicitaciones han desaparecido.

Acerca del tiempo de espera

¿Qué pasa con el tiempo de espera? Este campo significa que si el contenedor no se detiene dentro del tiempo transcurrido desde el momento en que se le solicita que se detenga, se eliminará.

Paramos todos los contenedores necesarios e innecesarios

¿Qué sucede si necesita detener todos los contenedores que se están ejecutando actualmente? Intentemos resolver este problema:

package main

import (
	"context"
	"fmt"
	"log"

	"github.com/docker/docker/api/types/container"
	"github.com/docker/docker/client"
)

func main() {
	// Создаем клиента
	cli, err := client.NewClientWithOpts(client.FromEnv)
	// проверяем на ошибку
	if err != nil {
		log.Fatal(err)
	}
	// в конце программы закрываем клиента
	defer cli.Close()

	// получаем список всех запущенных на данный момент контейнеров
	runningContainer, err := cli.ContainerList(context.Background(), container.ListOptions{})
	if err != nil {
		log.Fatal(err)
	}

	for _, c := range runningContainer {
		fmt.Printf("сейчас попрошу остановиться контейнер %s\n", c.ID(:10))
		// Останавливаем контейнер
		if err = cli.ContainerStop(context.Background(), c.ID, container.StopOptions{}); err != nil {
			log.Fatal(err)
		}
		fmt.Printf("контейнер %s больше не бежит\n", c.ID(:10))
	}
}

Estadisticas, estadisticas

package main

import (
	"context"
	"fmt"
	"io"
	"log"
	"log/slog"
	"os"

	"github.com/docker/docker/api/types/container"
	"github.com/docker/docker/api/types/image"
	"github.com/docker/docker/client"
	"github.com/docker/go-connections/nat"
)

func main() {
	// Создаем клиента
	cli, err := client.NewClientWithOpts(client.FromEnv)
	if err != nil {
		// проверяем на ошибку
		log.Fatal(err)
	}
	// в конце программы закрываем клиента
	defer cli.Close()

	logger := slog.New(
		slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}),
	)

	ctx := context.Background()

	if err = pullImage("docker/welcome-to-docker", cli, &ctx, logger); err != nil {
		log.Fatal(err)
	}
	containerID, err := buildImage("docker/welcome-to-docker", cli, &ctx, logger)
	if err != nil {
		log.Fatal(err)
	}

	if err = getStats(&ctx, cli, containerID); err != nil {
		log.Fatal(err)
	}
}

func pullImage(
	imageName string,
	cli *client.Client,
	ctx *context.Context,
	logger *slog.Logger,
) error {
	// объявляем константу op, где будет храниться название действия для легкого определения источника ошибки
	const op = "pullImage"

	logger = logger.With(slog.String("op", op))

	// получаем мой образ. Аналог консольной команды docker pull egorklimenko/test-logs
	res, err := cli.ImagePull(*ctx, imageName, image.PullOptions{})
	// проверяем на ошибку
	if err != nil {
		return fmt.Errorf("%s: %w", op, err)
	}
	// в конце программы закрываем данные, которые получили. Не сам образ, а именно сообщение о его удачном получении
	defer res.Close()

	// из-за того, что полученные данные храняться в io.ReadCloser, их можно вывести в консоль таким образом
	io.Copy(os.Stdout, res)
	return nil
}

func buildImage(
	imageName string,
	cli *client.Client,
	ctx *context.Context,
	logger *slog.Logger,
) (containerID string, err error) {
	// объявляем константу op, где будет храниться название действия для легкого определения источника ошибки
	const op = "buildImage"

	logger = logger.With(slog.String("op", op))

	// создаем hostConfig, где будем привязывать порт 9990 к 0.0.0.0
	hostConfig := &container.HostConfig{
		PortBindings: nat.PortMap{
			// 80 - изначальный порт welcome-to-docker
			"80/tcp": ()nat.PortBinding{
				{
					HostIP:   "0.0.0.0",
					HostPort: "9990", // новый порт
				},
			},
		},
	}

	// создаем контейнер с названием SuperContainer
	resp, err := cli.ContainerCreate(*ctx, &container.Config{
		Image: imageName,
		// tty - выключаем использование интерактивной консоли внутри контейнера
		Tty: false,
		// Указываем hostConfig
	}, hostConfig,
		nil, nil, "SuperContainer")
	// проверяем на ошибку
	if err != nil {
		return "", fmt.Errorf("%s: %w", op, err)
	}

	// стартуем наш контейнер
	if err = cli.ContainerStart(*ctx, resp.ID, container.StartOptions{}); err != nil {
		return "", fmt.Errorf("%s: %w", op, err)
	}

	return resp.ID, nil
}

func getStats(ctx *context.Context, cli *client.Client, containerID string) error {
	const op = "getStats"

	// получаем статистику
	stats, err := cli.ContainerStats(*ctx, containerID, false)
	if err != nil {
		return fmt.Errorf("%s: %w", op, err)
	}
	defer stats.Body.Close()

	io.Copy(os.Stdout, stats.Body)

	return nil
}

Después del inicio, verá una gran cantidad de texto en la consola; si no, entonces algo anda mal; Podrás trabajar con estos datos en el futuro.

Conclusión

Creo que eso es todo, esta no es, por supuesto, una guía completa, pero intenté presentarles el concepto de Docker SDK para facilitar las cosas. Para una descripción completa del mismo, hay documentación oficial.

Publicaciones Similares

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *