In the previous chapter, Container Client Adapters, we built a "Universal Translator" that allows Dozzle to speak to both Docker and Kubernetes.
However, asking the Docker engine for a list of containers every single time a user refreshes the page is inefficient. Itβs slow, it consumes resources, and it doesn't scale well if multiple users are looking at the dashboard.
In this chapter, we will build the In-Memory Container Store to solve this problem.
Imagine a busy warehouse.
1. In the morning, the clerk does one full count of the inventory. 2. Throughout the day, whenever a truck arrives (Stock In) or leaves (Stock Out), the computer updates the count automatically. 3. When a customer calls, the clerk looks at the screen and answers instantly.
In Dozzle, the Container Store is that computer screen. It acts as a live cache.
Without a store, the flow looks like this:
/api/containers.docker.ListContainers().If 100 users open Dozzle, the Docker Daemon gets hit 100 times. This causes lag.
The ContainerStore sits between the User and the Client Adapter. It keeps a copy of the container list in the application's memory (RAM).
container start, container die).
Let's look at internal/container/container_store.go.
At its heart, the store is just a fancy Map (Key-Value pair). We use xsync.Map which is a special map designed to be safe when multiple parts of the code try to read or write to it at the same time.
// internal/container/container_store.go
type ContainerStore struct {
// The "Cache": Maps ContainerID -> Container Struct
containers *xsync.Map[string, *Container]
// The "Universal Translator" from Chapter 1
client Client
// A channel to receive updates from Docker/K8s
events chan ContainerEvent
}
When we create the store, we immediately start a background process (go s.init()) to keep it alive.
func NewContainerStore(ctx context.Context, client Client, ...) *ContainerStore {
s := &ContainerStore{
containers: xsync.NewMap[string, *Container](),
client: client,
events: make(chan ContainerEvent),
// ...
}
// Start the background brain
go s.init()
return s
}
When the frontend asks for containers, we don't call Docker. We just read our map. This is incredibly fast (microseconds vs milliseconds).
func (s *ContainerStore) ListContainers(labels ContainerLabels) ([]Container, error) {
containers := make([]Container, 0)
// Iterate over our in-memory map
s.containers.Range(func(_ string, c *Container) bool {
containers = append(containers, *c)
return true
})
return containers, nil
}
Beginner Note: Because
s.containersis in memory, this function returns instantly, no matter how slow the Docker engine is running!
The magic happens in the init() method. This runs in an infinite loop, waiting for signals from the system.
Here is the flow of the ContainerStore logic:
Let's look at how we handle these events in Go. We use a select statement to listen for messages on the events channel.
// internal/container/container_store.go (Simplified)
func (s *ContainerStore) init() {
// 1. Initial Fetch (The "Morning Inventory Count")
s.checkConnectivity()
for {
// 2. Wait for updates (The "Truck arriving at dock")
select {
case event := <-s.events:
switch event.Name {
case "start":
// Fetch new container details and add to store
s.handleStartEvent(event)
case "die":
// Update the specific container in the map to "exited"
s.markContainerAsExited(event.ActorID)
case "destroy":
// Remove from map completely
s.containers.Delete(event.ActorID)
}
case <-s.ctx.Done():
return // Stop if app shuts down
}
}
}
When a container dies (stops), we don't need to re-fetch the whole list. We just update that one specific entry in our map.
case "die":
// Compute updates the map safely
s.containers.Compute(event.ActorID, func(c *Container, loaded bool) (*Container, op) {
if loaded {
// Update the status in memory
c.State = "exited"
c.FinishedAt = time.Now()
return c, xsync.UpdateOp
}
return c, xsync.CancelOp
})
The ContainerStore isn't just a static list; it's reactive. When the store updates its own map, it also needs to tell the frontend, "Hey! The list changed!"
It does this by maintaining a list of Subscribers.
func (s *ContainerStore) SubscribeEvents(ctx context.Context, events chan<- ContainerEvent) {
// Add a channel to the list of subscribers
s.subscribers.Store(ctx, events)
// Cleanup when the request ends
go func() {
<-ctx.Done()
s.subscribers.Delete(ctx)
}()
}
When an event is processed in the loop above, the store loops through all s.subscribers and forwards the event to them.
By implementing the In-Memory Container Store, we have transformed Dozzle from a simple API proxy into a real-time, high-performance application.
But simply having the data in memory isn't enough. We are building a log viewer. We need a way to capture, process, and stream the logs from these containers efficiently.
Next Chapter: Log Processing Pipeline
Generated by Code IQ