1. Introduction

Docker enables us to encapsulate applications and their dependencies into isolated containers. Maintaining accurate time in these containerized applications is crucial, especially when the applications depend on precise timekeeping.

In this tutorial, we’ll demonstrate how to set time dynamically in a Docker container.

2. Setting Time in a Docker Container

To begin with, a Docker container picks up time from its host machine. To clarify, the host machine is the machine on which the container is running.

However, it’s not uncommon to get the incorrect time in a Docker container. For example, the host machine might be in a different time zone from the target time zone of the container. This could happen, for example, if we deploy a container running a local app in a cloud service.

Additionally, another inconvenience can be an inaccurate host machine time, which the container will automatically adapt to. We may have time-based logic running in a container, such as a cron job running at a specific time.

Therefore, while in development, we may need to adjust container time to verify certain test scenarios.

2.1. Using a Bash Script

Here, we’ll make a simple script that displays the current time.

First, let’s create the working directory:

$ mkdir timeapp
$ cd timeapp
~/timeapp#

Next, let’s write a simple Bash script, time_display.sh:

#!/bin/sh

while true; do
    currentTime=$(date +"%Y-%m-%d %H:%M:%S")
    echo "Current time: $currentTime"
    sleep 1
done

Next, let’s add the Dockerfile for our time app:

FROM alpine:3.13

WORKDIR /app

COPY time_display.sh .

RUN chmod +x time_display.sh

CMD ["./time_display.sh"]

Once we’re done with the Dockerfile, we need to make a docker-compose file to help us manage and run the container:

version: '3.8' 
services:
  time-app:
    build: .
    restart: always

Now, let’s build the container:

$ docker-compose build
Building time-app
[+] Building 3.0s (9/9) FINISHED docker:default
=> [internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 150B 0.0s
=> [internal] load metadata for docker.io/library/alpine:3.13 1.9s
=> [internal] load .dockerignore 0.0s
=> => transferring context: 2B 0.0s
=> CACHED [1/4] FROM docker.io/library/alpine:3.13@sha256:469b6e04ee185740477efa44ed5bdd64a07bb 0.0s
=> [2/4] WORKDIR /app 0.1s
=> [internal] load build context 0.1s
=> => transferring context: 172B 0.0s
=> [3/4] COPY time_display.sh . 0.1s
=> [4/4] RUN chmod +x time_display.sh 0.5s
=> exporting to image 0.2s
=> => exporting layers 0.2s
=> => writing image sha256:f94476c96203b234adb3cec426c6aeb8fe2ec367568f56f72d92ddda7baf5c3c 0.0s
=> => naming to docker.io/library/timeapp_time-app

After the container is built, it’s time to run it in the detected mode:

$ docker-compose up -d
Creating network "timeapp_default" with the default driver
Creating timeapp_time-app_1 ... done

At this point, let’s check the container logs to see the time output:

 $ docker-compose logs
Attaching to timeapp_time-app_1
time-app_1  | Current time: 2024-05-23 12:22:24
time-app_1  | Current time: 2024-05-23 12:22:25
time-app_1  | Current time: 2024-05-23 12:22:26

From this, the container inherits the time from the host machine.

2.2. Using NTP

Network Time Protocol (NTP) syncs time between systems in a network. It uses a client-server architecture where the NTP client gets time from an NTP server. The client queries time at regular intervals from the server and adjusts its time based on the information received.

There are many publicly accessible NTP servers available on the internet. The advantage of using a public NTP server is that sync is automatic, which is ideal for a production environment. Meanwhile, the disadvantage is that some NTP servers may have specific requirements, thus needing additional configurations.

To begin, let’s update the Dockerfile to use a chrony NTP client:

FROM alpine:3.13

# Install Chrony NTP client
RUN apk add --no-cache chrony

# Copy the time display script
COPY time_display.sh /app/

# Make the script executable
RUN chmod +x /app/time_display.sh

# Copy the Chrony configuration file
COPY chrony.conf /etc/chrony/chrony.conf

# Start the Chrony NTP client service and the time display script
CMD ["sh", "-c", "chronyd -f /etc/chrony/chrony.conf && /app/time_display.sh"]

The Dockerfile pulls Alpine Linux as the base image and installs an NTP client. Subsequently, we make the time_display.sh script executable and copy the NTP configuration. Finally, we start the NTP client service and the time display script.

Next, let’s configure an NTP server by creating chrony.conf file in the directory:

server ke.pool.ntp.org iburst
driftfile /var/lib/chrony/drift
rtcsync
makestep 1.0 3
logdir /var/log/chrony

We can update the above config to use any NTP server of our liking, including a custom-built one. In our case, we’re using the Kenyan NTP server.

Let’s also update our docker-compose.yml:

version: '3.8'

services:
  time-app:
    build: .
    privileged: true
    network_mode: host
    restart: unless-stopped

Now, let’s build the container:

$ docker-compose build
Building time-app
[+] Building 1.8s (10/10) FINISHED                                                      docker:default
 => [internal] load build definition from Dockerfile                                              0.0s
 => => transferring dockerfile: 437B                                                              0.0s
 => [internal] load metadata for docker.io/library/alpine:3.13                                    1.7s
 => [internal] load .dockerignore                                                                 0.0s
 => => transferring context: 2B                                                                   0.0s
 => [1/5] FROM docker.io/library/alpine:3.13@sha256:469b6e04ee185740477efa44ed5bdd64a07bbdd6c7e5  0.0s
 => [internal] load build context                                                                 0.0s
 => => transferring context: 66B                                                                  0.0s
 => CACHED [2/5] RUN apk add --no-cache chrony                                                    0.0s
 => CACHED [3/5] COPY time_display.sh /app/                                                       0.0s
 => CACHED [4/5] RUN chmod +x /app/time_display.sh                                                0.0s
 => CACHED [5/5] COPY chrony.conf /etc/chrony/chrony.conf                                         0.0s
 => exporting to image                                                                            0.0s
 => => exporting layers                                                                           0.0s
 => => writing image sha256:a11bd93e7fd9822003e02a1c43868bede77277a4ae9fbebd79bd53410d5609b6      0.0s
 => => naming to docker.io/library/timeapp_time-app

So, let’s now run the container:

$ docker-compose up -d
Recreating timeapp_time-app_1 ... done

Importantly, we can check the running containers:

$ docker-compose ps
       Name                     Command               State   Ports
-------------------------------------------------------------------
timeapp_time-app_1   sh -c chronyd && /app/time ...   Up

Lastly, let’s check the time in the container:

docker-compose logs
Attaching to timeapp_time-app_1
time-app_1  | Current time: 2024-05-24 15:03:30
time-app_1  | Current time: 2024-05-24 15:03:31
time-app_1  | Current time: 2024-05-24 15:03:32

This displays the current time as per the Kenyan NTP server.

Further, let’s run a test to verify if the chrony client is working:

$ docker exec -it d8ddabc030ec sh
/ # chronyc tracking
Reference ID    : A077D8CA (ntp1.icolo.io)
Stratum         : 3
Ref time (UTC)  : Fri May 24 15:15:31 2024
System time     : 0.000277201 seconds fast of NTP time
Last offset     : -0.000319062 seconds
RMS offset      : 0.002345609 seconds
Frequency       : 2.857 ppm fast
Residual freq   : -0.060 ppm
Skew            : 15.561 ppm
Root delay      : 0.276250660 seconds
Root dispersion : 0.005212975 seconds
Update interval : 65.5 seconds
Leap status     : Normal
/ # chronyc sources
MS Name/IP address         Stratum Poll Reach LastRx Last sample
===============================================================================
^* ntp1.icolo.io                 2   6   377    45  -1608us[-1927us] +/-  143ms

From the chrony logs, we can see the client working and the NTP server responding.

2.3. Using an Environment Variable

To pass config information at runtime to containers, we use environment variables. These are key-value pairs that we use to pass the application settings. The upside of using an environment is that it centralizes configuration in one place. This makes it simple to manage when we have multiple variables.

To use environment variables in Docker Compose, we create a text file called .env. We place it in the root of the project, next to docker-compose.yml.

Let’s dive into this. First, we create a .env file and set the time:

TIME="2022-05-01 11:53:20"  # Desired system time in UTC

Then, we need to modify our docker-compose.yml to pass the environment variable to the container:

version: "3.8"

services:
  time-app:
    build: .  # build context
    environment:
      - FAKETIME=${TIME}  # Inject the environment variable for libfaketime
    cap_add:
      - SYS_TIME  # Grant the container the SYS_TIME capability

Next, let’s create a script to set the time inside the container using the environment variable. To clarify, we name the script set-time.sh:

#!/bin/bash

# Ensure libfaketime is properly initialized
export LD_PRELOAD=/usr/lib/x86_64-linux-gnu/faketime/libfaketime.so.1

# Retrieve the desired time from the environment variable
container_time=${FAKETIME}

# Check if the time value is empty
if [ -z "$container_time" ]; then
  echo "Error: FAKETIME environment variable is not set."
  exit 1
fi

# Set the system time using libfaketime
date -s "$container_time"

echo "System time set to: $container_time"

# Verify that the system time is set correctly
current_system_time=$(date +"%Y-%m-%d %H:%M:%S")
echo "Verified system time: $current_system_time"

# Ensure that the time remains constant
while true; do
  currentTime=$(date +"%Y-%m-%d %H:%M:%S")
  echo "Current time: $currentTime"
  sleep 1
done

Now, we need to update the Dockerfile:

FROM ubuntu:20.04

# Install required packages
RUN apt-get update \
    && apt-get install -y tzdata libfaketime \
    && rm -rf /var/lib/apt/lists/*

# Copy the set-time.sh script into the container
COPY set-time.sh /set-time.sh

# Make the script executable
RUN chmod +x /set-time.sh

# Set the command to run the script
CMD ["/bin/bash", "/set-time.sh"]

This Dockerfile builds the Docker image, installs the required packages, sets up the scripts, and, in the end, runs the commands that calculate the time offset and export it as the environmental variable TIME_OFFSET.

Let’s update our time_display.sh script to continually display the time in the container:

#!/bin/sh

while true; do
    currentTime=$(date +"%Y-%m-%d %H:%M:%S")
    echo "Current time: $currentTime"
    sleep 1
done

The above setup ensures the time displayed inside the container is consistent with the specified time in the .env by using the time_display.sh script.

Now, we’re ready to build the service:

$ docker-compose build
Building time-app
[+] Building 15.7s (9/9) FINISHED                                                       docker:default
 => [internal] load build definition from Dockerfile                                              0.0s
 => => transferring dockerfile: 396B                                                              0.0s
 => [internal] load metadata for docker.io/library/ubuntu:20.04                                   1.8s
 => [internal] load .dockerignore                                                                 0.0s
 => => transferring context: 2B                                                                   0.0s
 => CACHED [1/4] FROM docker.io/library/ubuntu:20.04@sha256:0b897358ff6624825fb50d20ffb605ab0eae  0.0s
 => [2/4] RUN apt-get update     && apt-get install -y tzdata libfaketime     && rm -rf /var/li  13.1s
 => [internal] load build context                                                                 0.1s
 => => transferring context: 802B                                                                 0.0s
 => [3/4] COPY set-time.sh /set-time.sh                                                           0.1s
 => [4/4] RUN chmod +x /set-time.sh                                                               0.3s
 => exporting to image                                                                            0.3s
 => => exporting layers                                                                           0.3s
 => => writing image sha256:e4d763176aac90ff14f45bd6d18f23fc7c1eda47ac8cdd8a976ed80d0df7a6e8      0.0s
 => => naming to docker.io/library/timeapp_time-app

To test our setup, let’s start the container:

$ docker-compose up -d
Creating network "timeapp_default" with the default driver
Creating timeapp_time-app_1 ... done

Finally, let’s follow the container logs to see if it updates the time:

 $ docker-compose logs
Attaching to timeapp_time-app_1
time-app_1  | Sun May  1 11:53:20 UTC 2022
time-app_1  | System time set to: 2022-05-01 11:53:20
time-app_1  | Verified system time: 2022-05-01 11:53:20
time-app_1  | Current time: 2022-05-01 11:53:20
time-app_1  | Current time: 2022-05-01 11:53:20
time-app_1  | Current time: 2022-05-01 11:53:20
time-app_1  | Current time: 2022-05-01 11:53:20

From the logs, we verify that the container takes the time specified in the .env file.

3. Conclusion

In this article, we explored setting time dynamically in a Docker container.

Keeping accurate time helps maintain the functionality and integrity of applications within Docker containers. As a result, this enhances the performance and reliability of containerized applications.