Post

Installing containerized apps using rpms

Configuring and managing `docker` images in airgapped system can be challending. Let's see how we can ease IT Admin's job by letting them use what they have been using for years now.

Installing containerized apps using rpms

In previous blog we have gone through manual steps of exporting a container and importing it on an airgapped machine. In this blog we will go through how we can remove the barier of learning docker for the IT Admins.

The Barier

Over many years IT Admins have been installing the software via standard packages like rpm, deb, pkg etc. Now they have to learn docker for the new applications which are being shipped on docker.

Earlier they used to manage the status of different services running on a machine via service or systemctl command but now they have to use docker ps to know the status or a service.

They have been using journalctl to view the logs of a service, but now they have to use docker logs. Let’s see how we can help them use docker via these standard tools.

Creating RPM for a docker service

Prerequiste

We will need following packages to be installed for building an RPM.

  • podman or docker for pulling and exporting the images.
  • rpmdevtools for setting up the RPM build area and creating spec file.
  • rpm-build for building the RPM.
  • dnf-plugins-core for downloading any dependent RPMs like podman or curl etc.

Preparing for Building RPM

Before we start building RPM we have to know what an RPM is. RPM stands for RedHat Package Manager. RPM is a package manager which allows you to ship your software packaged as an .rpm file. RPM packages can be of 2 types Binary packages and Source packages. Binary packages are the type of packages where you package pre-built binaries and you don’t have to compile or download anything. Source packages are the type of packages where you have to package source code into the package and then compile and install it on the target machine.

For this exercise we will be building a Binary package using nginx image. We are not building Source package as the application is already compiled and installed inside the container.

You can use rpmdev-setuptree to setup a build area. By default it will treat your $HOME/rpmbuild as the build area. In order to change this path you have to change HOME env variable before running this command.

1
2
3
HOME=/tmp/docker-to-rmp rpmdev-setuptree

ls -la /tmp/docker-to-rmp/

It will create a directory structure like mentioned below:

1
2
3
4
5
6
7
.rpmmacros
rpmbuild/
├── BUILD
├── RPMS
├── SOURCES
├── SPECS
└── SRPMS
  • BUILD folder is where you will build your code or have intermediate files.
  • RPMS folder will contain the RPM for binary type rpms.
  • SOURCES folder should have all the source code which you want to use for your build.
  • SPECS folder should have your rpm spec
  • SRPMS folder will contain the RPM, if you are building the source rpm.
  • .rpmmacros file is macro file.

The next step is to create a spec file. Spec file contains the package specification like package name, version, vendor, scripts, changelog etc.

You can use rpmdev-newspec command to create a spec file.

1
2
3
cd /tmp/docker-to-rmp/rpmbuild/SPECS
rpmdev-newspec -o nginx.spec
cat nginx.spec

Above command will create a minimal spec file which looks like below:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
Name:           nginx
Version:
Release:        1%{?dist}
Summary:

License:
URL:
Source0:

BuildRequires:
Requires:

%description


%prep
%autosetup


%build
%configure
%make_build


%install
rm -rf $RPM_BUILD_ROOT
%make_install


%files
%license add-license-file-here
%doc add-docs-here



%changelog
* Sat Jul 25 2025 root
-

By default rpmdev-newspec command creates the spec file suitable for Source package. We would need to modify it for our needs.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
Name:           nginx
Version:        1.28
Release:        0
Summary:        nginx container packaged as rpm
Group:		      Applications/System
License:	      MIT
Vendor:		      Bikramjeet Singh
URL:		        http://blogs.bikramjs.in/
Packager:	      Software Group <packager@blogs.bikramjs.in>
BuildArch:      noarch
Provides:       nginx

%description
nginx container packaged as rpm

%pre

%install

%post

%files
%defattr(-,root,root,-) # Tell RPM manager that the default owner of all the files is root
%dir
/opt/docker-rpm/nginx # Tell RPM package manger that this RPM is managing this directory and everything under it


%preun

%postun

%changelog
* Mon Jul 21 2025 Bikramjeet Singh <packager@blogs.bikramjs.in>
- Sample changelog

We can cache this spec file instead of generating it for each build.

Automating image import and clean up

Now the next thing is to import the docker image and make it available for use and manage the whole lifecycle of the image. RPM spec file has many script macros which can be used to automate stuff at variaous stages. Let’s outline what do we need to do and see which script makes sense for them:

  • We need to bundle the image archive into the RPM. %install runs at RPM build time for Binary package and RPM installation for Source package. This is the perfect place to export the image.

    1
    2
    
    podman image pull docker.io/library/nginx:1.28
    podman image save docker.io/library/nginx:1.28 -o "%{buildroot}/opt/docker-rpm/nginx/nginx-1.28.tar"
    
  • We need to import the image archive on target system where we will install the RPM. %post runs after installing the RPM. That means we will have our image archive available at /opt/docker-rpm/nginx/nginx-1.28.tar when this step is being run.

    1
    
    podman load -i "/opt/docker-rpm/nginx/nginx-1.28.tar"
    
  • We need to recreate the running containers when upgrading. Here first we need to identify containers which are running the old image for this we would need to split this section into %pre and %post scripts. %pre script runs before installing the package and both %pre and %post scripts get 1 argument which tells them if it is a fresh install or upgrade scenario.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    
    %pre
    if [ $1 == 1 ];then
      echo "Pre Installing nginx"
    elif [ $1 == 2 ];then
      echo "Pre Upgrading nginx"
      OLD_IMAGE_TAG=`rpm -q --info "nginx" | grep "Release     : " | cut -b 15-`
      echo "docker.io/library/nginx:${OLD_IMAGE_TAG}" > "/tmp/nginx.old_image"
    
      podman ps -a --format="\{\{ \.ID \}\} \{\{ .Image \}\}" | grep docker.io/library/nginx | cut -b -12 > "/tmp/nginx.old_containers"
      podman ps --format='\{\{ \.ID \}\} \{\{ .Image \}\}' | grep docker.io/library/nginx | cut -b -12 > "/tmp/nginx.running_containers"
    fi
    

    Please remove escape char \ from the format. I’ve used it because the blog site generator assumes it as a variable.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    
    %post
    podman load -i "/opt/docker-rpm/nginx/nginx-1.28.tar"
    
    if [ $1 == 1 ];then
      echo "Post Installing nginx"
    elif [ $1 == 2 ];then
      echo "Post Upgrading nginx"
        
      podman stop $(cat "/tmp/nginx.running_containers")
    
      cat /tmp/nginx.running_containers | while read CONTAINER_ID
      do
        echo "Upgrading ${CONTAINER_ID}"
    
        # Find the command used to create the container
        CREATE_COMMAND=`podman inspect ${CONTAINER_ID} | jq -M -r '.[0].Config.CreateCommand |= .[:-1] | .[0].Config.CreateCommand | join(" ")'`
    
        # Remvoe the old container
        podman rm -f ${CONTAINER_ID}
    
        # Run or Create the container
        NEW_CONTAINER_ID=`${CREATE_COMMAND} -d docker.io/library/nginx:1.28`
    
        # Start the container in case the create_command is just creating the container
        podman start ${NEW_CONTAINER_ID}
      done
        
    fi
    

    Please note that CreateCommand section is only available in podman. You may have to tweak the command to find the container command.

  • We need to clean up the old images on target system when user upgrades the package. %preun script runs both at after upgrade and before uninstalling. It gets an argument to differentiate between the 2 cases.

    1
    2
    3
    4
    5
    6
    7
    
    if [ $1 == 1 ];then
      echo "Pre Uninstall while Upgrading nginx"
      OLD_IMAGE=`cat "/tmp/nginx.old_image"`
      podman image rm ${OLD_IMAGE}
    elif [ $1 == 0 ];then
      echo "Pre Uninstalling nginx"
    fi
    
  • We need to clean up the image on target system when user choses to uninstall the package. podman or docker requires us to stop and remove any containers before deleting any image. So here we will have to update the %preun script and create a %postun script.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    
    %preun
    if [ $1 == 1 ];then
      echo "Pre Uninstall while Upgrading nginx"
      OLD_IMAGE=`cat "/tmp/nginx.old_image"`
      podman image rm ${OLD_IMAGE}
    elif [ $1 == 0 ];then
      echo "Pre Uninstalling nginx"
    
      podman ps -a --format="\{\{ \.ID \}\} \{\{ .Image \}\}" | grep docker.io/library/nginx | cut -b -12 > "/tmp/nginx.containers"
      podman stop $(cat "/tmp/nginx.containers")
      podman rm -f $(cat "/tmp/nginx.containers")
    fi
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    
    %postun
    if [ $1 == 1 ];then
      echo "Post Uninstall while Upgrading nginx"
    elif [ $1 == 0 ];then
      echo "Post Uninstalling nginx"
    
      podman image rm -f docker.io/library/nginx:1.28
      rm -Rf /opt/docker-rpm/nginx
    fi
    

Building RPM

Now we our spec file is ready to be used. We need to update %_topdir in the macrofile as we changed the build area from the default build area.

1
echo "%_topdir /tmp/docker-to-rmp/rpmbuild/" >> /tmp/docker-to-rmp/.rpmmacros

Finally we can fire the rpmbuild command to build the package.

1
rpmbuild --load=/tmp/docker-to-rmp/.rpmmacros -bb /tmp/docker-to-rmp/rpmbuild/SPECS/nginx.spec

You can define multiple macros in a single file and You can use multiple --load= flags to specify multiple macro files.

Using systemctl to manage the container

We can wrap the podman commands under a service file as well to make it easy for IT Admins to start/stop the container. To do that first we need to create a service file like below:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
[Unit]
Description=Docker to RPM
Requires=network-online.target
After=network-online.target

[Service]
Type=simple

ExecStartPre=podman container create --rm --publish-all --label "managed_by=docker-to-rpm" --name nginx docker.io/library/nginx:1.28
ExecStart=podman container start nginx
# ExecReload=
ExecStop=podman container stop nginx
# ExecStopPost=

TimeoutStartSec=120
TimeoutStopSec=60

# Restart behavior:
# Restart the service if it fails, with a delay, to make it restart-safe
Restart=on-failure
RestartSec=10s

[Install]
WantedBy=multi-user.target

Next we would need to update our different scripts for systemctl support.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
%install
podman image pull docker.io/library/nginx:1.28
podman image save docker.io/library/nginx:1.28 -o "%{buildroot}/opt/docker-rpm/nginx/nginx-1.28.tar"

%pre
if [ $1 == 1 ];then
    echo "Pre Installing nginx"
elif [ $1 == 2 ];then
    echo "Pre Upgrading nginx"
    if systemctl is-enabled --quiet nginx.service && systemctl is-active --quiet nginx.service ; then
        systemctl stop nginx.service
    fi
    OLD_IMAGE_TAG=`rpm -q --info "nginx" | grep "Release     : " | cut -b 15-`
    echo "docker.io/library/nginx:${OLD_IMAGE_TAG}" > "/tmp/nginx.old_image"
fi

%post
podman load -i "/opt/docker-rpm/nginx/nginx-1.28.tar"
systemctl daemon-reload

if [ $1 == 1 ];then
    echo "Post Installing nginx"
elif [ $1 == 2 ];then
    echo "Post Upgrading nginx"
    if systemctl is-enabled --quiet nginx.service ; then
        systemctl start nginx.service
    fi
fi

%preun
if [ $1 == 1 ];then
    echo "Pre Uninstall while Upgrading nginx"
    OLD_IMAGE=`cat "/tmp/nginx.old_image"`
    podman image rm ${OLD_IMAGE}
elif [ $1 == 0 ];then
    echo "Pre Uninstalling nginx"
    systemctl disable nginx.service
    systemctl stop nginx.service
fi

%postun
if [ $1 == 1 ];then
    echo "Post Uninstall while Upgrading nginx"
elif [ $1 == 0 ];then
    echo "Post Uninstalling nginx"
    rm -Rf "/opt/docker-rpm/nginx/"
    rm -f "/etc/systemd/system/nginx.service"

    systemctl daemon-reload

    podman container rm -f nginx
    podman image rm -f docker.io/library/nginx:1.28
fi

Here we are assuming that there will be only 1 container for the image and the container is being managed by the systemctl service file.

The service file above doesn’t direct the container logs to journalctl command. To do that we need to update our service file to attach the stdout and stderr to the container.

1
2
3
4
5
6
...
[Service]
Type=exec
...
ExecStart=podman container start --attach nginx
...

Please note that I’ve changed the service Type as well to indicate that systemctl should treat the service to be up as long as the command is running.

The Service file is exposing all the ports which container exposes to random ports on the host. We can fix the ports in service file as shown below:

1
2
3
4
5
...
[Service]
...
ExecStartPre=podman container create --rm -p "8080:80" --label "managed_by=docker-to-rpm" --name nginx docker.io/library/nginx:1.28
...

Managing multi-container workloads

Most of the time we develop applications which depends on other applications like Database, Cache and so on. We can adapt above steps to orchestrate multiple containers as well. Here podman’s compose plugin can come handy to manage and connect multiple containers together.

We can use multiple strategies to manage the containers via podman’s compose plugin. I’ve tried to list few of them below:

  • We can put multiple containers in a single RPM file along with single compose file.
  • We can have multiple app RPMs and 1 compose file RPM to orchestrate the application.
  • We can create multiple RPMs and have with app specific compose file for each RPM. And then combine multiple compose files with -f flag.

Creating Multiple RPMs with their specific compose file is the best option in my opinion as it give you the freedom to manage upgrades for the different components separately.

Conclusion

Containers are becoming defacto standard for shipping new software. IT Admins have to adopt new workflows to install containerized workloads on Airgapped systems. It is a tedious job to export and deploy containerized workloads. We can ease their lifes by packaging the containers as standard packages and then wrapping orchestration part as systemctl services. This provides a better control to the Software vendor in managing their software in Airgapped installations.

I’ve created a python script to automate RPM creation for a given docker image. You can download it from GitHub.

This post is licensed under CC BY 4.0 by the author.