Building and Packaging
How to build the collector natively, as Docker images and as portable release binaries for x86_64 and 64-bit ARM, how releases are published, and how to run the resulting binary. For the development workflow (tests, CI, the simulator) see the Developer Guide; for deploying with systemd see Deployment.
What gets built
The workspace produces two binaries:
collector-app, the collector (cratecrates/collector-app);sunspec-sim, the SunSpec Modbus TCP simulator (cratecrates/sunspec-sim), for local development and tests.
| Build | Command | Output |
|---|---|---|
| Debug | cargo build --workspace | target/debug/collector-app, target/debug/sunspec-sim |
| Release | cargo build --release --locked -p collector-app -p sunspec-sim | target/release/collector-app, target/release/sunspec-sim |
| Portable release (x86_64 or aarch64) | cross build --release --locked --target <target> -p collector-app -p sunspec-sim --features collector-app/vendored-openssl | target/<target>/release/collector-app, target/<target>/release/sunspec-sim |
| Collector image | docker build -t sunspec-collector . | /app/collector-app in the image |
| Simulator image | docker build --target simulator -t sunspec-simulator . | /app/sunspec-sim in the image |
--locked builds with the committed Cargo.lock, as CI and the Dockerfile do. The workspace uses
Cargo's default release profile.
Toolchain and MSRV
The minimum supported Rust version is 1.88, set as rust-version in the workspace
Cargo.toml. The Docker builder stage uses the same version (rust:1.88-slim-bookworm), so the
Docker build checks that the MSRV still works; the other CI jobs use the latest stable Rust. When
the MSRV changes, change both places.
Prerequisites
Debian / Ubuntu
sudo apt-get update
sudo apt-get install -y build-essential cmake pkg-config libssl-dev libcurl4-openssl-dev
These are the packages CI installs (build-essential is preinstalled on the CI runners).
macOS
xcode-select --install # compiler, linker and SDK (the SDK provides the curl headers)
brew install cmake openssl@3
openssl-sys finds Homebrew's OpenSSL without further configuration. Native macOS binaries link
libssl and libcrypto from Homebrew dynamically, so openssl@3 must stay installed on the
machine that runs them.
Why cmake, OpenSSL and curl
The Kafka client is rdkafka 0.39 with the features cmake-build, ssl, tokio and zstd:
- cmake and a C/C++ toolchain.
cmake-buildcompiles librdkafka 2.12 (bundled inrdkafka-sys 4.10.0+2.12.1) from source with CMake. No system librdkafka is used, so the version is the same on every platform. - OpenSSL.
sslbuilds librdkafka with TLS support against the system OpenSSL, which needs the headers at build time (libssl-dev) and the library at run time (libssl3), unless thevendored-opensslfeature is used (see below). - curl headers. librdkafka is built with curl disabled and the binary does not link libcurl.
However, librdkafka 2.12's
rdkafka_conf.cincludes<curl/curl.h>under#ifdef WITH_OAUTHBEARER_OIDC, and the generatedconfig.halways defines that macro (as 0 or 1), so the header has to be present for the build to compile. - zstd and SQLite need nothing from the system:
zstd-syscompiles its bundled zstd, and sqlx'ssqlitefeature compiles a bundled SQLite.
The first build compiles librdkafka and therefore takes noticeably longer than later builds.
Statically linked OpenSSL
collector-app has an opt-in feature vendored-openssl (forwarded to avro-kafka and rdkafka's
ssl-vendored). With it, OpenSSL is compiled from source and linked statically, so the binary
does not depend on the OpenSSL of the machine it runs on. Building OpenSSL needs perl and
make.
cargo build --release --locked -p collector-app --features vendored-openssl
The release builds use it; native and Docker builds do not.
Native builds
cargo build --workspace # debug, all crates
cargo build --release --locked -p collector-app # release collector
cargo build --release --locked -p collector-app -p sunspec-sim
A native Linux release build links OpenSSL 3 and glibc dynamically; check the full list for your
system with ldd target/release/collector-app. To install it, copy the binary, for example to
/usr/local/bin/collector-app for the systemd unit described in Deployment.
Docker images
The Dockerfile has these stages:
| Stage | Base | Contents |
|---|---|---|
builder | rust:1.88-slim-bookworm | Installs cmake, build-essential, libssl-dev, libcurl4-openssl-dev, pkg-config and python3, then runs cargo build --release --locked -p collector-app -p sunspec-sim and copies both binaries to /out |
prebuilt | scratch | Only with --build-arg BINARIES=prebuilt: copies dist/bin/<arch>/collector-app and sunspec-sim to /out instead of compiling (see Release images) |
runtime | debian:bookworm-slim | Installs ca-certificates, libssl3 and curl (for the health check) and creates the system user and group collector |
simulator | runtime | /app/sunspec-sim, runs as collector, exposes 1502, ENTRYPOINT ["/app/sunspec-sim"] with default arguments --bind 0.0.0.0:1502 --units 1 |
collector | runtime | /app/collector-app, runs as collector with working directory /app/data (owned by collector), exposes 9090, a HEALTHCHECK on /healthz, ENTRYPOINT ["/app/collector-app"]; the default target |
docker build -t sunspec-collector . # collector (default target)
docker build --target simulator -t sunspec-simulator . # simulator
Build cache
The Dockerfile starts with # syntax=docker/dockerfile:1 and uses BuildKit cache mounts for the
cargo registry (/usr/local/cargo/registry) and the target directory (/app/target), so
rebuilds after a source change reuse the compiled dependencies, librdkafka included. Cache mounts
need BuildKit, which is the default builder in current Docker Engine and Docker Desktop releases.
Because the target directory is a cache mount and not part of the image, the binaries are copied
to /out in the same step. Both images share the builder stage, which builds both binaries in
one cargo invocation.
.dockerignore keeps target/, .git/, .github/, docs/, docker/ and SQLite files out of
the build context.
Running the collector image
docker run -d --name sunspec-collector \
-p 9090:9090 \
-v sunspec-data:/app/data \
-e SUNSPEC_STATIC_DEVICES=192.168.1.20:1 \
-e SUNSPEC_KAFKA_BROKERS=kafka.example.internal:9092 \
sunspec-collector
-
Buffer. The image does not set
SUNSPEC_BUFFER_PATH. The collector runs in/app/data, so the default relativebuffer.path(sunspec-buffer.sqlite) is/app/data/sunspec-buffer.sqlite, and abuffer.pathfrom a config file orSUNSPEC_BUFFER_PATHis used as given (relative paths resolve against/app/data). Keep/app/dataon a volume so buffered samples survive container replacement. A new named volume takes its ownership from the image, so it is writable bycollector; a bind-mounted host directory must be made writable for that user yourself. -
Upgrading from older images. Images that set
SUNSPEC_BUFFER_PATH=/app/data/buffer.sqlitestored the buffer under that name. To keep sending rows already buffered in an existing volume, setSUNSPEC_BUFFER_PATH=/app/data/buffer.sqlite(or move the file and its-waland-shmfiles tosunspec-buffer.sqlitewhile the collector is stopped). -
Config file. Mount it and set
SUNSPEC_CONFIG, or pass--config: the image's entrypoint is/app/collector-app, so arguments after the image name go to the collector:docker run -d -v "$PWD/collector.toml:/app/collector.toml:ro" -v sunspec-data:/app/data \ -p 9090:9090 sunspec-collector --config /app/collector.toml -
Environment files. With
--env-file, put comments on their own lines: Docker does not strip trailing# ...from values, and the collector rejects values it cannot parse. -
Health check.
curl -fsS "http://localhost:${SUNSPEC_METRICS_PORT:-9090}/healthz"every 30 s (timeout 3 s, start period 20 s, 3 retries), so it followsSUNSPEC_METRICS_PORT. If you set the port only in a config file ([metrics] port), also setSUNSPEC_METRICS_PORTor override the check with--health-cmd. -
Stopping.
docker stopsends SIGTERM, which starts the graceful shutdown (pollers stop, queued samples are written, the uplink drains for up tobuffer.shutdown_drain_timeout_ms). Docker sends SIGKILL after 10 s by default;docker stop -t 30matches the 30 s the systemd unit allows. -
Schema and version.
docker run --rm sunspec-collector --print-schemaprints the Avro schema of the published records, and--versionthe collector version.
Running the simulator image
docker run --rm -p 1502:1502 sunspec-simulator --units 1,2
Arguments replace the default --bind 0.0.0.0:1502 --units 1; the bind address defaults to
0.0.0.0:1502 anyway. See the Developer Guide for all flags.
Docker Compose stack
docker-compose.yml runs a complete local pipeline without hardware:
| Service | Image | Notes |
|---|---|---|
kafka | apache/kafka:3.9.1 | Single node in KRaft mode (broker and controller). Listeners: kafka:9092 inside the compose network, localhost:29092 from the host. Topics are created automatically with 3 partitions; data in the kafka-data volume. |
simulator | sunspec-simulator:local (built from the simulator target) | Units 1 and 2 on port 1502 |
collector | sunspec-collector:local (built from the collector target) | Polls simulator:1 and simulator:2, publishes to kafka:9092, topic sunspec.telemetry; port 9090 on the host; buffer in the collector-data volume; starts once Kafka is healthy; restart: unless-stopped |
prometheus | prom/prometheus:v3.5.0 | localhost:9091 on the host; scrapes collector:9090 every 15 s (docker/prometheus.yml) |
docker compose up --build # build the images and start everything
curl localhost:9090/healthz # 200 with a JSON body once the pipeline runs
curl -s localhost:9090/metrics | grep '^sunspec_uplink_records_total'
# Record keys on the topic (the values are binary Avro)
docker compose exec kafka /opt/kafka/bin/kafka-console-consumer.sh \
--bootstrap-server localhost:9092 --topic sunspec.telemetry --from-beginning \
--max-messages 10 --property print.key=true --property print.value=false
docker compose down -v # stop and remove the containers and volumes
docker compose down without -v keeps the volumes, and with them the collector's buffer and the
Kafka data. docker compose stop gives containers 10 s before killing them; use
docker compose stop --timeout 30 collector for a full graceful stop, as CI does.
Release builds
Release binaries are built with cross for
x86_64-unknown-linux-gnu and aarch64-unknown-linux-gnu, in images derived from the cross-rs
0.2.5 images (Ubuntu 16.04). They need glibc 2.18 or newer and link nothing else that a target
system might lack: OpenSSL is vendored, librdkafka, SQLite, zstd and zlib are compiled in, and the
only shared libraries are glibc's and libgcc_s.
cargo install cross --locked
target=aarch64-unknown-linux-gnu
docker build -t "sunspec-cross-$target" -f docker/Dockerfile.cross docker
# x86_64: pass the pinned image from the release workflow's build matrix
# docker build -t sunspec-cross-x86_64-unknown-linux-gnu -f docker/Dockerfile.cross \
# --build-arg BASE_IMAGE=ghcr.io/cross-rs/x86_64-unknown-linux-gnu:0.2.5@sha256:<digest> \
# --build-arg DEB_ARCH=amd64 --build-arg MULTIARCH=x86_64-linux-gnu docker
cross build --release --locked --target $target -p collector-app -p sunspec-sim \
--features collector-app/vendored-openssl
readelf -d target/$target/release/collector-app | grep NEEDED # glibc and libgcc_s only
Cross.tomlmaps each target to the image tagsunspec-cross-<target>; build the image under that tag first.docker/Dockerfile.crossstarts fromBASE_IMAGE, the target'sghcr.io/cross-rs/<target>:0.2.5image pinned by digest (the default is the aarch64 one; the release workflow's build matrix lists both). It adds the target's Debian architecture if it is not the image's own, installscmake,make,perl,pkg-configandlibcurl4-openssl-devfor the target (the curl headers librdkafka needs at compile time), and pointsPKG_CONFIG_LIBDIRandPKG_CONFIG_PATHat the target's multiarch directory.--features collector-app/vendored-opensslis required: the images' OpenSSL is older than the version librdkafka 2.12 needs. OpenSSL is compiled from source (hencemakeandperl).
Archives
scripts/package-release.sh <version> <target> <bin-dir> writes
dist/sunspec-collector-<version>-<target>.tar.gz, one directory holding collector-app,
sunspec-sim, README.md, CHANGELOG.md, LICENSE-MIT, LICENSE-APACHE,
LICENSE-SUNSPEC-MODELS (the bundled model definitions), config.example.toml,
sunspec-collector.env and sunspec-collector.service. It also copies the binaries to
dist/bin/<amd64|arm64>/ for the release images.
scripts/install.sh installs an archive on a Linux machine; see
Deployment. Its SUNSPEC_ROOT, SUNSPEC_TARGET and SUNSPEC_BASE_URL
variables install under another directory, for another target, or from local files, which is how
CI tests it.
Release images
docker build --build-arg BINARIES=prebuilt packages the binaries in dist/bin/<arch>/ instead
of compiling them, so a multi-arch image needs no compilation under emulation:
docker buildx build --platform linux/amd64,linux/arm64 --build-arg BINARIES=prebuilt \
--target collector -t sunspec-collector .
The published images are ghcr.io/imrans110/sunspec-collector and
ghcr.io/imrans110/sunspec-simulator, tagged with the version (0.5.0), the minor series
(0.5), latest for the newest non-prerelease, and sha-<commit>.
Release workflow
.github/workflows/release.yml runs on pushes to main, on pull requests that change the
Dockerfiles, Cross.toml, scripts/ or the workflow, on manual dispatch, and on v* tags:
| Job | What it does |
|---|---|
| Prepare | Lints scripts/ with shellcheck; works out the version. On a tag it must equal the workspace version, point at a commit on main, and have a CHANGELOG.md section |
| Build | Cross-compiles both targets; checks the ELF machine, that only glibc and libgcc_s are linked, and that no glibc symbol is newer than 2.18; packages the archives |
| Assemble | Adds install.sh (with the version filled in), SHA256SUMS and the release notes; installs with sudo sh install.sh on the runner, starts the service under systemd against the simulator, checks it polls, and reinstalls to check the environment file is kept. On a tag (and on a manual run) it attests the archives, install.sh and SHA256SUMS, writes the signed bundle as sunspec-collector-<version>.intoto.jsonl and verifies each file against it |
| Image | Builds each image for linux/amd64 and linux/arm64 from the archived binaries and runs --version on both platforms (under QEMU for arm64). On a tag it pushes the image, attests it (the attestation is pushed to GHCR too) and verifies the attestation |
| Publish | On a tag only: creates the GitHub Release with the archives, install.sh, SHA256SUMS and the provenance bundle |
Every run except a tag push is a dry run: nothing is pushed or published. The archives and the
release files are kept as the run's artifacts (archive-<target> and release). A manual run
(gh workflow run release.yml --ref <branch>) also attests its release files, to test the
attestation steps; pull request and main dry runs skip them.
Verifying a release
See Verifying a release.
Cutting a release
-
Pick the version (the Kafka record format and the metric names are part of the public interface, so changing them needs a new minor version before 1.0).
-
In a pull request, set
versionunder[workspace.package]inCargo.toml, runcargo checksoCargo.lockfollows, and rename## [Unreleased]inCHANGELOG.mdto## [<version>] - <date>with a new empty## [Unreleased]above it. -
After it merges, tag the merge commit on
mainand push the tag:git switch main && git pull git tag -a v0.6.0 -m "v0.6.0" git push origin v0.6.0 -
The workflow publishes the images and the release. The GHCR packages are linked to this public repository and take its visibility, so they can be pulled without logging in.
Running the binary
See Command-line options for the options, the environment, files, ports, signals and exit status.