DEV Community

Mustafa ERBAY
Mustafa ERBAY

Posted on • Originally published at mustafaerbay.com.tr

CI/CD Build Cache Management: Time Savings and Infrastructure Costs

Introduction: Hidden Costs in CI/CD Pipelines

Every day, we deploy dozens, sometimes hundreds, of times. The faster and smoother this process, the more efficiently teams work. At the heart of CI/CD pipelines are build operations. However, these builds, especially in large projects, can consume significant time and resources. A build taking hours prolongs developer waiting times, breaks the fast feedback loop, and most importantly, increases infrastructure costs. Not only build times, but also the CPU, RAM, and disk I/O used during builds must be considered.

In this article, we will focus on a cost item frequently encountered and often overlooked in CI/CD pipelines: build cache management. From my observations in my own projects and during consulting, incorrect management of build caches can lead to significant and unnecessary waste of time and money. Time is the most valuable asset for development and operations teams. One way to use this time more efficiently is through intelligent build cache management.

What is Build Cache and Why is it Important?

A build cache is a mechanism that stores repetitive work performed during the compilation or packaging of a software project. For example, in a Java project, dependencies are not downloaded or compiled repeatedly in every build; they are cached. Similarly, in a frontend project, the node_modules folder or compiled JavaScript/CSS files can be cached. The basic principle is: if a file or operation has been performed before and its input hasn't changed, use the cached output instead of recalculating it.

The biggest benefit of this approach is that it dramatically shortens build times. Especially in large monorepos or projects with many dependencies, running every build from scratch can take hours. With cache usage, these times can be reduced to minutes, or even seconds. This allows developers to get faster feedback, deploy more frequently, and generally be more productive.

ℹ️ Cache Types

In CI/CD pipelines, two main types of cache are generally used:

  • Dependency Cache: Stores project dependencies (e.g., JAR files for Maven/Gradle, node_modules for npm/yarn).
  • Build Output Cache: Stores compiled files, artifacts, or intermediate outputs from previous builds.

These two types work together to optimize build times.

Cache Management in CI/CD Tools: General Approaches

Various CI/CD tools offer different mechanisms for managing build caches. Cache management is a fundamental part of pipelines in popular platforms like Jenkins, GitLab CI, GitHub Actions, and CircleCI. Generally, caches are managed as follows:

  1. Defining the Cache Key: Determines what the cache will be based on and when it will be triggered. This usually relies on factors such as the hash of project files, the hash of dependency files, or a specific version number. A new cache is created when the key changes.
  2. Defining the Cache Directory: Specifies which folders or files will be cached. For example, directories like node_modules, .m2, and .gradle are frequently cached.
  3. Storing and Restoring Cache: Before a build starts, the cache corresponding to the defined cache key is restored. After the build completes, the newly created or updated cache is stored with the specified key.
  4. Cache Cleanup: Cleaning up old or unused caches is also important. Otherwise, disk space quickly fills up, and costs increase.

Although each tool has its own specific commands and configurations, the basic principles are similar. For example, in GitHub Actions, the actions/cache action is used, while in GitLab CI, directories and keys are defined under the cache: keyword.

GitHub Actions Example: The Power of Cache

The actions/cache action is very useful for cache management in GitHub Actions. With a simple configuration, you can cache dependencies or build outputs.

name: CI with Cache

on: [push]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v4

    - name: Cache npm dependencies
      uses: actions/cache@v4
      with:
        path: ~/.npm
        key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
        restore-keys: |
          ${{ runner.os }}-npm-

    - name: Use Node.js
      uses: actions/setup-node@v4
      with:
        node-version: '20'
        cache: 'npm' # This line automatically manages npm cache

    - name: Install dependencies
      run: npm ci

    - name: Build project
      run: npm run build
Enter fullscreen mode Exit fullscreen mode

In this example, a cache key is created using the hash of the package-lock.json file. If this file changes, a new cache is created. Otherwise, the existing cache is restored. This allows the npm ci command to run much faster because the node_modules directory is not downloaded repeatedly.

💡 Choosing a Cache Key

Care must be taken when defining the cache key. A too broad key (e.g., just the OS) might always cause the cache to be rebuilt. A too narrow key (e.g., every commit hash) limits cache reuse. Typically, the hash of lock files or specific configuration files is a good starting point.

Cost Impact of Build Cache: With Numerical Data

Cache management not only increases speed but also directly impacts infrastructure costs. How long a build takes determines how long CI/CD runners (worker servers) will be busy. Most cloud-based CI/CD services charge runners hourly. These costs can reach significant figures, especially with heavy usage.

Let's say a project's build normally takes 30 minutes, and without cache, this extends to 1.5 hours. If 100 builds are performed per day, this means 100 * (1.5 hours - 0.5 hours) = 100 hours saved per day. If the hourly runner cost is 0.1 USD, this translates to 10 USD saved per day, or 300 USD per month. In large projects or when using more expensive runners, this figure multiplies.

However, the cost is not limited to runner time. Disk space, CPU, and memory usage during builds are also important. Caches, especially unnecessarily large or old ones, can fill up expensive storage space. Disk space costs should not be overlooked. Some cloud providers also charge for snapshots or object storage.

⚠️ Disk Space Issue

Disk space management in CI/CD runners is critical. Docker images, build artifacts, and caches can quickly consume space. Regular cleanup mechanisms and intelligent cache policies are key to preventing this issue.

Practical Steps to Optimize Your Caches

Efficient use of build caches is possible with a few simple but effective steps. Here are some methods I've tried and found successful:

  1. Use Correct Cache Keys: Understand your project's structure and dependencies well. Using the hashes of files like package-lock.json, yarn.lock, pom.xml, build.gradle, requirements.txt as keys is generally a good starting point. When these files change, we know the build needs to run again.
  2. Avoid Unnecessary Caches: Don't try to cache everything. Only cache what truly saves time and doesn't change frequently. For example, caching temporary files that change with every commit is illogical.
  3. Clean Caches Regularly: Periodically clean old caches using your CI/CD tool or your own scripts. This frees up disk space and reduces costs. Some tools offer an option to automatically delete old caches.
  4. Use Layered Caching: Using separate caches for different layers can be beneficial. For example, dependency cache and compiled code cache can be kept separate. This prevents one layer from invalidating the other when it changes.
  5. Evaluate Distributed Cache Solutions: In very large projects or distributed build systems, local runner caches may be insufficient. Distributed cache solutions like Redis, MinIO, or cloud storage services can offer a more scalable solution.

A Real Scenario: Cache Loss in an E-commerce Project

While working on an e-commerce project, we noticed that frontend builds were taking quite a long time. The npm ci command took about 15-20 minutes each time. We had defined a cache key using the hash of the package-lock.json file, but our caches were frequently invalidated. Upon closer inspection, we found that CI/CD runners sometimes failed to clean up temporary node_modules directories or other cached files. This resulted in almost a fresh installation every time.

To solve the problem, we configured the actions/cache action more carefully and used the restore-keys option to try and restore caches with similar keys even when an exact key match wasn't found. We also used Docker-based build environments to ensure consistency across runners and that each build started in a clean environment. With these changes, the npm ci time dropped to an average of 5 minutes. This meant a saving of 10-15 minutes per build. With 50 builds daily, we saved approximately 25 hours of runner time per month, resulting in significant cost savings.

🔥 Risk of Over-Caching

Using cache is great, but overdoing it can also lead to problems. Old or misconfigured caches can cause your project to use incorrect versions or lead to unexpected errors. Therefore, it's important to regularly review your cache keys and cleanup policies.

Advanced Cache Strategies: Build Kit and Nx

Some modern build tools and monorepo management systems offer more advanced cache strategies. For example, tools like Nx intelligently cache not only dependencies but also intermediate build outputs. Nx tracks the dependencies and outputs of each package or application in your project. When a package changes, only that package and its dependent packages are recompiled. Everything else is retrieved from the cache.

This approach is revolutionary in monorepos. When you make a change, instead of recompiling the entire monorepo, only a small affected part is rebuilt. This incredibly reduces build times and also increases cache accuracy. Nx offers its own distributed cache solution and can synchronize this cache to remote servers (e.g., S3, GCS) or CI/CD environments.

Conclusion: Cache Management is an Optimization Opportunity

Build cache management in CI/CD pipelines is an optimization area too important to overlook. It not only shortens build times but also reduces infrastructure costs and enables developers to work more efficiently. With the right strategies, it's possible to save time and money.

Remember, cache management is not a one-time task. As your projects evolve, your dependencies change, and your build processes become more complex, you need to regularly review and update your cache policies. This continuous improvement process will ensure your pipelines always run as efficiently as possible.

Top comments (0)