Skip to main content

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.

Using Shared Libraries Would Make Programs More Modern, Testable, Efficient

The adoption of shared libraries aligns perfectly with modern software development practices, offering advantages that go far beyond simple resource sharing. Consider the impact on modularity: when programs rely on shared libraries, they naturally develop cleaner interfaces and better separation of concerns. This modularity isn't just theoretical - it manifests in practical benefits throughout the development lifecycle.

Testing becomes substantially more manageable with shared libraries. Instead of testing entire static binaries as monolithic units, developers can focus on testing the interactions between their code and well-defined library interfaces. This approach not only reduces testing complexity but also helps identify issues more precisely. When a bug is found in a shared library, fixing it benefits all applications simultaneously.

The efficiency gains from shared libraries extend throughout the entire software lifecycle. Build times decrease significantly when programs don't need to compile their dependencies from scratch. Deployments become faster and more reliable when they can rely on existing system libraries. Memory usage improves as multiple programs share the same library code in memory, and cache utilization becomes more efficient when common code paths are shared across applications.

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 transport

  • ipc:// - 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:

// Thread-safe context creation
void *context = zmq_ctx_new();

// Socket creation - not thread-safe
void *socket = zmq_socket(context, ZMQ_PUSH);

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:

int hwm = 1000;
zmq_setsockopt(socket, ZMQ_SNDHWM, &hwm, sizeof(hwm));

// To prevent dropping messages when HWM is reached
int nodrop = 1;
zmq_setsockopt(socket, ZMQ_XPUB_NODROP, &nodrop, sizeof(nodrop));

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:

zmq_send(socket, message, size, ZMQ_DONTWAIT);
// Code continues immediately without waiting for the send operation outcome

Performance Considerations

ZeroMQ is particularly well-suited for high-performance scenarios where:

  1. You need to decouple the message producer from the consumer

  2. The critical section needs to emit messages without blocking

  3. You want to avoid the overhead of a message broker

  4. 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.

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.