Tools: Essential Guide: Building tiny-docker-go in Go: What I Learned from Building a Tiny Docker-like Runtime

Tools: Essential Guide: Building tiny-docker-go in Go: What I Learned from Building a Tiny Docker-like Runtime

Building tiny-docker-go in Go: What I Learned from Building a Tiny Docker-like Runtime

Why I started with Go

The 10-day plan

Day 1: Project Setup and CLI Foundation

Small lesson from Day 1

Day 2: Adding Linux Namespaces

UTS namespace

PID namespace

Mount namespace

Parent and child process model

Day 3: RootFS and chroot

chroot is not full container security

Mounting /proc

Day 4: Container ID and Metadata

Small lesson from Day 4

Day 5: Logs

Small Linux detail: stdout and stderr matter

Day 6: Stop and Lifecycle Management

PID 1 problem

Day 7: cgroups and Memory Limits

Testing memory limits

cgroup v1 vs cgroup v2

Day 8: Network Namespace

Small lesson from Day 8

Day 9: Bridge and veth Networking

Debugging container networking

Day 10: Polish, README, and Architecture

What I learned about containers

Namespaces vs cgroups

Small Linux details that mattered

1. PID 1 is special

2. /proc must match the PID namespace

3. chroot changes /, but it is not a complete security model

4. Logs are mostly stdout and stderr

5. Graceful stop matters

6. cgroups are kernel-enforced

7. A new network namespace has no useful network by default

8. veth pairs are like virtual cables

9. NAT is what makes outbound internet work in the simple bridge model

10. Metadata turns a process into something manageable

What this project is not

What I want to add next

1. Better image support

2. OverlayFS

3. Port mapping

4. Better process supervision

5. Capabilities

6. Seccomp

7. User namespace

8. OCI runtime spec

Final thoughts

References I use Docker almost every day. I use it for local development, backend services, databases, staging environments, CI/CD pipelines, and sometimes even for debugging production-like issues. Like many developers, I became comfortable with commands like: But for a long time, Docker still felt like a black box to me. I knew how to use it. I knew how to write Dockerfiles. I knew how to debug containers when something failed. But I did not deeply understand what actually happens under the hood when we run a container. So I decided to build a small Docker-like container runtime in Go. The project is called tiny-docker-go. https://github.com/amirsefati/tiny-docker-go The goal was not to rebuild Docker. Docker is a mature platform with a huge ecosystem: image builds, registries, storage drivers, networking drivers, logging drivers, security features, orchestration integrations, plugins, and many other production-grade details. My goal was much smaller: Build a tiny runtime step by step, so I can understand the Linux ideas behind containers. Docker’s own documentation describes containers as isolated processes that run on a host and have their own filesystem, networking, and process tree. That sentence looks simple, but it hides a lot of Linux internals. To understand that sentence, I needed to touch the real building blocks: This article is a summary of the full 10-day journey. It is not a tutorial for building a production runtime. It is a developer story about learning containers by building a tiny version of one. I chose Go because it fits this kind of project very well. Go makes it simple to build CLI tools, execute processes, work with files, handle signals, and call lower-level Linux syscalls when needed. Also, many important container projects are written in Go. Docker itself, containerd, runc, Kubernetes, and many cloud-native tools use Go heavily. So using Go felt natural. For this project, I wanted the code to stay simple and readable. I did not want to hide everything behind too many abstractions too early. At the same time, I wanted the structure to be extensible enough so I could add one feature every day without rewriting the whole project. That balance became one of the main lessons of the project. When you build systems software, the hard part is not only writing code that works today. The hard part is writing code that can survive the next feature. I split the project into 10 small parts: This helped me avoid one common mistake: Trying to build “Docker” in one step. Instead, I treated each day as one small question. Can I execute a command through my own CLI? Can I run that command inside new Linux namespaces? Can I give that process a different root filesystem? Can I remember what I started? Can I stop a running container? Can I isolate networking? Can I connect the container back to the outside world? Can I explain the architecture clearly? That made the project much easier to continue. On Day 1, I did not start with namespaces. That may sound strange because namespaces are one of the most exciting parts of containers. But I wanted to start with the boring foundation first. The initial project structure looked like this: I added basic commands: At this stage, only run actually did something. It executed a normal Linux command on the host. This was not a container yet. There was no isolation. But this step mattered because it gave me a stable CLI shape. I wanted the outside interface to look like a tiny version of Docker: Even before the internals were ready, the product shape was clear. That helped a lot later. A container runtime is still a command runner at the beginning. Before thinking about advanced kernel features, I needed a clean way to receive a command, validate it, execute it, and return output to the terminal. A lot of systems projects start like this. First, build a simple interface. Then make the implementation smarter behind that interface. Day 2 was where the project started to feel like a real container runtime. Linux namespaces are one of the core ideas behind containers. A namespace gives a process a different view of some system resource. The important thing is this: A container is not a virtual machine. It is still a Linux process, but it sees a more isolated view of the system. That sentence changed how I think about Docker. Docker does not boot a new kernel like a VM. It starts a process on the host kernel, but configures isolation around it. In Go, I started experimenting with syscall.SysProcAttr and clone flags. A simplified version looks like this: This creates the child process in new namespaces. The first namespaces I added were: UTS namespace lets the container have its own hostname. Inside the child process, I could call: Then inside the container: That was a small moment, but it felt important. The process was still running on my machine, but it had its own hostname. That was the first visible sign of isolation. PID namespace was more interesting. With a new PID namespace, the process inside the container can see itself as PID 1. On Linux, PID 1 is special. It is the init process of that namespace. It has responsibilities around signal handling and reaping zombie processes. This is why container entrypoints matter. If the main process inside a container does not handle signals correctly, stopping the container can behave badly. This also helped me understand why tools like tini exist in container environments. Mount namespace gave the container its own mount table. That means the process can have different mounts from the host. At this point, I was not yet fully changing the filesystem, but I prepared the project for mounting /proc later. One small Linux detail I learned here: When working with mount namespaces, mount propagation can surprise you. If mounts are shared with the host, changes inside one namespace may propagate in ways you do not expect. Real runtimes are careful about making mounts private before doing container setup. This is one of those details that you do not think about when using Docker normally. But when building a runtime, it becomes visible very quickly. One design pattern I used was the parent/child model with: The parent process receives the CLI command. Then it starts a child process by re-executing the same binary: The parent is responsible for setup and management. The child enters the isolated environment and runs the target command. This pattern made the code easier to reason about. There is a clear split: This was the first time tiny-docker-go started to feel like a real runtime. On Day 3, I added filesystem isolation. Namespaces isolate views of system resources, but a container also needs a filesystem. When I run an Alpine container, I expect to see Alpine files: I should not see the host root filesystem. For the first version, I used chroot. After that, / inside the process points to the rootfs directory. Inside the container: shows Alpine information if the rootfs is Alpine. This was another important moment. It still was not Docker, but it started to look like the core of a container. chroot is useful for learning, but it is not complete container isolation by itself. Historically, chroot was not designed as a full security boundary. A real runtime usually uses more careful filesystem setup, often with pivot_root, mount namespaces, read-only mounts, bind mounts, capabilities, seccomp, AppArmor or SELinux, and other hardening layers. For this project, chroot was enough because my goal was educational. I wanted to understand the basic idea: Give the process a different /. That one idea explains a lot. A container process does not magically have a filesystem. The runtime prepares one. After entering the rootfs, I mounted /proc: Without /proc, commands like ps may not work correctly inside the container. This helped me understand another detail: Many Linux tools do not get information from some secret API. They read from virtual filesystems like /proc. For example, ps depends on /proc to inspect processes. So if the container has a PID namespace but /proc is not mounted correctly, the view inside the container can be confusing. This is one of those small details that makes containers feel less magical. After Day 3, I could start isolated processes. But I had a new problem: How do I remember them? That means Docker stores metadata about containers. So on Day 4, I added a simple metadata store. I used a local directory like: Each container gets a config.json. This was simple, but it changed the architecture. Before this, run was just executing a process. After this, run was creating a managed container record. That is a big conceptual difference. A runtime needs memory. Not RAM memory, but operational memory. It needs to remember: Then ps became meaningful. Instead of being a placeholder, it could read metadata files and show containers. A very simple output could look like: A container runtime is partly a process manager and partly a state manager. Starting the process is only half of the job. Remembering and managing it is the other half. This helped me understand why Docker has a daemon. If containers can continue running after the CLI exits, something needs to track them. My tiny runtime did this in a simple way with JSON files. Docker does it in a much more complete way. But the idea is similar. On Day 5, I added logging. This sounded easy at first. Just redirect stdout and stderr to a file, right? For detached containers, that works. But logs became more interesting when I thought about interactive mode. I want stdin, stdout, and stderr attached to my terminal. But if I run a detached process, I want logs written to a file. So the runtime needs to understand different modes: Docker has this same concept in a more advanced way. docker logs reads logs from the container’s configured logging driver, and docker logs --follow streams new output. For my tiny version, I kept it simple: The -f mode can be implemented like a basic tail -f. A container does not need to know about “logging” as a high-level concept. Most container logging starts from something simple: The process writes to stdout and stderr. The runtime captures those streams. That is why good containerized apps usually log to stdout/stderr instead of writing only to local files. This is a small detail, but it matters a lot in production. If your app logs only to a file inside the container, then your logging pipeline may not see it unless you mount volumes or configure extra collection. On Day 6, I implemented stop. The first version was simple: The runtime reads metadata, gets the PID, and sends a signal. The normal graceful flow is: This is similar to Docker’s stop behavior. Docker sends a termination signal first, and after a timeout it sends SIGKILL if the process does not exit. This taught me a practical lesson: Stopping a container is not the same as killing a process immediately. A good runtime gives the process a chance to clean up. For example, a backend service may need to: If we send SIGKILL immediately, the process cannot handle it. SIGKILL cannot be caught. SIGTERM can be caught. So graceful shutdown starts with SIGTERM. This day also connected back to PID namespaces. Inside a PID namespace, the main process becomes PID 1. PID 1 has special behavior on Linux. If it does not handle signals properly, stopping the container may not behave as expected. That helped me understand why some containers use an init process. It also made me more careful about what command I use as the container entrypoint. A simple shell may behave differently from a proper application process. This is one reason container lifecycle management is more subtle than it looks. Day 7 was about cgroups. Namespaces answer this question: What can the process see? cgroups answer a different question: How much can the process use? That difference is important. Namespaces isolate visibility. cgroups control resources. With cgroups, the runtime can limit or account for resources such as: For this project, I focused on memory limit using cgroup v2. On many modern Linux systems, cgroup v2 is mounted around: A simplified container cgroup path might be: To limit memory, the runtime can write to: Then the runtime adds the process PID to: After that, the kernel applies the limit to that process group. This was one of my favorite parts of the project. Because suddenly “memory limit” stopped being an abstract Docker option. behind the scenes, the runtime eventually has to express that limit to the kernel. The exact implementation is more complex in Docker, but the basic idea became clear. A simple way to test memory limits is to run a command that allocates memory. For example, inside a container rootfs with Python: If the memory limit is 128 MB, the process should fail or be killed by the kernel. This is where container behavior becomes very real. The runtime does not “watch memory” manually in a loop. The kernel enforces the limit. That is the power of cgroups. I focused on cgroup v2 because it is the modern unified hierarchy. In cgroup v1, different controllers could be mounted in different hierarchies. In cgroup v2, the model is unified and cleaner. But cgroup v2 also has rules that you need to respect. For example, controller availability depends on the system, and some controllers must be enabled in parent cgroups before child cgroups can use them. This is where I learned another systems programming lesson: The code can be correct but the host can still reject the setup because the kernel or systemd cgroup configuration is different. So a real runtime needs strong detection, good errors, and compatibility handling. My tiny runtime does not handle every host setup. But it made the concept clear. On Day 8, I added network namespace support. This was the day where containers became both clearer and more confusing. A network namespace gives a process its own network stack. That includes its own: the container got its own network namespace. But then something interesting happened: The container had no network. A new network namespace starts isolated. Even loopback may need to be brought up manually. So the first step was simply: inside the namespace. This taught me a simple but important point: Network isolation does not automatically mean working networking. It means the container has a separate network world. The runtime still needs to connect that world to something. At this stage, I added a --net none or --net isolated style mode. That made the behavior explicit. Inside the container: would show only the isolated namespace interfaces. Before this project, I mostly thought about Docker networking from the user side: But from the runtime side, networking starts much lower: Docker hides all of that. Building even a tiny version forced me to see the real steps. Day 9 was one of the most difficult and useful parts. The goal was to give the container internet access. For that, I needed a simple bridge and veth pair. The model looks like this: A veth pair works like a virtual cable. Whatever enters one side comes out the other side. The host keeps one side. The container gets the other side. The bridge connects the host-side veth to a small virtual network. The steps are roughly: Then inside the container namespace: Finally, on the host, NAT is needed: Also IP forwarding must be enabled: This is the point where I started to appreciate Docker networking much more. Because every simple Docker command hides many small Linux networking operations. The useful commands were: Some issues I hit or expected: This part reminded me that networking bugs are usually not one big bug. They are often one missing small step. One missing NAT rule. On Day 10, I focused on making the project understandable. A learning project is more valuable when other people can read it. So I improved the README and documented: The final mental model looks like this: And the user-facing commands look like this: But it is not just a toy CLI anymore. It demonstrates many of the core ideas behind containers. After building this project, my mental model of Docker changed. Before, I thought of Docker mostly as: Now I think about it more like: That is a much more useful model. A container is not magic. But it is a carefully prepared process. That is the core idea. One of the clearest lessons was the difference between namespaces and cgroups. I would explain it like this: This distinction is simple, but it explains so much. If a container cannot see host processes, that is namespace isolation. If a container gets killed after using too much memory, that is cgroup enforcement. If a container has its own IP address, that is network namespace plus virtual networking. If a container sees Alpine files instead of host files, that is rootfs setup plus mount isolation. Docker combines all of these into one clean developer experience. This project taught me many small Linux details that are easy to miss when only using Docker. The first process inside a PID namespace becomes PID 1. PID 1 handles signals differently and is responsible for reaping orphaned child processes. This matters for container shutdown. If /proc is not mounted inside the container correctly, tools like ps may show confusing information. Mounting proc inside the container is not just cosmetic. It affects how process information is visible. chroot is useful for learning filesystem isolation. But real containers need stronger filesystem and security handling. Container logging starts with capturing process output. If your app logs to stdout/stderr, the runtime can collect it naturally. A runtime should usually send SIGTERM first. SIGKILL should be the fallback. This gives the process a chance to shut down cleanly. The runtime does not manually police memory in a loop. It writes limits into cgroup files, then the kernel enforces them. Isolation comes first. Connectivity must be built. One side stays on the host. One side goes into the container. That simple idea powers a lot of container networking. Without NAT and IP forwarding, the container may have an IP but still not reach the internet. Without metadata, you only started a process. With metadata, you can list it, stop it, inspect it, and read its logs. tiny-docker-go is not a Docker replacement. It does not support real image pulling. It does not implement OCI fully. It does not have production security. It does not have a daemon. It does not have advanced volume management. It does not have complete port publishing. It does not handle all cgroup configurations. It does not support all namespace combinations safely. It does not include seccomp, AppArmor, SELinux, or capabilities hardening yet. The goal is not production. The goal is learning. Actually, keeping it small made the learning better. When a project becomes too complete, it can hide the concept again. I wanted the opposite. I wanted the concept to stay visible. After these 10 days, there are many possible next steps. Some features I want to explore: Right now, rootfs is local. A next step could be: Even if it is not a full registry implementation, I can start with downloading and unpacking rootfs archives. Docker images are layer-based. A good next step is to use OverlayFS: This would make the filesystem model closer to real containers. Outbound internet is one thing. Publishing container ports is another. This would require NAT/DNAT rules or a proxy approach. The runtime could track exit status, update metadata automatically, and clean up resources more reliably. Linux capabilities are very important for container security. Instead of giving a process full root power, Linux can split privileges into smaller capabilities. Dropping capabilities would make the runtime more realistic. Seccomp can restrict which syscalls a process can use. This is another important container hardening feature. User namespaces are powerful because they can make a process think it is root inside the container while mapping it to a less privileged user on the host. This is a very interesting security feature. Eventually, I want to read more about the OCI runtime spec and compare my tiny runtime with how real runtimes are structured. This project made Docker feel less magical and more impressive. Less magical because I can now see the Linux pieces behind it. More impressive because I understand how many details Docker handles for us. Running a container sounds simple: But under that command, a runtime needs to prepare isolation, filesystem, networking, logs, metadata, signals, and resource limits. Building tiny-docker-go helped me understand those pieces one by one. The most important lesson for me was this: A container is just a Linux process, but the runtime carefully shapes the world around that process. That world includes what the process can see, what it can use, where its files come from, how its logs are captured, how it receives signals, and how it connects to the network. This is why building a tiny container runtime is such a useful learning project. You do not need to rebuild Docker completely. You only need to rebuild enough of it to understand the ideas. That is what I tried to do with tiny-docker-go. You can follow the project here: https://github.com/amirsefati/tiny-docker-go Templates let you quickly answer FAQs or store snippets for re-use. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse

Command

Copy

$ -weight: 500;">docker run -weight: 500;">docker ps -weight: 500;">docker logs -weight: 500;">docker -weight: 500;">stop -weight: 500;">docker compose up -weight: 500;">docker run -weight: 500;">docker ps -weight: 500;">docker logs -weight: 500;">docker -weight: 500;">stop -weight: 500;">docker compose up -weight: 500;">docker run -weight: 500;">docker ps -weight: 500;">docker logs -weight: 500;">docker -weight: 500;">stop -weight: 500;">docker compose up tiny--weight: 500;">docker-go/ ├── cmd/ │ └── tiny--weight: 500;">docker-go/ │ └── main.go ├── internal/ │ ├── app/ │ ├── cli/ │ └── runtime/ ├── go.mod └── README.md tiny--weight: 500;">docker-go/ ├── cmd/ │ └── tiny--weight: 500;">docker-go/ │ └── main.go ├── internal/ │ ├── app/ │ ├── cli/ │ └── runtime/ ├── go.mod └── README.md tiny--weight: 500;">docker-go/ ├── cmd/ │ └── tiny--weight: 500;">docker-go/ │ └── main.go ├── internal/ │ ├── app/ │ ├── cli/ │ └── runtime/ ├── go.mod └── README.md tiny--weight: 500;">docker-go run tiny--weight: 500;">docker-go ps tiny--weight: 500;">docker-go -weight: 500;">stop tiny--weight: 500;">docker-go logs tiny--weight: 500;">docker-go run tiny--weight: 500;">docker-go ps tiny--weight: 500;">docker-go -weight: 500;">stop tiny--weight: 500;">docker-go logs tiny--weight: 500;">docker-go run tiny--weight: 500;">docker-go ps tiny--weight: 500;">docker-go -weight: 500;">stop tiny--weight: 500;">docker-go logs go run ./cmd/tiny--weight: 500;">docker-go run echo hello go run ./cmd/tiny--weight: 500;">docker-go run echo hello go run ./cmd/tiny--weight: 500;">docker-go run echo hello tiny--weight: 500;">docker-go run /bin/sh tiny--weight: 500;">docker-go ps tiny--weight: 500;">docker-go logs <id> tiny--weight: 500;">docker-go -weight: 500;">stop <id> tiny--weight: 500;">docker-go run /bin/sh tiny--weight: 500;">docker-go ps tiny--weight: 500;">docker-go logs <id> tiny--weight: 500;">docker-go -weight: 500;">stop <id> tiny--weight: 500;">docker-go run /bin/sh tiny--weight: 500;">docker-go ps tiny--weight: 500;">docker-go logs <id> tiny--weight: 500;">docker-go -weight: 500;">stop <id> -weight: 500;">docker run alpine sh -weight: 500;">docker run alpine sh -weight: 500;">docker run alpine sh cmd.SysProcAttr = &syscall.SysProcAttr{ Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS, } cmd.SysProcAttr = &syscall.SysProcAttr{ Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS, } cmd.SysProcAttr = &syscall.SysProcAttr{ Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS, } UTS namespace PID namespace Mount namespace UTS namespace PID namespace Mount namespace UTS namespace PID namespace Mount namespace syscall.Sethostname([]byte("tiny-container")) syscall.Sethostname([]byte("tiny-container")) syscall.Sethostname([]byte("tiny-container")) tiny-container tiny-container tiny-container /proc/self/exe /proc/self/exe /proc/self/exe exec.Command("/proc/self/exe", "child", ...) exec.Command("/proc/self/exe", "child", ...) exec.Command("/proc/self/exe", "child", ...) parent process ├── parse CLI ├── prepare config ├── -weight: 500;">start child with namespaces └── track metadata child process ├── set hostname ├── prepare filesystem ├── mount proc └── exec user command parent process ├── parse CLI ├── prepare config ├── -weight: 500;">start child with namespaces └── track metadata child process ├── set hostname ├── prepare filesystem ├── mount proc └── exec user command parent process ├── parse CLI ├── prepare config ├── -weight: 500;">start child with namespaces └── track metadata child process ├── set hostname ├── prepare filesystem ├── mount proc └── exec user command /bin/sh /etc/os-release /lib /usr /bin/sh /etc/os-release /lib /usr /bin/sh /etc/os-release /lib /usr syscall.Chroot(rootfs) os.Chdir("/") syscall.Chroot(rootfs) os.Chdir("/") syscall.Chroot(rootfs) os.Chdir("/") -weight: 600;">sudo tiny--weight: 500;">docker-go run --rootfs ./rootfs/alpine /bin/sh -weight: 600;">sudo tiny--weight: 500;">docker-go run --rootfs ./rootfs/alpine /bin/sh -weight: 600;">sudo tiny--weight: 500;">docker-go run --rootfs ./rootfs/alpine /bin/sh cat /etc/os-release cat /etc/os-release cat /etc/os-release syscall.Mount("proc", "/proc", "proc", 0, "") syscall.Mount("proc", "/proc", "proc", 0, "") syscall.Mount("proc", "/proc", "proc", 0, "") -weight: 500;">docker ps -weight: 500;">docker inspect <container> -weight: 500;">docker logs <container> -weight: 500;">docker -weight: 500;">stop <container> -weight: 500;">docker ps -weight: 500;">docker inspect <container> -weight: 500;">docker logs <container> -weight: 500;">docker -weight: 500;">stop <container> -weight: 500;">docker ps -weight: 500;">docker inspect <container> -weight: 500;">docker logs <container> -weight: 500;">docker -weight: 500;">stop <container> /var/lib/tiny--weight: 500;">docker/containers/<id>/ /var/lib/tiny--weight: 500;">docker/containers/<id>/ /var/lib/tiny--weight: 500;">docker/containers/<id>/ { "id": "abc123", "command": ["/bin/sh"], "hostname": "tiny-container", "rootfs": "./rootfs/alpine", "-weight: 500;">status": "running", "created_at": "2026-05-12T10:00:00Z", "pid": 12345 } { "id": "abc123", "command": ["/bin/sh"], "hostname": "tiny-container", "rootfs": "./rootfs/alpine", "-weight: 500;">status": "running", "created_at": "2026-05-12T10:00:00Z", "pid": 12345 } { "id": "abc123", "command": ["/bin/sh"], "hostname": "tiny-container", "rootfs": "./rootfs/alpine", "-weight: 500;">status": "running", "created_at": "2026-05-12T10:00:00Z", "pid": 12345 } CONTAINER ID PID STATUS COMMAND abc123 12345 running /bin/sh CONTAINER ID PID STATUS COMMAND abc123 12345 running /bin/sh CONTAINER ID PID STATUS COMMAND abc123 12345 running /bin/sh logFile, _ := os.Create("container.log") cmd.Stdout = logFile cmd.Stderr = logFile logFile, _ := os.Create("container.log") cmd.Stdout = logFile cmd.Stderr = logFile logFile, _ := os.Create("container.log") cmd.Stdout = logFile cmd.Stderr = logFile tiny--weight: 500;">docker-go logs <container-id> tiny--weight: 500;">docker-go logs <container-id> tiny--weight: 500;">docker-go logs <container-id> /var/lib/tiny--weight: 500;">docker/containers/<id>/container.log /var/lib/tiny--weight: 500;">docker/containers/<id>/container.log /var/lib/tiny--weight: 500;">docker/containers/<id>/container.log tiny--weight: 500;">docker-go run /bin/sh tiny--weight: 500;">docker-go run /bin/sh tiny--weight: 500;">docker-go run /bin/sh interactive mode ├── stdin -> terminal ├── stdout -> terminal └── stderr -> terminal detached mode ├── stdin -> maybe closed ├── stdout -> log file └── stderr -> log file interactive mode ├── stdin -> terminal ├── stdout -> terminal └── stderr -> terminal detached mode ├── stdin -> maybe closed ├── stdout -> log file └── stderr -> log file interactive mode ├── stdin -> terminal ├── stdout -> terminal └── stderr -> terminal detached mode ├── stdin -> maybe closed ├── stdout -> log file └── stderr -> log file tiny--weight: 500;">docker-go logs <id> tiny--weight: 500;">docker-go logs -f <id> tiny--weight: 500;">docker-go logs <id> tiny--weight: 500;">docker-go logs -f <id> tiny--weight: 500;">docker-go logs <id> tiny--weight: 500;">docker-go logs -f <id> tiny--weight: 500;">docker-go -weight: 500;">stop <container-id> tiny--weight: 500;">docker-go -weight: 500;">stop <container-id> tiny--weight: 500;">docker-go -weight: 500;">stop <container-id> send SIGTERM wait if still running, send SIGKILL -weight: 500;">update metadata send SIGTERM wait if still running, send SIGKILL -weight: 500;">update metadata send SIGTERM wait if still running, send SIGKILL -weight: 500;">update metadata /sys/fs/cgroup /sys/fs/cgroup /sys/fs/cgroup /sys/fs/cgroup/tiny--weight: 500;">docker/<container-id>/ /sys/fs/cgroup/tiny--weight: 500;">docker/<container-id>/ /sys/fs/cgroup/tiny--weight: 500;">docker/<container-id>/ echo 134217728 > memory.max echo 134217728 > memory.max echo 134217728 > memory.max cgroup.procs cgroup.procs cgroup.procs echo <pid> > cgroup.procs echo <pid> > cgroup.procs echo <pid> > cgroup.procs -weight: 500;">docker run --memory 128m ... -weight: 500;">docker run --memory 128m ... -weight: 500;">docker run --memory 128m ... python3 -c "a = 'x' * 200 * 1024 * 1024; print('allocated')" python3 -c "a = 'x' * 200 * 1024 * 1024; print('allocated')" python3 -c "a = 'x' * 200 * 1024 * 1024; print('allocated')" syscall.CLONE_NEWNET syscall.CLONE_NEWNET syscall.CLONE_NEWNET ip link set lo up ip link set lo up ip link set lo up tiny--weight: 500;">docker-go run --net isolated --rootfs ./rootfs/alpine /bin/sh tiny--weight: 500;">docker-go run --net isolated --rootfs ./rootfs/alpine /bin/sh tiny--weight: 500;">docker-go run --net isolated --rootfs ./rootfs/alpine /bin/sh -p 8080:80 -weight: 500;">docker network ls -weight: 500;">docker network inspect -p 8080:80 -weight: 500;">docker network ls -weight: 500;">docker network inspect -p 8080:80 -weight: 500;">docker network ls -weight: 500;">docker network inspect create network namespace create interface move interface into namespace assign IP set route configure NAT create network namespace create interface move interface into namespace assign IP set route configure NAT create network namespace create interface move interface into namespace assign IP set route configure NAT Host network namespace │ ├── eth0 / main host interface │ ├── td0 bridge │ └── veth-host │ └── container network namespace └── veth-container Host network namespace │ ├── eth0 / main host interface │ ├── td0 bridge │ └── veth-host │ └── container network namespace └── veth-container Host network namespace │ ├── eth0 / main host interface │ ├── td0 bridge │ └── veth-host │ └── container network namespace └── veth-container bridge td0: 10.10.0.1/24 container eth0: 10.10.0.2/24 default gateway: 10.10.0.1 bridge td0: 10.10.0.1/24 container eth0: 10.10.0.2/24 default gateway: 10.10.0.1 bridge td0: 10.10.0.1/24 container eth0: 10.10.0.2/24 default gateway: 10.10.0.1 ip link add td0 type bridge ip addr add 10.10.0.1/24 dev td0 ip link set td0 up ip link add veth-host type veth peer name veth-container ip link set veth-host master td0 ip link set veth-host up ip link set veth-container netns <container-pid> ip link add td0 type bridge ip addr add 10.10.0.1/24 dev td0 ip link set td0 up ip link add veth-host type veth peer name veth-container ip link set veth-host master td0 ip link set veth-host up ip link set veth-container netns <container-pid> ip link add td0 type bridge ip addr add 10.10.0.1/24 dev td0 ip link set td0 up ip link add veth-host type veth peer name veth-container ip link set veth-host master td0 ip link set veth-host up ip link set veth-container netns <container-pid> ip addr add 10.10.0.2/24 dev veth-container ip link set veth-container name eth0 ip link set eth0 up ip route add default via 10.10.0.1 ip addr add 10.10.0.2/24 dev veth-container ip link set veth-container name eth0 ip link set eth0 up ip route add default via 10.10.0.1 ip addr add 10.10.0.2/24 dev veth-container ip link set veth-container name eth0 ip link set eth0 up ip route add default via 10.10.0.1 iptables -t nat -A POSTROUTING -s 10.10.0.0/24 -j MASQUERADE iptables -t nat -A POSTROUTING -s 10.10.0.0/24 -j MASQUERADE iptables -t nat -A POSTROUTING -s 10.10.0.0/24 -j MASQUERADE sysctl -w net.ipv4.ip_forward=1 sysctl -w net.ipv4.ip_forward=1 sysctl -w net.ipv4.ip_forward=1 ip addr ip link ip route ip netns iptables -t nat -L -n -v sysctl net.ipv4.ip_forward ping ip addr ip link ip route ip netns iptables -t nat -L -n -v sysctl net.ipv4.ip_forward ping ip addr ip link ip route ip netns iptables -t nat -L -n -v sysctl net.ipv4.ip_forward ping tiny--weight: 500;">docker-go │ ├── CLI │ ├── run │ ├── ps │ ├── logs │ └── -weight: 500;">stop │ ├── Runtime │ ├── parent process │ ├── child process │ ├── namespace setup │ ├── rootfs setup │ └── command execution │ ├── State │ ├── container id │ ├── metadata json │ ├── pid │ ├── -weight: 500;">status │ └── created_at │ ├── Logs │ └── stdout/stderr capture │ ├── Cgroups │ ├── memory.max │ └── cgroup.procs │ └── Network ├── network namespace ├── bridge ├── veth pair └── NAT tiny--weight: 500;">docker-go │ ├── CLI │ ├── run │ ├── ps │ ├── logs │ └── -weight: 500;">stop │ ├── Runtime │ ├── parent process │ ├── child process │ ├── namespace setup │ ├── rootfs setup │ └── command execution │ ├── State │ ├── container id │ ├── metadata json │ ├── pid │ ├── -weight: 500;">status │ └── created_at │ ├── Logs │ └── stdout/stderr capture │ ├── Cgroups │ ├── memory.max │ └── cgroup.procs │ └── Network ├── network namespace ├── bridge ├── veth pair └── NAT tiny--weight: 500;">docker-go │ ├── CLI │ ├── run │ ├── ps │ ├── logs │ └── -weight: 500;">stop │ ├── Runtime │ ├── parent process │ ├── child process │ ├── namespace setup │ ├── rootfs setup │ └── command execution │ ├── State │ ├── container id │ ├── metadata json │ ├── pid │ ├── -weight: 500;">status │ └── created_at │ ├── Logs │ └── stdout/stderr capture │ ├── Cgroups │ ├── memory.max │ └── cgroup.procs │ └── Network ├── network namespace ├── bridge ├── veth pair └── NAT tiny--weight: 500;">docker-go run --rootfs ./rootfs/alpine /bin/sh tiny--weight: 500;">docker-go ps tiny--weight: 500;">docker-go logs <container-id> tiny--weight: 500;">docker-go -weight: 500;">stop <container-id> tiny--weight: 500;">docker-go run --rootfs ./rootfs/alpine /bin/sh tiny--weight: 500;">docker-go ps tiny--weight: 500;">docker-go logs <container-id> tiny--weight: 500;">docker-go -weight: 500;">stop <container-id> tiny--weight: 500;">docker-go run --rootfs ./rootfs/alpine /bin/sh tiny--weight: 500;">docker-go ps tiny--weight: 500;">docker-go logs <container-id> tiny--weight: 500;">docker-go -weight: 500;">stop <container-id> images + containers + Dockerfile + ports + volumes images + containers + Dockerfile + ports + volumes images + containers + Dockerfile + ports + volumes container = isolated Linux process + prepared filesystem + resource limits + networking + lifecycle metadata container = isolated Linux process + prepared filesystem + resource limits + networking + lifecycle metadata container = isolated Linux process + prepared filesystem + resource limits + networking + lifecycle metadata Namespaces control what a process can see. Cgroups control what a process can use. Namespaces control what a process can see. Cgroups control what a process can use. Namespaces control what a process can see. Cgroups control what a process can use. PID namespace: The process sees its own process tree. UTS namespace: The process sees its own hostname. Mount namespace: The process sees its own mount table. Network namespace: The process sees its own network interfaces and routes. Cgroups: The process can only use a limited amount of memory, CPU, pids, or IO. PID namespace: The process sees its own process tree. UTS namespace: The process sees its own hostname. Mount namespace: The process sees its own mount table. Network namespace: The process sees its own network interfaces and routes. Cgroups: The process can only use a limited amount of memory, CPU, pids, or IO. PID namespace: The process sees its own process tree. UTS namespace: The process sees its own hostname. Mount namespace: The process sees its own mount table. Network namespace: The process sees its own network interfaces and routes. Cgroups: The process can only use a limited amount of memory, CPU, pids, or IO. tiny--weight: 500;">docker-go pull alpine tiny--weight: 500;">docker-go pull alpine tiny--weight: 500;">docker-go pull alpine lowerdir = image layer upperdir = container writable layer workdir = overlay work directory merged = final container rootfs lowerdir = image layer upperdir = container writable layer workdir = overlay work directory merged = final container rootfs lowerdir = image layer upperdir = container writable layer workdir = overlay work directory merged = final container rootfs tiny--weight: 500;">docker-go run -p 8080:80 ... tiny--weight: 500;">docker-go run -p 8080:80 ... tiny--weight: 500;">docker-go run -p 8080:80 ... -weight: 500;">docker run nginx -weight: 500;">docker run nginx -weight: 500;">docker run nginx - Linux namespaces - root filesystems - process lifecycle - network namespaces - bridge networking - container metadata - Project structure and CLI foundation - Linux namespaces - Root filesystem isolation - Container IDs and metadata - Stop and lifecycle management - cgroups and memory limits - Network namespace - Bridge and veth networking - Polish, README, roadmap, and lessons learned - cmd/ contains the executable entrypoint. - internal/cli handles user-facing commands. - internal/runtime handles process execution. - internal/app wires things together. - PID namespace gives a different process tree. - UTS namespace gives a different hostname. - Mount namespace gives a different mount table. - Network namespace gives a different network stack. - User namespace gives a different view of user and group IDs. - IPC namespace isolates IPC resources. - Cgroup namespace isolates cgroup views. - its own hostname - its own PID namespace - its own mount namespace - its own root filesystem - What did I -weight: 500;">start? - What PID belongs to this container? - Where are its logs? - Is it running or stopped? - What command did it -weight: 500;">start with? - What rootfs did it use? - close database connections - finish current requests - release locks - write final state - sometimes devices and other controllers depending on system configuration - routing table - IP addresses - firewall rules view - loopback device - loopback was down - veth interface was created but not moved correctly - IP address was missing - default route was missing - NAT rule was missing - host forwarding was disabled - DNS was not configured - interface name inside namespace was not what I expected - project goal - architecture - installation - usage examples - known limitations - what each feature demonstrates - this process should see this hostname - this process should see this PID tree - this process should use this root filesystem - this process should have this memory limit - this process should write logs here - this process should be connected to this network - this process should be stopped with these signals - Docker docs — Running containers: https://docs.-weight: 500;">docker.com/engine/containers/run/ - Docker docs — -weight: 500;">docker run: https://docs.-weight: 500;">docker.com/reference/cli/-weight: 500;">docker/container/run/ - Docker docs — Container logs: https://docs.-weight: 500;">docker.com/reference/cli/-weight: 500;">docker/container/logs/ - Docker docs — Container -weight: 500;">stop: https://docs.-weight: 500;">docker.com/reference/cli/-weight: 500;">docker/container/-weight: 500;">stop/ - Linux man-pages — namespaces: https://man7.org/linux/man-pages/man7/namespaces.7.html - Linux man-pages — PID namespaces: https://man7.org/linux/man-pages/man7/pid_namespaces.7.html - Linux kernel docs — cgroup v2: https://docs.kernel.org/admin-guide/cgroup-v2.html