The Hidden Costs of Static Linking and Containerization: A Critical Analysis
Statically-linked Programs are the evil
The trend toward static linking represents a fundamental regression in software engineering principles. By bundling every dependency directly into the executable, we're not just bloating our binaries - we're actively dismantling decades of progress in software modularization. Each statically linked program becomes an island, disconnected from the ecosystem of shared improvements and security updates.
Consider what happens when a critical vulnerability is discovered in a commonly used library. In a properly designed system using shared libraries, a single system update would protect all applications simultaneously. Instead, with static linking, we must embark on a complex and error-prone process of identifying every affected program, rebuilding each one individually, and ensuring they all get redeployed. This process isn't just inefficient - it's dangerously irresponsible and prone to oversights that could leave vulnerable versions running indefinitely.
Containerized Programs are the Evil (with an uppercased E at the beginning)
If static linking is problematic, containerization compounds these issues exponentially. Not only do we face the fundamental problems of static linking, but we now multiply them across isolated environments. Each container becomes its own separate universe with redundant copies of shared resources, creating a nightmare of inefficiency and complexity.
The storage implications alone are staggering. Where a traditional system might maintain one copy of a commonly used library, a containerized environment duplicates this library across potentially hundreds of containers. This multiplication effect extends to memory usage as well - shared libraries can no longer be shared across container boundaries, leading to massive memory bloat as each container loads its own copy of identical code.
Deployment times suffer significantly as entire container images must be transferred across networks. What should be a simple application update becomes an exercise in moving entire system images. The orchestration requirements grow exponentially, requiring complex management systems just to keep track of all these isolated environments.
Both produce unsafe applications and expose you at risks
Static linking and containerization create dangerous security time bombs by embedding frozen versions of libraries into applications, making it impossible to quickly patch discovered vulnerabilities. When a critical security flaw is found in a shared library, systems using dynamic linking can deploy a single patch to protect all applications simultaneously, while statically linked applications require individual rebuilds and redeployments - a process so complex that vulnerable versions often remain in production indefinitely. Containerization worsens this problem by bundling entire operating system environments, expanding the vulnerability surface exponentially and making security audits nightmarishly complex. Each container potentially includes thousands of packages that need individual tracking and updating, creating an unmanageable maze of dependencies. The false sense of security provided by containerization leads to dangerous complacency, as developers mistakenly believe container isolation protects them from security vulnerabilities while sharing the same kernel. Sensitive information like API keys often gets baked into static binaries or container images, making secret rotation difficult and increasing exposure risks. The solution lies in transparent, centrally managed dependencies that enable rapid security responses, not in the isolation and rigidity that static linking and containerization impose.
Why the C Language is Really Good in Making Software Reusable
The C language's approach to software reuse through shared libraries represents one of its most sophisticated yet underappreciated features. At its core, C's ability to create stable Application Binary Interfaces (ABIs) enables a level of code reuse that modern approaches often fail to match. This isn't just about saving disk space - it's about creating a genuine ecosystem of reusable components.
The straightforward linking model in C allows programs to dynamically load code at runtime, enabling true resource sharing between applications. When multiple programs use the same library, they share the actual memory pages containing that code, significantly reducing the system's memory footprint. This sharing extends beyond mere storage efficiency - it enables system-wide optimizations that benefit all applications simultaneously.
Furthermore, C's model enables clear separation between applications and their dependencies while maintaining high performance. Platform-specific optimizations can be implemented at the library level, automatically benefiting all applications that use that library. This model has successfully powered Unix-like systems for decades, demonstrating its effectiveness in real-world deployments.
It's ridiculous to find how a language from 1970 offers better code reusability than the ones from 2020s. 55 years ago some wise people envisioned very smart concepts that are still foundational.
Why an Operating System That Doesn't Reuse Libraries is Not an OS by Definition
The fundamental purpose of an operating system extends far beyond merely managing hardware resources - it serves as a platform for sharing and coordinating resources among applications. When we bypass this through static linking and containerization, we're essentially creating multiple parallel "operating systems" within one host, fundamentally undermining the very concept of what an operating system should be.
Consider the primary responsibilities of an operating system: it must efficiently allocate system resources, manage shared memory, and coordinate access to hardware. These functions aren't meant to be isolated - they're designed to create a cooperative environment where applications can share resources efficiently. When each application brings its own copy of system libraries, we're not just duplicating code - we're fragmenting the very foundation of system resource management.
The provision of shared services represents another crucial aspect of operating system functionality. By maintaining common libraries available to all applications, the operating system creates a standardized platform for development and execution. This standardization isn't just about convenience - it's about creating a reliable, secure, and efficient computing environment. When applications bypass these shared services through static linking, they're effectively declaring independence from the operating system's core functionality.
The Only Modern Revolution I See Coming is Stateless OSes
The future of operating systems lies not in static linking or containerization, but in embracing a truly stateless architecture that revolutionizes how we think about system resources and application dependencies. This vision combines the best aspects of immutable infrastructure with the efficiency of shared resources, creating a new paradigm for system design.
At the core of this revolution is the concept of an immutable system layer containing verified, signed dependencies. These system libraries would be read-only and atomic in their updates, eliminating many traditional points of failure in system maintenance. However, unlike current trends toward static linking, these libraries would be shared efficiently across all applications.
The user space in such a system would be truly dynamic, with intelligent library loading and version management. Instead of each application maintaining its own copies of libraries, the system would efficiently manage shared resources, loading different versions only when absolutely necessary and optimizing memory usage across all applications. This approach would maintain the isolation benefits that developers seek from static linking while eliminating the resource waste and security risks.
Security in this new paradigm would be fundamentally different from current approaches. Instead of relying on the isolation of static linking or containers, security would be built on a foundation of verified execution paths and centralized vulnerability management. System-wide security patches could be applied instantly and atomically, eliminating the complex coordination currently required to update statically linked applications.
The revolution isn't about finding new ways to isolate our code - it's about creating intelligent systems that can dynamically manage dependencies while maintaining security and efficiency. By moving away from the current trends of static linking and containerization, we can build systems that are truly efficient, secure, and maintainable. The future lies not in isolation, but in intelligent sharing of resources and code.
Containerized or statically-linked applications should be embraced only for playground or very small deployments
While I find useful using containers or statically-linked applications to build toy projects, and I am going to use them for these purposes, they cannot certainly be a reference model for the future.
snap, flatpak, AppImage, Docker should not be taken so seriously as they pretend to be.
Exchanging messages between processes (or even threads within the same program) using ZeroMQ
Inter-Process Communication with ZeroMQ (and Protocol Buffers)
Introduction
Some may certainly say that, when you are writing so called "daemons" under Linux/Unix OSes or "services" under Windows, you might want to use OS primitives/reuse existing libraries to make your programs communicate each other. And I strongly agree with the point: it is always a good idea to use a well-tested and solid library to implement such fundamental features such as message queues.
For example, under Linux you can use D-Bus, which allows IPC at scale within the OS scope. Or, in the microservices space, you can leverage on message brokers like RabbitMQ or Kafka to stream your messages through sophisticated routing logic. However, at times you are just looking for something trivial and simple to send and queue messages where at the same time you look for brokerless setup but still you are willing to leverage on some of the features that message queuing systems offer for free with ease. That's where ZeroMQ comes in.
What is ZeroMQ?
ZeroMQ (ØMQ) is a high-performance asynchronous messaging library aimed at use in distributed or concurrent applications. It provides a message queue, but unlike message-oriented middleware, a ZeroMQ system can run without a dedicated message broker. The API layer provides a message-oriented abstraction of asynchronous network communication, multiple messaging patterns, message filtering, and more.
Supported Transport Protocols
ZeroMQ supports various transport protocols through different URI schemes where the main ones are:
tcp://
- TCP transportipc://
- Inter-process communication (Unix domain sockets)inproc://
- In-process communication between threads
Thread Safety and Context Management
In both C and Go, ZeroMQ contexts are thread-safe, but sockets are not. Here's how to handle them properly:
In Go:
// Thread-safe context creation context, _ := zmq4.NewContext() // Socket creation should be done in the thread that uses it socket, _ := context.NewSocket(zmq4.PUSH) defer socket.Close()
In C:
Intra-Thread Communication Example
Here's an example of thread communication using ZeroMQ in C:
#include <zmq.h> #include <pthread.h> #include <stdio.h> void* worker_routine(void* context) { void* receiver = zmq_socket(context, ZMQ_PULL); zmq_connect(receiver, "inproc://workers"); while (1) { char buffer[256]; zmq_recv(receiver, buffer, 255, 0); printf("Received: %s\n", buffer); } zmq_close(receiver); return NULL; } int main() { void* context = zmq_ctx_new(); void* sender = zmq_socket(context, ZMQ_PUSH); zmq_bind(sender, "inproc://workers"); pthread_t worker; pthread_create(&worker, NULL, worker_routine, context); // Send messages const char* message = "Hello Worker!"; zmq_send(sender, message, strlen(message), 0); sleep(1); // Allow time for message processing zmq_close(sender); zmq_ctx_destroy(context); return 0; }
High Water Mark and Flow Control
ZeroMQ provides flow control through the High Water Mark (HWM) feature. When the HWM is reached, ZeroMQ will either block or drop messages depending on the socket type and configuration:
Protocol Buffers Integration
Since ZeroMQ only transfers raw bytes, it pairs well with Protocol Buffers for structured data serialization. Here's an example using both C++ and Go:
First, define your protocol buffer:
// message.proto syntax = "proto3"; message DataMessage { string content = 1; int64 timestamp = 2; }
Using it in Go:
package main import ( "log" "github.com/pebbe/zmq4" "google.golang.org/protobuf/proto" examplepb "path/to/generated/proto" ) func main() { // Create a ZeroMQ context context, err := zmq4.NewContext() if err != nil { log.Fatalf("Failed to create ZeroMQ context: %v", err) } defer context.Term() // Ensure the context is terminated when the program exits // Create a ZeroMQ Subscriber socket subscriber, err := context.NewSocket(zmq4.SUB) if err != nil { log.Fatalf("Failed to create subscriber socket: %v", err) } defer subscriber.Close() // Connect to the publisher err = subscriber.Connect("tcp://127.0.0.1:5555") if err != nil { log.Fatalf("Failed to connect subscriber: %v", err) } // Subscribe to all messages err = subscriber.SetSubscribe("") if err != nil { log.Fatalf("Failed to set subscription: %v", err) } log.Println("Subscriber started, waiting for messages...") for { // Receive the serialized message data, err := subscriber.RecvBytes(0) if err != nil { log.Printf("Failed to receive message: %v", err) continue } // Deserialize the message var message examplepb.ExampleMessage err = proto.Unmarshal(data, &message) if err != nil { log.Printf("Failed to deserialize message: %v", err) continue } // Print the received message log.Printf("Received message: ID=%s, Content=%s", message.Id, message.Content) } }
Explanation
Context: The zmq.NewContext() function creates a new ZeroMQ context, which is required to create sockets.
Socket: The context.NewSocket(zmq.SUB) function creates a new SUB socket for subscribing to messages.
Connect: The subscriber.Connect("tcp://localhost:5555") function connects the subscriber to the publisher’s address.
Subscribe: The socket.SetSubscribe("") function subscribes to all messages (an empty string means subscribe to everything). This acts as a way to subscribe to a string prefix (so called "topic" in other MQ systems)
Recv: The socket.RecvBytes(0) function blocks until a message is received.
Asynchronous Message Emission
ZeroMQ supports non-blocking sends using the ZMQ_DONTWAIT
flag:
Performance Considerations
ZeroMQ is particularly well-suited for high-performance scenarios where:
You need to decouple the message producer from the consumer
The critical section needs to emit messages without blocking
You want to avoid the overhead of a message broker
You need reliable message delivery without managing it yourself
The library handles many complex aspects automatically:
Message framing
Connection handling and reconnection
Message queuing
Fair message distribution
Transport abstraction
ZeroMQ Messaging Patterns
ZeroMQ supports several fundamental messaging patterns, each designed for specific use cases:
Push/Pull (Pipeline)
The Push/Pull pattern creates a one-way data distribution pipeline. Messages sent by pushers are load-balanced among all connected pullers.
// Pusher (sender) void *pusher = zmq_socket(context, ZMQ_PUSH); zmq_bind(pusher, "tcp://*:5557"); // Puller (receiver) void *puller = zmq_socket(context, ZMQ_PULL); zmq_connect(puller, "tcp://localhost:5557");
Use cases:
Parallel task distribution
Workload distribution in producer/consumer scenarios
Data pipeline processing
Pub/Sub (Publisher/Subscriber)
Publishers send messages while subscribers receive them based on topics. Each subscriber can subscribe to multiple topics.
// Publisher publisher, _ := zmq4.NewSocket(zmq4.PUB) publisher.Bind("tcp://*:5563") // Send message with topic publisher.Send("weather.london temperature:22", 0) // Subscriber subscriber, _ := zmq4.NewSocket(zmq4.SUB) subscriber.Connect("tcp://localhost:5563") subscriber.SetSubscribe("weather.london")
Use cases:
Event broadcasting
Real-time data feeds
System monitoring
Live updates
Request/Reply (REQ/REP)
A synchronous pattern where each request must be followed by a reply.
// Server (Reply) void *responder = zmq_socket(context, ZMQ_REP); zmq_bind(responder, "tcp://*:5555"); // Client (Request) void *requester = zmq_socket(context, ZMQ_REQ); zmq_connect(requester, "tcp://localhost:5555");
Use cases:
Remote procedure calls (RPC)
Service APIs
Task delegation with acknowledgment
Dealer/Router
An advanced asynchronous pattern that allows for complex routing scenarios.
// Router router, _ := zmq4.NewSocket(zmq4.ROUTER) router.Bind("tcp://*:5555") // Dealer dealer, _ := zmq4.NewSocket(zmq4.DEALER) dealer.Connect("tcp://localhost:5555")
Use cases:
Load balancing
Asynchronous request/reply
Complex routing topologies
Service meshes
Pattern Selection Guidelines
When choosing a pattern, consider:
Message Flow Direction:
One-way: Push/Pull or Pub/Sub
Two-way: Request/Reply or Dealer/Router
Synchronization Requirements:
Synchronous: Request/Reply
Asynchronous: Push/Pull, Pub/Sub, Dealer/Router
Scalability Needs:
Fan-out: Pub/Sub
Load balancing: Push/Pull or Dealer/Router
Both: Combination of patterns
Message Delivery Guarantees:
At-most-once: Pub/Sub
At-least-once: Request/Reply
Custom guarantees: Dealer/Router
Example: Combining Patterns
Here's an example combining Pub/Sub with Push/Pull for a logging system:
package main import ( "github.com/pebbe/zmq4" "log" ) func main() { // Event publisher publisher, _ := zmq4.NewSocket(zmq4.PUB) publisher.Bind("tcp://*:5563") // Log collector collector, _ := zmq4.NewSocket(zmq4.PULL) collector.Bind("tcp://*:5564") // Worker that processes logs and publishes events go func() { worker, _ := zmq4.NewSocket(zmq4.PUSH) worker.Connect("tcp://localhost:5564") subscriber, _ := zmq4.NewSocket(zmq4.SUB) subscriber.Connect("tcp://localhost:5563") subscriber.SetSubscribe("error") // Process messages... }() }
This setup allows for: - Real-time error broadcasting (Pub/Sub) - Reliable log collection (Push/Pull) - Scalable processing workers - Decoupled components
The choice of pattern significantly impacts your system's behavior, performance, and scalability. It's often beneficial to combine patterns to achieve more complex messaging requirements while maintaining simplicity in individual components.
Conclusion
When you need a lightweight, broker-less messaging solution with good performance characteristics, ZeroMQ provides an excellent balance of features and simplicity. It's particularly valuable in scenarios where you need to quickly implement reliable inter-process or inter-thread communication without the overhead of a full message broker infrastructure.
While it may not replace more robust solutions like Kafka for large-scale distributed systems, ZeroMQ fills an important niche for local and small-scale distributed messaging needs, especially when performance is a critical factor.
Useful Links
ZeroMQ Official Website: https://zeromq.org/
ZeroMQ Documentation: https://zeromq.org/get-started/
Protocol Buffers Official Website: https://protobuf.dev/
Protocol Buffers Documentation: https://protobuf.dev/overview/
ZeroMQ Guide: https://zguide.zeromq.org/
Building a Lightweight Node.js Background Job Scheduler: A Practical Solution for Simple Web Applications
Building a Lightweight Node.js Background Job Scheduler
As developers, we often come across situations where a fully-fledged background job system, with all its bells and whistles, might be overkill for our project needs. This was the case for me when I built a custom background job scheduler in TypeScript and Node.js, designed to handle essential tasks without the overhead of larger, more complex solutions.
The Need for a Simple Solution
My project involved a web application that required periodic background tasks, such as data synchronization, cleanup jobs, and basic system monitoring. While there are many mature background job frameworks available, most were too feature-heavy for what I needed. I wanted something small, efficient, and easy to integrate into my Docker-based setup, without introducing unnecessary complexity.
That’s when I decided to write my own scheduler—lean, concise, and perfect for simple backend apps or as a side container to complement larger web applications.
The Custom Scheduler: Small but Effective
The goal was to create a minimalistic background job scheduler that could be easily run in a Docker container alongside the main web application. Written in TypeScript and Node.js, the solution is focused purely on executing periodic tasks with the least amount of code possible, while ensuring it’s flexible enough to be extended for future needs.
Unlike robust job schedulers like Bull or Agenda, my custom scheduler strips away non-essential features and focuses on what truly matters for small applications: reliability and ease of use. It supports scheduling jobs at specific intervals, retrying on failure, and executing scripts or commands as needed. By keeping the codebase concise, the scheduler can be easily maintained and quickly deployed.
Seamless Integration into Docker
The scheduler is designed to be packaged as a multi-layer Docker container. This approach allows me to include all the necessary CLI tools and backend executables in one place, ensuring that the container remains isolated but tightly integrated with the rest of the application.
This makes it an ideal sidecar container to handle tasks for a larger web application. Its small footprint ensures that it won’t introduce significant overhead, making it an excellent choice for environments where resources are limited, such as microservices architectures or smaller backend deployments.
Real-Time Observability: Telegram Bot and Email Alerts
One of the unique aspects of this project was adding easy observability and control via a Telegram bot and email notifications. While the scheduler itself is minimalistic, I wanted to ensure I had a convenient way to monitor job status and handle any failures without diving into logs or dashboards.
The Telegram bot integration allows me to start or stop jobs, check their status, and receive instant notifications when something goes wrong. This real-time control, paired with email alerts for periodic updates or error logs, ensures I stay informed even when the jobs are running in the background.
Perfect for Small-Scale Applications
This background job scheduler might not have the rich feature set of other established systems, but that’s exactly why it works so well in certain scenarios. For smaller applications or web services that don’t require a heavy-duty job queue, this solution offers a lightweight, easy-to-manage alternative. It handles the basics efficiently, making it perfect for production environments where simplicity and performance are crucial.
Final Thoughts
Creating this custom background job scheduler has been a rewarding experience. It’s not meant to replace more feature-rich systems, but rather fill the gap for projects where adding complex tooling would be overkill. With a small and concise codebase, seamless Docker integration, and real-time observability via Telegram and email, this scheduler has become an invaluable part of my workflow.
If you're working on a small backend or need a side container to handle background jobs without the complexity of larger frameworks, this custom solution might be just what you're looking for.
If you're tackling a similar challenge in your project, I highly recommend to have a try with the Background Job scheduler: https://github.com/alexpacio/background-job-scheduler
Full-fledged API + e2e tests + benchmark + IaC + Helm charts + more as an (interesting) exercise!
Last week, I was contacted for a coding challenge. The project seemed interesting, so I decided to take it on. At the very least, I would learn something new, which I was eager to explore: Pulumi, k6, FastAPI and some fancy modern things that make you look like a cool dev!
The project involved creating a simple REST API in Python, which needed to be packaged with Helm, ready for deployment in a Kubernetes (K8s) cluster, and including all the essential tools required.
Requirements
A Python REST API backend program. The choice of framework was up to me
Unit and e2e tests
Swagger/OpenAPI documentation
A CDK-like deployment script to automate the API’s dependencies: Terraform or Pulumi (for an AWS CDK-like experience). Since I was curious, I chose Pulumi
Backend API should store its state via AWS PaaS services. Either a real AWS account or an automated way to use Localstack, a tool that simulates AWS’s APIs in your local environment, would have worked
A Helm package to deploy the Kubernetes cluster
Health checking mechanisms
Scripts to tie everything together, making the cluster reproducible by following a series of steps outlined in a README file.
Horizontal autoscaling
Built-in application benchmarking
The ultimate goal is now clear: creating a self-contained package that could easily reproduce a complete, production-ready REST API backend in a cloud native and scalable setup.
Result
Here’s the result of my work: https://github.com/alexpacio/python-webapp-boilerplate
Although there are a few rough edges and minor aspects that I handled in a superficial way, this serves as an example of how a modern, asynchronous FastAPI backend can be delivered within a single Git repository. I believe it's a solid boilerplate to start with, and it allowed me to explore new tools like Pulumi, FastAPI, and other recent technologies.
Thoughts
Even though I feel like the entire AWS ecosystem is increasingly filling gaps (perhaps revenue gaps?) with bloat everywhere, I’ve been impressed with how concise, easy to use, develop, and reproduce a project like this can be. From this foundation, you can build a very sophisticated system while keeping everything in one place. Additionally, you can test your AWS services locally and connect them later by simply using the Helm values file for the infrastructure or the .env file in the root folder for the application properties.
As mentioned, there’s definitely room for several small adjustments to make it work seamlessly, but I think this is a very solid and complete starting point! I may have missed something important, as I dedicated only a small portion of my time to this challenge.
Imagine having a temporary Kubernetes (K8s) namespace just to run your end-to-end (e2e) tests, reproducing the entire AWS PaaS stack and your application in a replicated and horizontally scalable way: you run the tests, collect the report, and then dispose of the environment.
The same approach can be used for your benchmarking needs.
With every change you make to your code, you can potentially test each step in your local cluster, which is automatically initialized in every aspect.
Just focus on your infrastructure and application code, set your environment variable files, and your portable cluster is up and running!
Outcome
I think I’ll continue to work on this. Cloud-native applications that avoid being locked into a specific cloud provider while still being distributed, scalable, and easy to use are possible. I look forward to building an opinionated version of this soon. Stay tuned!
Contribute!
Feel free to use it as you wish, and let me know if you have any comments.
Cheers!
Hello world!
This is my first post! I'm excited to start sharing tips on backend development, cloud computing, and more.