Введение

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

Начнем сначала: виртуализация

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

Звучит круто, правда? Но затраты ресурсов на сам гипервизор не радуют совсем. Поэтому в 2005 году разработчики Linux добавили в ядро такую штуку, как OpenVZ.

OpenVZ

OpenVZ — это первая попытка уйти от виртуализации в привычном смысле и сократить использование ресурсов. Это технология виртуализации на уровне операционной системы (если точнее, то на уровне ядра Linux). Как говорят разработчики Linux, затраты на виртуализацию составляют всего 1-3%, что несравнимо меньше стандартных на тот момент решений.

Но появилось ограничение: с помощью OpenVZ можно запускать только дистрибутивы Linux.

Плавно перейдем к контейнерам

Вернемся в 2002 год, когда в ядро Linux добавили первое пространство имен для изоляции файловой системы — mount. А в 2007 году Google разработал Process Containers, который умел ограничивать использование аппаратных ресурсов для процессов. Позднее этот механизм, к тому времени переименованный в cgroups, был добавлен в ядро Linux. В 2013 году это расширили и добавили в ядро пространства имен пользователей (user).

А еще в 2008 году (простите, что так перемещаюсь во времени, но так нужно) в Linux добавили такую штуку, как Linux Containers (LXC).

LXC

Эта штука умела запускать несколько контейнеров Linux на одном сервере. Она использовала уже существующий cgroups и неймспейсы. И это рано или поздно должно было превратиться во что-то очень важное и удобное. Так и случилось.

Docker

Наконец-то мы доехали до основной темы статьи :) в 2013 году никому не известная компания Docker, Inc выпустила свой продукт — Docker. На тот момент Docker был просто удобной оберткой над LXC, но именно из-за удобства он очень быстро набрал популярность.

Позднее он перешел на собственную библиотеку libcontainer (не менее связанную с ядром Linux). А когда стало понятно, что контейнеры — это надолго, компании Linux Foundation и Docker в 2015 году создали Open Container Initiative, которая до сих пор занимается разработкой стандартов для Linux-контейнеров.

В чем вообще прикол контейнеров?

Контейнеры — это такая прекрасная штука, которая может запустить операционную систему (Linux, да) в отдельном процессе, изолированном от остальной системы. Сам он будет уверен, что в системе только он и запущен. А изоляция эта обеспечивается все теми же containers и cgroups. Собственно, Docker так и работает.

Давайте определимся с понятиями (не теми, что на зоне)

  • Image. Образ. Файл, в который упакована среда исполнения приложения и самое это приложение. То, из чего вы запускаете контейнеры.
  • Registry Server. То место, где хранятся образы — туда мы можем запушить наш образ или стянуть оттуда существующий. Самый популярный и дефолтный — Docker Hub, но можно создавать и свои.
  • Container. То, что запускается из образа, тот самый изолированный процесс.
  • Container Engine. Движок контейнеризации. То, что скачивает образы и запускает контейнеры (ну, не совсем, это нам так кажется). Ну, собственно, главный пример — Docker.
  • Container Runtime. А вот эта штука действительно запускает контейнеры. runc, crun, вот это все.
  • Host. Система, где все это происходит.

А теперь можно и к практике

Что мы с этим докером делаем? Скачиваем образы (по дефолту с Docker Hub, но можно указать и другой registry server). Это делается командой docker pull. К примеру: docker pull mongo:6.2 скачает вам образ MongoDB версии 6.2. Если не указывать версию явно, скачается последняя версия (latest). Команда эта необязательна: мы можем сделать docker create mongo:6.2 (создать на основе скачанного образа контейнер) или docker run mongo:6.2 (создать из образа контейнер и запустить его). Тогда образ скачается автоматически, если он еще не скачан.

Вот самые популярные команды, которые мы запускаем:

# справочная информация
docker --help # список доступных команд
docker <command> --help # информация по команде

docker --version # версия Docker
docker info # общая информация о системе

# работа с образами
docker search debian # поиск образов по ключевому слову debian

docker pull ubuntu # скачивание последней версии (тег по умолчанию latest) официального образа ubuntu (издатель не указывается) из репозитория по умолчанию docker.io/library
docker pull prom/prometheus # скачивание последней версии (latest) образа prometheus от издателя prom из репозитория docker.io/prom
docker pull docker.io/library/ubuntu:18.04 # скачивание из репозитория docker.io официального образа ubuntu с тегом 18.04

docker images # просмотр локальных образов

docker rmi <image_name>:<tag> # удаление образа. Вместо <image_name>:<tag> можно указать <image_id>. Для удаления образа все контейнеры на его основе должны быть как минимум остановлены
docker rmi $(docker images -aq) # удаление всех образов

# работа с контейнерами
docker run hello-world # Hello, world! в мире контейнеров
docker run -it ubuntu bash # запуск контейнера ubuntu и выполнение команды bash в интерактивном режиме
docker run --name docker-getting-started --publish 8080:80 docker/getting-started # запуск контейнера gettind-started с отображением (маппингом) порта 8080 хоста на порт 80 внутрь контейнера
docker run --detach --name mongodb docker.io/library/mongo:4.4.10 # запуск контейнера mongodb с именем mongodb в фоновом режиме. Данные будут удалены при удалении контейнера!

docker ps # просмотр запущенных контейнеров
docker ps -a # просмотр всех контейнеров (в том числе остановленных)
docker stats --no-stream # просмотр статистики

docker start alpine # создание контейнера из образа alpine

docker start <container_name> # запуск созданного контейнера. Вместо <container_name> можно указать <container_id>
docker start $(docker ps -a -q) # запуск всех созданных контейнеров

docker stop <container_name> # остановка контейнера. Вместо <container_name> можно указать <container_id>
docker stop $(docker ps -a -q) # остановка всех контейнеров

docker rm <container_name> # удаление контейнера. Вместо <container_name> можно указать <container_id>
docker rm $(docker ps -a -q) # удаление всех контейнеров

# система
docker system info # общая информация о системе (соответствует docker info)
docker system df # занятое место на диске
docker system prune -af # удаление неиспользуемых данных и очистка диска

Есть прикол: внутри контейнера есть собственная файловая система, которая снаружи не видна и удаляется при удалении контейнера. Иногда мы хотим пробросить какие-то файлы внутрь контейнера, чтобы сохранить их на хосте (ну, к примеру, с той же монгой мы наверняка хотим иметь все данные в базе локально). Так как мы можем пошарить данные контейнера с хостом?

Есть два метода:

  • named volumes — именованные тома хранения данных. Cохраняются в ./docker/volumes, не удаляются при удалении контейнера, могут шариться между несколькими контейнерами.
  • bind mount — монтирование каталога с хоста в контейнер. Простой проброс каталога с хоста в контейнер. Таким образом, данные в проброшенных файлах доступны и хранятся локально.

Вот как это все использовать:

# справочная информация
docker <command> --help

# named volume
docker run --detach --name jenkins --publish 80:8080 --volume=jenkins_home:/var/jenkins_home/ jenkins/jenkins:lts-jdk11 # запуск контейнера jenkins с подключением каталога /var/jenkins_home как тома jenkins_home
docker volume ls # просмотр томов
docker volume prune # удаление неиспользуемых томов и очистка диска. Для удаления тома все контейнеры, в которых он подключен, должны быть остановлены и удалены

# bind mount
# запуск контейнера node-exporter с монтированием каталогов внутрь контейнера в режиме read only: /proc хоста прокидывается в /host/proc:ro внутрь контейнера, /sys - в /host/sys:ro, а / - в /rootfs:ro
docker run \
-p 9100:9100 \
-v "/proc:/host/proc:ro" \
-v "/sys:/host/sys:ro" \
-v "/:/rootfs:ro" \
--name node-exporter prom/node-exporter:v1.1.2

Создание образов

Да, можно не просто качать уже существующие образы, но и создавать свои. Есть такая штука — Dockerfile.

Вот супер-базовый пример создания образов (на самом деле, этот самый докерфайл — просто набор команд, которые нужно выполнить, чтобы создать образ):

# Dockerfile
# создание файла Dockerfile декларативного описания
FROM ubuntu:20.04
RUN apt-get update && apt-get install -y iputils-ping

# запуск команды build из каталога с Dockerfile для создания образа simust/ubuntu-ping:20.04
docker build -t ubuntu-ping:20.04 .
docker images

# tag, login, push
docker tag ubuntu-ping:20.04 our-registry/ubuntu-ping:20.04 # создание из локального образа ubuntu-ping:20.04 тега с репозиторием для издателя simust
docker images
# вход в репозиторий docker.io под пользователем simust и отправка образа
docker login -u simust docker.io
docker push our-registry/ubuntu-ping:20.04

Понятно, что registry можно не указывать, если нам достаточно иметь образ локально или мы хотим использовать Docker Hub как дефолтный registry.

Docker Compose

Docker Compose — это такая удобная простая штука для развертывания проектов. Она объединяет в себе сборку, загрузку и запуск нескольких контейнеров. По умолчанию для такого используется файл docker-compose.yml.

Выглядеть он может примерно так:

version: '3.5'
services:
  postgres:
    image: postgres:latest
    restart: unless-stopped
    volumes:
      - ./private/var/lib/postgresql:/var/lib/postgresql
    environment:
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=postgres
      - POSTGRES_DB=test_app_db
    healthcheck:
      test: ['CMD-SHELL', 'pg_isready -U postgres']
      interval: 5s
      timeout: 5s
      retries: 5
    ports:
      - '5432:5432'

  mongo:
    image: mongo:latest
    restart: unless-stopped
    volumes:
      - ./private/mongodb_data_container:/var/lib/postgresql
    healthcheck:
      test: echo 'db.runCommand({serverStatus:1}).ok' | mongosh admin -u root -p rootpassword --quiet | grep 1
      interval: 5s
      timeout: 5s
      retries: 5
    environment:
      MONGO_INITDB_ROOT_USERNAME: root
      MONGO_INITDB_ROOT_PASSWORD: rootpassword
    ports:
      - "27017:27017"
  api:
    build:
      context: ./
      dockerfile: ./deploy/Dockerfile
    environment:
      - POSTGRES_DB_HOST=postgres
      - POSTGRES_DB_USERNAME=postgres
      - POSTGRES_DB_PASSWORD=postgres
      - POSTGRES_DB_NAME=test_app_db
      - POSTGRES_DB_PORT=5432
      - MONGO_HOST=mongo
      - MONGO_USERNAME=root
      - MONGO_PASSWORD=rootpassword
      - MONGO_PORT:27017
      - APP_PORT:3000
    ports:
      - "3000:3000"
    depends_on:
      mongo:
        condition: service_healthy
      postgres:
        condition: service_healthy

Выглядит понятно, правда? Мы хотим скачать последние версии postgres и mongo и сбилдить наше приложение из докерфайла, после чего запустить все это добро, дождаться готовности postgres и mongo и запустить наше приложение, сделав так, чтобы оно было доступно на 3000 порту.

Cобираем мы все это дело так: docker-compose build, а запускаем так: docker-compose up. Стопнуть все можно так: docker-compose down, почитать логи так: docker-compose logs -f app. docker-compose ps покажет нам запущенные сервисы, а docker-compose exec [service name] [command] — выполнить команду внутри запущенного сервера.

Итоги

Я очень постарался просто и коротко все объяснить, но в статье такого формата не распишешь все возможности Docker. Эта статья поможет вам базово научиться работать с докером, но, пожалуйста, привыкайте читать документацию. Вот она, документация — там все сильно подробнее.

А так — ну, контейнер вы запустить сможете, образ сбилдить — тоже. Уже неплохо.