Let’s build a package for QNAP QTS!

I recently deployed a Prometheus instance on my local network to collect metrics for a few servers I run. Most importantly, I wanted to collect metrics from my QNAP NAS.

For those not familiar, QNAP NAS machines are just Linux boxes underneath the covers. They’ve got a simple administration UI but otherwise behave like a Linux server. That also means we can run bog standard Go programs on there like node_exporter.

However, one thing that was less straightforward was how to get a service running continuously on the QNAP. Because it’s an appliance, it doesn’t really support having the init system arbitrarily modified. It has facilities for installing services but you’ve got to use the qpkg format.

Installing the QDK

Unsurprisingly, the documentation for building a .qpkg isn’t easy to track down. Most of the top hits are are QNAP-specific forums with out of date information or links on where to get the QDK. Fortunately, after some digging, I did find it on QNAP’s wiki here.

After downloading the QDK, it can be installed just like any other package. You can do that from the App Center:

Creating the Package Scaffold

For this demonstration, we’re going to build a package for node_exporter. In order to produce the binary, I cloned the node_exporter repo, cross-compiled the binary to an amd64 Linux binary and voila, we’ve got the binary:

~/C/github.com ❯❯❯ git clone https://github.com/prometheus/node_exporter.git
Cloning into 'node_exporter'...
remote: Enumerating objects: 51, done.
remote: Counting objects: 100% (51/51), done.
remote: Compressing objects: 100% (35/35), done.
remote: Total 12957 (delta 19), reused 33 (delta 14), pack-reused 12906
Receiving objects: 100% (12957/12957), 10.80 MiB | 10.04 MiB/s, done.
Resolving deltas: 100% (7837/7837), done.
~/C/github.com ❯❯❯ cd node_exporter
~/C/g/node_exporter ❯❯❯ git clone https://github.com/prometheus/node_exporter.git
~/C/g/node_exporter ❯❯❯ env GOOS=linux GOARCH=amd64 go build
~/C/g/node_exporter ❯❯❯ file node_exporter
node_exporter: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, Go BuildID=X0wmN4wtWSX3qBatvkn6/R0-Yi8NYQORIYlHqZSgw/LwuSK8po11NfvJ4geVSt/8cNoB4VdGQmUALgLz7QO, not stripped

Note: I’m assuming you have SSH access enabled on the QNAP NAS.

We’ll SSH to the system using the admin user and make a Packages directory:

scp node_exporter admin@qnap:/root/
ssh admin@qnap
mkdir $HOME/Packages

Then we’ll use the qbuild tool to create a package scaffold:

[admin@BrownHole Packages]# qbuild --create-env node_exporter
[admin@BrownHole Packages]# ls -alh node_exporter/
total 16K
drwxr-xr-x 12 admin administrators  300 2021-04-12 20:46 ./
drwxr-xr-x  4 admin administrators   80 2021-04-12 20:46 ../
drwxr-xr-x  2 admin administrators   40 2020-05-27 04:21 arm_64/
drwxr-xr-x  2 admin administrators   40 2020-05-27 04:21 arm-x19/
drwxr-xr-x  2 admin administrators   40 2020-05-27 04:21 arm-x31/
drwxr-xr-x  2 admin administrators   40 2020-05-27 04:21 arm-x41/
-rw-r--r--  1 admin administrators   14 2021-04-12 20:46 build_sign.csv
drwxr-xr-x  2 admin administrators   40 2020-05-27 04:21 config/
drwxr-xr-x  2 admin administrators   40 2020-05-27 04:21 icons/
-rw-r--r--  1 admin administrators 4.4K 2020-05-27 04:20 package_routines
-rw-r--r--  1 admin administrators 3.5K 2021-04-12 20:46 qpkg.cfg
drwxr-xr-x  2 admin administrators   60 2021-04-12 20:46 shared/
drwxr-xr-x  2 admin administrators   40 2020-05-27 04:21 x86/
drwxr-xr-x  2 admin administrators   40 2020-05-27 04:21 x86_64/
drwxr-xr-x  2 admin administrators   40 2020-05-27 04:21 x86_ce53xx/

The scaffold includes a number of different folders named after CPU architectures. Any of the folders starting with arm- or x86 are architecture specific. These directories are where our architecture specific binaries are placed.

The qpkg.cfg file is where the package metadata is stored. This is similar to an RPM spec or even a Cargo.toml file for a Rust library. It has the name, version, and license of the package:

[admin@BrownHole node_exporter]# cat qpkg.cfg
# Name of the packaged application.
# Name of the display application.
QPKG_DISPLAY_NAME="Prometheus Node Exporter"
# Version of the packaged application.
# Author or maintainer of the package
# License for the packaged application
QPKG_LICENSE="Apache 2.0"
# One-line description of the packaged application


Another file of interest is shared/node_exporter.sh. This is effectively the service control file. Here’s the contents of the skeleton service control script:

QPKG_ROOT=`/sbin/getcfg $QPKG_NAME Install_Path -f ${CONF}`
APACHE_ROOT=`/sbin/getcfg SHARE_DEF defWeb -d Qweb -f /etc/config/def_share.info`

case "$1" in
    ENABLED=$(/sbin/getcfg $QPKG_NAME Enable -u -d FALSE -f $CONF)
    if [ "$ENABLED" != "TRUE" ]; then
        echo "$QPKG_NAME is disabled."
        exit 1


    $0 stop
    $0 start

    echo "Usage: $0 {start|stop|restart}"
    exit 1

exit 0

The most interesting pieces for us will be the start) and stop) sections of the case statement. This is where we’ll add our start and stop logic for node_exporter.

Here’s the diff of what was added to the start/stop logic implemented:

--- shared/test123.sh
+++ ../node_exporter/shared/node_exporter.sh
@@ -12,11 +12,17 @@
         echo "$QPKG_NAME is disabled."
         exit 1
-    ;;
+    /bin/ln -sf $QPKG_ROOT /opt/$QPKG_NAME
+    /bin/ln -sf $QPKG_ROOT/bin/node_exporter /usr/bin/node_exporter

+    $QPKG_ROOT/node_exporter &
+    ;;
+    killall -s SIGTERM node_exporter
+    rm -rf /usr/bin/node_exporter
+    rm -rf /opt/$QPKG_NAME


Basically, for start, we:

  1. link the package root to /opt/ under the package name - this is to add our binary into the path
  2. link the binary path in the package root to /usr/bin/
  3. start the node_exporter in the background

For the stop command, we’ll:

  1. send SIGTERM to node_exporter
  2. remove the symlinks we created to the directories and binary

Building the Package

This part’s easy. Inside the /root/Packages/node_exporter directory, just run qbuild:

[admin@BrownHole node_exporter]# qbuild
Creating archive with data files for arm_64...
Creating archive with control files...
Creating QPKG package...
Creating archive with data files for x86_64...
Creating archive with control files...
Creating QPKG package...
[admin@BrownHole node_exporter]# ls -alh build
total 17M
drwxr-xr-x  2 admin administrators  120 2021-04-12 21:17 ./
drwxr-xr-x 13 admin administrators  320 2021-04-12 21:17 ../
-rw-r--r--  1 admin administrators 7.9M 2021-04-12 21:17 node_exporter_1.1.2_arm_64.qpkg
-rw-r--r--  1 admin administrators   72 2021-04-12 21:17 node_exporter_1.1.2_arm_64.qpkg.md5
-rw-r--r--  1 admin administrators 8.4M 2021-04-12 21:17 node_exporter_1.1.2_x86_64.qpkg
-rw-r--r--  1 admin administrators   72 2021-04-12 21:17 node_exporter_1.1.2_x86_64.qpkg.md5
[admin@BrownHole node_exporter]#

Note: I added an arm64 binary in addition to amd64 which is why you see arm_64 mentioned.

Once the build completes, you’ll have a new build directory that contains the per-architecture .qpkg files and their md5 sums.

Installing the Package

Just as we installed the QDK via the App Center UI, you can do the same with the .qpkg files we generated for node_exporter. After installing, you should see something like:

Now let’s check to make sure it’s running:

[admin@BrownHole ~]# ps aux | grep node_exporter
 3999 admin       960 S   grep node_exporter
23132 admin     14616 S   /share/CE_CACHEDEV1_DATA/.qpkg/node_exporter/node_exporter
[admin@BrownHole ~]#

Let’s check that it dumps stats the way we’d expect:

[admin@BrownHole ~]# curl http://localhost:9100/metrics
# HELP go_gc_duration_seconds A summary of the pause duration of garbage collection cycles.
# TYPE go_gc_duration_seconds summary
go_gc_duration_seconds{quantile="0"} 2.2558e-05
go_gc_duration_seconds{quantile="0.25"} 3.6544e-05
# HELP node_vmstat_pswpout /proc/vmstat information field pswpout.
# TYPE node_vmstat_pswpout untyped
node_vmstat_pswpout 0
# HELP process_cpu_seconds_total Total user and system CPU time spent in seconds.
# TYPE process_cpu_seconds_total counter
process_cpu_seconds_total 2033.84
# HELP process_max_fds Maximum number of open file descriptors.
# TYPE process_max_fds gauge
process_max_fds 1024
# HELP process_open_fds Number of open file descriptors.
# TYPE process_open_fds gauge
process_open_fds 10
# HELP process_resident_memory_bytes Resident memory size in bytes.
# TYPE process_resident_memory_bytes gauge
process_resident_memory_bytes 1.554432e+07
# HELP process_start_time_seconds Start time of the process since unix epoch in seconds.
# TYPE process_start_time_seconds gauge
process_start_time_seconds 1.6175077516e+09
# HELP process_virtual_memory_bytes Virtual memory size in bytes.
# TYPE process_virtual_memory_bytes gauge
process_virtual_memory_bytes 7.36231424e+08
# HELP process_virtual_memory_max_bytes Maximum amount of virtual memory available in bytes.
# TYPE process_virtual_memory_max_bytes gauge
process_virtual_memory_max_bytes 1.8446744073709552e+19
# HELP promhttp_metric_handler_errors_total Total number of internal errors encountered by the promhttp metric handler.
# TYPE promhttp_metric_handler_errors_total counter
promhttp_metric_handler_errors_total{cause="encoding"} 0
promhttp_metric_handler_errors_total{cause="gathering"} 0
# HELP promhttp_metric_handler_requests_in_flight Current number of scrapes being served.
# TYPE promhttp_metric_handler_requests_in_flight gauge
promhttp_metric_handler_requests_in_flight 1
# HELP promhttp_metric_handler_requests_total Total number of scrapes by HTTP status code.
# TYPE promhttp_metric_handler_requests_total counter
promhttp_metric_handler_requests_total{code="200"} 51950
promhttp_metric_handler_requests_total{code="500"} 0
promhttp_metric_handler_requests_total{code="503"} 0
[admin@BrownHole ~]#

Yep - that looks good. Now we can set our Prometheus scraper to hit this server and collect some metrics:

The Code

If you want to look at the package definition, I’ve uploaded it on my sourcehut page.

Posted on 2021-04-12