Введение в Docker (2 часть)

Репозиторий на Github с примерами кода: https://github.com/dsc-sgu/docker-lecture-part2

Деплой приложения на сервер

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

ssh-keygen

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

Сборка образа

docker build -t app:0.1 .

Сохраняем образ в архив

docker save app:0.1 > app.tar

Загружаем образ на сервер

scp ./app.tar user@server:/path/to/destination

Подключаемся к серверу

ssh user@server

Загружаем образ из файла

docker load < app.tar

Копируем docker-compose (прописав в него название образа, загруженного из файла). И запускаемся:

nvim docker-compose.yaml
docker compose up -d

Поздравляем! Мы запустились! Теперь можем проверить, что всё работает, с компьютера клиента

curl http://server:port/products

Эксперименты над Dockerfile’ом сервиса на Go

Для начала втупую скопируем все файлы проекта, после чего запустим процесс компиляции.

FROM golang:1.23-alpine as builder

WORKDIR /app
COPY . .
RUN go build -o ./main main.go

FROM alpine:3.20
COPY --from=builder /app/main /app/main

ENTRYPOINT ["/app/main"]

Попробуем собрать наш образ:

docker compose build app

Всё хорошо, но давайте теперь модифицируем наш код:

nvim main.go

Попробуем собрать наш образ ещё раз:

docker compose build app

Как мы можем заметить, наши зависимости начали скачиваться заново.

Теперь напишем наш Dockerfile по-другому:

FROM golang:1.23-alpine as builder

WORKDIR /app
COPY go.mod go.sum .
RUN go mod download

COPY . .
RUN go build -o ./main main.go

FROM alpine:3.20
COPY --from=builder /app/main /app/main

ENTRYPOINT ["/app/main"]

Соберём:

docker compose build app

Попробуем снова модифицировать наш код:

nvim main.go

И опять соберём образ:

docker compose build app

Теперь мы не скачиваем зависимости заново.

Разница между shell- и exec-режимами

Если мы взглянем на Dockerfile питоновского проекта из первой части, то мы увидим очень интересную конструкцию CMD, где каждое слово в команде пишется в кавычках, а между ними ставится запятая.

CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

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

CMD uvicorn main:app --host 0.0.0.0 --port 8000

Выглядит лаконично, но есть нюанс.

Давайте для простоты сделаем специальный Dockerfile, на котором мы посмотрим разницу между shell- и exec-режимами.

FROM alpine:3.20
CMD ["ping", "ya.ru"]

Запустим контейнер и выполним команду ps внутри него:

docker build -t aboba:1.0 .
docker run aboba:1.0
docker ps  # Смотрим ID контейнера
docker exec <ID-контейнера> ps
PID   USER     TIME  COMMAND
    1 root      0:00 ping ya.ru
    6 root      0:00 ps

Мы наблюдаем 2 процесса. Один процесс – это команда ps. Он тут есть в целом по понятным причинам. А вот другой процесс – это команда ping, которую мы прописали в Dockerfile. Поскольку ps обычно отрабатывает и завершает свою работу, фактически в нашем контейнере работает только один процесс – ping. Более того, он имеет PID = 1. Этот факт нам понадобится дальше, когда мы перепишем Dockerfile в shell-режиме:

FROM alpine:3.20
CMD ping ya.ru

Давайте теперь соберём и запустим наш контейнер:

docker build -t aboba:2.0 .
docker run aboba:2.0
docker ps  # Смотрим ID контейнера
docker exec <ID-контейнера> ps

И получим… Тоже самое?

PID   USER     TIME  COMMAND
    1 root      0:00 ping ya.ru
    7 root      0:00 ps

Окей. А тогда в чём же разница? Давайте попробуем заменить Alpine на Debian:

FROM debian:12.9
RUN apt-get update -y
RUN apt-get install -y iputils-ping
RUN apt-get install -y procps
CMD ping ya.ru

Собираем и запускаем:

docker build -t aboba:2.0 .
docker run aboba:2.0
docker ps  # Смотрим ID контейнера

# Введём флаг -ef, чтобы видеть ID родительнского процесса (PPID)
docker exec <ID-контейнера> ps -ef

А вот тут уже есть какие-то различия в списке процессов:

UID          PID    PPID  C STIME TTY          TIME CMD
root           1       0  0 05:28 ?        00:00:00 /bin/sh -c ping ya.ru
root           7       1  0 05:28 ?        00:00:00 ping ya.ru
root          20       0 75 05:29 ?        00:00:00 ps -ef

Что мы видим?

  1. Процессом с PID = 1 является /bin/sh, а не ping.
  2. ping имеет PID равный 7.
  3. Кроме того, его PPID равен 1, а это значит, что /bin/sh является родительским процессом для ping.

Что же будет, если мы попробуем остановить контейнер, послав сигнал SIGINT при помощи Ctrl+C?

  1. Контейнер, созданный из образа aboba будет завершён.
  2. Контейнер, созданный из образа aboba2 аналогично.
  3. А вот aboba3 будет игнорировать наши попытки его завершить (именно так и начинается Skynet).

Чтобы понять, в чём разница, мы взглянем на вывод команды docker inspect aboba и docker inspect aboba3. Эти команда нам распечатают JSON, в котором содержится метаинформация про наши образы. Там много любопытной информации, проливающей свет на то, как Docker устроен, но нас интересуют конкретные несколько строк:

docker inspect aboba
[
  {
    ...
    "Config": {
      ...
      "Cmd": ["ping", "ya.ru"],
      ...
    }
    ...
  }
]
docker inspect aboba3
[
  {
    ...
    "Config": {
      ...
      "Cmd": ["/bin/sh", "-c", "ping ya.ru"],
      ...
    }
    ...
  }
]

Как мы можем наблюдать, у нас по-разному запускается наш ping. В первом случае он запускается напрямую. Во втором же случае он запускается через /bin/sh. Собственно поэтому он и является родительским процессом для ping. И именно поэтому сигналы до процесса ping не доходят, ведь в Docker’е сигналы, посланные контейнеру, всегда идут до процесса с PID = 1, которым в aboba3 является /bin/sh.

Но что же с aboba2? Давайте тоже для него запустим docker inspect aboba2:

[
  {
    ...
    "Config": {
      ...
      "Cmd": ["/bin/sh", "-c", "ping ya.ru"],
      ...
    }
    ...
  }
]

И мы получаем то же самое… Но почему же мы получаем то же поведение, что и у aboba? Я задался таким же вопросом, когда готовился к этой лекции. Для изучения этой темы я решил воспользоваться статьёй на Хабер за 2017 год: https://habr.com/ru/companies/slurm/articles/329138/

Сама по себе статья хорошая, однако, она оказалось немного неактуальной для новых версий Alpine. Дело в том, что Alpine вместо стандартного пакета GNU Coreutils использует BusyBox. При чём, видимо модифицированный, поскольку в других дистрибутивах, где используется BusyBox, поведение sh было больше похоже на образ aboba3. Скорее всего, разработчики Alpine, нацеленные на пользователей Docker, решили модифицировать оболочку командной строки, чтобы она не имела тех багов, которые возникают с aboba3.

Тем не менее, несмотря на то, что в Alpine shell-форма не имеет тех багов, которые есть в Debian, всё же разработчики Docker рекомендуют использовать exec-форму.

Разница между CMD и ENTRYPOINT

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

FROM alpine:3.20
CMD ["echo", "Hello, World!"]
$ docker build -t hello-world-image:1.0 .
$ docker run hello-world-image:1.0
Hello, world!

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

$ docker run hello-world-image:1.0 echo "Aboba"
Aboba

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

FROM alpine:3.20
ENTRYPOINT ["echo", "Hello, World!"]
$ docker build -t hello-world-image:2.0 .
$ docker run hello-world-image:2.0
Hello, world!

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

$ docker run hello-world-image:2.0 echo "Aboba"
Hello, World! echo Aboba

Как мы видим, при использовании ENTRYPOINT переопределяется не вся команда, а только её аргументы. Это может быть удобно, если ваша программа принимает какие-либо аргументы.

При этом мы всё ещё можем переопределить команду, которая будет выполнена при запуске контейнера, используя флаг --entrypoint:

docker run --entrypoint ps hello-world-image:2.0
PID   USER     TIME  COMMAND
    1 root      0:00 ps

ENTRYPOINT + CMD

Мы можем использовать CMD для указания аргументов по умолчанию, которые будут переданы в ENTRYPOINT:

FROM alpine:3.20
ENTRYPOINT ["echo"]
CMD ["Hello, world!"]

В таком случае по-умолчанию команде echo в качестве аргумента будет передаваться строка Hello, world!. Однако, если мы укажем другой аргумент, он заменить аргумент, прописанный CMD.

$ docker build -t hello-world-image:3.0 .
$ docker run hello-world-image:3.0
Hello, world!
$ docker run hello-world-image:3.0 Aboba
Aboba

Конфигурирование приложений в Docker при помощи переменных окружения

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

uv init example1
cd example1
uv add pydantic pydantic-settings python-dotenv
from pydantic_settings import BaseSettings, SettingsConfigDict
from time import sleep


class Settings(BaseSettings):
    db_url: str
    api_key: str

    model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")


def main():
    settings = Settings()
    print(settings, flush=True)

    while True:
        sleep(1)


if __name__ == "__main__":
    main()

Здесь мы использовали библиотеки pydantic и python-dotenv. Первая позволяет производить валидацию нашей конфигурации, а вторая позволяет записывать значения переменных окружения из файла .env. Как правило, это делается для удобства разработчика, чтобы ему не пришлось постоянно вводить эти переменные в терминале. Впрочем, если .env файла нет, python-dotenv будет считывать значения, что называется, “по-старинке” из непосредственно переменных окружения. Этот факт нам пригодится позже.

Напишем Dockerfile для нашего приложения (данный файл можно улучшить, но это мы рассмотрим позже):

FROM python:3.13-slim AS builder
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
ADD . /app
WORKDIR /app
RUN ["uv", "sync", "--frozen"]
CMD ["uv", "run", "main.py"]

Теперь напишем .env файл:

DB_URL=aboba
API_KEY=aboba

Соберём и запустим наш образ:

docker build -t config-example:1.0 .
docker run config-example:1.0

Что ж, наше приложение работает!

db_url='aboba' api_key='aboba'

Однако есть несколько проблем:

  1. Мы в образ копируем директорию .venv, которая создана на хостовой машине. Это проблема по двум причинам:
    1. Операционная система на хостовой машине и в контейнере могут отличаться. А поскольку помимо зависимостей на Python у нас могут быть и бинарные зависимости, это может привести к проблемам. (Впрочем, если мы посмотрим внимательно на логи, uv автоматически удаляет случайно скопированное нами виртуальное окружение и создаёт своё. Но так происходит не всегда, особенно если не использовать uv).
    2. .venv может весить очень много (например, в больших проектах или проектах, использующих ML-библиотеки) => копирование будет происходить долго. Мы просто тратим время на операции, которые нам не нужны. В любом случае, нам нужно создавать своё виртуальное окружение для образа.
  2. Также мы копируем .env файл в наш образ. Это уже проблема в безопасности. В образе не должны храниться секреты, по типу паролей, API-ключей и прочего, так как в случае утечки образа (или если ваш образ общедоступный) ваши секреты будут скомпрометированы, не говоря уже о том, что мы не сможем по-разному конфигурировать контейнеры, запускаемые из этого образа.
  3. Мы копируем кэш-файлы и папки, по типу __pycache__. Как и .venv, кэши являются платформозависимыми и не должны быть включены в образ по тем же причинам.
  4. Если вы используете Git и директория .git находится в корне проекта, то она тоже будет копироваться, увеличивая размер образа, хотя для сборки образа она как правило не нужна.

Мы бы могли модифицировать команду ADD в Dockerfile, например, используя регулярные выражения или вручную прописав все необходимые для копирования файлы. Но есть более удобный способ исключить из копирования ненужные файлы – .dockerignore файл. Он работает аналогично тому, как работает .gitignore. В .dockerignore файле можно указать паттерны файлов и директорий, которые не должны быть скопированы в образ. Создадим .dockerignore файл в корне проекта:

.gitignore
.git
.venv
__pycache__
.env

Давайте снова соберём и запустим наш образ:

docker build -t config-example:2.0 .
docker run config-example:2.0

И теперь наше приложение не работает:

...
pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings
db_url
  Field required [type=missing, input_value={}, input_type=dict]
    For further information visit https://errors.pydantic.dev/2.10/v/missing
api_key
  Field required [type=missing, input_value={}, input_type=dict]
    For further information visit https://errors.pydantic.dev/2.10/v/missing

А вот как исправить эту ошибку, вы узнаете в 3 части этой лекции.