Урок курса
Слой контейнера и named volume
Docker и Docker Compose: контейнеризация приложений1. Файл после перезапуска и нового запуска
Образ Docker состоит из набора неизменяемых слоёв, доступных только для чтения. При создании контейнера демон Docker добавляет поверх них один тонкий слой для записи — изменяемый слой (writable layer, или container layer). Все файловые операции процесса внутри контейнера — создание временных файлов, правка существующих или удаление — происходят именно в этом слое.
Жизненный цикл writable layer привязан к конкретному экземпляру контейнера. Когда контейнер останавливается, его изменяемый слой сохраняется на диске хоста. При последующем запуске (docker start) или перезапуске (docker restart) процесс видит все ранее записанные файлы. Однако полное удаление контейнера (docker rm) безвозвратно уничтожает его изменяемый слой.
Для проверки этого поведения и последующей работы с файловым хранилищем соберём специальный учебный образ course-notes-storage:1.0.0 из каталога main_app. Простой образ course-notes:1.0.0 из предыдущих уроков работал от пользователя root и не содержал выделенного каталога под данные. В предоставленном архиве проекта подготовлен файл Dockerfile.versioned, в котором уже настроен запуск процесса от непривилегированного пользователя 10001:10001, системный каталог /app защищён от записи, а каталог /data создан и передан во владение пользователю 10001.
Перейдём в каталог проекта main_app и выполним сборку образа:
docker build -f Dockerfile.versioned --build-arg APP_VERSION=1.0.0 -t course-notes-storage:1.0.0 .
В этой команде:
-f Dockerfile.versionedявно указывает Docker использовать готовый многоэтапный файл сборки вместо стандартногоDockerfile;--build-arg APP_VERSION=1.0.0передаёт аргумент сборки для подстановки соответствующего файла версии;-t course-notes-storage:1.0.0задаёт имя и тег создаваемого образа;- точка
.в конце определяет текущую директорию как контекст сборки.
В собранном образе каталог /app принадлежит root, поэтому процесс с UID 10001 не может писать в корень приложения. Общесистемным каталогом, доступным для записи в изменяемом слое, остаётся стандартный каталог /tmp.
Запустим контейнер с именем cf_docker_course_l19_layer_test в фоновом режиме с флагом -d:
docker run -d --name cf_docker_course_l19_layer_test course-notes-storage:1.0.0
Команда возвращает идентификатор созданного контейнера. Создадим проверочный файл в каталоге /tmp изменяемого слоя:
docker exec cf_docker_course_l19_layer_test sh -c "echo 'state in layer' > /tmp/temp_layer.txt"
Проверим наличие и содержимое файла:
docker exec cf_docker_course_l19_layer_test cat /tmp/temp_layer.txt
Вывод команды:
state in layer
Перезапустим контейнер командой docker restart:
docker restart cf_docker_course_l19_layer_test
Команда выводит имя перезапущенного контейнера:
cf_docker_course_l19_layer_test
Снова прочитаем файл через cat:
docker exec cf_docker_course_l19_layer_test cat /tmp/temp_layer.txt
Вывод остаётся прежним: state in layer. Перезапуск основного процесса не затронул состояние writable layer.
Теперь проверим, что произойдёт при удалении контейнера. Сначала остановим его, а затем удалим объект контейнера:
docker stop cf_docker_course_l19_layer_test
docker rm cf_docker_course_l19_layer_test
Вместе с контейнером демон Docker полностью удалил его изменяемый слой. Запустим абсолютно новый контейнер с тем же именем из того же образа:
docker run -d --name cf_docker_course_l19_layer_test course-notes-storage:1.0.0
Попробуем прочитать файл /tmp/temp_layer.txt в новом экземпляре:
docker exec cf_docker_course_l19_layer_test cat /tmp/temp_layer.txt
В среде Debian GNU cat возвращает стандартную ошибку отсутствия файла и ненулевой код возврата:
cat: /tmp/temp_layer.txt: No such file or directory
Новый экземпляр стартует с чистым изменяемым слоем. Совпадение имени контейнера не возвращает файлы удалённого экземпляра.
Остановим и удалим тестовый контейнер перед переходом к именованным томам:
docker stop cf_docker_course_l19_layer_test
docker rm cf_docker_course_l19_layer_test
Интерактивная модель сохранения данных
Скачать docker_storage_model.zip
Распакуйте архив и откройте storage_lab.html в браузере. Это автономная учебная модель; Docker она не запускает.
2. Данные отдельно от экземпляра
Чтобы сохранять данные независимо от жизненного цикла контейнеров, Docker предоставляет отдельную сущность — именованный том (named volume). Том представляет собой выделенный каталог на хосте, которым полностью управляет демон Docker вне файловой системы конкретного контейнера.
Жизненный цикл named volume отделён от контейнера. Том создаётся явно либо автоматически при первом монтировании, сохраняется при остановке или удалении любых подключённых к нему контейнеров и удаляется только явной отдельной командой управления томами.
Создадим собственный учебный том с именем cf_docker_course_l19_data с помощью команды docker volume create:
docker volume create cf_docker_course_l19_data
Команда возвращает имя созданного тома:
cf_docker_course_l19_data
Для просмотра метаданных объекта хранилища используется команда docker volume inspect <имя_тома>:
docker volume inspect cf_docker_course_l19_data
Команда возвращает массив с описанием тома в формате JSON:
[
{
"CreatedAt": "2026-03-30T10:00:00Z",
"Driver": "local",
"Labels": null,
"Mountpoint": "/var/lib/docker/volumes/cf_docker_course_l19_data/_data",
"Name": "cf_docker_course_l19_data",
"Options": null,
"Scope": "local"
}
]
В выводе инспекции важны три поля:
Name— уникальное имя тома, по которому контейнеры подключаются к этому хранилищу.Driver— механизм хранения. По умолчанию применяется драйверlocal, размещающий данные в каталогах, управляемых Docker Engine.Mountpoint— физический путь к каталогу на уровне ядра Linux. При запуске Docker Desktop на Windows или macOS этот путь находится внутри служебной виртуальной машины Docker, а не напрямую в проводнике операционной системы хоста. Читать и изменять файлы вMountpointнапрямую через хост не следует: согласованность прав и структуры каталогов гарантируется только тогда, когда доступ к ним осуществляется через контейнеры.
Для подключения именованного тома при запуске контейнера используется флаг --mount. Его аргументы задаются списком пар ключ=значение, разделённых запятыми: --mount type=volume,source=<имя_тома>,target=<путь_в_контейнере> (вместо source и target допускаются краткие формы src и dst).
Когда процесс внутри контейнера обращается к каталогу target, операции чтения и записи направляются непосредственно в том, минуя изменяемый слой контейнера.
В подготовленном образе course-notes-storage:1.0.0 каталог /data уже создан и передан во владение пользователю с UID 10001. При передаче переменной окружения STORAGE=file сервис по умолчанию сохраняет состояние в файл /data/items.json. Если смонтировать том cf_docker_course_l19_data в каталог /data, файл базы заметок будет храниться в независимом объекте Docker.
Запустим контейнер приложения из собранного ранее образа course-notes-storage:1.0.0, подключив созданный том, включив файловый режим и опубликовав порт 8000 на свободном локальном порту хоста 127.0.0.1:18084:
docker run -d \
--name cf_docker_course_l19_app \
--mount type=volume,source=cf_docker_course_l19_data,target=/data \
-e STORAGE=file \
-p 127.0.0.1:18084:8000 \
course-notes-storage:1.0.0
Команда возвращает идентификатор запущенного контейнера. Сервис запущен, а точка /data выведена за пределы жизненного цикла изменяемого слоя.
3. Проверить два места хранения
Сравним поведение двух мест хранения: каталога /data, куда смонтирован именованный том, и каталога /tmp, который относится к обычному изменяемому слою контейнера.
Перед первой записью через HTTP API разберём параметры утилиты curl:
-iвключает вывод HTTP-заголовков ответа, что позволяет явно увидеть код статуса сервера;-X POSTзадаёт HTTP-метод запроса для создания ресурса вместо метода по умолчаниюGET;-H "Content-Type: application/json"передаёт HTTP-заголовок, сообщающий серверу, что тело запроса передаётся в формате JSON;-d '{"text": "file volume note"}'передаёт полезную нагрузку запроса. В командной оболочке POSIX shell тело заключается в одинарные кавычки, чтобы двойные кавычки внутри JSON передавались серверу буквально и не интерпретировались терминалом хоста.
Отправим запрос на добавление заметки на опубликованный адрес 127.0.0.1:18084:
curl -i -X POST http://127.0.0.1:18084/notes \
-H "Content-Type: application/json" \
-d '{"text": "file volume note"}'
Сервер возвращает подтверждение успешного создания записи с кодом статуса 201 Created и созданным объектом в теле ответа:
HTTP/1.0 201 Created
Content-Type: application/json; charset=utf-8
Content-Length: 37
{"id": 1, "text": "file volume note"}
Приложение сохранило этот объект в файл /data/items.json внутри тома. Одновременно создадим файл маркера в изменяемом слое контейнера по пути /tmp:
docker exec cf_docker_course_l19_app sh -c "echo 'layer note' > /tmp/marker.txt"
Проверим наличие обоих файлов внутри работающего контейнера:
docker exec cf_docker_course_l19_app cat /data/items.json
docker exec cf_docker_course_l19_app cat /tmp/marker.txt
Оба файла на месте: первый содержит массив с сохранённой заметкой, второй — строку layer note.
Выполним перезапуск контейнера командой docker restart:
docker restart cf_docker_course_l19_app
После перезапуска проверим доступность обоих файлов. Выполним GET-запрос к коллекции заметок:
curl -s http://127.0.0.1:18084/notes
Ответ возвращает сохранённую запись:
{"items": [{"id": 1, "text": "file volume note"}]}
Проверим файл в каталоге /tmp:
docker exec cf_docker_course_l19_app cat /tmp/marker.txt
Вывод возвращает layer note. На этапе docker restart оба файла сохраняются. Успешный restart не доказывает независимость хранилища, поскольку контейнер не уничтожался и его изменяемый слой остался нетронутым.
Теперь полностью уничтожим контейнер: сначала остановим его, а затем удалим объект контейнера:
docker stop cf_docker_course_l19_app
docker rm cf_docker_course_l19_app
Контейнер cf_docker_course_l19_app удалён. Вместе с ним безвозвратно уничтожен его изменяемый слой. Но том cf_docker_course_l19_data остался существовать в хранилище демона Docker.
Запустим совершенно новый экземпляр контейнера из собранного ранее образа course-notes-storage:1.0.0, подключив к нему тот же самый именованный том cf_docker_course_l19_data:
docker run -d \
--name cf_docker_course_l19_app \
--mount type=volume,source=cf_docker_course_l19_data,target=/data \
-e STORAGE=file \
-p 127.0.0.1:18084:8000 \
course-notes-storage:1.0.0
Проверим оба ресурса в новом контейнере. Сначала запросим список заметок через API:
curl -s http://127.0.0.1:18084/notes
Ответ подтверждает сохранность данных:
{"items": [{"id": 1, "text": "file volume note"}]}
Заметка пережила полное удаление и пересоздание контейнера с новым идентификатором. Теперь проверим файл маркера в изменяемом слое /tmp/marker.txt:
docker exec cf_docker_course_l19_app cat /tmp/marker.txt
Команда завершается ошибкой отсутствия файла:
cat: /tmp/marker.txt: No such file or directory
Распространённое заблуждение заключается в предположении, что удаление контейнера через docker rm автоматически очищает примонтированные тома. Это не так: docker rm удаляет только контейнер и его writable layer. Если разработчик ожидает сброса базы данных или файлового хранилища к исходному состоянию и просто пересоздаёт контейнер с прежним именем тома, приложение продолжит работать со старыми данными.
Чтобы действительно уничтожить данные в томе, необходимо явно удалить сам том с помощью команды docker volume rm. Сделать это можно только тогда, когда том не используется ни одним работающим или остановленным контейнером.
Проведём штатную уборку учебных ресурсов. Сначала остановим и удалим контейнер:
docker stop cf_docker_course_l19_app
docker rm cf_docker_course_l19_app
И только теперь, когда к тому не привязан ни один контейнер, удалим сам именованный том:
docker volume rm cf_docker_course_l19_data
Команда возвращает имя удалённого тома:
cf_docker_course_l19_data
После этого данные файла /data/items.json окончательно стёрты. Повторный запуск контейнера с этим именем тома приведёт к созданию уже абсолютно нового пустого хранилища.
Локальная лабораторная / самопроверка
Что уже дано
Работайте в POSIX-терминале с Linux-контейнерами. Сохраните выданные файлы в новый каталог l19_lab, который ещё не существует; если занят, выберите другой. Выберите уникальный префикс, например LAB=cf_docker_task_l19_run01; замените run01 при повторе. Перед созданием проверьте свои предполагаемые имена через docker container ls -a, network inspect и volume inspect, если они используются. Занятый объект не удаляйте: выберите другой префикс. Ошибку связи с Engine не принимайте за отсутствие ресурса. Порты хоста должны быть свободны; PORTS не исключает отдельный процесс ОС. При неожиданном bind-конфликте остановите опыт и согласованно замените host-порт/URL, не останавливая прежние службы. Все новые имена контейнеров/сетей/томов и проект Compose начинаются с выбранного LAB. Готовые app.py/SQL менять или писать не требуется. Соберите выданный Dockerfile из этого каталога в свой образ ${LAB}:app как подготовку; рецепт уже известен, кроме явно дефектного файла текущего задания.
Выданный Dockerfile — полная versioned-рецептура с UID/GID10001, доступными /tmp и /data. STORAGE=file хранит коллекцию в /data/items.json. Новое имя тома — ${LAB}_data; оно должно отсутствовать до создания. API использует18219.
Что нужно сделать
Создайте новый том и подключите его к /data. Запустите сервис в файловом режиме, добавьте через POST заметку volume-proof-19 и создайте маркер /tmp/marker.txt с текстом layer-proof-19. Сравните файлы/HTTP после restart, затем после stop/rm и нового run того же образа с прежним томом. Сохраните фактические ID до/после; не выполняйте повторный POST перед итоговым чтением.
Ввод и вывод
Вы самостоятельно вводите Docker/Compose-команды на хосте и сохраняете их реальные результаты в локальный текстовый отчёт. Печатать кодовое слово, писать Python/SQL или заполнять форму ответа не требуется. Эта TEXT-лабораторная не имеет автоматической платформенной проверки.
Проверка результата:
- После restart видны и прежняя заметка, и маркер; ID экземпляра не меняется.
- После удаления и нового Container ID прежняя заметка с тем же фактическим id читается через GET /notes, а /tmp/marker.txt отсутствует. Наличие только объекта volume без чтения текста недостаточно.
Сохраните команды, фактические ID/коды и наблюдения до очистки. После опыта штатно остановите и удалите только контейнеры своего LAB; для Compose используйте то же -p и down без-v, затем удалите только свою свободную сеть. Тома с контрольными записями сохраняйте, кроме отдельно оговорённого разрушительного сравнения урока27. Если позже удаляете собственный одноразовый том, сначала назовите точное имя и потерю его данных; чужие ресурсы и общие образы сохраняйте.
Ограничения:
- Локальная самопроверка; платформа не запускает весь Docker-проект, не оценивает отчёт автоматически и не подтверждает выполнение ученика.
- Никакого prune, privileged, Docker socket или реальных секретов. Только собственные новые учебные ресурсы.
- Эталон/ошибочные варианты проверяются отдельно на локальном Engine29.6.2; это не сертификация Latorn Docker27 или всех ОС.
Файл app.py:
import json
import os
from pathlib import Path
import signal
import sys
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.request import urlopen
META = json.loads(Path(__file__).with_name("app_metadata.json").read_text())
VERSION = Path(__file__).with_name("version.txt").read_text().strip()
MODE = os.getenv("STORAGE", "memory")
LOCK = threading.RLock()
MEMORY = []
def log(event, **fields):
print(json.dumps({"event": event, **fields}), flush=True)
def password():
direct, filename = os.getenv("PASSWORD", ""), os.getenv("PASSWORD_FILE", "")
if direct and filename:
raise ValueError("PASSWORD and PASSWORD_FILE are mutually exclusive")
value = Path(filename).read_text().rstrip("\r\n") if filename else direct
if not value:
raise ValueError("postgres mode requires PASSWORD or PASSWORD_FILE")
return value
def connect():
import psycopg
return psycopg.connect(host=os.getenv("DB_HOST", "db"), port=int(os.getenv("DB_PORT", "5432")),
dbname=os.getenv("DB_NAME", "course"), user=os.getenv("DB_USER", "course"),
password=password(), connect_timeout=2, options="-c statement_timeout=3000")
def file_path():
return Path(os.getenv("DATA_FILE", "/data/items.json"))
def rows():
with LOCK:
if MODE == "memory":
return list(MEMORY)
if MODE == "file":
p = file_path()
return json.loads(p.read_text()) if p.exists() else []
with connect() as db:
records = db.execute("SELECT id, text FROM course_items ORDER BY id").fetchall()
return [{"id": r[0], "text": r[1]} for r in records]
def add(text):
with LOCK:
if MODE == "postgres":
with connect() as db:
row = db.execute("INSERT INTO course_items (text) VALUES (%s) RETURNING id, text", (text,)).fetchone()
return {"id": row[0], "text": row[1]}
current = rows()
item = {"id": max([r["id"] for r in current], default=0) + 1, "text": text}
current.append(item)
if MODE == "memory":
MEMORY[:] = current
else:
p = file_path()
temp = p.with_suffix(".tmp")
temp.write_text(json.dumps(current), encoding="utf-8")
temp.replace(p)
return item
def ready():
if MODE == "postgres":
with connect() as db:
found = db.execute("SELECT version FROM course_schema WHERE singleton = TRUE").fetchone()
if found != (1,):
raise RuntimeError("schema 1 required; run supplied init command")
db.execute("SELECT id FROM course_items LIMIT 1")
elif MODE == "file":
p = file_path()
if not p.parent.is_dir() or not os.access(p.parent, os.W_OK):
raise RuntimeError("DATA_FILE parent must exist and be writable")
rows()
elif MODE != "memory":
raise ValueError("STORAGE must be memory, file or postgres")
def init():
if MODE == "postgres":
with connect() as db:
db.execute("CREATE TABLE IF NOT EXISTS course_schema (singleton boolean PRIMARY KEY CHECK (singleton), version integer NOT NULL)")
db.execute("INSERT INTO course_schema VALUES (TRUE, 1) ON CONFLICT (singleton) DO NOTHING")
version = db.execute("SELECT version FROM course_schema").fetchone()
if version != (1,):
raise RuntimeError("refusing incompatible schema")
db.execute("CREATE TABLE IF NOT EXISTS course_items (id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, text varchar(200) NOT NULL)")
elif MODE == "file":
p = file_path()
if not p.exists():
p.write_text("[]", encoding="utf-8")
ready()
print(json.dumps({"status": "initialized", "schema": 1}))
class Handler(BaseHTTPRequestHandler):
def log_message(self, fmt, *args):
log("request", method=self.command, path=self.path, detail=fmt % args)
def send(self, code, data):
body = json.dumps(data, ensure_ascii=False).encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
try:
if self.path == "/":
filename = os.getenv("MESSAGE_FILE")
message = Path(filename).read_text().strip() if filename else os.getenv("MESSAGE", "hello from Docker")
self.send(200, {"service": META["service"], "message": message, "storage": MODE})
elif self.path == "/health":
self.send(200, {"status": "alive"})
elif self.path == "/ready":
ready()
self.send(200, {"status": "ready"})
elif self.path == "/version":
self.send(200, {"service": META["service"], "version": VERSION, "schema": 1})
elif self.path == META["collection"]:
ready()
self.send(200, {"items": rows()})
elif self.path == "/upstream":
url = os.environ["DEPENDENCY_URL"]
with urlopen(url, timeout=2) as reply:
self.send(200, {"dependency": json.load(reply)})
else:
self.send(404, {"error": "not_found"})
except Exception as exc:
log("dependency_error", kind=type(exc).__name__)
self.send(503, {"status": "not_ready"} if self.path == "/ready" else {"error": "storage_or_dependency_unavailable"})
def do_POST(self):
if self.path != META["collection"]:
self.send(404, {"error": "not_found"})
return
try:
length = int(self.headers.get("Content-Length", "0"))
if not 0 < length <= 4096:
raise ValueError("body size")
data = json.loads(self.rfile.read(length))
text = data.get("text") if isinstance(data, dict) else None
if not isinstance(text, str) or not 1 <= len(text.strip()) <= 200:
raise ValueError("text must have 1..200 characters")
except (ValueError, TypeError):
self.send(400, {"error": "invalid_text"})
return
try:
ready()
self.send(201, add(text.strip()))
except Exception as exc:
log("storage_error", kind=type(exc).__name__)
self.send(503, {"error": "storage_unavailable"})
def serve():
if MODE not in ("memory", "file", "postgres"):
raise ValueError("invalid STORAGE")
if MODE == "postgres":
password() # configuration errors fail immediately; DB outages do not kill HTTP
Path("/tmp/course-app.pid").write_text(str(os.getpid()))
server = ThreadingHTTPServer((os.getenv("HOST", "0.0.0.0"), int(os.getenv("PORT", "8000"))), Handler)
server.daemon_threads = True
stopping = threading.Event()
def stop(signum, _frame):
if not stopping.is_set():
stopping.set()
log("signal_received", signal=signal.Signals(signum).name)
threading.Thread(target=server.shutdown, daemon=True).start()
signal.signal(signal.SIGTERM, stop)
signal.signal(signal.SIGINT, stop)
log("started", service=META["service"], version=VERSION, storage=MODE, uid=os.getuid())
server.serve_forever(poll_interval=0.1)
server.server_close()
log("stopped")
def main():
command = sys.argv[1] if len(sys.argv) > 1 else "serve"
if command == "serve":
serve()
elif command == "init":
init()
elif command == "seed":
ready()
for text in META["seed"]:
if not any(row["text"] == text for row in rows()):
add(text)
print(json.dumps({"items": rows()}))
elif command == "inspect":
ready()
print(json.dumps({"items": rows()}))
elif command == "check":
ready()
print(json.dumps({"status": "ready", "schema": 1}))
elif command == "http-check":
with urlopen("http://127.0.0.1:" + os.getenv("PORT", "8000") + "/ready", timeout=2) as r:
print(r.read().decode())
elif command == "fail":
log("intentional_failure")
sys.exit(7)
elif command == "allocate":
# Use ONLY with a bounded container memory limit; held blocks force observable allocation.
blocks = []
while True:
blocks.append(bytearray(1024 * 1024))
else:
raise ValueError("unknown supplied command")
if __name__ == "__main__":
try:
main()
except Exception as exc:
log("fatal", kind=type(exc).__name__)
sys.exit(1)
Файл requirements.txt:
psycopg==3.2.6 psycopg-binary==3.2.6 typing-extensions==4.12.2
Файл app_metadata.json:
{
"service": "course-notes",
"collection": "/notes",
"seed": [
"first supplied note",
"data survives container recreation"
]
}
Файл version.txt:
1.0.0
Файл Dockerfile:
FROM python:3.12.10-slim-bookworm AS dependencies
WORKDIR /build
COPY requirements.txt .
RUN python -m pip install --no-cache-dir --prefix=/install -r requirements.txt && printf 'build stage only\n' > /build/build-only.txt
FROM python:3.12.10-slim-bookworm AS runtime
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1
WORKDIR /app
COPY --from=dependencies /install /usr/local
COPY app.py app_metadata.json ./
ARG APP_VERSION=1.0.0
COPY versions/${APP_VERSION}.txt ./version.txt
RUN groupadd --gid 10001 app && useradd --uid 10001 --gid 10001 --no-create-home app && mkdir /data && chown 10001:10001 /data
USER 10001:10001
EXPOSE 8000
STOPSIGNAL SIGTERM
CMD ["python", "-u", "app.py", "serve"]
Файл versions/1.0.0.txt:
1.0.0
Лабораторная выполняется локально. Latorn не запускает Docker Compose или весь проект и не подтверждает выполнение лабораторной автоматически.
Задача 1
Файл /tmp/report.txt записан в writable layer: тома/tmpfs там нет, образ его не содержит, старт приложения его не меняет. Сначала выполнен restart, затем stop и rm, затем новый run с тем же именем. Какое действие уничтожило сохранённый файл?
Задача 2
В команду docker run -d --name notes-service ... course-notes:1.0.0 нужно подставить --mount для named volume app-data точно в/data. Какой параметр задаёт именно эту область хранения?
Продолжить с сохранением прогресса
Перейдите к уроку в учебном кабинете.
