Введение в 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
Что мы видим?
- Процессом с PID = 1 является
/bin/sh, а неping. pingимеет PID равный 7.- Кроме того, его PPID равен 1, а это значит, что
/bin/shявляется родительским процессом дляping.
Что же будет, если мы попробуем остановить контейнер, послав
сигнал SIGINT при помощи Ctrl+C?
- Контейнер, созданный из образа
abobaбудет завершён. - Контейнер, созданный из образа
aboba2аналогично. - А вот
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'
Однако есть несколько проблем:
- Мы в образ копируем директорию .venv, которая создана на хостовой машине.
Это проблема по двум причинам:
- Операционная система на хостовой машине и в контейнере могут отличаться.
А поскольку помимо зависимостей на Python у нас могут быть и бинарные
зависимости, это может привести к проблемам. (Впрочем, если мы посмотрим
внимательно на логи,
uvавтоматически удаляет случайно скопированное нами виртуальное окружение и создаёт своё. Но так происходит не всегда, особенно если не использоватьuv). .venvможет весить очень много (например, в больших проектах или проектах, использующих ML-библиотеки) => копирование будет происходить долго. Мы просто тратим время на операции, которые нам не нужны. В любом случае, нам нужно создавать своё виртуальное окружение для образа.
- Операционная система на хостовой машине и в контейнере могут отличаться.
А поскольку помимо зависимостей на Python у нас могут быть и бинарные
зависимости, это может привести к проблемам. (Впрочем, если мы посмотрим
внимательно на логи,
- Также мы копируем
.envфайл в наш образ. Это уже проблема в безопасности. В образе не должны храниться секреты, по типу паролей, API-ключей и прочего, так как в случае утечки образа (или если ваш образ общедоступный) ваши секреты будут скомпрометированы, не говоря уже о том, что мы не сможем по-разному конфигурировать контейнеры, запускаемые из этого образа. - Мы копируем кэш-файлы и папки, по типу
__pycache__. Как и.venv, кэши являются платформозависимыми и не должны быть включены в образ по тем же причинам. - Если вы используете 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 части этой лекции.