Telegraf is a good fit for edge observability: one static binary, a plugin system that covers system metrics, MQTT, Modbus RTU, and InfluxDB output in a single config file. Adding it to the rpi5-iot-gateway — a Yocto-built RPi5 system running a Thread border router, MQTT broker, and RAUC OTA stack — looked like a straightforward packaging task.

No Telegraf recipe exists in meta-openembedded. The OE layer index has entries in four layers — meta-luneos, meta-webosose, meta-ossystems-base, and meta-influx — but none go above 1.21.4, and Telegraf 1.31 changed plugin registration in a way that makes those recipe structures wrong, not just stale. That meant writing from scratch, which surfaced two non-obvious build issues.

The harder problem was credentials. The image rootfs is a squashfs image replaced wholesale on every RAUC OTA update — any credential baked into the rootfs is either shared across every device running that build, or silently wiped on the next upgrade. The standard fallback, EnvironmentFile=, exposes secrets in /proc/$PID/environ and core dumps — a trade-off that’s acceptable on a server you can audit, not on a fleet of devices you can’t. This post covers the recipe, the two build failures, and the credential model that the image layout actually requires.

Four layers, none past 1.21.4

Telegraf is a Go module, so the recipe inherits go-mod. The binary entry point is cmd/telegraf. A dedicated system user handles isolation:

GO_IMPORT = "github.com/influxdata/telegraf"
PV = "1.31.0"
SRCREV = "fbfaba054e62413b6a0a90372281e687d9ff1238"

SRC_URI = " \
    git://github.com/influxdata/telegraf.git;protocol=https;nobranch=1;destsuffix=${BPN}-${PV}/src/${GO_IMPORT} \
    file://telegraf.service \
    file://telegraf.conf \
    file://telegraf.tmpfiles \
"

inherit go-mod systemd useradd

GO_INSTALL = "${GO_IMPORT}/cmd/telegraf"

GROUPADD_PARAM:${PN} = "-f --system telegraf; -f --system dialout"
USERADD_PARAM:${PN} = "--system -d /var/lib/telegraf -m -s /sbin/nologin \
                       --gid telegraf --groups dialout telegraf"

That gets the build wired. First bitbake telegraf failed immediately.

300 plugins, two conflicting symbols

runtime/cgo(.text): symbol __cgoenqueue defined in both
  vendor/github.com/Azure/azure-kusto-go/...
  vendor/github.com/snowflakedb/gosnowflake/...
duplicate symbol error

Two vendored packages — azure-kusto-go and gosnowflake — both expose a C symbol __cgoenqueue generated by their shared cel-go/antlr4 dependency. The linker sees two definitions and stops.

The root cause is how Telegraf 1.31 changed plugin registration. Before 1.31, each plugin category had a single all.go that imported every plugin unconditionally. From 1.31 onward, each plugin has its own file gated by a build constraint:

//go:build !custom || inputs.cpu

package all

import _ "github.com/influxdata/telegraf/plugins/inputs/cpu"

Without the custom tag, !custom is true and all 300+ plugins compile. Two of them drag in conflicting vendored symbols via the same generated C code.

Telegraf ships a custom_builder tool1 that reads a config file and prints exactly which build tags are needed. Running --dry-run against the gateway telegraf.conf gave the tag list:

TELEGRAF_PLUGIN_TAGS = "custom,inputs.cpu,inputs.disk,inputs.internal,\
inputs.mem,inputs.modbus,inputs.mqtt_consumer,inputs.net,inputs.processes,\
inputs.system,inputs.temp,outputs.influxdb,parsers.json,secretstores.systemd"

GOBUILDFLAGS:append = " -tags ${TELEGRAF_PLUGIN_TAGS}"

Adding a plugin later means appending its tag here and adding the corresponding stanza to telegraf.conf. custom_builder --dry-run is the source of truth — don’t guess.

The same linker, a different package pair

Build tags cleared the first error. The second came from go.bbclass defaults.

go.bbclass appends -buildmode=pie by default.2 Telegraf’s upstream Makefile builds with CGO_ENABLED=0 and no PIE — all selected plugins in this profile are pure Go. Enabling PIE against CGO-disabled code caused the Go generic linker to produce another duplicate symbol error, from a different package pair in the antlr4/cel-go dependency tree. Same pattern: two packages generating the same C symbol from shared generated code. The symptom kept surfacing from different package combinations until the flags matched upstream.

Matching upstream’s release build:

CGO_ENABLED = "0"
GOBUILDFLAGS:remove = "-buildmode=pie"
# -linkshared requires CGO — clear it for a pure-Go build
GO_LINKSHARED = ""

One more go.bbclass default to override: do_compile[network] = "0". Go module resolution for this recipe happens at compile time rather than fully during do_fetch, so network access is needed:

do_compile[network] = "1"

After these changes the binary builds cleanly.

Neither the rootfs nor the environment

The rootfs constraint is straightforward: squashfs is replaced wholesale on every OTA update, so anything baked in either gets wiped on upgrade or is identical across every device running that image. Neither is acceptable for service credentials.

Environment variables are ruled out too, and less obviously so. If you reach for EnvironmentFile=-/etc/default/telegraf with credentials stored as KEY=value pairs, they end up readable in /proc/$PID/environ:

MQTT_PASSWORD=...
INFLUXDB_PASSWORD=...

Any process running as root — audit daemons, monitoring agents, anything with ptrace access — can read them. They also appear in core dumps. An Environment= drop-in in a systemd unit has the same exposure: the value serialises into the unit state under /run/systemd/ and is visible via systemctl show.

The solution is LoadCredential= (systemd 250+)3, which creates a per-unit tmpfs at service activation — isolated from the environment, not visible in /proc, not in argv. Combined with Telegraf 1.28’s secretstores.systemd plugin4, credentials never appear in the config file either. A provisioning script fills the credstore at first boot; the values survive OTA because /etc is an overlay fs with its upper layer on the persistent data partition (mmcblk0p5, ext4). OTA replaces the squashfs lower layer but leaves the data partition intact.

The full mechanics of LoadCredential= — how the tmpfs is structured, the permission model, how to provision it at manufacturing time, and how it composes with SetCredentialEncrypted= — are worth a dedicated post. That’s coming. For now, the relevant config:

# telegraf.service (excerpt)
LoadCredential=mqtt_username:/etc/credstore/telegraf.service.mqtt_username
LoadCredential=mqtt_password:/etc/credstore/telegraf.service.mqtt_password
LoadCredential=influxdb_username:/etc/credstore/telegraf.service.influxdb_username
LoadCredential=influxdb_password:/etc/credstore/telegraf.service.influxdb_password
# telegraf.conf (excerpt)
[[secretstores.systemd]]
  id = "systemd"
  prefix = ""

[[outputs.influxdb]]
  urls = ["${INFLUXDB_URL}"]
  database = "${INFLUXDB_DATABASE}"
  username = "@{systemd:influxdb_username}"
  password = "@{systemd:influxdb_password}"

[[inputs.mqtt_consumer]]
  servers = ["tcp://localhost:1883"]
  username = "@{systemd:mqtt_username}"
  password = "@{systemd:mqtt_password}"
  topics = ["sensors/+/data"]
  data_format = "json"

INFLUXDB_URL and INFLUXDB_DATABASE are non-secret defaults from EnvironmentFile=-/etc/default/iotgw-observability. The @{systemd:key} references resolve at runtime from $CREDENTIALS_DIRECTORY.

The guard that ran as the wrong user

The credstore files ship as zero-byte placeholders, mode 0600, owned root:root. Before provisioning runs on a fresh device, Telegraf must not start — bad credentials cause a restart loop and journal noise.

The first cut used ExecCondition= with test -s checks in [Service]. It looked correct. The service skipped cleanly on a fresh device — exactly the behaviour we wanted. It was only when testing the fix on device that test -s kept returning failure even after the credstore files were populated. The reason: ExecCondition= runs under the service’s User= — in this case telegraf. The credstore directory is 0700 root:root. The telegraf user has no execute permission on it, so test -s returns EACCES regardless of file content. The guard worked initially only because the files happened to be empty — both empty files and inaccessible paths look the same to test -s.

ConditionFileNotEmpty=5 is the right directive — it runs in PID 1’s context (root), is designed exactly for this check, and belongs in [Unit] not [Service]. The fix:

--- a/telegraf.service
+++ b/telegraf.service
@@ -3,6 +3,10 @@ Description=Telegraf metrics agent
 After=network.target mosquitto.service
 Wants=mosquitto.service
+ConditionFileNotEmpty=/etc/credstore/telegraf.service.mqtt_username
+ConditionFileNotEmpty=/etc/credstore/telegraf.service.mqtt_password
+ConditionFileNotEmpty=/etc/credstore/telegraf.service.influxdb_username
+ConditionFileNotEmpty=/etc/credstore/telegraf.service.influxdb_password
 StartLimitIntervalSec=5min
@@  LoadCredential=influxdb_password:/etc/credstore/telegraf.service.influxdb_password
-# Skip service start until required credentials are non-empty.
-ExecCondition=/bin/sh -c 'test -s /etc/credstore/telegraf.service.mqtt_username'
-ExecCondition=/bin/sh -c 'test -s /etc/credstore/telegraf.service.mqtt_password'
-ExecCondition=/bin/sh -c 'test -s /etc/credstore/telegraf.service.influxdb_username'
-ExecCondition=/bin/sh -c 'test -s /etc/credstore/telegraf.service.influxdb_password'
 ExecStart=/usr/bin/telegraf --config /etc/telegraf/telegraf.conf
[Unit]
ConditionFileNotEmpty=/etc/credstore/telegraf.service.mqtt_username
ConditionFileNotEmpty=/etc/credstore/telegraf.service.mqtt_password
ConditionFileNotEmpty=/etc/credstore/telegraf.service.influxdb_username
ConditionFileNotEmpty=/etc/credstore/telegraf.service.influxdb_password

When any file is absent or empty, systemd marks the unit condition-failed and skips it cleanly — no Restart= cycle, no journal errors:

telegraf.service: Skipped due to unmet condition checks.
Condition check resulted in Telegraf metrics agent being skipped.

Once the provisioning unit writes non-empty values and exits, the next start attempt clears all conditions.

On device

With the fixed service file deployed, journal on first start with test credentials in the credstore:

systemd[1]: Started Telegraf metrics agent.
telegraf[897]: Loading config: /etc/telegraf/telegraf.conf
telegraf[897]: Available plugins: 11 inputs, 0 aggregators, 0 processors, 2 parsers, 1 outputs, 1 secret-stores
telegraf[897]: Loaded inputs: cpu disk internal mem mqtt_consumer net processes system temp
telegraf[897]: Loaded secretstores: systemd
telegraf[897]: Loaded outputs: influxdb
telegraf[897]: Tags enabled: host=iot-gateway role=iot-gateway site=plant-a
telegraf[897]: [outputs.influxdb] When writing to [http://127.0.0.1:8086]: dial tcp 127.0.0.1:8086: connect: connection refused
telegraf[897]: [telegraf] Error running agent: starting input inputs.mqtt_consumer: not Authorized

Loaded secretstores: systemd confirms credentials were read from $CREDENTIALS_DIRECTORY. The MQTT not Authorized error means Telegraf reached the broker and attempted authentication with the credential — the secret store flow worked. The failures are expected: these are test credentials and the local InfluxDB instance isn’t running.

systemctl show confirms no credentials in the service environment:

Environment=
EnvironmentFiles=/etc/default/iotgw-observability (ignore_errors=yes)

The environment file only carries non-secret defaults:

INFLUXDB_URL=http://127.0.0.1:8086
INFLUXDB_DATABASE=gateway_data

To verify nothing leaks into the process environment or /proc, running the same LoadCredential= setup as the telegraf service under User=telegraf:

$ ls -la $CREDENTIALS_DIRECTORY
dr-xr-x---+ 2 root root 80 Apr 23 05:35 .
drwxr-xr-x  3 root root 60 Apr 23 05:35 ..
-r--r-----+ 1 root root 14 Apr 23 05:35 mqtt_password
-r--r-----+ 1 root root 14 Apr 23 05:35 mqtt_username

$ env | grep -iE "pass|secret|mqtt|influx"
(none)

$ cat /proc/self/environ | tr '\0' '\n' | grep -iE "pass|secret|mqtt|influx"
(none)

Credentials exist only as files in the per-unit tmpfs — mode 0500 on the directory, 0400 on each file, owned by the service user. They are not in the environment, not in argv, and not in /proc/$PID/environ. The tmpfs is created at service activation and destroyed when the service exits.

LoadCredential= requires systemd 250. The RPi5 BSP on Scarthgap ships systemd 255, so that’s not a constraint here. On BSPs below 250, SetCredential= (available from 247) bakes a value into the unit file at build time — no help for device-specific secrets. Below that there’s no clean answer from systemd alone.

Full source for the recipe, service file, and tmpfiles config is in meta-iot-gateway.