Найти в Дзене
Я, Golang-инженер

#73. Пишем Dockerfile, собираем Docker-образы и запускаем контейнеры с сетевым приложением: руководство по Докер от простого к сложному

Это статья об основах программирования на Go. На канале я рассказываю об опыте перехода в IT с нуля, структурирую информацию и делюсь мнением. Хой, джедаи и амазонки! В этой публикации систематизирую знания о докере на хорошем базовом уровне, достаточном для прикладных целей. Что в этой публикации будет: что такое докер и докерфайл; как писать докерфайл так, чтобы контейнеры запускались, веб-приложения в них корректно работали, а образ не занимал гигабайт памяти. А также покажу основные команды докера и объясню, как не запутаться в созданных образах и контейнерах. Работать с докером будем через терминал. Чего в этой публикации не будет: установка Docker, работа с Docker Desktop, управление образами и контейнерами через Docker-Compose, Docker Swarm или тем более через K8s; развёртывание приложений из контейнеров в облаке. Более подробно разобраться и освоить Docker меня побудила задача в мастерской Яндекс Практикума, где я в составе команды разрабатываю сервис для подачи и решения жалоб
Оглавление

Это статья об основах программирования на Go. На канале я рассказываю об опыте перехода в IT с нуля, структурирую информацию и делюсь мнением.

Хой, джедаи и амазонки!

В этой публикации систематизирую знания о докере на хорошем базовом уровне, достаточном для прикладных целей.

Что в этой публикации будет: что такое докер и докерфайл; как писать докерфайл так, чтобы контейнеры запускались, веб-приложения в них корректно работали, а образ не занимал гигабайт памяти. А также покажу основные команды докера и объясню, как не запутаться в созданных образах и контейнерах. Работать с докером будем через терминал.

Чего в этой публикации не будет: установка Docker, работа с Docker Desktop, управление образами и контейнерами через Docker-Compose, Docker Swarm или тем более через K8s; развёртывание приложений из контейнеров в облаке.

Более подробно разобраться и освоить Docker меня побудила задача в мастерской Яндекс Практикума, где я в составе команды разрабатываю сервис для подачи и решения жалоб пользователей. Одной из моей задач было обеспечить работу приложения в Docker-контейнере.

1. Что такое Docker?

Docker (Докер) - программа для создания образов и развёртывания ПО на основе таких образов, в которых помимо самого ПО включена вся необходимая для работы ПО среда: ОС или её фрагменты, библиотеки, конкретные файлы и прочее.

Докер написан на Golang.

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

Как установить докер на свою ОС можно почитать в документации к ОС. Если по какой-то причине у вас Linux Kali, можно почитать об установке докера в публикации о белом хакинге: *клик*.

Проверить, установлен ли докер на компьютере, можно командой:

docker --version
Терминал
Терминал

Докер для создания образа считывает и интерпретирует инструкции из Dockerfile (докерфайл).

Dockerfile (Докерфайл) - это текстовый документ с инструкциями по созданию образа докера.

Docker-инструкции можно рассматривать, как декларативную форму языка. По схеме работы он похож на язык запросов SQL, когда программист указывает, что нужно сделать без деталей реализации, а ПО само решает, как эффективно выполнить инструкцию. Для SQL за эту реализацию отвечает СУБД, например, PostgreSQL или SQLite, а для docker-инструкций это делает Docker.

Официальная документация по синтаксису docker-запросов: *клик*. Ниже фрагмент документации:

Фрагмент документации по Dockerfile
Фрагмент документации по Dockerfile

Докерфайл строится по схеме:

  1. Инструкция + аргумент/ы
  2. Инструкция + аргументы
  3. Инструкция + аргумент/ы и т.д.

Инструкции не чувствительны к регистру, но принято их писать ЗАГЛАВНЫМИ БУКВАМИ для визуального отделения от аргументов.

Аргументы - это данные, передаваемые в инструкции; например команды оболочки командной строки или названия базовых образов.

Ниже некоторые инструкции, которые я использую на практике:

  1. FROM - определяет базовый образ, на основе которого произойдёт сборка образа пользователя;
  2. WORKDIR - указывает рабочую директорию для следующих инструкций;
  3. COPY - копирует файлы в образ;
  4. RUN - выполняет команды в оболочке ОС;
  5. EXPOSE - информационная метка для IT-специалиста в виде документирования образа; указывает, через какой порт/ы приложение внутри контейнера слушает сеть;
  6. CMD - команда, которая автоматически будет выполнена при старте контейнера. Например, запустит приложение, ради которого создаётся контейнер; или может отсутствовать, или запустить оболочку командной строки внутри контейнера.

Другие инструкции рассматривать не буду, для создания докер-образов хорошего базового уровня, достаточно этих. А такие инструкции, например, как ENV для переменных окружения, часто создаются не в докерфайлах, а в докер-компоуз, который управляет (обычно) небольшим количеством образов и настраивает взаимодействие между контейнерами.

Пользовательские докер-образы строятся на основе ранее созданных докер-образов. База докер-образов - это Docker-hub (докер-хаб): *клик*. Туда можно загрузить и свой образ.

Что тут интересно - первые образы, на основе которых можно создавать пользовательские образы, разработчики docker создавали не на основе докерфайла, а более сложными сценариями, которые мне не интересны.

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

Разберём серию примеров с Docker и команды для работы Docker.

2. Создаём образы в Dockerfile

Начнём с простого приложения на Go и такого же простого докерфайла, а затем и постепенно будем усложнять приложение и смотреть, как будет усложняться докерфайл, чтобы всё корректно работало.

2.1. Приложение V1

Напишем самое простое приложение:

Простое приложение
Простое приложение

Создали файл - написали код, запустили - работает. Отлично. Теперь мы хотим, чтобы это приложение запускалось через контейнер.

Напишем простой докерфайл:

Докерфайл
Докерфайл

Синим IDE подсвечивает докер-инструкции, белым - аргументы, передаваемые в эти инструкции. Зелёные - комментарии, нужны только IT-специалисту, а докером они отбрасываются. Разберём каждую строку.

2.1.1. FROM golang:1.23.3

golang:1.23.3 - аргумент для инструкции FROM. А конкретно, он обозначает образ языка Go конкретной версии из докерхаба. Зайдём на страничку докерхаба, введём в поисковик golang и посмотрим, что нам предлагает докерхаб для официальных образов го:

https://hub.docker.com/_/golang
https://hub.docker.com/_/golang

Множество различных версий базового образа. Вот какую хотим, такую и используем. Какую хотим? По крайней мере с той версией языка, которая у нас на компьютере установлена.

2.1.2. WORKDIR /app

/app - аргумент инструкции WORKDIR, который означает каталог, который я создаю в образе и устанавливаю его рабочим для последующих инструкций.

Это как аналог команды в bash cd full_path_to_dir с той разницей, что при инструкции FROM, если каталог/каталоги не существуют в образе, они будут созданы и будет установлен текущим рабочим.

2.1.3. COPY main.go .

Здесь на самом деле два аргумента: main.go и . (точка).

Первый аргумент (main.go) - это полный путь к файлу или каталогу, который нужно скопировать из файловой системы компьютера в образ.

Второй аргумент (точка) - полный путь куда скопируются данные в образе. Точка означает, что копирование выполняется в текущий рабочий каталог образа (установлен этапом выше).

2.1.4. Сборка образа

В ООП есть понятие класс: класс - это схема для создания объекта. А образ - это схема для создания докер-контейнеров.

Запускаем сборку образа командой:

docker build .

Точка после build означает, что докерфайл находится в той же директории, где мы находитмся в терминале. Если это не так, нужно указывать путь, а не точку.

Если команда docker build . и последующие команды не работают, попробуйте впереди добавлять sudo. Не буду касаться здесь причин этого, главное сейчас разобраться с докерфайлом:

sudo docker build .

Выглядит в терминале создание образа примерно так:

Терминал
Терминал

Докер хранит все образы в специальной файловой структуре. Где находится сама файловая структура можно посмотреть через команду:

docker info

Там должна быть строка Docker Root Dir - это и есть путь в т.ч. к образам Docker:

Терминал
Терминал

Можно посмотреть, что в этом каталоге:

Терминал
Терминал

Как видим, есть каталог image.

2.1.5. Исследуем созданный образ

Есть два способа запустить контейнеры, не считая опций с флагами: по ID образа и по его имени. Имя образа задаётся через флаг на этапе сборки, мы этот флаг не использовали. Поэтому запустим через ID.

Чтобы найти ID, воспользуемся командой:

docker images
Терминал
Терминал

Находим только-что созданный образ. Видим, что отсутствует имя (репозиторий), тег (версия), но есть ID. А также есть размер образа - 837 МБ. Очень много с учётом того, что мы запускаем программу, которая печатает всего одну строчку. В следующих примерах посмотрим, как уменьшить этот размер, а пока запустим то, что получилось.

2.1.6. Запускаем контейнер по ID образа

Контейнер - это исполняемый экземпляр образа.

Контейнер по ID образа запускается командой:

docker run <ID-образа>
Терминал
Терминал

В терминале нет результата выполнения программы. Он просто стартанул и завершил работу. Почему?

Потому что при запуске командой docker run, докер запускает процесс в контейнере. Этот процесс соответствует инструкции CMD, которая прописывается в докерфайле. У нас такой инструкции нет.

Но что же делать, программа-то в контейнере есть. Можно получить доступ к командной строке внутри контейнера и вручную запустить оттуда программу. Делается это с помощью флага -it и аргумента /bin/sh, по крайней мере, если базовый образ - Linux:

docker run -it <ID образа> /bin/sh

флаг -i означает интерактивный режим;

флаг -t выделяет псевдотерминал контейнера;

аргумент /bin/sh указывает конкретную команду, которую следует выполнить при запуске контейнера, а именно - оболочку командной строки shell.

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

docker run -it <ID образа>
Работа в терминале
Работа в терминале

Появился псевдотерминал контейнера. Командой ls я посмотрел содержимое каталога и запустил программу go run main.go. Для выхода из псевдотерминала воспользовался командой exit.

Отлично, контейнер создан. Важный момент: каждый раз при создании контейнера по ID образа, мы создаём разные экземпляры контейнеров. И они остаются в ПЗУ компьютера, никуда не деваются. Что с этим можно сделать?

2.1.7. Смотрим информацию о контейнере

Просмотреть все докер-контейнеры, какие есть на компьютере, можно командой:

docker ps -a

ps (process status) - это команда, которая показывает список активных (работающих в данный момент) контейнеров.

флаг -a - это all, показывает включить в отображение не только активные, но и не запущенные контейнеры.

Терминал
Терминал

В общих чертах все столбцы, думаю, понятны. Интересен столбец Name.

Наименования контейнеров, такие как optimistic_wescoff и heuristic_cray, - это автоматически сгенерированные имена, которые Docker присваивает контейнерам, если мы не указали имя контейнера при его создании.

Это делается для удобства, чтобы каждый контейнер имел какое-то уникальное имя, которое можно использовать для обращения к нему, вместо того чтобы запоминать его ID.

Главным образом в контейнерах хранится информация к какому образу он относится и данные, созданные в процессе работы контейнера. Docker не предоставляет способа посмотреть размер контейнера, по крайней мере я не нашёл такой способ.

2.1.8. Удаляем контейнеры

Если какие-то контейнеры нам не нужны, и хотя места они могут занимать немного, чтобы самим не запутаться, лучше почистить память.

Для удаления контейнера нужно использовать команду:

docker rm <CONTAINER_ID или CONTAINER_NAME>

Удалить запущенный контейнер не выйдет. Сперва его нужно остановить:

docker stop <CONTAINER_ID или CONTAINER_NAME>

Эта команда отправляет ОС сигнал SIGTERM для плавной остановки контейнера, т.е. выдаётся время для завершения всех запущенных процессов внутри контейнера и безопасного сохранения данных. Если контейнер не останавливается в установленное время (по умолчанию 10 секунд), Docker отправляет ОС сигнал SIGKILL, чтобы принудительно завершить процесс.

Можно остановить все контейнеры командой:

docker stop $(docker ps -q)

Радикальный способ удаления контейнеров - удалить все контейнеры. Если вдруг у вас творится творческий хаос из десятков ненужных контейнеров, и вы хотите навести порядок:

docker container prune

2.1.9. Вариации запуска контейнеров

Можно при создании нового экземпляра контейнера из образа указать флаг, автоматически удаляющий контейнер при его завершении:

docker run --rm <ID-образа>

Если же мы хотим запустить существующий контейнер по ID-контейнера (не ID-образа) или по его имени, нужно использовать команду:

docker start <CONTAINER_ID или CONTAINER_NAME>

Эта команда не создаёт псевдотерминал, а только запускает контейнер без возможности взаимодействовать с ним.

Для прямого взаимодействия через терминал, нужно выполнить команду:

docker exec -it <CONTAINER_ID или CONTAINER_NAME> /bin/sh

Она запустит оболочку внутри контейнера в интерактивном режиме и позволит взаимодействовать с содержимым контейнера.

Ещё такой момент: когда мы запускаем контейнер в первый раз, мы всегда используем информацию об образе для запуска - ID образа или имени (имя образа пока мы не задавали). А вот для запуска существующего контейнера для использования данных, полученных в ходе предыдущей работы контейнера, нужно пользоваться уже информацией контейнера: ID контейнера или его имя.

2.1.10. Изученные команды

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

docker --version // Показывает версию установленного на компьютере Docker, если он установлен
docker info // Показывает информацию об установленном на компьютере Docker, включая версию, количество контейнеров и образов, используемые сети и хранилища.
docker build . // Создаёт образ из Dockerfile, который находится в текущем каталоге. Используется для сборки пользовательского образа приложения.
docker images // Показывает список образов, хранящихся на локальном компьютере, включая их размер, теги и идентификаторы.
docker run <ID или имя образа> // Запускает контейнер из указанного образа. Контейнер будет работать в фоновом режиме, если не указаны другие параметры.
docker run -it <ID или имя образа> // Запускает контейнер из указанного образа в интерактивном режиме, предоставляя терминал для взаимодействия с контейнером.
docker run --rm <ID или имя образа> // Запускает экземпляр контейнера по образу и автоматически удаляет контейнер после его остановки.
docker ps -a // Показывает список всех контейнеров на компьютере: работающие и остановленные.
docker start <ID или имя контейнера> // Запускает ранее созданный контейнер по его ID или имени.
docker exec <ID или имя контейнера> /bin/sh // Команда запускает псевдотерминал из работающего контейнера по его ID или имени.
docker stop <ID или имя контейнера> // Останавливает работающий контейнер по его ID или имени.
docker stop $(docker ps -q) //Останавливает все работающие контейнеры, получая их ID через (docker ps -q).
docker rm <ID или имя контейнера> // Удаляет указанный контейнер по его ID или имени, если он остановлен.
docker container prune // Удаляет все остановленные контейнеры, очищая пространство на диске, занятое ними.

Немало использовали команд для работы с докерфайлом всего из трёх инструкций.

Я обратил внимание, что у команд докера есть флаги с одним минусом, такие как docker ps -a, и есть с двумя минусами docker run --rm <CONTAINER_ID>.

- Один минус перед флагом - используется для коротких флагов, которые могут комбинироваться с другими короткими флагами, например как с флагами -it.

-- Два минуса используются для длинных флагов, которые всегда пишутся отдельно и не комбинируются с другими флагами.

В целом, история как и с прочими утилитами в bash.

В следующем параграфе уменьшим размер образа и сделаем автоматический запуск программы в контейнере без необходимости работы в псевдотерминале.

2.2. Приложение V2: снижаем размер образа

2.2.1. Ещё немного об ОС образа

В предыдущем параграфе мы посмотрели, что размер образа равен 837 МБ. Чем вызван такой размер?

Главным образом - операционной системой, используемой по-умолчанию при сборке контейнера. Какая сейчас ОС в контейнере? Войдём в псевдотерминал контейнера и посмотрим информацию об ОС командой:

uname
Терминал
Терминал

Вижу, что используется GNU/Linux с ядром 6.10.14 в контейнере ffd16d80f28e. Не особо много информации. Больше можно посмотреть, гуляя по ссылкам докерхаба и исследуя докерфайл для выбранного базового образа. Но докерхаб мы изучим для других целей.

2.2.2. Изучаем докерхаб голанга

Рассмотрим ещё раз докерхаб языка голанг. В нём есть раздел "Image Variants".

Формат golang:<version>, который мы использовали в первой редакции докерфайла - это базовый образ.

Этот формат может быть дополнен типом и тегом (версией) конкретной ОС, на которой будет базироваться Go в образе. Список таких поддерживаемых типов ОС с их тегами (версиями) прописан в начале страницы Golang в докерхабе.

Самым легковесным образом будет golang:<version>-alpine.

Вот страница отдельного образа ОС Alpine (без Go) на докерхабе:

https://hub.docker.com/_/alpine
https://hub.docker.com/_/alpine

Alpine Linux - это легковесный дистрибутив Linux весом около 5 МБ. Т.е. вместо загрузки тяжеловесной ОС мы загрузим всего 5 МБ. Как это можно сделать в докерфайле?

2.2.3. Пишем докерфайл

Докерфайл
Докерфайл

Размер образа станет намного меньше. Нужно обратить внимание на примечание разработчиков Го в докерхабе, что образы на основе этой ОС являются экспериментальными, и программы на основе этого образа могут привести к неожиданному поведению.

Кстати, когда мы указываем alpine, докерхаб воспринимает это, как последнюю версию ОС Alpine, т.е. использует тег latest. Корректнее в докерхабе указывать тип ОС и её тег (версию), например, так: alpine:latest. Вместо latest может быть любая существующая версия, указанная на станице с документацией образа докерхаба.

2.2.4. Запускаем контейнер

Поэкспериментируем и посмотрим, что выйдет.

Терминал
Терминал

Итак, размер образа уже 246 МБ, а был с ОС по-умолчанию 800+ МБ. Запустим контейнер и посмотрим на работу приложения и служебную информаци:

Терминал
Терминал

Всё работает. Образ стал меньше в 3 с лишним раза: экономия ресурсов. Но всё равно очень много - 246 МБ. Как же так, если ОС весит всего 5 МБ?

Основную часть здесь занимает язык Go. А есть ли возможность не помещать его в образ?

2.2.5. Удаляем Go из образа

Идея следующая: мы предварительно создаём бинарный файл нашего приложения на нашей основной ОС с языком Go, а далее в докерфайле копируем этот бинарный файл, а не код программы. Исходный образ будет Linux Alpine без Go.

Создаём бинарный файл нашего приложения командой:

go build -o <name_app> <path_to_go-file>

В моём случае я создал бинарный файл с именем main (без расширения).

Изменяем докерфайл:

Докерфайл
Докерфайл

Создаём образ, проверяем его размер:

Терминал
Терминал

Размер образа без языка Go и объёмной ОС теперь 10 МБ. С языком Go был 200 МБ+. А первоначальный образ с языком Go и тяжеловесной ОС был 800 МБ+.

Запускаем контейнер и проверяем работоспособность программы:

Терминал
Терминал

Программа работает.

Контейнер с приложением весят всего 10 МБ. И пусть, что приложение может только печатать в терминал, тут сама суть - делая ОС с приложением могут запускаться практически на любом ПК, где есть докер.

Здесь была ручная работа по созданию бинарного файла. Этот процесс можно автоматизировать.

2.2.6. Автоматизируем сборку бинарного файла

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

Идея в чём: создаём образ и кладём в него язык Go на базе ОС. Далее компилируем бинарный файл внутри образа с помощью средств языка Go. Докерфайл будет выглядеть примерно так:

Докерфайл
Докерфайл

Я запускать этот файл не буду. Здесь в чём проблема: конечный образ будет тяжеловесным из-за того, что в нём будет язык Go. Т.е. образ исходя из данных выше, будет весить ~200 МБ. Можно ли это оптимизировать, чтобы образ весил ~10 МБ для экономии ресурсов?

Да, и это выполняется за счёт многоступенчатой сборки.

На первом этапе мы используем более тяжеловесный исходный образ с языком Go и легковесной Linux. На этом этапе копируем данные с компьютера и компилируем их в бинарный файл.

На втором этапе сборки берём только легковесный образ Линукс и копируем в него только-что скомпилированный бинарный файл.

Докерфайл может выглядеть примерно так:

Докерфайл
Докерфайл

Что здесь обновилось:

В первой инструкции появилась дополнение AS builder в правой части. AS - слово из синтаксиса языка инструкций докер, а builder - это алиас на этот исходный образ. В дальнейшем мы будем ссылаться на этот алисас.

Также появился второй этап сборки, где мы используем легковесную ОС Alpine.

В конце в COPY мы через флаг --from, определённый в синтаксисе языка инструкций докер, указываем, что копируем данные по такому-то пути (/app/simple_app) из такого-то образа (builder) в каталог, в котором находимся в текущем образе (точка).

Собираем образ, проверяем его размер:

Терминал
Терминал

Размер собранного образа как и ранее - чуть меньше 10 МБ.

Запускаем контейнер:

Терминал
Терминал

Ну что я могу сказать - программа работает, мы автоматизировали создание бинарного файла. Хотя при этом докерфайл увеличился вдвое.

Хочу ещё оптимизировать создание образа - а именно, дать ему уже имя и тег, т.к. всё запускаем по непонятным цифрам, т.е. по ID образа. А также развить автоматизацию, чтобы не нужно было запускать оболочку контейнера для запуска программы, а программа выполнялась сама при запуске контейнера.

2.2.7. Автоматизация запуска приложения в контейнере

Чтобы при запуске контейнера выполнялась какая-то команда по-умолчанию, нужно воспользоваться инструкцией CMD:

CMD ["path_to_binary_file"]

Докерфайл будет выглядеть так:

Докерфайл
Докерфайл

Чтобы задать имя образу, для сборки используем не базовую команду:

docker build .

А используем флаг -t или --tag:

docker build -t <имя_образа или имя_образа:версия> .

Процесс будет выглядеть примерно так:

Терминал
Терминал

Проверяем информацию об образе:

Терминал
Терминал

Отлично, размер образа тот же, и появилось название образа и версия. Запустим контейнер по имени образа:

Терминал
Терминал

Отлично, приложение запустилось без плясок с запуском терминала из контейнера. Программа в контейнере отработала и контейнер завершил работу.

Была проделана огромная работа: на нашей ОС запустилась другая ОС, а в ней была выполнена программа, и всё за очень короткое время. Подумайте, сколько времени запускается ОС на компьютере, и за сколько запустился контейнер - разница существенная.

Далее посмотрим, как работать с сетевыми приложениями в контейнере.

2.3. Приложение V3: работаем с сетью

2.3.1. Дорабатываем приложение

Изменим приложение, чтобы оно стало сетевым. Добавим минимальный функционал, чтобы сервер слушал запросы с эндпоинта /ping на порту 8080 и отвечал словом pong:

Код
Код

Проверим работу: запустим приложение:

Терминал
Терминал

Перейдём в браузере по localhost:8080/ping. В браузере появляется:

Браузер
Браузер

Вернёмся в терминал:

Терминал
Терминал

Код работаем, всё в порядке. Создаём образ.

2.3.2. Создаём образ, запускаем контейнер

Докерфайл я не менял, он остался как в предыдущем параграфе:

Докерфайл
Докерфайл

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

docker build . --tag net_app:v1.0

Созданный образ весит уже почему-то не ~10, а ~15 МБ:

Терминал
Терминал

Запускаем:

Терминал
Терминал

А вот запросы из браузера до приложения в контейнере почему-то не доходят:

Браузер
Браузер

Связано это с тем, что при запуске контейнера мы не пробросили порт.

Запуск экземпляра контейнера - это создание операционной системой процесса. Процесс может быть привязан к порту, либо не быть привязан. Если процесс должен общаться с другими процессами, в т.ч. по сети - процессами, работающими на другом компьютере, - то процесс должен быть привязан к порту на хосте (компьютере).

У контейнера есть два порта: порт хоста и (виртуальный) порт контейнера.

Порт хоста - это тот порт, к которому привязывается запущенный ОС процесс; в данном случае процесс - это экземпляр контейнера.

(Виртуальный) Порт контейнера - порт, который связывает порт хоста с портом внутри контейнера. Через порт контейнера, сетевое приложение, работающее в контейнере, связывается скажем так с "внешним миром".

Эти два порта могут быть одинаковыми или нет.

Так, наше приложение внутри контейнера работает на порту 8080. Это порт контейнера. Нужно явно указать при запуске контейнера из образа, что контейнер общается с "внешним миром" и указать порт хоста и соответствующий ему (виртуальный) порт контейнера. Делается это командой:

docker run -p <порт хоста>:<порт контейнера> <ID или имя образа>

или с теми флагами, что мы использовали ранее:

docker run --rm -p <порт хоста>:<порт контейнера> <ID образа>
Терминал
Терминал

Теперь запросы браузера доходят до приложения в контейнере:

Браузер
Браузер

2.3.3. Указываем внешний порт в докерфайле

В синтаксисе языка докер есть инструкция Expose, которая не влияет на сборку образа, а просто говорит IT-специалисту, на каком порту внутри контейнера работает приложение:

Докерфайл
Докерфайл

Это такой способ документирования приложения в докерфайле.

Всё так, да не совсем так.

Если при запуске контейнера из данного образа использовать флаг -P, то будет проброшен случайный порт хоста к указанному в EXPOSE внутреннему порту контейнера.

Рассмотрим пример. Создан образ по вышеуказанному докерфайлу, запускаю контейнер в флагом -P:

Терминал
Терминал

Чтобы посмотреть информацию о запущенных контейнерах, открою другой терминал и посмотрю информацию:

Терминал
Терминал

Докер привязал к указанному в инструкции EXPOSE внутреннему порту 8080, порт хоста 55000.

Зайдём в браузер и изменим порт в строке запроса и отправим запрос:

Браузер
Браузер

Запрос дошёл до контейнера. Посмотрим, что в терминале запущенного контейнера:

Терминал
Терминал

Видим информацию о запросе.

Таким образом инструкция EXPOSE может применяться для пробрасывания порта, но такое использование явно ситуативно, т.к. предполагает неопределённость назначения порта хоста.

2.4. Нестандартные библиотеки

В разработке мы используем нестандартные библиотеки. Например, библиотека Gin для обработки HTTP-запросов.

Перепишем код приложения:

Код
Код

Начинается история с импортом внешней библиотеки - в коде подчёркнута отсутствующая библиотека.

Нужно определить наш проект как модуль Go. Делается это созданием файла go.mod в корневом каталоге проекта, в котором фиксируется версия языка и все используемые в проекте сторонние библиотеки с их версиями.

Создадим файл go.mod и скачаем необходимую библиотеку:

Создали файл go.mod
Создали файл go.mod

Загрузили библиотеку:

Терминал
Терминал

Теперь в коде библиотека не подчёркнута красным: она импортирована:

Код
Код

А в файле go.mod появилось много строк:

Код
Код

Комментарий indirect говорит о том, что библиотека не используется напрямую, но нужна для работы другой библиотеки. В частности - для Gin.

Также появился файл go.sum при импорте внешней библиотеки:

Терминал
Терминал

Файл go.sum нужен для повышения безопасности приложения и связан с хешированием информации о зависимостях из файла go.mod.

Хорошо, библиотеку загрузили, зависимости создали - и что? Как это влияет на контейнер?

Если мы соберём контейнер по-старому образцу, приложение в нём не соберётся и образ сделать мы не сможем.

Убедимся в этом:

Ошибка при сборке
Ошибка при сборке

Нужно добавить в докерфайл загрузку зависимостей.

Можно это сделать несколькими способами, обычно используется следующая схема:

  1. Загружаем базовый образ (язык+ОС) для первого этапа сборки;
  2. Устанавливаем рабочий каталог в образе первого этапа сборки;
  3. Копируем файлы go.mod и go.sum.
  4. Запускаем загрузку зависимостей и проверку загрузки.
  5. Далее копируем остальные файлы приложения и собираем приложение. На этом первый этап сборки заканчивается. Второй этап сборки без изменений.

Вот как может выглядеть доработанный докерфайл и сборка образа:

Код
Код

По сути, мы в докерфайл добавили две строчки после установления рабочего каталога образа:

COPY go.mod go.sum ./
RUN go mod download && go mod verify

Проверим размер образа:

Терминал
Терминал

Образ размером 19 МБ: на 4 МБ больше, чем без сторонней HTTP-библиотеки.

Запустим, проверим:

Терминал
Терминал

В терминале штатная инфо библиотеки Gin. Проверяем в браузере:

Браузер
Браузер

В браузере всё работает. Смотрим ещё раз терминал - появилось ли инфо о запросе:

Терминал
Терминал

Да, инфо появилось. Отлично, со всем разобрались.

2.5. Надёжная ОС

Ранее я писал, что Alpine хотя и легковесная ОС, но всё же экспериментальная для приложений на Go. Для большей надёжности разрабатываемых приложений, полезно применять более предназначенные для этого образы.

Идея следующая: компилировать приложения можно на Alpine, а для следующего этапа сборки использовать более тяжеловесную ОС. Например, debian:bullseye-slim.

Докерфайл тогда будет выглядеть так:

Докерфайл
Докерфайл

Соберём образ и посмотрим, сколько он будет весить:

Терминал
Терминал

~92 МБ с ОС Debian, против 19 МБ с Linux Alpine.

Проверим работу приложения в контейнере:

Терминал
Терминал

Всё работает, отлично.

Что ещё имеется в реальных приложениях? Настройка приложения через конфигурационные файлы. Добавим их в приложение и докер-образ.

2.6. Наличие обязательных файлов в образе

Идея в следующем: на этапе финальной сборки, добавляем обязательные файлы, которые нужны исполняемому (скомпилированному бинарному) файлу в процессе работы. В первую очередь это файлы конфигурации.

Почитать о конфигурировании можно здесь: *клик*.

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

Код
Код

Добавили файл конфигурации, добавили код для чтения конфигурации.

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

Докерфайл
Докерфайл

Запускаем сборку, проверяем инфо о созданном образе:

Терминал
Терминал

Образ стал весить чуть больше за счёт загрузки доп библиотеки и файла конфигурации.

Запускаем, проверяем:

Терминал
Терминал

Всё работает.

На этом можно завершить разбор с докером. Повторим основные полезные команды.

2.7. Полезные докер-команды

Работа с отдельными образами и контейнерами в докер редко используется, соответственно команды для этих целей также редко используются.

Для разработки часто используется инструмент управления одним или несколькими образами и контейнерами Docker Compose. В процессе эксплуатации на серверах используются более продвинутые системы - Kubernetes.

Самое важное при работе с докером: понимать принцип работы докера, уметь писать докерфайл и находить нужные исходные образы.

При этом есть ряд полезных команд, которые пригодятся несмотря на наличие Докер Компоуза или Кубернетиса. На мой взгляд, вот они:

docker images - посмотреть, какие есть образы;
docker rmi <ID или имя образа> - удалить образ;
docker ps -a - посмотреть информацию о всех контейнерах.

Среди этих команд нет команды создания образа и его запуска, т.к. они при разработке ПО обычно выполняются инструментами Docker Compose с созданием сразу нескольких связанных контейнеров. И в терминале используются свои команды для этих целей.

Также здесь нет множества ситуативных команд, например запуск оболочки внутри контейнера, просмотра информации о сетях между контейнерами или других вещей. Это всё придёт с практикой.

3. Выводы

В публикации познакомились, что такое докер и как его использовать. Многое осталось нерассмотренным, но главное я для себя выяснил - как строится докерфайл и почему там всё именно так, как сделать образы компактными, а приложения внутри контейнеров могли общаться с сетью.

На этом всё, благодарю, что дочитали публикацию до конца. Успехов, и будем на связи.

https://ru.freepik.com/free-photo/aerial-view-container-cargo-ship-sea_13180387.htm
https://ru.freepik.com/free-photo/aerial-view-container-cargo-ship-sea_13180387.htm

Бро, ты уже здесь? 👉 Подпишись на канал для начинающих IT-специалистов «Войти в IT» в Telegram, будем изучать IT вместе 👨‍💻👩‍💻👨‍💻