Пробуем вытащить Dockerfile из готового образа

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

docker history --no-trunc python | awk '{$1=$2=$3=$4=""; print $0}' | \
 grep -v COMMENT | nl | sort -nr | sed 's/^[^A-Z]*//' | \
 sed -E 's/[0-9]+(\.[0-9]+)?(B|KB|MB|GB|kB)[[:space:|]].*//g'

Разберёмся, что здесь происходит:

  • docker history –no-trunc python: выводит полную (необрезанную) историю образа python, показывая все слои и команды/метаданные, которые использовались при его сборке.
  • awk '{$1=$2=$3=$4=«»; print $0}': удаляет первые четыре колонки (ID слоя, время создания и прочее), оставляя только команду, использованную на каждом шаге.
  • grep -v COMMENT: убирает строки-комментарии (обычно это заголовки таблицы).
  • nl: нумерует строки - это нужно, чтобы потом можно было восстановить порядок (Docker показывает историю в обратном порядке - от последнего слоя к первому).
  • sort -nr: сортирует строки по убыванию номера - так мы получаем слои от базового к финальному, как в обычном Dockerfile.
  • sed 's/^[^A-Z]*': убирает номера строк и лишние пробелы в начале. * sed -E 's/[0-9]+(\.[0-9]+)?(B|KB|MB|GB|kB)space.*g': пытается убрать указания на размеры файлов и всё, что идёт после них.

Когда и зачем это может пригодиться

Эта команда бывает полезна, когда:

  • У вас нет доступа к исходному Dockerfile, но есть сам образ.
  • Вы хотите проверить образ на наличие потенциально опасных или неэффективных слоёв.
  • Нужно понять, как происходит сборка - например, для переноса, отладки или оптимизации.

Пример использования

docker history --no-trunc n8nio/n8n  | awk '{$1=$2=$3=$4=""; print $0}' | grep -v COMMENT | nl | sort -nr | sed 's/^[^A-Z]*//' | sed -E 's/[0-9]+(\.[0-9]+)?(B|KB|MB|GB|kB)[[:space:|]].*//g'
 
ADD alpine-minirootfs-3.21.2-aarch64.tar.gz / # buildkit
CMD ["/bin/sh"]
ENV NODE_VERSION=20.18.2
RUN /bin/sh -c addgroup -g 1000 node && adduser -u 1000 -G node -s /bin/sh -D node && apk add --no-cache libstdc++ && apk add --no-cache --virtual .build-deps curl && ARCH= OPENSSL_ARCH='linux*' && alpineArch="$(apk --print-arch)" && case "${alpineArch##*-}" in x86_64) ARCH='x64' CHECKSUM="b7e78c523c18168074cda97790eac4fc9f00dbfc09052ad5ccc91c36df527265" OPENSSL_ARCH=linux-x86_64;; x86) OPENSSL_ARCH=linux-elf;; aarch64) OPENSSL_ARCH=linux-aarch64;; arm*) OPENSSL_ARCH=linux-armv4;; ppc64le) OPENSSL_ARCH=linux-ppc64le;; s390x) OPENSSL_ARCH=linux-s390x;; *) ;; esac && if [ -n "${CHECKSUM}" ]; then set -eu; curl -fsSLO --compressed "https://unofficial-builds.nodejs.org/download/release/v$NODE_VERSION/node-v$NODE_VERSION-linux-$ARCH-musl.tar.xz"; echo "$CHECKSUM node-v$NODE_VERSION-linux-$ARCH-musl.tar.xz" | sha256sum -c - && tar -xJf "node-v$NODE_VERSION-linux-$ARCH-musl.tar.xz" -C /usr/local --strip-components=1 --no-same-owner && ln -s /usr/local/bin/node /usr/local/bin/nodejs; else echo "Building from source" && apk add --no-cache --virtual .build-deps-full binutils-gold g++ gcc gnupg libgcc linux-headers make python3 py-setuptools && export GNUPGHOME="$(mktemp -d)" && for key in C0D6248439F1D5604AAFFB4021D900FFDB233756 DD792F5973C6DE52C432CBDAC77ABFA00DDBF2B7 CC68F5A3106FF448322E48ED27F5E38D5B0A215F 8FCCA13FEF1D0C2E91008E09770F7A9A5AE15600 890C08DB8579162FEE0DF9DB8BEAB4DFCF555EF4 C82FA3AE1CBEDC6BE46B9360C43CEC45C17AB93C 108F52B48DB57BB0CC439B2997B01419BD92F80A A363A499291CBBC940DD62E41F10027AF002F8B0 ; do gpg --batch --keyserver hkps://keys.openpgp.org --recv-keys "$key" || gpg --batch --keyserver keyserver.ubuntu.com --recv-keys "$key" ; done && curl -fsSLO --compressed "https://nodejs.org/dist/v$NODE_VERSION/node-v$NODE_VERSION.tar.xz" && curl -fsSLO --compressed "https://nodejs.org/dist/v$NODE_VERSION/SHASUMS256.txt.asc" && gpg --batch --decrypt --output SHASUMS256.txt SHASUMS256.txt.asc && gpgconf --kill all && rm -rf "$GNUPGHOME" && grep " node-v$NODE_VERSION.tar.xz\$" SHASUMS256.txt | sha256sum -c - && tar -xf "node-v$NODE_VERSION.tar.xz" && cd "node-v$NODE_VERSION" && ./configure && make -j$(getconf _NPROCESSORS_ONLN) V= && make install && apk del .build-deps-full && cd .. && rm -Rf "node-v$NODE_VERSION" && rm "node-v$NODE_VERSION.tar.xz" SHASUMS256.txt.asc SHASUMS256.txt; fi && rm -f "node-v$NODE_VERSION-linux-$ARCH-musl.tar.xz" && find /usr/local/include/node/openssl/archs -mindepth 1 -maxdepth 1 ! -name "$OPENSSL_ARCH" -exec rm -rf {} \; && apk del .build-deps && node --version && npm --version # buildkit
ENV YARN_VERSION=1.22.22
RUN /bin/sh -c apk add --no-cache --virtual .build-deps-yarn curl gnupg tar && export GNUPGHOME="$(mktemp -d)" && for key in 6A010C5166006599AA17F08146C2130DFD2497F5 ; do gpg --batch --keyserver hkps://keys.openpgp.org --recv-keys "$key" || gpg --batch --keyserver keyserver.ubuntu.com --recv-keys "$key" ; done && curl -fsSLO --compressed "https://yarnpkg.com/downloads/$YARN_VERSION/yarn-v$YARN_VERSION.tar.gz" && curl -fsSLO --compressed "https://yarnpkg.com/downloads/$YARN_VERSION/yarn-v$YARN_VERSION.tar.gz.asc" && gpg --batch --verify yarn-v$YARN_VERSION.tar.gz.asc yarn-v$YARN_VERSION.tar.gz && gpgconf --kill all && rm -rf "$GNUPGHOME" && mkdir -p /opt && tar -xzf yarn-v$YARN_VERSION.tar.gz -C /opt/ && ln -s /opt/yarn-v$YARN_VERSION/bin/yarn /usr/local/bin/yarn && ln -s /opt/yarn-v$YARN_VERSION/bin/yarnpkg /usr/local/bin/yarnpkg && rm yarn-v$YARN_VERSION.tar.gz.asc yarn-v$YARN_VERSION.tar.gz && apk del .build-deps-yarn && yarn --version && rm -rf /tmp/* # buildkit
COPY docker-entrypoint.sh /usr/local/bin/ # buildkit
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["node"]
COPY / / # buildkit
RUN /bin/sh -c rm -rf /tmp/v8-compile-cache* # buildkit
WORKDIR /home/node
ENV NODE_ICU_DATA=/usr/local/lib/node_modules/full-icu
EXPOSE map[5678/tcp:{}]
ARG N8N_VERSION=1.76.1
RUN |1 N8N_VERSION=1.76.1 /bin/sh -c if [ -z "$N8N_VERSION" ] ; then echo "The N8N_VERSION argument is missing!" ; exit 1; fi # buildkit
LABEL org.opencontainers.image.title=n8n
LABEL org.opencontainers.image.description=Workflow Automation Tool
LABEL org.opencontainers.image.source=https://github.com/n8n-io/n8n
LABEL org.opencontainers.image.url=https://n8n.io
LABEL org.opencontainers.image.version=1.76.1
ENV N8N_VERSION=1.76.1
ENV NODE_ENV=production
ENV N8N_RELEASE_TYPE=stable
RUN |1 N8N_VERSION=1.76.1 /bin/sh -c set -eux; npm install -g --omit=dev n8n@${N8N_VERSION} --ignore-scripts && npm rebuild --prefix=/usr/local/lib/node_modules/n8n sqlite3 && rm -rf /usr/local/lib/node_modules/n8n/node_modules/@n8n/chat && rm -rf /usr/local/lib/node_modules/n8n/node_modules/n8n-design-system && rm -rf /usr/local/lib/node_modules/n8n/node_modules/n8n-editor-ui/node_modules && find /usr/local/lib/node_modules/n8n -type f -name "*.ts" -o -name "*.js.map" -o -name "*.vue" | xargs rm -f && rm -rf /root/.npm # buildkit
ARG TARGETPLATFORM=linux/arm64
ARG LAUNCHER_VERSION=1.1.0
COPY n8n-task-runners.json /etc/n8n-task-runners.json # buildkit
RUN |3 N8N_VERSION=1.76.1 TARGETPLATFORM=linux/arm64 LAUNCHER_VERSION=1.1.0 /bin/sh -c if [[ "$TARGETPLATFORM" = "linux/amd64" |]]; then export ARCH_NAME="amd64"; elif [[ "$TARGETPLATFORM" = "linux/arm64" |]]; then export ARCH_NAME="arm64"; fi; mkdir /launcher-temp && cd /launcher-temp && wget https://github.com/n8n-io/task-runner-launcher/releases/download/${LAUNCHER_VERSION}/task-runner-launcher-${LAUNCHER_VERSION}-linux-${ARCH_NAME}.tar.gz && wget https://github.com/n8n-io/task-runner-launcher/releases/download/${LAUNCHER_VERSION}/task-runner-launcher-${LAUNCHER_VERSION}-linux-${ARCH_NAME}.tar.gz.sha256 && echo "$(cat task-runner-launcher-${LAUNCHER_VERSION}-linux-${ARCH_NAME}.tar.gz.sha256) task-runner-launcher-${LAUNCHER_VERSION}-linux-${ARCH_NAME}.tar.gz" > checksum.sha256 && sha256sum -c checksum.sha256 && tar xvf task-runner-launcher-${LAUNCHER_VERSION}-linux-${ARCH_NAME}.tar.gz --directory=/usr/local/bin && cd - && rm -r /launcher-temp # buildkit
COPY docker-entrypoint.sh / # buildkit
RUN |3 N8N_VERSION=1.76.1 TARGETPLATFORM=linux/arm64 LAUNCHER_VERSION=1.1.0 /bin/sh -c mkdir .n8n && chown node:node .n8n # buildkit
ENV SHELL=/bin/sh
USER node
ENTRYPOINT ["tini" "--" "/docker-entrypoint.sh"]

Как видно, получаем нечто отдалённо напоминающее настоящий Dockerfile. Конечно, это не полное восстановление: комментарии, порядок ENV и ARG, детали RUN могут отличаться, но всё же это вполне пригодно для анализа.

Есть ли способ лучше?

Да, определённо. Как видно из вывода команды docker history в связке с sed и awk, результат выходит довольно шумным. Но всё же он даёт общее представление о том, что находится внутри образа. Более продвинутая альтернатива - это инструмент под названием dive. Его нужно установить на вашу локальную машину.

Вот процесс установки на macOS и пример использования:

brew install dive
==> Auto-updating Homebrew...
Adjust how often this is run with HOMEBREW_AUTO_UPDATE_SECS or disable with
HOMEBREW_NO_AUTO_UPDATE. Hide these hints with HOMEBREW_NO_ENV_HINTS (see `man brew`).
==> Auto-updated Homebrew!
Updated 1 tap (homebrew/cask).
==> New Casks
voicenotes
 
You have 8 outdated formulae and 3 outdated casks installed.
 
==> Downloading https://ghcr.io/v2/homebrew/core/dive/manifests/0.13.1
############################################################################################################################ 100.0%
==> Fetching dive
==> Downloading https://ghcr.io/v2/homebrew/core/dive/blobs/sha256:f09a27e21a4b76122d74e9a776219ab7377efaf30dff7d8d7e3016aac375d14a
############################################################################################################################ 100.0%
==> Pouring dive--0.13.1.arm64_sequoia.bottle.tar.gz
🍺  /opt/homebrew/Cellar/dive/0.13.1: 6 files, 9MB
==> Running `brew cleanup dive`...
Disable this behaviour by setting HOMEBREW_NO_INSTALL_CLEANUP.
Hide these hints with HOMEBREW_NO_ENV_HINTS (see `man brew`).
 
dive
When and Why to Use This
This command is useful when:
 
You dont have access to the Dockerfile but have the image.
 
You want to audit an image for potentially dangerous or inefficient layers.
 
You need to understand the build process for migration or optimization.
 
dive n8nio/n8n
Image Source: docker://n8nio/n8n
Extracting image from docker-engine... (this can take a while for large images)
Analyzing image...
Building cache...

Что касается Linux, то он пока далеко не в каждом репозитории появился. Из более-менее известных дистрибутивов есть в репозиториях ALT Linux и Arch Linux. Впрочем в Debian и RH всё равно без проблем можно поставить, вместо установки из репо, можно загрузить со страницы релизных сборок на гитхабе: https://github.com/wagoodman/dive/releases

Выводы

Хотя связка docker history с shell-скриптами - это вполне изобретательный способ заглянуть внутрь Docker-образа и понять, как он был собран, до идеала ей далеко. Оптимизация слоёв и обрезанные метаданные могут скрыть реальные шаги сборки.

Если хочется чего-то более точного, интерактивного и удобного в анализе - dive даёт гораздо более ясное представление о слоях образа и соответствующих командах. Независимо от того, проверяете ли вы сторонние образы, отлаживаете CI-пайплайн или пытаетесь оптимизировать собственный Dockerfile - правильно подобранный инструмент может сэкономить время и избавить от гаданий.