Docker itself, in both build and runtime, looks like a mysterious blackbox, doing magic tricks. Whenever I was writing a Dockerfile, in the FROM instruction, I was asking myself, “I build the image from a base image. Then they build the base image from what?”
In this article, by decomposing a docker image, it’ll be shown that what those magic tricks really are. The approach that is discussed here, could be applied on any image, but to demonstrate the procedure, alpine:latest and redis:latest images used.
In the first part, we decompress all layers of redis:latest image, see the materials of each layer and observe the metadata available on the images.
In the second part, we work on alpine:latest image, decompress it and hen we modify os-release file on it to something custom. Then, we will build an image based on decomporessed and modified layers (actually it has one layer) and run our custom alpine, let’s call it alpyne.
Note 1: Due to some troubles in my current internet connection, I have used a domestic image registry to pull images. So, the docker.iranserver.com you see here, is about that mirror.
Note 2: For those who want to follow step by step: I have used jq and tree here. Make sure you have them or install by apt install jq tree.
Part 1 — Let’s see what is inside an image
Overview
Inside a docker image, there are:
- Directories and files related to base image and the instructions modifying it.
- Metadata: Providing necessary information for Docker engine or other container tools, like build system architecture, location of layers, version, OCI related data and etc.
It will be shown what these stuff look like, but for our purpose, the manifest.json is the key file. Let’s see:
Step I: Export image into a tar file
Command:
# template:
# sudo docker image save <IMG_NAME> -o output/file/path
sudo docker image alpine:latest -o /home/ubuntu/images/redis.tar
Expected output:
Nothing much, just list files and see if it exists:
$ ls -lh
-rw------- 1 root root 53M Apr 16 16:42 redis.tar
Step II: Decompress and see what’s inside the box
Command:
# tar -vfz <TAR_FILE>
cd /home/ubuntu/images/
tar -xvf redis.tar
Expected output:
Let’s use tree to see the directory structure and files inside the decompressed image:
.
├── blobs
│ └── sha256
│ ├── 0f931ab31f877a72e4a9793c6e3239c58d16ab3b919b87afc2fb5e3efbccee84
│ ├── 10c8260904f543a19de2be28ebf4ae66f547788bc4889c379e4552070feae34f
│ ├── 20994e17edaf8f74ec3818e3357192eb6559351e986bf149fca75ad38f6c9510
│ ├── 312488b568d1571d8328db9b8996e462f5c7eb89f74a8979915c053fd797c7de
│ ├── 33a7abb8ddac5c84562a5cd6dc7e225c6aa0c6e5fe899aedeea13075a2dbd55d
│ ├── 4f4fb700ef54461cfa02571ae0db9a0dc1e0cdb5577484a6d75e68dc38e8acc1
│ ├── 52e3bc646886e82af9d1404199974abeefb4a81ec86fd920a9d60b849130cb5e
│ ├── 5a9436fedbc68b48a010e333972d452f9e35e53d80669d4097700e71b06f97d4
│ ├── 9e520ebf273b4704f16eb19f4976e70cf6a4121a4cd29628932abcdcd570a46e
│ ├── a019c005570189bb038155c1dfb1a269b59b83f9ceee22fd5f42de205ac19c06
│ ├── bf00abeea330ac1d4fd69b55d48a18bbb5a35dce8d093a522acbeb488c210341
│ ├── ec781dee3f4719c2ca0dd9e73cb1d4ed834ed1a406495eb6e44b6dfaad5d1f8f
│ ├── f48022b492388343fb665e5d634311e5339f624d4467551fbd030d24fc18dbf0
│ └── f67c1d840f274a20f22bcb2fea1d66d4c978dab0b5f19e0893178de44498bf9d
├── index.json
├── manifest.json
└── oci-layout
3 directories, 17 files
Description:
Every image has a structure like above. Layers and some metadata exists in blobs/sha256, and there are index.json, manifest.json and oci-layout in the main directory.
At first let’s view manifest.json, index.json and oci-layout:
manifest.json:
This is the main metadata for our work. It shows the layer files, information about tags, and a config file which shows how the image is built and what each instruction in Dockerfile does. You can see this config in Step III.
$ cat manifest.json | jq
[
{
"Config": "blobs/sha256/f67c1d840f274a20f22bcb2fea1d66d4c978dab0b5f19e0893178de44498bf9d",
"RepoTags": [
"docker.iranserver.com/redis:latest"
],
"Layers": [
"blobs/sha256/ec781dee3f4719c2ca0dd9e73cb1d4ed834ed1a406495eb6e44b6dfaad5d1f8f",
"blobs/sha256/312488b568d1571d8328db9b8996e462f5c7eb89f74a8979915c053fd797c7de",
"blobs/sha256/0f931ab31f877a72e4a9793c6e3239c58d16ab3b919b87afc2fb5e3efbccee84",
"blobs/sha256/5a9436fedbc68b48a010e333972d452f9e35e53d80669d4097700e71b06f97d4",
"blobs/sha256/10c8260904f543a19de2be28ebf4ae66f547788bc4889c379e4552070feae34f",
"blobs/sha256/4f4fb700ef54461cfa02571ae0db9a0dc1e0cdb5577484a6d75e68dc38e8acc1",
"blobs/sha256/20994e17edaf8f74ec3818e3357192eb6559351e986bf149fca75ad38f6c9510"
]
}
]
In the layers section, there are compressed layers with a hashed name. This list is ordered, meaning the first element is the base image and the next layers are diff layers, each one a modification by an instruction. For instance, if you use alpine base image and COPY TEST_DIR/ . in the Dockerfile, the second layer only has TEST_DIR in it. It is shown in the Step III.
By decompressing all the the files inblobs/sha256/* and moving them in a single directory, you will have the directories and files that will be in the final image.
index.json:
This file is an entry-point for docker images and almost serves the same purpose manifest.json do. Modern images use this file to handle multi-platform support and standardization.
$ cat index.json | jq
{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.index.v1+json",
"manifests": [
{
"mediaType": "application/vnd.oci.image.index.v1+json",
"digest": "sha256:a019c005570189bb038155c1dfb1a269b59b83f9ceee22fd5f42de205ac19c06",
"size": 10205,
"annotations": {
"containerd.io/distribution.source.docker.iranserver.com": "redis",
"io.containerd.image.name": "docker.iranserver.com/redis:latest",
"org.opencontainers.image.ref.name": "latest"
}
}
]
}
oci-layout:
Related to the Open Container Initiative (OCI).
$ cat oci-layout | jq
{
"imageLayoutVersion": "1.0.0"
}
Important note: As you can see there are files in blobs/sha256 that are not in manifest.json section Layers. What are they? They are config files too. Providing metadata for when you try to used that saved image in another system. In other words, with these metadata Docker can used an exported image without rebuilding it.
I intentionally bypass those data since they are somehow unnecessary for the purpose of this article. So, as we mentioned before, we just need manifest.json.
Step III: Decompress layers
To dive deeply into the image layers, we must decompress each file in the layer section ofmanifest.json. That can be done manually or automated by a script like this:
### IMPORTANT NOTE: do not use this script directly and mind the directories.
layer_index=0
for LAYER_TAR_ADDR in $(cat data/$IMG_NAME/manifest.json | jq .[].Layers[] -r | xargs); do
LAYER_TAR_FNAME=$(echo $LAYER_TAR_ADDR | awk -F/ '{print $3}');
echo " -> Layer filename: $LAYER_TAR_FNAME";
mv data/$IMG_NAME/$LAYER_TAR_ADDR data/$CUSTOM_IMAGE/;
mkdir data/$CUSTOM_IMAGE/LAYER_$layer_index;
tar -xf data/$CUSTOM_IMAGE/$LAYER_TAR_FNAME -C data/$CUSTOM_IMAGE/LAYER_$layer_index;
rm -rf data/$CUSTOM_IMAGE/$LAYER_TAR_FNAME
layer_index=$((layer_index+1))
done
The above script finds each layer file in manifest.json and decompresses it into directory LAYER_$layer_index. The result is something like this:
$ tree -L 2 -I metadata
.
├── LAYER_0
│ ├── bin -> usr/bin
│ ├── boot
│ ├── dev
│ ├── etc
│ ├── home
│ ├── lib -> usr/lib
│ ├── lib64 -> usr/lib64
│ ├── media
│ ├── mnt
│ ├── opt
│ ├── proc
│ ├── root
│ ├── run
│ ├── sbin -> usr/sbin
│ ├── srv
│ ├── sys
│ ├── tmp
│ ├── usr
│ └── var
├── LAYER_1
│ └── etc
├── LAYER_2
│ └── var
├── LAYER_3
│ ├── etc
│ ├── root
│ ├── tmp
│ ├── usr
│ └── var
├── LAYER_4
│ └── data
├── LAYER_5
└── LAYER_6
└── usr
We can now see directly how these diff layers together will make our image final state.
Part 2 — Build & run a custom Alpine image: Alpyne
Overview
For this step, I used my own Bash scripts, decompress.sh and build.sh. The first one covers all the steps in the Part I, in an ordered and pretty way. You can find them here in my github.
Step I: Deeply decompress alpine image
We do exactly the same procedure reviewed in Part 1. Then move the decompressed layer into LAYER_0/ and all other metadata into metadata/ directory. I used the decompress.sh script.
$ tree custom-docker_iranserver_com__img__alpine__ver__latest/ -L 2
custom-docker_iranserver_com__img__alpine__ver__latest/
├── LAYER_0
│ ├── bin
│ ├── dev
│ ├── etc
│ ├── home
│ ├── lib
│ ├── media
│ ├── mnt
│ ├── opt
│ ├── proc
│ ├── root
│ ├── run
│ ├── sbin
│ ├── srv
│ ├── sys
│ ├── tmp
│ ├── usr
│ └── var
└── metadata
├── 25109184c71bdad752c8312a8623239686a9a2071e8825f20acb8f2198c3f659
├── 3d940f86d1354d1660b1c5233c191d93e93dcbbffb39cb0bef0d13b7890fc767
├── 59855d3dceb3ae53991193bd03301e082b2a7faa56a514b03527ae0ec2ce3a95
├── 9e595aac14e0f965871911c846f7913a373999123cbae27d591ec9e51b481582
├── a40c03cbb81c59bfb0e0887ab0b1859727075da7b9cc576a1cec2c771f38c5fb
├── caa817ad3aea1c72c5caada23e3a3f4d8a0677b444777174f7a9d3d8f025bcdf
├── fe2385f276937dcf780967a5385767fd34b34580c8ed8d303a0cd1485a692635
├── index.json
├── manifest.json
└── oci-layout
20 directories, 10 files
Let’s see os-release file:
$ cat LAYER_0/etc/os-release
NAME="Alpine Linux"
ID=alpine
VERSION_ID=3.23.3
PRETTY_NAME="Alpine Linux v3.23"
HOME_URL="https://alpinelinux.org/"
BUG_REPORT_URL="https://gitlab.alpinelinux.org/alpine/aports/-/issues"
And let’s modify it, using nano or vim to this:
NAME="ALPYNE Linux"
ID=alpyne
VERSION_ID=0.0.1
PRETTY_NAME="ALPYNE Linux v0"
Step II: Build the custom alpine image from scratch
Put a Dockerfile and optionally a .dockerignore into the directory of root file system. It is the alpine image main layer, decompressed. Or if you used the decompress.sh file, it resides in directory LAYER_0 .
FROM scratch
COPY . .
# make a login shell
CMD ["bin/sh", "-l"]
The scratch here simply means there is no need to pull any base image for this Dockerfile. You somehow build your own image with it. Bare kernel. It’s pretty cool IMO.
Then build image and run an ephereal container by:
$ sudo docker build . -t alpyne:latest
...
$ sudo docker run -it --rm alpyne:latest
Now in the container, you can see the modified /etc/os-release by cating it. All these steps could be done by build.sh script mentioned above.
It was a pretty simple example. One can make alpine image even smaller by omitting unnecessary binaries in /bin and /sbin or modifying root file system structure. In fact, this procedure is like adding a user-space into exsiting debian kernel (on ubuntu that I used) by docker. In other words, to utilize an existing linux kernel, one must mount a root file system, a shell and some binaries (those so-called commands) into the kernel and then, a linux distribution is born. Whatever lies on top of kernel is user-space.
Part 3 — Conclusion
It’s not magic at all
As you probably know, unlike VMware or VirtualBox, Docker uses the host machine’s kernel to build images and run containers. So, there is nothing magical about Docker and this article. With a good knowledge of OS kernel and user-space, one can make their own images, knowing that FROM scratch is actually a bare kernel and user-space utilities and root file system must be mounted above it, before instructions to run the desired binary or script.
Now everything make sense
By decomping an image, now it makes sense that why baking .env files directly into an image, instead of passing it in build time, is a major security concern. Now we can totally see how including unnecessary files into the image can increase its size and why we should mind some considerations during writing a Dockerfile to make it smaller. From now, multi-stage builds make sense. There is nothing special about those instructions in a Dockerfile, we just tell the engine what to do.
Optional: Reviewing Config file mentioned in `manifest.json`
Let’s see what is inside the Config file mentioned in manifest.json:
{
"architecture": "amd64",
"config": {
"ExposedPorts": {
"6379/tcp": {}
},
"Env": [
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
],
"Entrypoint": [
"docker-entrypoint.sh"
],
"Cmd": [
"redis-server"
],
"WorkingDir": "/data"
},
"created": "2026-03-16T22:41:08.88930508Z",
"history": [
{
"created": "2026-03-16T00:00:00Z",
"created_by": "# debian.sh --arch 'amd64' out/ 'trixie' '@1773619200'",
"comment": "debuerreotype 0.17"
},
{
"created": "2026-03-16T22:35:11.228799966Z",
"created_by": "RUN /bin/sh -c set -eux; \tgroupadd -r -g 999 redis; \tuseradd -r -g redis -u 999 redis # buildkit",
"comment": "buildkit.dockerfile.v0"
},
{
"created": "2026-03-16T22:35:13.447885443Z",
"created_by": "RUN /bin/sh -c set -eux; \tapt-get update; \tapt-get install -y --no-install-recommends \t\ttzdata \t; \trm -rf /var/lib/apt/lists/* # buildkit",
"comment": "buildkit.dockerfile.v0"
},
{
"created": "2026-03-16T22:41:08.693206422Z",
"created_by": "ARG REDIS_DOWNLOAD_URL=https://github.com/redis/redis/archive/refs/tags/8.6.1.tar.gz",
"comment": "buildkit.dockerfile.v0",
"empty_layer": true
},
{
"created": "2026-03-16T22:41:08.693206422Z",
"created_by": "ARG REDIS_DOWNLOAD_SHA=88ff5661160bf4b12aba2dfc579b131c202e75a3ac1f0b1d06db05a9929d5a89",
"comment": "buildkit.dockerfile.v0",
"empty_layer": true
},
{
"created": "2026-03-16T22:41:08.693206422Z",
"created_by": "RUN |2 REDIS_DOWNLOAD_URL=https://github.com/redis/redis/archive/refs/tags/8.6.1.tar.gz REDIS_DOWNLOAD_SHA=88ff5661160bf4b12aba2dfc579b131c202e75a3ac1f0b1d06db05a9929d5a89 /bin/sh -c set -eux; \t\tsavedAptMark=\"$(apt-mark showmanual)\"; \tapt-get update; \tapt-get install -y --no-install-recommends \t\tca-certificates \t\twget \t\tdpkg-dev \t\tgcc \t\tg++ \t\tlibc6-dev \t\tlibssl-dev \t\tmake; \t\tarch=\"$(dpkg --print-architecture | awk -F- '{ print $NF }')\"; \tcase \"$arch\" in \t\t'amd64') export BUILD_WITH_MODULES=yes; export INSTALL_RUST_TOOLCHAIN=yes; export DISABLE_WERRORS=yes ;; \t\t'arm64') export BUILD_WITH_MODULES=yes; export INSTALL_RUST_TOOLCHAIN=yes; export DISABLE_WERRORS=yes ;; \t\t*) echo >&2 \"Modules are NOT supported! unsupported architecture: '$arch'\"; export BUILD_WITH_MODULES=no ;; \tesac; \tif [ \"$BUILD_WITH_MODULES\" = \"yes\" ]; then \t\tapt-get update; \t\tapt-get install -y --no-install-recommends \t\t\tgit \t\t\tcmake \t\t\tpython3 \t\t\tpython3-pip \t\t\tpython3-venv \t\t\tpython3-dev \t\t\tunzip \t\t\trsync \t\t\tclang \t\t\tautomake \t\t\tautoconf \t\t\tlibtool \t\t\tg++; \tfi; \t\trm -rf /var/lib/apt/lists/*; \t\twget -O redis.tar.gz \"$REDIS_DOWNLOAD_URL\"; \techo \"$REDIS_DOWNLOAD_SHA *redis.tar.gz\" | sha256sum -c -; \tmkdir -p /usr/src/redis; \ttar -xzf redis.tar.gz -C /usr/src/redis --strip-components=1; \trm redis.tar.gz; \tgrep -E '^ *createBoolConfig[(]\"protected-mode\",.*, *1 *,.*[)],$' /usr/src/redis/src/config.c; \tsed -ri 's!^( *createBoolConfig[(]\"protected-mode\",.*, *)1( *,.*[)],)$!\\10\\2!' /usr/src/redis/src/config.c; \tgrep -E '^ *createBoolConfig[(]\"protected-mode\",.*, *0 *,.*[)],$' /usr/src/redis/src/config.c; \t\tgnuArch=\"$(dpkg-architecture --query DEB_BUILD_GNU_TYPE)\"; \textraJemallocConfigureFlags=\"--build=$gnuArch\"; \tcase \"${arch##*-}\" in \t\tamd64 | i386 | x32) extraJemallocConfigureFlags=\"$extraJemallocConfigureFlags --with-lg-page=12\" ;; \t\t*) extraJemallocConfigureFlags=\"$extraJemallocConfigureFlags --with-lg-page=16\" ;; \tesac; \textraJemallocConfigureFlags=\"$extraJemallocConfigureFlags --with-lg-hugepage=21\"; \tgrep -F 'cd jemalloc && ./configure ' /usr/src/redis/deps/Makefile; \tsed -ri 's!cd jemalloc && ./configure !&'\"$extraJemallocConfigureFlags\"' !' /usr/src/redis/deps/Makefile; \tgrep -F \"cd jemalloc && ./configure $extraJemallocConfigureFlags \" /usr/src/redis/deps/Makefile; \t\texport BUILD_TLS=yes; \tmake -C /usr/src/redis -j \"$(nproc)\" all; \tmake -C /usr/src/redis install; \t\tserverMd5=\"$(md5sum /usr/local/bin/redis-server | cut -d' ' -f1)\"; export serverMd5; \tfind /usr/local/bin/redis* -maxdepth 0 \t\t-type f -not -name redis-server \t\t-exec sh -eux -c ' \t\t\tmd5=\"$(md5sum \"$1\" | cut -d\" \" -f1)\"; \t\t\ttest \"$md5\" = \"$serverMd5\"; \t\t' -- '{}' ';' \t\t-exec ln -svfT 'redis-server' '{}' ';' \t; \t\tmake -C /usr/src/redis distclean; \trm -r /usr/src/redis; \t\tapt-mark auto '.*' > /dev/null; \t[ -z \"$savedAptMark\" ] || apt-mark manual $savedAptMark > /dev/null; \tfind /usr/local -type f -executable -exec ldd '{}' ';' \t\t| awk '/=>/ { so = $(NF-1); if (index(so, \"/usr/local/\") == 1) { next }; gsub(\"^/(usr/)?\", \"\", so); printf \"*%s\\n\", so }' \t\t| sort -u \t\t| xargs -r dpkg-query --search \t\t| cut -d: -f1 \t\t| sort -u \t\t| xargs -r apt-mark manual \t; \tapt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false; \trm -rf /var/cache/debconf/*; \t\tredis-cli --version; \tredis-server --version # buildkit",
"comment": "buildkit.dockerfile.v0"
},
{
"created": "2026-03-16T22:41:08.850839326Z",
"created_by": "RUN |2 REDIS_DOWNLOAD_URL=https://github.com/redis/redis/archive/refs/tags/8.6.1.tar.gz REDIS_DOWNLOAD_SHA=88ff5661160bf4b12aba2dfc579b131c202e75a3ac1f0b1d06db05a9929d5a89 /bin/sh -c mkdir /data && chown redis:redis /data # buildkit",
"comment": "buildkit.dockerfile.v0"
},
{
"created": "2026-03-16T22:41:08.867548851Z",
"created_by": "WORKDIR /data",
"comment": "buildkit.dockerfile.v0"
},
{
"created": "2026-03-16T22:41:08.88930508Z",
"created_by": "COPY docker-entrypoint.sh /usr/local/bin/ # buildkit",
"comment": "buildkit.dockerfile.v0"
},
{
"created": "2026-03-16T22:41:08.88930508Z",
"created_by": "ENTRYPOINT [\"docker-entrypoint.sh\"]",
"comment": "buildkit.dockerfile.v0",
"empty_layer": true
},
{
"created": "2026-03-16T22:41:08.88930508Z",
"created_by": "EXPOSE map[6379/tcp:{}]",
"comment": "buildkit.dockerfile.v0",
"empty_layer": true
},
{
"created": "2026-03-16T22:41:08.88930508Z",
"created_by": "CMD [\"redis-server\"]",
"comment": "buildkit.dockerfile.v0",
"empty_layer": true
}
],
"os": "linux",
"rootfs": {
"type": "layers",
"diff_ids": [
"sha256:188c9b34dfbe022075d01fc4f5a305412909ef97de440783c15043e68e1b1913",
"sha256:c0b8d9a9a2250b10ac9f2713b80e7e6adc3f85c5bca7e55ed390ad100434aefd",
"sha256:ef9755694139a2c4b37106e7401761cd58423b95fe1348fbde7f70d581ec8bb9",
"sha256:ca68b753246e51ad12e33bdaa509c1dc1974dbea9807ce36fe7e43a6542426e7",
"sha256:f1418f63d83b3b1c462a63c7b379cd3cede961272254d8fd028fab5f7ae04953",
"sha256:5f70bf18a086007016e948b04aed3b82103a36bea41755b6cddfaf10ace3c6ef",
"sha256:5dae68cc2759c81bb67025b9472851806952306c09ff79c6b10b1a1f78f0c79f"
]
}
}As you can see, information about environment, ports, working directory and etc. are all available here. It looks [almost] like the output of history subcommand:
$ sudo docker history docker.iranserver.com/redis:latest
IMAGE CREATED CREATED BY SIZE COMMENT
a019c0055701 4 weeks ago CMD ["redis-server"] 0B buildkit.dockerfile.v0
<missing> 4 weeks ago EXPOSE map[6379/tcp:{}] 0B buildkit.dockerfile.v0
<missing> 4 weeks ago ENTRYPOINT ["docker-entrypoint.sh"] 0B buildkit.dockerfile.v0
<missing> 4 weeks ago COPY docker-entrypoint.sh /usr/local/bin/ # … 24.6kB buildkit.dockerfile.v0
<missing> 4 weeks ago WORKDIR /data 4.1kB buildkit.dockerfile.v0
<missing> 4 weeks ago RUN |2 REDIS_DOWNLOAD_URL=https://github.com… 8.19kB buildkit.dockerfile.v0
<missing> 4 weeks ago RUN |2 REDIS_DOWNLOAD_URL=https://github.com… 61.4MB buildkit.dockerfile.v0
<missing> 4 weeks ago ARG REDIS_DOWNLOAD_SHA=88ff5661160bf4b12aba2… 0B buildkit.dockerfile.v0
<missing> 4 weeks ago ARG REDIS_DOWNLOAD_URL=https://github.com/re… 0B buildkit.dockerfile.v0
<missing> 4 weeks ago RUN /bin/sh -c set -eux; apt-get update; a… 41kB buildkit.dockerfile.v0
<missing> 4 weeks ago RUN /bin/sh -c set -eux; groupadd -r -g 999… 41kB buildkit.dockerfile.v0
<missing> 4 weeks ago # debian.sh --arch 'amd64' out/ 'trixie' '@1… 87.4MB debuerreotype 0.17
An interesting point that can be figured out with both history command and by observing decompressed layers, is that not all instructions increase image size. For instance CMD instructions as long as it doesn’t generate any data and only run something, doesn’t increase image size. Or WORKDIR instruction. So, adding lines of instruction in Dockerfile doesn’t always results in bigger images.
Cheers!
From Scratch: Deep dive into a docker image and build a custom one was originally published in Level Up Coding on Medium, where people are continuing the conversation by highlighting and responding to this story.