Как правильно тестировать образа Docker

Как правильно тестировать образа Docker

В процессе создания программного обеспечения тестирование является одним из ключевых моментов. Но не все компоненты имеют очевидные, известные и понятные пути тестирования. Например, образы Docker либо вообще не тестируются, либо тестируются только на исполняемость. В этой статье я покажу вам, как протестировать образ Docker, чтобы убедиться, что он работает на 100% по назначению.

Что есть тестирование

Юнит‑тес­тирова­ние (или модульное тестирование) — это процесс разработки программного обеспечения, в процессе которого проверяется функциональность отдельных модулей исходного кода. Этот тест обычно используется в самой разработке программного обеспечения, но сложно сразу представить модульное тестирование образа Docker.

Давайте посмотрим на простейший Dockerfile:

FROM busybox:1.32.1
RUN echo 'Hello, World!' > /test.txt

Здесь нам нужно выполнить только одно действие – добавить в файл со строчкой Hello, World!  в файл /test.txt.

Как мы можем убедиться, что достигаем желаемого результата? Вы можете запустить собранный контейнер и увидеть, что, с одной стороны, необходимый файл присутствует, а с другой стороны, его содержимое соответствует ожиданиям.$ docker build -t test .

[+] Building 7.7s (6/6) FINISHED
$ docker run --rm test ls -lha /test.txt
-rw-r--r-- 1 root root 14 Feb 20 19:26 /test.txt
$ docker run --rm test cat /test.txt
Hello, World!

В общем, это довольно неудобно.На счастье существует фреймворк terratest.Его можно использовать для написания тестов на Golang для Dockersdub-compose) так же,как и для обычного кода!

Взгля­нем на прог­рам­мную реали­зацию дан­ного тес­та:

package docker_test

 
import (
"testing"
 
"github.com/gruntwork-io/terratest/modules/docker"
"github.com/stretchr/testify/assert"
)
 
func TestDockerImage(t *testing.T) {
// Определяем название образа для тестирования
tag := "test"
buildOptions := &docker.BuildOptions{
Tags: []string{tag},
}
// Собираем образ из Dockerfile’а
docker.Build(t, "../", buildOptions)
// Фактически выставляем как опции запуск контейнера со следующими командами
// Команда, которая вернет 'exists', если файл существует
eOpts := &docker.RunOptions{Command: []string{"sh", "-c", "[ -f /test.txt ] && echo exists"}}
// Команда, которая вернет содержимое файла
cOpts := &docker.RunOptions{Command: []string{"cat", "/test.txt"}}
// Запускаем контейнер с проверкой на наличие файла
chkExisting := docker.Run(t, tag, eOpts)
// Проверяем, что вывод равен желаемому
assert.Equal(t, "exists", chkExisting)
// Запускаем контейнер с выводом содержимого файла
chkContent := docker.Run(t, tag, cOpts)
// Проверяем, что вывод равен желаемому
assert.Equal(t, "Hello, World!", chkContent)
}

Стало заметно удобнее! Благодаря полноценному языку программирования мы можем создавать гораздо более сложные тестовые сценарии, использовать docker API и так далее.

Проведение теста для проверки HTTP-сервера с зависимостями

К сожалениютакие примерыкак Hello Worldредко объясняют реальные варианты использования технологийпоэтому давайте представим немного более сложный случайНапримересть приложение Golang (простой HTTPсервер):

package main

 
import (
"fmt"
"net/http"
)
 
func hello(w http.ResponseWriter, req *http.Request) {
fmt.Fprintf(w, "hello")
}
 
func main() {
http.HandleFunc("/hello", hello)
http.ListenAndServe(":8000", nil)
}

Допустим, приложению для работы также нужен двоичный файл curl. Тогда Dockerfile будет выглядеть так:

# Первым делом собираем само приложение

FROM golang:1.16 as builder
WORKDIR /src/app
COPY ./main.go /src/app
RUN CGO_ENABLED=0 go build -o /go/bin/app main.go
 
# Далее собираем базовый образ из alpine, добавляя туда бинарник curl
FROM alpine:3.13.2 AS basis
RUN apk add --no-cache curl
 
# Следующим номером открываем порт 8080 и добавляем бинарник из шага сборки
FROM basis AS production
EXPOSE 8080
COPY --from=builder /go/bin/app /usr/bin/app
ENTRYPOINT [ "/usr/bin/app" ]

Что здесь мож­но про­верить:

  • на­личие бинар­ника curl;
  • что сер­вер успешно под­нима­ется и порт 8080 открыт и прос­лушива­ется.

Взгля­нем, какие мож­но написать тес­ты (код пол­ностью дос­тупен в кон­це статьи).

Вы­несем сбор­ку обра­зов в отдель­ную фун­кцию, что­бы не пов­торять­ся:

func BuildWithTarget(t *testing.T, dCtx string, tag string, target string) {

buildOptions := &docker.BuildOptions{
Tags: []string{tag},
// Target для сборки multi-stage
Target: target,
}
docker.Build(t, dCtx, buildOptions)
}

Пер­вым тес­том про­верим, как и в пре­дыду­щем при­мере, наличие бинар­ника curl:

func TestBasisLayer(t *testing.T) {

tag := fmt.Sprintf("go_demo:%s", BasisTarget)
// Собирается образ с нужным таргетом
BuildWithTarget(t, "../", tag, BasisTarget)
// И далее схожим образом проверяем наличие файла curl
opts := &docker.RunOptions{
Command: []string{"sh", "-c", "[ -f /usr/bin/curl ] && echo exists"},
Remove: true,
}
chkExisting := docker.Run(t, tag, opts)
assert.Equal(t, "exists", chkExisting)
}

Вто­рым — дос­тупен ли HTTP-сер­вер. Здесь уже слож­нее:

func TestProductionLayerServerAvailability(t *testing.T) {

tag := fmt.Sprintf("go_demo:%s", ProdTarget)
BuildWithTarget(t, "../", tag, ProdTarget)
 
// Обязательно выставляем параметр Detach, в противном случае
// процесс зависнет на выводе запущенного контейнера.
// Параметр -P позволит пробросить порт на случайный свободный
// порт на хосте, тем самым позволяя избежать ошибки с выбором занятого порта
opts := &docker.RunOptions{
Remove: true,
Detach: true,
OtherOptions: []string{"-P"},
}
// Далее запускаем контейнер и получаем его ID
cntId := docker.RunAndGetID(t, tag, opts)
// Через интерфейс функции Inspect получаем проброшенный порт
cntInsp := docker.Inspect(t, cntId)
hostPort := cntInsp.GetExposedHostPort(uint16(8000))
url := fmt.Sprintf("http://localhost:%d/hello", int(hostPort))
// Используя http_helper из библиотеки terratest, можно сделать
// запрос к выбранному URL и проверить результаты запроса
status, _ := http_helper.HttpGet(t, url, &tls.Config{})
assert.Equal(t, 200, status)
// В последнюю очередь удаляем использованный контейнер
docker.Stop(t, []string{cntId}, &docker.StopOptions{})
}

Compose и базы данных

Давайте рассмотрим более сложный пример, когда приложению необходимо подключиться к базе данных Postgres. Официальный образ может добавить скрипт, который настроит схему и добавит некоторые тестовые данные. Мы используем это на этапе тестирования.

При­мера скрип­та ини­циали­зации БД:

#!/bin/bash

set -e
 
# Создаем тестовую базу
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
CREATE DATABASE demo;
GRANT ALL PRIVILEGES ON DATABASE demo TO postgres;
EOSQL
 
# Добавляем таблицу и «тестовые данные»
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "demo" <<-EOSQL
CREATE TABLE demo (
id SERIAL PRIMARY KEY,
messages VARCHAR(100) NOT NULL
);
INSERT INTO demo(messages) VALUES ('hello_xakep.ru!');
EOSQL

В код прог­раммы добавим прос­тую фун­кцию, которая будет забирать из БД стро­ку по ее ID в таб­лице:

func getPsqlData(id string) string {

host := os.Getenv("DB_HOST")
port := os.Getenv("DB_PORT")
user := os.Getenv("DB_USER")
password := os.Getenv("DB_PASS")
dbname := os.Getenv("DB_NAME")
psqlconn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable", host, port, user, password, dbname)
db, err := sql.Open("postgres", psqlconn)
if err != nil {
panic(err)
}
defer db.Close()
rows, err := db.Query(fmt.Sprintf(`SELECT "messages" FROM "demo" WHERE id=%s`, id))
if err != nil {
panic(err)
}
var message string
defer rows.Close()
for rows.Next() {
err = rows.Scan(&message)
if err != nil {
panic(err)
}
}
return message
}

Dockerfile для сбор­ки при­ложе­ния выг­лядит схо­же с прош­лым при­мером, но добави­лось ска­чива­ние пакетов:

FROM golang:1.16 as builder

WORKDIR /src/app

COPY ./ /src/app
RUN go get -d -v -u all
RUN CGO_ENABLED=0 go build -o /go/bin/srvapp server.go
 
FROM alpine:3.12.0
EXPOSE 8000
COPY --from=builder /go/bin/srvapp /usr/bin/srvapp
ENTRYPOINT ["/usr/bin/srvapp"]

Для тес­тирова­ния напишем docker-compose-файл, который запус­кает БД, и рядом соб­ранное при­ложе­ние:

version: '3.1'

services:
# База данных с пробросом скрипта для инициализации
db:
image: postgres
environment:
POSTGRES_PASSWORD: dont_use_this_in_prod
volumes:
- ./scripts:/docker-entrypoint-initdb.d
# Серверная часть с указанием параметров подключения к БД и пробросом портов
server:
image: demo:server
environment:
DB_USER: postgres
DB_PASS: dont_use_this_in_prod
DB_HOST: db
DB_PORT: 5432
DB_NAME: demo
ports:
- 8000:8000
depends_on:
- db

Сам слу­чай тес­тирова­ния стал слож­нее, но тест — про­ще. Прос­тая фун­кция для сбор­ки самого обра­за:

func BuildDockerImage(t *testing.T, dCtx string, tag string) {

buildOptions := &docker.BuildOptions{

Tags: []string{tag},
}
docker.Build(t, dCtx, buildOptions)
}
 
И сам  тест:

func TestServerAvailability(t *testing.T) {

// Сборка образа
BuildDockerImage(t, "../", "demo:server")
 
// Указание в опциях контекста для docker-compose
opts := &docker.Options{
WorkingDir: "../",
}
// Обязательно указываем, что вне зависимости от исхода теста контейнеры будут удалены
defer docker.RunDockerCompose(t, opts,"down")
// Запускаем docker-compose
docker.RunDockerCompose(t, opts, "up","-d")
// Не очень элегантное решение, но надо подождать, чтобы база данных успела инициализировать схему
time.Sleep(20*time.Second)
url := "http://localhost:8000/message"
// Проверка ответов от запроса
status, response := http_helper.HttpGet(t, url, &tls.Config{})
assert.Equal(t, 200, status)
assert.Equal(t, "hello_xakep.ru!", response)
}

Ес­ли все прош­ло хорошо, то вывод очень лаконич­ный.

success

success

Ес­ли тест валит­ся по каким‑то при­чинам, то будет доволь­но прос­то понять, что имен­но пош­ло не так.

failed

failed

С помощью этой платформы вы можете протестировать все этапы создания в самом файле Dockerа также общую функциональность приложенияИ убедитесьчто полнофункциональные образы попрежнему отправляются в окружения.

Пол­ный код тес­та HTTP-сер­вера:

package docker_test

 
import (
"crypto/tls"
"fmt"
"github.com/gruntwork-io/terratest/modules/docker"
http_helper "github.com/gruntwork-io/terratest/modules/http-helper"
"github.com/stretchr/testify/assert"
"testing"
)
 
const (
BasisTarget = "basis"
ProdTarget = "production"
)
 
func BuildWithTarget(t *testing.T, dCtx string, tag string, target string) {
buildOptions := &docker.BuildOptions{
Tags: []string{tag},
Target: target,
}
docker.Build(t, dCtx, buildOptions)
}
 
func TestBasisLayer(t *testing.T) {
tag := fmt.Sprintf("go_demo:%s", BasisTarget)
BuildWithTarget(t, "../", tag, BasisTarget)
 
opts := &docker.RunOptions{
Command: []string{"sh", "-c", "[ -f /usr/bin/curl ] && echo exists"},
Remove: true,
}
chkExisting := docker.Run(t, tag, opts)
assert.Equal(t, "exists", chkExisting)
}
 
func TestProductionLayerServerAvailability(t *testing.T) {
tag := fmt.Sprintf("go_demo:%s", ProdTarget)
BuildWithTarget(t, "../", tag, ProdTarget)
 
opts := &docker.RunOptions{
Remove: true,
Detach: true,
OtherOptions: []string{"-P"},
}
cntId := docker.RunAndGetID(t, tag, opts)
cntInsp := docker.Inspect(t, cntId)
hostPort := cntInsp.GetExposedHostPort(uint16(8000))
url := fmt.Sprintf("http://localhost:%d/hello", int(hostPort))
status, _ := http_helper.HttpGet(t, url, &tls.Config{})
assert.Equal(t, 200, status)
docker.Stop(t, []string{cntId}, &docker.StopOptions{})
}

Пол­ный код при­мера тес­тирова­ния с помощью docker-compose:

package docker_test

 
import (
"crypto/tls"
"github.com/gruntwork-io/terratest/modules/docker"
http_helper "github.com/gruntwork-io/terratest/modules/http-helper"
"github.com/stretchr/testify/assert"
"testing"
"time"
)
 
func BuildDockerImage(t *testing.T, dCtx string, tag string) {
buildOptions := &docker.BuildOptions{
Tags: []string{tag},
}
docker.Build(t, dCtx, buildOptions)
}
 
func TestServerAvailability(t *testing.T) {
BuildDockerImage(t, "../", "demo:server")
 
opts := &docker.Options{
WorkingDir: "../",
}
defer docker.RunDockerCompose(t, opts,"down")
docker.RunDockerCompose(t, opts, "up","-d")
time.Sleep(20*time.Second)
url := "http://localhost:8000/message"
status, response := http_helper.HttpGet(t, url, &tls.Config{})
assert.Equal(t, 200, status)
assert.Equal(t, "hello_xakep.ru!", response)
 
Порядок)
 
Благодаря Docker, вы можете легко переместить свое приложение в другую среду: просто загрузите образ в репозиторий и загрузите его на сервер. И вам не нужно беспокоиться о том, что используемое приложение израсходует все ваши ресурсы — Docker позволяет настраивать количество ресурсов, выделяемых каждому приложению.

 

 

Click to rate this post!
[Total: 0 Average: 0]

Leave a reply:

Your email address will not be published.