Пользуемся Docker, не выходя из Go
- понедельник, 23 сентября 2024 г. в 00:00:05
Привет, Хабр!
Зовут меня Егор, я программирую на Go и в этой статье хочу поделиться информацией про Docker и Golang.
Скажу сразу, если ты пришел за Docker SDK, пролистывай немного вниз, там все будет.
Кто такой Докер? Как заявляет сама компания, Докер - №1 программа по контейнерезации для разработчиков ПО. В этой статье я не буду объяснять, что это, зачем и почему именно он, для этого есть как офиц. документация, так и хорошие статьи на Хабре. Если кратко - то Докер - это инструмент, который позволяет запускать программы в некой песочнице(контейнере) с целевой ОС - как правило линукс. Самое главное преимущество Докера - это упаковывать все нужное для твоей программы(например: зависимости) в один модуль. И ресурсов эта вещь тратит намного меньше, чем та же виртуальная машина.
Но, хочу отметить насчет Go.
Для нашего любимого Go не надо держать все пакеты языка на целевой машине. Необходимы только системные зависимости.
Еще - при сборке используем два контейнера: build и deploy. Думаю названия говорят сами за себя, но тем не менее: build - собираем наше приложение: компилятор Гошки, его библиотеки, зависимоти и т.д. deploy - содержит самый минимум и деплоит наше приложение, собранное в build - буквально запускает его, запускает исполняемые приложения. По другому - запускает артефакты.
Надеюсь, как делать Dockerfile для приложения на Гошке ты знаешь, если нет - ознакомься с этим, повторюсь - эта статья не про это.
Ну, что-ж, переходим к Docker SDK
Зачем нужен Docker SDK? Самая важная причина - тестирование. Можно собирать метрики, делать редирект трафика, автоподьем контейнеров, анализировать логи в реальном времени, создавать образы и еще кучу всего, что упрощает тестирование. Я постараюсь охватить эти пункты в этой статье.
Docker SDK работает с Docker API. По факту, ты можешь попробовать поработать и с ним, но я не буду останавливаться на этом пункте. Если интересно, то вот ссылка: https://docs.docker.com/reference/api/engine/
Сам Docker SDK в Go - github.com/docker/docker/client.
Давай попробуем скачать образ с Docker Hub не выходя из Go. Я предлагаю скачать hello-world, но ты можешь скачать любой другой, например: docker/welcome-to-docker. Создадим такую программу:
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)
}
Важно: при запуске программы напиши sudo: sudo go run main.go
. Это необходимо потому, что пуллинг образа можно осуществить только от прав супергофера.
После этого запусти ее. Должно быть выведено примерно такое сообщение:
{"status":"Pull complete","progressDetail":{},"id":"c1ec31eb5944"}
{"status":"Digest: sha256:91fb4b041da273d5a3273b6d587d62d518300a6ad268b28628f74997b93171b2"}
{"status":"Status: Downloaded newer image for hello-world:latest"}
Если у тебя статус "{"status":"Status: Image is up to date for hello-world:latest"}", то это значит, что на твоем компуктере уже установлен образ hello-world. Для проверки программы его необходимо удалить командой sudo docker rmi -f hello-world
.
После установки попробуй запустить этот образ. В консоли напиши команду sudo docker run hello-world
. Там же должна появиться строка с "hello from Docker".
Возможно, ты хочешь запуллить свой приватный образ. Для этого надо немного изменить программу:
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)
}
В данном случае мы заходим в аккаунт, чтобы запуллить приватный образ этого же аккаунта.
После получения образа ты обязательно захочешь запустить его. Да не просто запустить, а прямо из Гошки. Давай это сделаем. Я специально залил на Docker Hub свой образ, чтобы ты тоже мог его использовать для тренировки разделения логов на ошибочные и стандартный вывод:
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: https://habr.com/ru/articles/840750/
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
}
После запуска программы у тебя должно появиться два файла: logs.json и error_logs.json. Если в error_logs.json у тебя написано "{"error": "this is an error"}", а в logs.json написано "{"message": "this is a message"}", значит все отработало правильно.
Теперь насчет состояний контейнера. Есть три состояния:
const (
WaitConditionNotRunning WaitCondition = "not-running"
WaitConditionNextExit WaitCondition = "next-exit"
WaitConditionRemoved WaitCondition = "removed"
)
Первое - которое мы использовали - ожидание завершения программы, то есть ожидание состояния, когда контейнер НЕ работает.
Второе - "next-exit" - состояние, когда контейнер вот-вот выйдет.
Третье - "removed" - состояние, когда контейнер удален.
Контейнер можно запустить и в фоновом режиме, для этого надо немного изменить код, убрав ожидание контейнера. По идее, так запускаются большинство контейнеров. Но, в таком случае не получится контролировать логи. Для этого есть другие способы, и я думаю, что более разумные, чем тот, который использовали мы. Хотя если твое приложение работает маленький промежуток времени, можно использовать и способ, который показывал я. Кстати, насчет логирования есть статьи на Хабре, например: https://habr.com/ru/articles/800781/
Также хочу отметить запуск на определенном порту. Это аналог параметра "-p привязываемый порт:изначальный порт". Давай посмотрим:
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
}
После запуска попробуй зайти на "localhost:9990". Там ты увидишь поздравления от Докера.
Для остановки того контейнера, который работает в фоновом режиме, необходимо написать команду "sudo docker kill SuperContainer", но давай сделаем это на Гошке:
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)
}
}
Давай запустим контейнер в фоновом режиме, например "docker/welcome-to-docker": sudo docker run -d -p 9990:80 docker/welcome-to-docker
. Зайдем на localhost:9990 и получим очередные поздравления(почему бы и нет). Читаем список запущенных контейнеров командой docker ps -a
, найдем там "welcome-to-docker" и скопируем его "container id". Теперь запускаем нашу прогу на Гошке: sudo go run main.go
и вводим айди контейнера. Снова заходим на localhost:9990 и грустим, потому что поздравления исчезли.
Что насчет timeout. Это поле означает, что если в течении переданного в timeout времени с момента просьбы контейнера остановится он не остановится, его убьют.
Что, если надо остановить все контейнеры, которые сейчас запущены? Давай попробуем решить эту проблему:
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])
}
}
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
}
После запуска ты увидишь огромное кол-во текста в консоли, если нет - то что-то не так. С этими данными ты можешь работать в будущем
На этом думаю все, это конечно не полный гайд, но я старался познакомить тебя с концепцией Docker SDK, чтобы дальше было легче. Для полного ознакомление с ним есть официальная документация.