Docker и Docker Compose — професионално ръководство

Част 2: Архитектура на Docker

Какво всъщност стои зад командата docker run и как Docker превръща един image в работещ container.

Ако искам да работя професионално с Docker, трябва да разбера какво има под капака. Не е необходимо всеки ден да мисля за namespaces, cgroups или OverlayFS. Но когато контейнерът не стартира, мрежата не работи, volume-ът се държи странно или Docker започне да консумира дисково пространство, тогава тези понятия престават да бъдат теория.

1. Защо изобщо ми е нужна архитектурата

В първата част разгледах основната идея зад Docker – контейнерът не е виртуална машина и не трябва да го използвам като такава. Това е важно, но само по себе си не е достатъчно.

Когато изпълня:

TERMINAL
docker run -d --name nginx nginx

изглежда така, сякаш Docker прави една проста операция: намира image, създава container и го стартира. В действителност зад тази команда стои цяла верига от компоненти.

Docker CLI
командата
dockerd
Docker Engine
containerd / runc
runtime
Linux Kernel
namespaces · cgroups · filesystem · networking

Тази архитектура е една от причините Docker да бъде толкова удобен за администратора. Аз работя основно с CLI и Compose, а по-ниските слоеве се грижат за изпълнението. Когато обаче нещо се счупи, трябва да мога да проследя пътя от командата до процеса.

2. Docker Engine

Docker Engine е сървърната част на Docker. Той е това, което реално управлява images, containers, networks и volumes. В Linux среда най-важният процес е dockerd.

Когато напиша:

TERMINAL
docker ps

командата не изпълнява сама контейнерите. CLI изпраща заявка към Docker daemon, а daemon-ът връща резултата. Това разделение е фундаментално.

Docker CLI

Инструментът, с който работя.

Docker daemon

Приема API заявки и управлява ресурсите.

Kernel / runtime

Осигурява реалното изпълнение и изолация.

2.1. Docker daemon – dockerd

На Linux обикновено мога да проверя състоянието на Docker така:

TERMINAL
systemctl status docker

systemctl is-active docker

ps aux | grep dockerd

Ако daemon-ът не работи, обикновеният Docker CLI няма към кого да изпрати заявката. Това е една от първите проверки, които правя при проблем с Docker.

Практическо правило: ако docker ps връща грешка от типа „Cannot connect to the Docker daemon“, не започвам да ремонтирам контейнерите. Първо проверявам самия Docker daemon и достъпа до него.

3. Docker CLI

Docker CLI е клиентът, с който давам команди на Docker. Команди като docker ps, docker pull, docker run, docker inspect и docker network ls са интерфейсът, който използвам всекидневно.

Важно е да не бъркам CLI с Engine. Мога да имам Docker CLI на една машина и да управлявам Docker daemon на друга, стига да имам подходящ API достъп.

ПРОВЕРКА
docker version

docker info

docker context ls

docker version е особено полезна команда, защото показва информация както за Client, така и за Server. Ако двете части не могат да се видят, проблемът вече е локализиран доста по-тясно.

4. Docker API

Между CLI и daemon-а стои Docker API. CLI не „влиза“ директно в dockerd. То изпраща API заявки.

По подразбиране на Linux комуникацията локално се извършва през Unix socket:

TERMINAL
ls -l /var/run/docker.sock

В много системи това е символична връзка към:

TERMINAL
/run/docker.sock

Този socket е изключително мощен интерфейс. Достъпът до него на практика означава много високи привилегии върху Docker host.

Това не е обикновен файл. Не давам достъп до docker.sock на произволни контейнери и не го публикувам през TCP без сериозна причина. Контейнер, който може да управлява Docker daemon-а, потенциално може да управлява и самия host.

5. containerd и runc

Тук вече слизаме един слой надолу. Docker daemon не върши сам цялата работа по стартирането на контейнерния процес. В съвременната архитектура Docker използва containerd като container runtime manager, а на по-ниско ниво runc реализира OCI container execution.

Docker CLI
Docker daemon (dockerd)
containerd
runc / OCI runtime
Linux Kernel

Не е необходимо всеки ден да управлявам ръчно тези компоненти. Напротив – обикновено не трябва. Но е важно да знам, че контейнерът не е „магия“, а процес, чието стартиране минава през конкретни слоеве.

5.1. Какво прави containerd

containerd се занимава с жизнения цикъл на контейнерите и управлението на container runtime. Той е отделен проект и се използва не само от Docker. Именно това разделяне е една от причините containerd да има значение и извън класическия Docker стек.

5.2. Какво прави runc

runc е ниско ниво OCI runtime. Той получава описание на контейнера и използва Linux механизмите за създаване на изолирана среда и стартиране на процеса.

Когато казвам „контейнерът е процес“, това вече има съвсем конкретен смисъл. Няма отделна операционна система, която чака някой да я „включи“. Има процес, стартиран с определени ограничения и изолация.

6. Linux Kernel – мястото, където реално се случва всичко

Docker не може да създаде изолация самостоятелно. Той използва възможностите на Linux Kernel. Два от най-важните механизма са namespaces и cgroups.

Към тях трябва да добавя filesystem слоя, networking, capabilities, seccomp и други kernel механизми. Именно комбинацията от тези възможности създава това, което аз възприемам като container.

7. Linux Namespaces

Namespaces изолират изгледа на процесите към определени ресурси на системата. Най-просто казано: два процеса могат да работят на един и същ Kernel, но да виждат различна среда.

Namespace Какво изолира Практически смисъл
PID Process IDs Контейнерът вижда собствено дърво от процеси.
NET Network stack Собствени interfaces, routes и ports.
MNT Mount points Собствен изглед към filesystem.
UTS Hostname Контейнерът може да има собствен hostname.
IPC Inter-process communication Изолация на IPC ресурси.
USER User/Group IDs Позволява user ID mapping между host и container.
CGROUP Cgroup view Изолира изгледа към control groups.

Например, ако изпълня:

TERMINAL
docker run --rm alpine ps

получавам изглед към процесите вътре в контейнера, а не нормалния списък с всички процеси на host системата. Това е една от най-добрите демонстрации на идеята зад container isolation.

8. cgroups – контрол върху ресурсите

Ако namespaces определят какво вижда процесът, cgroups определят до голяма степен какви ресурси може да използва.

Това е особено важно при production системи. Не искам един контейнер да изяде цялата RAM и да превърне останалите услуги в жертва.

Docker може да задава ограничения за CPU, memory и други ресурси.

ПРИМЕР
docker run -d \
  --name test-nginx \
  --memory=512m \
  --cpus=1.0 \
  nginx

Тук давам на контейнера ограничение от 512 MB memory и приблизително един CPU. Това не означава, че контейнерът автоматично ще използва точно толкова. Означава, че задавам контрол върху максималното потребление според конкретната настройка.

Практика: не поставям ограничения механично на всеки контейнер. Първо измервам потреблението, после определям разумни граници. Особено при бази данни твърде агресивните ограничения могат да доведат до повече проблеми, отколкото решават.

9. Docker Images и Layers

Един от най-важните архитектурни принципи на Docker е, че image не е един монолитен файл. Image се изгражда от слоеве (layers).

Например един опростен Dockerfile:

Dockerfile
FROM debian:bookworm

RUN apt-get update && apt-get install -y nginx

COPY nginx.conf /etc/nginx/nginx.conf

CMD ["nginx", "-g", "daemon off;"]

Всяка инструкция, която променя filesystem-а, може да доведе до нов layer. Именно това позволява повторното използване на вече съществуващи слоеве и прави image cache толкова важен.

Container writable layer
Application layer
Package layer
Base image layer
Linux distribution base

Слоевете на image са по същество неизменяеми. Когато container стартира, Docker добавя writable layer за конкретния container. Точно тук се появява една много важна практическа последица: не трябва да разчитам на writable layer за постоянни данни.

10. OverlayFS и файловата система на контейнера

На Linux Docker обичайно използва storage driver, базиран на OverlayFS, когато средата го позволява. Идеята е няколко filesystem слоя да бъдат представени като един общ изглед.

От моя гледна точка това изглежда просто:

Image layers

read-only

+
Container layer

writable

Общ filesystem view

Това е изключително удобно, но има цена. Ако започна да записвам огромни количества данни директно във writable layer на контейнера, губя част от предимствата на модела и усложнявам управлението на данните.

11. Къде тогава живеят данните?

Тук идват volumes и bind mounts. Те отделят жизнения цикъл на данните от жизнения цикъл на контейнера.

11.1. Docker volume

TERMINAL
docker volume create mariadb-data

docker volume ls

docker volume inspect mariadb-data

11.2. Bind mount

TERMINAL
docker run -d \
  --name nginx \
  -v /srv/nginx/conf:/etc/nginx/conf.d:ro \
  nginx

Разликата между volume и bind mount ще бъде разгледана подробно в отделна част. Тук е достатъчно да запомня основния принцип:

Container може да бъде временен. Данните не трябва да бъдат.

12. Docker Networking

Контейнерите не получават просто „някакъв IP адрес“. Docker създава мрежова среда, в която контейнерите могат да комуникират според избраната network конфигурация.

При стандартната Docker инсталация най-често ще видя:

TERMINAL
docker network ls

Типичен резултат:

ПРИМЕРЕН РЕЗУЛТАТ
NETWORK ID     NAME      DRIVER    SCOPE
xxxxxx         bridge    bridge    local
xxxxxx         host      host      local
xxxxxx         none      null      local

В реални Compose проекти обикновено създавам собствени bridge networks. Това ми позволява да разделя услугите логически и да контролирам кой с кого комуникира.

reverse-proxy

nginx / proxy

web

WordPress / app

database

MariaDB / PostgreSQL

В Compose мога да имам например:

compose.yaml
services:
  web:
    image: nginx:alpine
    networks:
      - frontend

  db:
    image: mariadb:11
    networks:
      - backend

  app:
    image: myapp:latest
    networks:
      - frontend
      - backend

networks:
  frontend:
  backend:

Така app може да вижда и двата сегмента, докато web няма директна връзка към database network. Това е прост пример, но показва защо Docker networking е архитектурен инструмент, а не просто начин да получа IP адрес.

13. Жизнен цикъл на контейнера

Един container има жизнен цикъл. Той не е равнозначен на image. Това разграничение е критично.

Image
Create
Start
Running
Stopped
Removed

Мога да имам един image и много containers от него.

ПРИМЕР
docker run -d --name web1 nginx
docker run -d --name web2 nginx
docker run -d --name web3 nginx

И трите контейнера използват един и същ image, но са различни runtime обекти. Всеки има собствено име, metadata, network attachment и writable layer.

14. Как проверявам какво реално се случва

Когато нещо не ми хареса в Docker, не гадая. Използвам inspect, логове и системните инструменти.

ОСНОВНИ ПРОВЕРКИ
docker ps
docker ps -a

docker inspect <container>

docker logs <container>

docker stats

docker network inspect <network>

docker volume inspect <volume>

docker image inspect <image>

Например:

CONTAINER
docker inspect nginx

оттам мога да видя mounts, networks, environment, command, restart policy, IP адреси, image ID и множество други параметри.

Моето правило при диагностика: първо наблюдавам, после променям. docker inspect често ми дава повече информация от десет опита да „оправя“ контейнера на сляпо.

15. Какво всъщност става при docker run

Нека разгледам една проста команда:

TERMINAL
docker run -d --name web nginx

Опростено процесът изглежда така:

  1. Docker CLI приема командата.
  2. CLI изпраща заявка към Docker daemon.
  3. Daemon проверява дали image nginx е наличен.
  4. Ако не е наличен, image се изтегля от registry.
  5. Docker създава metadata за новия container.
  6. Създава се необходимата filesystem структура.
  7. Настройват се namespaces и cgroups.
  8. Конфигурира се network.
  9. При необходимост се монтират volumes или bind mounts.
  10. container runtime стартира процеса.
  11. Docker връща container ID.

Това е причината командата да изглежда проста, въпреки че под нея има доста работа.

16. Изолация не означава абсолютна сигурност

Тук трябва да бъда особено внимателен. Контейнерът не е магическа защитна стена и не трябва да го приемам като абсолютна security boundary.

Docker използва редица механизми за ограничаване на правата, включително Linux capabilities, seccomp и различни filesystem и namespace ограничения. Но конфигурацията има значение.

Например:

РИСКОВА НАСТРОЙКА
docker run --privileged ...

--privileged не е „още малко права“. Това е сериозна промяна на security модела на контейнера. Използвам го само когато имам конкретна причина и разбирам последствията.

Същото важи и за директното монтиране на чувствителни host директории и за предоставянето на Docker socket.

17. Къде се появява Docker Compose

Docker CLI е прекрасен инструмент за управление на отделни контейнери. Но когато приложението ми се състои от пет, десет или тридесет услуги, започвам да имам друг проблем: трябва да помня как точно съм ги създал.

Именно тук идва Docker Compose.

Вместо:

МНОГО КОМАНДИ
docker network create app-net
docker volume create db-data
docker run ...
docker run ...
docker run ...
docker run ...

описвам желаното състояние в YAML файл:

compose.yaml
services:
  app:
    image: nginx:alpine
    ports:
      - "8080:80"

  db:
    image: mariadb:11
    environment:
      MARIADB_ROOT_PASSWORD: example

  redis:
    image: redis:alpine

После:

TERMINAL
docker compose up -d

Compose не отменя архитектурата на Docker. То е инструмент върху нея. Това разграничение ще стане много важно в следващите части.

18. Моят модел за мислене

Когато работя с Docker, държа в главата си следната последователност:

Image
какво ще бъде стартирано
Container
конкретна runtime инстанция
Network
как комуникира
Volume
къде са данните
Policy
как се управлява

Ако имам проблем, първо определям в кой слой е.

Симптом Първо проверявам
Cannot connect to Docker daemon Docker service, socket, permissions, context
Container се рестартира постоянно docker logs, exit code, command, healthcheck
Приложението няма достъп до DB Docker network, service name, port, credentials
Данните изчезват Volume / bind mount / writable layer
Дискът се запълва Images, containers, volumes, logs, build cache
Контейнерът е бавен CPU, memory, I/O, storage driver, приложение

19. Грешки, които вече не си позволявам

  • Не пазя важни данни само във filesystem layer на контейнера.
  • Не редактирам произволно конфигурации вътре в контейнер и после да забравя какво съм променил.
  • Не използвам --privileged без конкретна причина.
  • Не публикувам Docker API към интернет.
  • Не давам Docker socket на контейнер, без да разбирам security последствията.
  • Не диагностицирам чрез налучкване.
  • Не приемам, че „container running“ означава „application healthy“.
  • Не бъркам image, container, volume и network – те са различни обекти с различен жизнен цикъл.

20. Най-важното от тази част

Ако трябва да запомня само няколко неща от тази глава, те са следните.

  • Docker CLI е инструментът, с който давам команди.
  • Docker daemon управлява Docker ресурсите.
  • containerd и runc участват в реалното изпълнение на контейнерите.
  • Linux Kernel осигурява основните механизми за изолация и контрол.
  • Namespaces изолират изгледа на процесите и ресурсите.
  • cgroups позволяват контрол върху ресурсите.
  • Images са основата, от която се създават containers.
  • Volumes и bind mounts отделят данните от жизнения цикъл на контейнера.
  • Networks определят комуникацията между услугите.
  • Compose описва желаното състояние на многоконтейнерни приложения.

Заключение

Docker изглежда прост, докато го използвам само с няколко команди. Когато започна да поддържам реална инфраструктура, простотата отпред вече не означава простота отдолу.

И точно това е хубавото. Не е необходимо да управлявам всеки слой ръчно. Не е необходимо да знам вътрешната реализация на всеки компонент наизуст. Но когато нещо се обърка, трябва да знам къде да търся.

За мен това е разликата между човек, който използва Docker, и човек, който администрира Docker.

В следващата част ще сляза още едно ниво надолу и ще разгледам Docker Images: как се създават, как работят layers, какво представлява image cache, защо редът на инструкциите в Dockerfile има значение и как една на пръв поглед дребна промяна може да направи build процеса многократно по-бавен или по-бърз.