<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Supun Sriyananda</title>
    <description>The latest articles on DEV Community by Supun Sriyananda (@ranaweerasupun).</description>
    <link>https://dev.to/ranaweerasupun</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3951298%2Fb6d6c160-d027-48c3-aeb0-91a507d5b6a8.jpeg</url>
      <title>DEV Community: Supun Sriyananda</title>
      <link>https://dev.to/ranaweerasupun</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ranaweerasupun"/>
    <language>en</language>
    <item>
      <title>How to cap the journal so it does not fill or wear out an SD card: Logging with journald</title>
      <dc:creator>Supun Sriyananda</dc:creator>
      <pubDate>Tue, 13 Oct 2026 23:30:00 +0000</pubDate>
      <link>https://dev.to/ranaweerasupun/how-to-cap-the-journal-so-it-does-not-fill-or-wear-out-an-sd-card-logging-with-journald-4p75</link>
      <guid>https://dev.to/ranaweerasupun/how-to-cap-the-journal-so-it-does-not-fill-or-wear-out-an-sd-card-logging-with-journald-4p75</guid>
      <description>&lt;p&gt;One of the most underappreciated benefits of running your application as a systemd service is that you get a powerful, structured logging system essentially for free. Any text your application prints to stdout or stderr is automatically captured, timestamped, and stored in something called the &lt;strong&gt;journal&lt;/strong&gt;. journal is systemd's centralised log database. This article covers how to read, filter, and manage those logs, and how to configure the journal to be a responsible citizen on embedded devices with limited storage. Yes, journal works well with embedded devices. But, it works well only if you configure it properly!&lt;/p&gt;




&lt;h2&gt;
  
  
  How systemd Captures Your Logs
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbjsik3uwymranl49pczl.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbjsik3uwymranl49pczl.png" alt="from-print-to-the-journal" width="800" height="586"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;When your service is running, systemd intercepts everything written to &lt;code&gt;stdout&lt;/code&gt; and &lt;code&gt;stderr&lt;/code&gt; and passes it to &lt;code&gt;systemd-journald&lt;/code&gt;. So, you don't need to change anything in your application code. Just a plain &lt;code&gt;print("sensor reading: 42.3")&lt;/code&gt; in Python becomes a structured journal entry with a precise timestamp. This entry comes with the service name, the process ID, and the message text all stored together.&lt;/p&gt;

&lt;p&gt;The only service file configuration needed to make this explicit is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="py"&gt;StandardOutput&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;journal&lt;/span&gt;
&lt;span class="py"&gt;StandardError&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;journal&lt;/span&gt;
&lt;span class="py"&gt;SyslogIdentifier&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;my-app&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;StandardOutput=journal&lt;/code&gt; and &lt;code&gt;StandardError=journal&lt;/code&gt; are actually the defaults in modern systemd, so you don't strictly need to specify them. But being explicit makes the service file self-documenting. Anyone reading it can immediately see where the logs go. &lt;code&gt;SyslogIdentifier&lt;/code&gt; sets a short tag attached to every journal entry from this service. This makes it easier to filter for your specific application even if multiple services share similar names.&lt;/p&gt;




&lt;h2&gt;
  
  
  Reading Logs with journalctl
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;journalctl&lt;/code&gt; command is our primary interface to the journal. Its most important option for service debugging is &lt;code&gt;-u&lt;/code&gt;. It filters entries to a specific unit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Show all historical logs for your service&lt;/span&gt;
journalctl &lt;span class="nt"&gt;-u&lt;/span&gt; my-app.service

&lt;span class="c"&gt;# Follow the log in real time — like 'tail -f' on a log file&lt;/span&gt;
journalctl &lt;span class="nt"&gt;-u&lt;/span&gt; my-app.service &lt;span class="nt"&gt;-f&lt;/span&gt;

&lt;span class="c"&gt;# Show only logs from the current boot&lt;/span&gt;
journalctl &lt;span class="nt"&gt;-u&lt;/span&gt; my-app.service &lt;span class="nt"&gt;-b&lt;/span&gt;

&lt;span class="c"&gt;# Show only logs from the previous boot (useful after a crash)&lt;/span&gt;
journalctl &lt;span class="nt"&gt;-u&lt;/span&gt; my-app.service &lt;span class="nt"&gt;-b&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;

&lt;span class="c"&gt;# Show logs from the last hour&lt;/span&gt;
journalctl &lt;span class="nt"&gt;-u&lt;/span&gt; my-app.service &lt;span class="nt"&gt;--since&lt;/span&gt; &lt;span class="s2"&gt;"1 hour ago"&lt;/span&gt;

&lt;span class="c"&gt;# Show logs between two specific timestamps&lt;/span&gt;
journalctl &lt;span class="nt"&gt;-u&lt;/span&gt; my-app.service &lt;span class="nt"&gt;--since&lt;/span&gt; &lt;span class="s2"&gt;"2025-12-16 10:00"&lt;/span&gt; &lt;span class="nt"&gt;--until&lt;/span&gt; &lt;span class="s2"&gt;"2025-12-16 11:00"&lt;/span&gt;

&lt;span class="c"&gt;# Show only error-level and above (useful to find crashes quickly)&lt;/span&gt;
journalctl &lt;span class="nt"&gt;-u&lt;/span&gt; my-app.service &lt;span class="nt"&gt;-p&lt;/span&gt; err

&lt;span class="c"&gt;# Show the last 50 lines, most recent first&lt;/span&gt;
journalctl &lt;span class="nt"&gt;-u&lt;/span&gt; my-app.service &lt;span class="nt"&gt;-n&lt;/span&gt; 50 &lt;span class="nt"&gt;-r&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The option I use frequently is &lt;code&gt;-b -1&lt;/code&gt;. It is particularly valuable for embedded systems. If your device rebooted unexpectedly due to a power cut, a kernel panic, you can look at the logs from the &lt;em&gt;previous&lt;/em&gt; boot to understand what was happening right before it went down. Magical isn't it!&lt;/p&gt;

&lt;p&gt;A note:&lt;/p&gt;

&lt;p&gt;Simply put, the Pi keeps a diary of what your programs do. That diary is the journal. The question is where the diary is kept, and there are two choices.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Storage=volatile&lt;/code&gt; means the diary is kept in RAM, like writing on a whiteboard. It's fast and doesn't wear out the SD card, but when the power goes off, the whiteboard is wiped. This is how my Pi in the appendix is set up.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Storage=persistent&lt;/code&gt; means the diary is kept on the SD card, like writing in a notebook. It survives reboots and power cuts, but every write slightly wears out the card, and the notebook can fill up.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;journalctl -b -1&lt;/code&gt; only works with persistent storage, because a volatile journal is wiped on reboot. Likewise, &lt;code&gt;SystemMaxUse&lt;/code&gt; only limits persistent storage; for volatile storage, the matching setting is &lt;code&gt;RuntimeMaxUse&lt;/code&gt;. I'll cover how to choose between the two in an upcoming post in this series.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqo7la4v8m8abnrhvlzto.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqo7la4v8m8abnrhvlzto.png" alt="which-boot-you-are-reading" width="800" height="376"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What are Log Priority Levels
&lt;/h2&gt;

&lt;p&gt;systemd's journal understands syslog-compatible priority levels. If your application logs at different severity levels, you can filter journal output to show only entries at or above a certain severity. The levels are written from most to least severe. These are &lt;code&gt;emerg&lt;/code&gt; (0), &lt;code&gt;alert&lt;/code&gt; (1), &lt;code&gt;crit&lt;/code&gt; (2), &lt;code&gt;err&lt;/code&gt; (3), &lt;code&gt;warning&lt;/code&gt; (4), &lt;code&gt;notice&lt;/code&gt; (5), &lt;code&gt;info&lt;/code&gt; (6), and &lt;code&gt;debug&lt;/code&gt; (7).&lt;/p&gt;

&lt;p&gt;For most embedded projects, simplest and most readable approach is Python's standard &lt;code&gt;logging&lt;/code&gt; module:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;logging&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;

&lt;span class="n"&gt;logging&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;basicConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;level&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;logging&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;INFO&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nb"&gt;format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;%(levelname)s: %(message)s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;stream&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stdout&lt;/span&gt;   &lt;span class="c1"&gt;# systemd captures stdout to the journal
&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;logger&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;logging&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getLogger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;__name__&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Service started successfully&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Sensor reading out of expected range&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Failed to connect to broker — will retry&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you use this pattern, your log messages stay clean and consistent. Not only that, you can filter them in the journal by searching for &lt;code&gt;ERROR&lt;/code&gt; or &lt;code&gt;WARNING&lt;/code&gt; in the message text with &lt;code&gt;journalctl -u my-app.service -g "ERROR"&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  How to Manage Journal Size on Embedded Devices
&lt;/h2&gt;

&lt;p&gt;This is an important practical concern that most tutorials skip. The Raspberry Pi typically uses an SD card for storage, and SD cards have two relevant limitations. They are finite in capacity, and a limited number of write cycles before they start to fail. By default, systemd's journal will grow to fill a significant fraction of available disk space. For a small SD card, this will eventually cause problems. If you leave the journal unconfigured on an embedded device, it becomes a recipe for running out of disk space or unnecessarily wearing out the SD card.&lt;/p&gt;

&lt;p&gt;The journal daemon is configured in &lt;code&gt;/etc/systemd/journald.conf&lt;/code&gt;. Open it with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;nano /etc/systemd/journald.conf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add or uncomment these lines in the &lt;code&gt;[Journal]&lt;/code&gt; section:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[Journal]&lt;/span&gt;
&lt;span class="c"&gt;# Maximum total size of all journal files on disk
&lt;/span&gt;&lt;span class="py"&gt;SystemMaxUse&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;50M&lt;/span&gt;

&lt;span class="c"&gt;# Maximum size of a single journal file
&lt;/span&gt;&lt;span class="py"&gt;SystemMaxFileSize&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;10M&lt;/span&gt;

&lt;span class="c"&gt;# Maximum time to store journal entries (optional, for low-activity devices)
&lt;/span&gt;&lt;span class="py"&gt;MaxRetentionSec&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;1month&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After making changes, restart the journal daemon:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart systemd-journald
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;50 megabytes is a reasonable upper bound for most edge devices. It is large enough to retain several days of logs at typical verbosity, and small enough to not strain a 16GB or 32GB SD card. Let's say your application is particularly chatty, then you may want to reduce this further, or adjust your application's log verbosity before reaching for the journal size limit. This is something you will learn to adjust by doing.&lt;/p&gt;

&lt;p&gt;Check the current journal disk usage at any time with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;journalctl &lt;span class="nt"&gt;--disk-usage&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;A Side Note For Curious You&lt;/strong&gt; - A &lt;code&gt;.conf&lt;/code&gt; is a configuration file, not a service unit file (&lt;code&gt;.service&lt;/code&gt;). &lt;code&gt;journald.conf&lt;/code&gt; is a plain text INI file located in &lt;code&gt;/etc/systemd/journald.conf&lt;/code&gt;. Its only job is to store settings—like size limits, retention times, and storage behavior—that tell the logging daemon how to behave. The systemd-journald daemon does have its own service file behind the scenes called &lt;code&gt;systemd-journald.service&lt;/code&gt;, but you rarely edit that. Instead, you edit &lt;code&gt;journald.conf&lt;/code&gt; to tune how that service handles log data.&lt;/p&gt;

&lt;p&gt;You might have already guessed by now. We can write a &lt;code&gt;.conf&lt;/code&gt; for our &lt;code&gt;myapp.service&lt;/code&gt; as well. Instead of modifying our &lt;code&gt;myapp.service&lt;/code&gt; file directly, we can create a &lt;code&gt;.conf&lt;/code&gt; file to override or add specific settings. This is standard practice in production environments because it keeps the core service file clean. More on this should be in a future article but not this one.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2z178lhgdk18qqakomwu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2z178lhgdk18qqakomwu.png" alt="how-the-journal-stays-under-50m" width="800" height="472"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Viewing Logs Without Paging
&lt;/h2&gt;

&lt;p&gt;By default, &lt;code&gt;journalctl&lt;/code&gt; opens output in a pager (like &lt;code&gt;less&lt;/code&gt;) which requires you to press &lt;code&gt;q&lt;/code&gt; to exit. When running quick diagnostic commands over SSH, this can be inconvenient. Add &lt;code&gt;--no-pager&lt;/code&gt; to get plain terminal output you can scroll or pipe to &lt;code&gt;grep&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Print all logs without opening a pager&lt;/span&gt;
journalctl &lt;span class="nt"&gt;-u&lt;/span&gt; my-app.service &lt;span class="nt"&gt;--no-pager&lt;/span&gt;

&lt;span class="c"&gt;# Find all lines containing "error" (case-insensitive)&lt;/span&gt;
journalctl &lt;span class="nt"&gt;-u&lt;/span&gt; my-app.service &lt;span class="nt"&gt;--no-pager&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; error

&lt;span class="c"&gt;# Count how many warning lines appeared in the last hour&lt;/span&gt;
journalctl &lt;span class="nt"&gt;-u&lt;/span&gt; my-app.service &lt;span class="nt"&gt;--since&lt;/span&gt; &lt;span class="s2"&gt;"1 hour ago"&lt;/span&gt; &lt;span class="nt"&gt;--no-pager&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; WARNING
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The combination of &lt;code&gt;journalctl&lt;/code&gt; filtering and standard Unix tools like &lt;code&gt;grep&lt;/code&gt;, &lt;code&gt;awk&lt;/code&gt;, and &lt;code&gt;wc&lt;/code&gt; gives you a surprisingly capable log analysis workflow without needing any additional tooling.&lt;/p&gt;

&lt;h2&gt;
  
  
  Appendix
&lt;/h2&gt;

&lt;p&gt;Here is the real output from my Raspberry pi.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;admin@sbrsl:~ &lt;span class="nv"&gt;$ &lt;/span&gt;journalctl &lt;span class="nt"&gt;--disk-usage&lt;/span&gt;
Archived and active journals take up 8.8M &lt;span class="k"&gt;in &lt;/span&gt;the file system.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;








&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;nano /etc/systemd/journald.conf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;#  This file is part of systemd.
#
#  systemd is free software; you can redistribute it and/or modify it under the
#  terms of the GNU Lesser General Public License as published by the Free
#  Software Foundation; either version 2.1 of the License, or (at your option)
#  any later version.
#
# Entries in this file show the compile time defaults. Local configuration
# should be created by either modifying this file, or by creating "drop-ins" in
# the journald.conf.d/ subdirectory. The latter is generally recommended.
# Defaults can be restored by simply deleting this file and all drop-ins.
#
# Use 'systemd-analyze cat-config systemd/journald.conf' to display the full config.
#
# See journald.conf(5) for details.
&lt;/span&gt;
&lt;span class="nn"&gt;[Journal]&lt;/span&gt;
&lt;span class="py"&gt;Storage&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;volatile&lt;/span&gt;
&lt;span class="c"&gt;#Compress=yes
#Seal=yes
#SplitMode=uid
#SyncIntervalSec=5m
#RateLimitIntervalSec=30s
#RateLimitBurst=10000
#SystemMaxUse=
#SystemKeepFree=
#SystemMaxFileSize=
#SystemMaxFiles=100
#RuntimeMaxUse=
#RuntimeKeepFree=
#RuntimeMaxFileSize=
#RuntimeMaxFiles=100
#MaxRetentionSec=
#MaxFileSec=1month
#ForwardToSyslog=yes
#ForwardToKMsg=no
#ForwardToConsole=no
#ForwardToWall=yes
#TTYPath=/dev/console
#MaxLevelStore=debug
#MaxLevelSyslog=debug
#MaxLevelKMsg=notice
#MaxLevelConsole=info
#MaxLevelWall=emerg
#LineMax=48K
#ReadKMsg=yes
#Audit=no
&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>linux</category>
      <category>raspberrypi</category>
      <category>devops</category>
      <category>iot</category>
    </item>
    <item>
      <title>How to keep secrets out of the service file in systemd</title>
      <dc:creator>Supun Sriyananda</dc:creator>
      <pubDate>Mon, 12 Oct 2026 14:51:00 +0000</pubDate>
      <link>https://dev.to/ranaweerasupun/how-to-keep-secrets-out-of-the-service-file-in-systemd-324d</link>
      <guid>https://dev.to/ranaweerasupun/how-to-keep-secrets-out-of-the-service-file-in-systemd-324d</guid>
      <description>&lt;p&gt;Two practical issues come up immediately when you move a real application into a systemd service. The first is that relative file path problem. The code works fine when you run it from your terminal but it stops finding its own files when systemd runs it. The second is that your application needs configuration: server addresses, API keys, database credentials. But hardcoding those values into your service file is both inflexible and a security risk. This artilce is about how to solve these two.&lt;/p&gt;

&lt;p&gt;If you want a concrete working example to follow along with, use the script from &lt;a href="https://github.com/ranaweerasupun/resources-tools/blob/main/systemd/examples/env-service/app_with_config.py" rel="noopener noreferrer"&gt;&lt;code&gt;examples/env-service/app_with_config.py&lt;/code&gt;&lt;/a&gt; in the GitHub repository. It is a Python service that reads all its configuration from environment variables — both sensitive secrets (via EnvironmentFile) and non-sensitive settings (via inline Environment directives).&lt;/p&gt;




&lt;h2&gt;
  
  
  The Working Directory Problem
&lt;/h2&gt;

&lt;p&gt;When you run a Python script from your terminal, your shell sets the working directory to wherever you currently are. If you're in &lt;code&gt;/home/pi/my-app/&lt;/code&gt; and your script opens &lt;code&gt;config.json&lt;/code&gt; without a full path, Python looks for &lt;code&gt;config.json&lt;/code&gt; in &lt;code&gt;/home/pi/my-app/&lt;/code&gt; and finds it. This is so natural that most developers don't even think about it.&lt;/p&gt;

&lt;p&gt;But when systemd runs your service things are different. The working directory is not set to your project folder. By default it's &lt;code&gt;/&lt;/code&gt;, the root of the filesystem. That same &lt;code&gt;open("config.json")&lt;/code&gt; call will look for &lt;code&gt;/config.json&lt;/code&gt;. I don't need to tell you what happens now. It fails with a FileNotFoundError, and crash your service before it has done anything useful.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F82h5rqb8zdjzzg2jg9vz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F82h5rqb8zdjzzg2jg9vz.png" alt="where-a-relative-path-lands" width="" height=""&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The fix is the &lt;code&gt;WorkingDirectory&lt;/code&gt; directive in the &lt;code&gt;[Service]&lt;/code&gt; section:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="py"&gt;WorkingDirectory&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/home/pi/my-app&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When you set this, any relative file paths your application uses will resolve relative to that directory, just as they would if you ran the script from there in your terminal. While you're at it, specify which user the service should run as. By default, systemd runs services as &lt;code&gt;root&lt;/code&gt;. This is more privilege than most applications need and it can create a security risk if the application is ever compromised.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="py"&gt;User&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;pi&lt;/span&gt;
&lt;span class="py"&gt;Group&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;pi&lt;/span&gt;
&lt;span class="py"&gt;WorkingDirectory&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/home/pi/my-app&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So running as a non-root user is the &lt;strong&gt;principle of least privilege&lt;/strong&gt;. It gives a process only the permissions it actually needs. If your MQTT client or sensor pipeline has a bug that an attacker could exploit, running as &lt;code&gt;pi&lt;/code&gt; means the attacker gets the capabilities of the &lt;code&gt;pi&lt;/code&gt; user and not root access to the entire system. However, the tradeoff is that you need to ensure the &lt;code&gt;pi&lt;/code&gt; user has read/write permission to the files your application needs, but that's almost always already the case for files in &lt;code&gt;/home/pi/&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;To find your 'User' and 'Group', run the command &lt;code&gt;id&lt;/code&gt;. This returns the active User ID (UID), Primary Group ID (GID), and all Supplementary Groups.&lt;/p&gt;




&lt;h2&gt;
  
  
  Inline Environment Variables
&lt;/h2&gt;

&lt;p&gt;The simplest way to pass configuration to a service is with the &lt;code&gt;Environment&lt;/code&gt; directive. It sets individual environment variables directly inside the service file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[Service]&lt;/span&gt;
&lt;span class="py"&gt;Environment&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"APP_PORT=8080"&lt;/span&gt;
&lt;span class="py"&gt;Environment&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"LOG_LEVEL=INFO"&lt;/span&gt;
&lt;span class="py"&gt;Environment&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"BROKER_HOST=localhost"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your Python code reads these with &lt;code&gt;os.environ.get("APP_PORT")&lt;/code&gt; or &lt;code&gt;os.getenv("APP_PORT", "8080")&lt;/code&gt; (the second argument being a default if the variable isn't set). This approach is fine for non-sensitive configuration. For example, server addresses, port numbers, feature flags, log verbosity. It makes debugging easy because it is clear, explicit, and the values are visible in &lt;code&gt;systemctl status&lt;/code&gt; output.&lt;/p&gt;

&lt;p&gt;However, you should never use &lt;code&gt;Environment&lt;/code&gt; for secrets. API keys, database passwords and authentication tokens are sensitive values that should never be visible in service file contents, which can be read by anyone with filesystem access. For secrets, what you need is an environment file.&lt;/p&gt;




&lt;h2&gt;
  
  
  Environment Files: The Right Way to Handle Secrets
&lt;/h2&gt;

&lt;p&gt;An &lt;strong&gt;EnvironmentFile&lt;/strong&gt; is a separate text file, usually stored in &lt;code&gt;/etc/&lt;/code&gt; with restricted permissions. It contains key-value pairs which systemd loads and injects into your service's environment before starting it. The service file references the path to this file rather than containing the values themselves.&lt;/p&gt;

&lt;p&gt;The good practice is to create the directory and the environment file first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /etc/my-app
&lt;span class="nb"&gt;sudo &lt;/span&gt;nano /etc/my-app/config.env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The file format is simple. It has a one variable per line, like the same &lt;code&gt;KEY=VALUE&lt;/code&gt; syntax we use in a shell script. Comments starting with &lt;code&gt;#&lt;/code&gt; are allowed and considered good practice. Always use short but clear comments:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/my-app/config.env&lt;/span&gt;
&lt;span class="c"&gt;# Server configuration&lt;/span&gt;
&lt;span class="nv"&gt;BROKER_HOST&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;mqtt.example.com
&lt;span class="nv"&gt;BROKER_PORT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;8883

&lt;span class="c"&gt;# Authentication. Keep this file private&lt;/span&gt;
&lt;span class="nv"&gt;API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;secret-api-put-here
&lt;span class="nv"&gt;DB_PASSWORD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;database-password
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now restrict the file's permissions so only root can read it. Most people skips this step. This is the crucial step that makes the whole thing secure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo chmod &lt;/span&gt;600 /etc/my-app/config.env
&lt;span class="nb"&gt;sudo chown &lt;/span&gt;root:root /etc/my-app/config.env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Finally, reference the file in your service with the &lt;code&gt;EnvironmentFile&lt;/code&gt; directive:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="py"&gt;EnvironmentFile&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/etc/my-app/config.env&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When systemd starts your service, it reads this file and sets all the listed variables in the service's environment. Our Python code reads them with &lt;code&gt;os.environ.get("API_KEY")&lt;/code&gt; exactly as it would read any other environment variable and doesn't care where they came from.&lt;/p&gt;

&lt;p&gt;Here, is a small but useful syntax trick: if you prefix the path with a minus sign (&lt;code&gt;-&lt;/code&gt;), systemd will ignore a missing file rather than failing to start the service. I always use this in development where the config file does not exist yet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="py"&gt;EnvironmentFile&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;-/etc/my-app/config.env&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fztgawuf61ooboxpiu4xf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fztgawuf61ooboxpiu4xf.png" alt="where-each-value-lives" width="800" height="560"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The Complete Service File So Far
&lt;/h2&gt;

&lt;p&gt;Combining everything from the last three articles, here's the service file in its current form:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[Unit]&lt;/span&gt;
&lt;span class="py"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;My Application&lt;/span&gt;
&lt;span class="py"&gt;After&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;network.target&lt;/span&gt;
&lt;span class="py"&gt;Wants&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;network.target&lt;/span&gt;

&lt;span class="nn"&gt;[Service]&lt;/span&gt;
&lt;span class="c"&gt;# Run as a non-root user for security
&lt;/span&gt;&lt;span class="py"&gt;User&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;pi&lt;/span&gt;
&lt;span class="py"&gt;Group&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;pi&lt;/span&gt;

&lt;span class="c"&gt;# Set the working directory so relative paths work correctly
&lt;/span&gt;&lt;span class="py"&gt;WorkingDirectory&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/home/pi/my-app&lt;/span&gt;

&lt;span class="c"&gt;# Load secrets and configuration from a separate, restricted file
&lt;/span&gt;&lt;span class="py"&gt;EnvironmentFile&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/etc/my-app/config.env&lt;/span&gt;

&lt;span class="c"&gt;# Non-sensitive configuration can go inline
&lt;/span&gt;&lt;span class="py"&gt;Environment&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"LOG_LEVEL=INFO"&lt;/span&gt;

&lt;span class="c"&gt;# The command that launches your application
&lt;/span&gt;&lt;span class="py"&gt;ExecStart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/usr/bin/python3 /home/pi/my-app/main.py&lt;/span&gt;

&lt;span class="c"&gt;# Restart automatically on unexpected crashes
&lt;/span&gt;&lt;span class="py"&gt;Restart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;on-failure&lt;/span&gt;
&lt;span class="py"&gt;RestartSec&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;5&lt;/span&gt;

&lt;span class="c"&gt;# Give up after 5 crashes within 60 seconds signals a real problem
&lt;/span&gt;&lt;span class="py"&gt;StartLimitIntervalSec&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;60&lt;/span&gt;
&lt;span class="py"&gt;StartLimitBurst&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;5&lt;/span&gt;

&lt;span class="nn"&gt;[Install]&lt;/span&gt;
&lt;span class="py"&gt;WantedBy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;multi-user.target&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After making changes, reload and restart as always:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl daemon-reload
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart my-app.service
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  How to Access Environment Variables in Python
&lt;/h2&gt;

&lt;p&gt;Here is how your application can read the environment variables set by the service file and environment file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;

&lt;span class="c1"&gt;# Read from EnvironmentFile (secrets)
&lt;/span&gt;&lt;span class="n"&gt;broker_host&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;BROKER_HOST&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;localhost&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;broker_port&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;BROKER_PORT&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1883&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;api_key&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;API_KEY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Read from inline Environment directive
&lt;/span&gt;&lt;span class="n"&gt;log_level&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;LOG_LEVEL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;INFO&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;api_key&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Using print() works fine since systemd captures stdout to the journal
&lt;/span&gt;    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ERROR: API_KEY is not set. Check /etc/my-app/config.env&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;SystemExit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Connecting to &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;broker_host&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;broker_port&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; with log level &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;log_level&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# And your code ...
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When systemd captures that error from stdout and logs it to the journal, you'll see exactly why the service failed. This is why I always use the pattern of failing fast with a clear, specific error message if a required variable is missing. It is much better than a cryptic exception from deep inside a library. &lt;/p&gt;

&lt;p&gt;In the next article, we will look at how to read, filter, and manage logs, and how to configure the journal so it does not fill or wear out an SD card. This is 101 on embedded devices with limited storage!&lt;/p&gt;

</description>
      <category>devops</category>
      <category>linux</category>
      <category>raspberrypi</category>
      <category>iot</category>
    </item>
    <item>
      <title>How to make a systemd service recover from crashes without looping forever: Restart policies and Dependencies</title>
      <dc:creator>Supun Sriyananda</dc:creator>
      <pubDate>Fri, 09 Oct 2026 12:30:00 +0000</pubDate>
      <link>https://dev.to/ranaweerasupun/how-to-make-a-systemd-service-recover-from-crashes-without-looping-forever-restart-policies-and-a69</link>
      <guid>https://dev.to/ranaweerasupun/how-to-make-a-systemd-service-recover-from-crashes-without-looping-forever-restart-policies-and-a69</guid>
      <description>&lt;p&gt;The minimal service from the previous document starts your application at boot, but it has a significant weakness: if the application crashes, systemd simply leaves it in a failed state. I don't have to remind that for a device running unattended in the field, something you might not physically access for weeks or months, a silent crash that's never recovered from is unacceptable. This document covers the two additions that transform a basic service into a resilient one: restart policies and dependency declarations.&lt;/p&gt;

&lt;p&gt;If you want a concrete working example to follow along with, use the script from &lt;a href="https://github.com/ranaweerasupun/resources-tools/blob/main/systemd/examples/python-service/hello_service.py" rel="noopener noreferrer"&gt;&lt;code&gt;examples/python-service/hello_service.py&lt;/code&gt;&lt;/a&gt; in the GitHub repository. It's a simple loop that logs a heartbeat message every ten seconds. It is straightforward enough to understand at a glance, but long-running enough to behave like a real service.&lt;/p&gt;




&lt;h2&gt;
  
  
  Restart Policies: For Surviving Crashes
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;Restart&lt;/code&gt; directive in the &lt;code&gt;[Service]&lt;/code&gt; section tells systemd what to do when your application's process exits. The default is &lt;code&gt;Restart=no&lt;/code&gt;. In this case systemd does nothing and your service just stays dead. &lt;/p&gt;

&lt;p&gt;There are several possible values, but in practice you'll almost always choose between two of them based on this simple question: " Should this service restart if you deliberately stop it with &lt;code&gt;systemctl stop&lt;/code&gt;? ".&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Restart=on-failure&lt;/code&gt; means: restart the service if the process exits with a non-zero exit code. It restarts if it is killed by a signal, or hits a timeout, but &lt;em&gt;not&lt;/em&gt; if it exits cleanly with exit code 0. The practical consequence is that &lt;code&gt;systemctl stop&lt;/code&gt; is a clean exist. So it doesn't trigger a restart, because systemd sends a clean termination signal which your application handles gracefully. For most production services this is the right choice because it means you can stop a service for maintenance without a restart loop annoying you.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Restart=always&lt;/code&gt; means: restart no matter what, even if the process exits with code 0. Obviously, this is appropriate for services that should &lt;em&gt;never&lt;/em&gt; be down or where even a clean exit is considered abnormal. It is a less common case but useful for things like a watchdog process or a service whose restart you want explicit control over from systemd alone.&lt;/p&gt;

&lt;p&gt;For the vast majority of embedded applications, &lt;code&gt;on-failure&lt;/code&gt; is the correct choice.&lt;/p&gt;

&lt;h3&gt;
  
  
  The RestartSec Pairing
&lt;/h3&gt;

&lt;p&gt;Always pair &lt;code&gt;Restart&lt;/code&gt; with &lt;code&gt;RestartSec&lt;/code&gt;. &lt;code&gt;RestartSec&lt;/code&gt; sets the delay in seconds before systemd attempts to restart the process. Without it, systemd restarts immediately. This may sound desirable, but think about why this creates a problem. Your service is crashing for a reason. That reason could be a dependency it needs isn't yet ready. For example, the network stack is still initialising, or a database is still starting up. So an immediate restart will just crash again, and again, and again. Even a small delay of five seconds gives the rest of the system time to settle into a stable state.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="py"&gt;Restart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;on-failure&lt;/span&gt;
&lt;span class="py"&gt;RestartSec&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;5&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  How to Prevent Infinite Crash Loops
&lt;/h3&gt;

&lt;p&gt;Now consider a more serious scenario: your service has a real bug that makes it crash immediately on every startup. Perhaps it could be a malformed configuration file or a missing Python dependency. If don't set a limit, systemd will restart it, watch it crash, restart it, watch it crash, forever. This consumes CPU, floods your logs with crash reports, and masks the real problem.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;StartLimitIntervalSec&lt;/code&gt; and &lt;code&gt;StartLimitBurst&lt;/code&gt; directives work together to prevent this. &lt;code&gt;StartLimitBurst&lt;/code&gt; defines the maximum number of restart attempts allowed, and &lt;code&gt;StartLimitIntervalSec&lt;/code&gt; defines the time window those attempts are counted in. To put simply, if the service restarts more than &lt;code&gt;StartLimitBurst&lt;/code&gt; times within &lt;code&gt;StartLimitIntervalSec&lt;/code&gt; seconds, systemd declares the service as failed and stops trying.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="py"&gt;StartLimitIntervalSec&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;60&lt;/span&gt;
&lt;span class="py"&gt;StartLimitBurst&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;5&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With these settings, systemd makes up to five restart attempts within any 60-second window. If the sixth crash happens within that window, it marks the service as failed and stops. At that point &lt;code&gt;systemctl status&lt;/code&gt; clearly shows that the restart limit was reached. In my personal experience, I have found this to be a very valuble signal. It means something is fundamentally broken and needs human attention, not more restart attempts.&lt;/p&gt;

&lt;p&gt;I used to imagine &lt;code&gt;StartLimitBurst&lt;/code&gt; as a circuit breaker. In electrical engineering, a circuit breaker trips when it detects a fault and it doesn't keep trying to send power through a short circuit. systemd's start limit is the same idea: try a reasonable number of times, then stop and let a human investigate rather than hammering a broken process indefinitely. How beautiful is that!&lt;/p&gt;




&lt;h2&gt;
  
  
  Dependency Declarations: Starting things in the Right Order
&lt;/h2&gt;

&lt;p&gt;Most real applications don't run in isolation. An MQTT client is useless if it tries to connect before the network interface exists. A database-backed service will crash if the database process hasn't finished starting. A camera streaming service first needs the camera hardware to be initialised. There is a way to express these dependencies explicitly in systemd. This way your service starts at the right moment in the boot sequence rather than just racing against everything else.&lt;/p&gt;

&lt;p&gt;The two most important dependency directives live in the &lt;code&gt;[Unit]&lt;/code&gt; section.&lt;/p&gt;

&lt;h3&gt;
  
  
  After: Ordering Without Hard Requirements
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;After=some.target&lt;/code&gt; tells systemd not to start your service until &lt;code&gt;some.target&lt;/code&gt; has been reached. This is purely about &lt;em&gt;ordering&lt;/em&gt; and it doesn't create a hard requirement. If &lt;code&gt;some.target&lt;/code&gt; fails entirely, your service still starts after it in the sequence. It just may find that what it was waiting for isn't actually available.&lt;/p&gt;

&lt;p&gt;The target you'll use most often is &lt;code&gt;network.target&lt;/code&gt;, which represents the point where the network subsystem has been initialised. On Raspberry Pi OS, this typically means the kernel network stack is ready and configured interfaces have been brought up.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="py"&gt;After&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;network.target&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A more reliable option for network-dependent services is &lt;code&gt;network-online.target&lt;/code&gt;. This is particularly valuble for those that connect to remote hosts. It doesn't just wait for the network subsystem to initialise but waits until at least one network interface has an active connection and is reachable. This is more conservative and also means a slightly longer boot time. But think about why it prevents some of the common failure modes. Imagine i senario where your service starts, tries to open a TCP connection to a remote server but fails because the network is still negotiating a DHCP address, and crashes before the restart delay kicks in.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="py"&gt;After&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;network-online.target&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Wants and Requires: What are Soft and Hard Dependencies
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;Wants=some.target&lt;/code&gt; and &lt;code&gt;Requires=some.target&lt;/code&gt; declare what your service &lt;em&gt;needs&lt;/em&gt;, not just what it should start &lt;em&gt;after&lt;/em&gt;. The difference is what happens when the dependency isn't available.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Wants&lt;/code&gt; is what is called a soft dependency. If &lt;code&gt;some.target&lt;/code&gt; isn't available or fails to start, systemd will still attempt to start your service. &lt;code&gt;Wants&lt;/code&gt; is like saying "I'd prefer this to be available, but I can try to run without it." This is appropriate when your application has internal handling for the dependency not being there. Which means you have thought about these cases and written you application code to handle them. For example, you write an MQTT client that queues messages if the broker is ever unreachable. Now you can use a soft dependency because you know that your application handles it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Requires&lt;/code&gt; is a hard dependency. If &lt;code&gt;some.target&lt;/code&gt; fails to start, systemd won't start your service either. Furthermore, if &lt;code&gt;some.target&lt;/code&gt; is stopped while your service is running, systemd will also stop your service. This is appropriate when your service cannot function at all without the dependency. For example, a service that reads from a database where the database is also managed by systemd.&lt;/p&gt;

&lt;p&gt;In practice, the combination you'll use most often for network-dependent services is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="py"&gt;After&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;network.target&lt;/span&gt;
&lt;span class="py"&gt;Wants&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;network.target&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This says "start after the network is ready, and express a preference for it to be available but if the network fails, keep the service running, because I handle network loss internally."&lt;/p&gt;




&lt;h2&gt;
  
  
  The Updated Service File
&lt;/h2&gt;

&lt;p&gt;Putting the restart policy and dependency declarations together, here's the service file updated from the minimal version:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[Unit]&lt;/span&gt;
&lt;span class="py"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;My Application&lt;/span&gt;
&lt;span class="py"&gt;After&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;network.target&lt;/span&gt;
&lt;span class="py"&gt;Wants&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;network.target&lt;/span&gt;

&lt;span class="nn"&gt;[Service]&lt;/span&gt;
&lt;span class="py"&gt;ExecStart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/usr/bin/python3 /home/pi/my-app/main.py&lt;/span&gt;
&lt;span class="py"&gt;Restart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;on-failure&lt;/span&gt;
&lt;span class="py"&gt;RestartSec&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;5&lt;/span&gt;
&lt;span class="py"&gt;StartLimitIntervalSec&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;60&lt;/span&gt;
&lt;span class="py"&gt;StartLimitBurst&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;5&lt;/span&gt;

&lt;span class="nn"&gt;[Install]&lt;/span&gt;
&lt;span class="py"&gt;WantedBy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;multi-user.target&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After editing the file, reload systemd and restart the service to apply the changes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl daemon-reload
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart my-app.service
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can test the restart policy right now without rebooting. Find the process ID from &lt;code&gt;systemctl status&lt;/code&gt;, then kill it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo kill&lt;/span&gt; &lt;span class="nt"&gt;-9&lt;/span&gt; &amp;lt;PID&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wait five seconds and run &lt;code&gt;systemctl status&lt;/code&gt; again. You should see the service back in &lt;code&gt;active (running)&lt;/code&gt; state with a new PID and a note that it was restarted. That automatic recovery is exactly what you want from a service running unattended in the field.&lt;/p&gt;




&lt;h2&gt;
  
  
  Common Dependency Targets Reference
&lt;/h2&gt;

&lt;p&gt;Beyond &lt;code&gt;network.target&lt;/code&gt;, a handful of other targets come up regularly in embedded service files. &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;local-fs.target&lt;/code&gt; ensures all local filesystems are mounted. This is useful if your service reads or writes files on a specific mount point. &lt;/li&gt;
&lt;li&gt;
&lt;code&gt;time-sync.target&lt;/code&gt; ensures the system clock has been synchronised via NTP, which matters for any service that timestamps data or signs tokens. &lt;/li&gt;
&lt;li&gt;
&lt;code&gt;bluetooth.target&lt;/code&gt; ensures the Bluetooth subsystem is available, relevant for Raspberry Pi projects that communicate with BLE devices. &lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can also use your services written by yourself. If a service depends on another service you've written yourself, you can declare &lt;code&gt;After=my-other-app.service&lt;/code&gt; and &lt;code&gt;Requires=my-other-app.service&lt;/code&gt; directly by service name. Clever isn't it?&lt;/p&gt;

&lt;p&gt;In this next article explains how to fix the relative-path failures that appear under systemd, run as a non-root user, and how to keep secrets out of the service file.&lt;/p&gt;

</description>
      <category>raspberrypi</category>
      <category>linux</category>
      <category>devops</category>
      <category>iot</category>
    </item>
    <item>
      <title>Write a minimal linux service file</title>
      <dc:creator>Supun Sriyananda</dc:creator>
      <pubDate>Thu, 08 Oct 2026 13:12:49 +0000</pubDate>
      <link>https://dev.to/ranaweerasupun/write-a-minimal-linux-service-file-27jo</link>
      <guid>https://dev.to/ranaweerasupun/write-a-minimal-linux-service-file-27jo</guid>
      <description>&lt;p&gt;Let's create a real, working service file from scratch. By the end you'll have a Python script registered with systemd, starting at boot, and showing up cleanly in &lt;code&gt;systemctl status&lt;/code&gt;. Every line of the service file is explained so you understand not just what to type but why it's there.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Application We're Registering
&lt;/h2&gt;

&lt;p&gt;For this example, suppose you have a Python script at &lt;code&gt;/home/admin/resources-tools/systemd/examples/python-service&lt;/code&gt;. The specific content doesn't matter much for learning systemd — what matters is that it's a long-running process (something that loops, listens for connections, or monitors sensors) rather than a script that runs once and exits.&lt;/p&gt;

&lt;p&gt;If you want a concrete working example to follow along with, use the script from &lt;a href="https://github.com/ranaweerasupun/resources-tools/blob/main/systemd/examples/python-service/hello_service.py" rel="noopener noreferrer"&gt;&lt;code&gt;examples/python-service/hello_service.py&lt;/code&gt;&lt;/a&gt; in the GitHub repository. It's a simple loop that logs a heartbeat message every ten seconds. It is straightforward enough to understand at a glance, but long-running enough to behave like a real service.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where Service Files are Located
&lt;/h2&gt;

&lt;p&gt;Service files belong in &lt;code&gt;/etc/systemd/system/&lt;/code&gt;. This directory is specifically reserved for service files you write and manage yourself. It is separate from the directories where systemd's own built-in services are, and separate from the directories package managers use. Putting your files here means they won't be overwritten by a system update, and they'll take precedence over any lower-priority defaults.&lt;/p&gt;

&lt;p&gt;The file must have a &lt;code&gt;.service&lt;/code&gt; extension. The name you give it becomes the name you use with &lt;code&gt;systemctl&lt;/code&gt;. So the convention is to choose something short and descriptive. In this tutorial I will be using &lt;code&gt;hello_python.service&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Minimal Service File
&lt;/h2&gt;

&lt;p&gt;Create the file with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;nano /etc/systemd/system/hello_python.service
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Type or paste the following:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[Unit]&lt;/span&gt;
&lt;span class="py"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;My Application&lt;/span&gt;

&lt;span class="nn"&gt;[Service]&lt;/span&gt;
&lt;span class="py"&gt;ExecStart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/usr/bin/python3 /home/pi/hello_python/main.py&lt;/span&gt;

&lt;span class="nn"&gt;[Install]&lt;/span&gt;
&lt;span class="py"&gt;WantedBy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;multi-user.target&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the absolute minimum. Let me explain what each line is doing.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxriosp3xfz5ldo5k07fw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxriosp3xfz5ldo5k07fw.png" alt="what-enable-changes" width="800" height="568"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The [Unit] Section
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;Description=My Application&lt;/code&gt; is a plain-English label. It appears whenever you inspect this service in &lt;code&gt;systemctl status&lt;/code&gt;, in journal logs or in any system monitoring tool. If you write something vague, you or anyone else using the system won't be able to identify the service at a glance after some time. Always write descriptive messages. For now I will be using "My Application", but in a real project write something like "Temperature Sensor Data Pipeline" or "MQTT Edge Client".&lt;/p&gt;

&lt;h3&gt;
  
  
  The [Service] Section
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;ExecStart=/usr/bin/python3 /home/admin/resources-tools/systemd/examples/python-service&lt;/code&gt; is the command that launches your program. A critical rule: &lt;strong&gt;you must use the full, absolute path to every executable&lt;/strong&gt;. You can't write &lt;code&gt;python3 hello_service.py&lt;/code&gt; because systemd doesn't use your shell's PATH environment. It needs to know the exact location of the Python interpreter. You can find it with &lt;code&gt;which python3&lt;/code&gt; in your terminal. On most Raspberry Pi systems it'll be &lt;code&gt;/usr/bin/python3&lt;/code&gt;. The path to your script must also be absolute.&lt;/p&gt;

&lt;h3&gt;
  
  
  The [Install] Section
&lt;/h3&gt;

&lt;p&gt;Systemd organises the boot process into &lt;strong&gt;targets&lt;/strong&gt;. Targets are milestones that represent specific states of the system. &lt;code&gt;multi-user.target&lt;/code&gt; represents the state where the system is fully booted, all essential hardware is initialised, networking is available, and the system is ready for normal use but before any graphical desktop starts. It essentially means "everything is ready, start normal services" point in the boot sequence. By declaring &lt;code&gt;WantedBy=multi-user.target&lt;/code&gt;, you're saying "start my service when the system reaches this state." This is the correct target for the vast majority of application services.&lt;/p&gt;

&lt;h3&gt;
  
  
  A note
&lt;/h3&gt;

&lt;p&gt;If you are using my example, in the Install section you need to fill 'User' and 'Group'. To find yours run the command &lt;code&gt;id&lt;/code&gt;. This returns the active User ID (UID), Primary Group ID (GID), and all Supplementary Groups.&lt;/p&gt;




&lt;h2&gt;
  
  
  Activating the Service
&lt;/h2&gt;

&lt;p&gt;Writing the file isn't enough on its own. Now we need to tell systemd the file exists and then enable it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Reload systemd's Configuration
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl daemon-reload
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;systemd reads unit files when it starts and caches their contents. When we create or modify a service file, systemd doesn't automatically notice the change. When you run &lt;code&gt;daemon-reload&lt;/code&gt;, it re-scans all unit file directories and updates its internal cache. Remember, &lt;strong&gt;you must run this every time you create or edit a service file&lt;/strong&gt;, or your changes will be ignored.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx4ten7366hrs4m9rem8y.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx4ten7366hrs4m9rem8y.png" alt="from-file-to-running-service" width="800" height="568"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Enable the Service
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable &lt;/span&gt;hello_python.service
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This creates a symbolic link inside the &lt;code&gt;multi-user.target.wants/&lt;/code&gt; directory that points to your service file. This symlink is what causes systemd to start our service at boot. If you are following this tutorial, go ahead and run this command. You can actually see the symlink after running enable.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-la&lt;/span&gt; /etc/systemd/system/multi-user.target.wants/ | &lt;span class="nb"&gt;grep &lt;/span&gt;hello_python
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here is the output from my Pi:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;admin@sbrsl:~ &lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-la&lt;/span&gt; /etc/systemd/system/multi-user.target.wants/ | &lt;span class="nb"&gt;grep &lt;/span&gt;hello_python
lrwxrwxrwx  1 root root   40 Oct  5 14:51 hello_python.service -&amp;gt; /etc/systemd/system/hello_python.service
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 3: Start the Service Now
&lt;/h3&gt;

&lt;p&gt;Enabling only affects future boots. If you want to start the service in the current session without rebooting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl start hello_python.service
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In practice, when deploying a new service, I almost always run all three commands in sequence:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl daemon-reload
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable &lt;/span&gt;hello_python.service
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl start hello_python.service
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Verifying it's Running
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl status hello_python.service
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If everything is working, the output will look something like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;admin@sbrsl:~ &lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl status hello_python.service
● hello_python.service - Python Heartbeat Service &lt;span class="o"&gt;(&lt;/span&gt;Tutorial Example&lt;span class="o"&gt;)&lt;/span&gt;
     Loaded: loaded &lt;span class="o"&gt;(&lt;/span&gt;/etc/systemd/system/hello_python.service&lt;span class="p"&gt;;&lt;/span&gt; enabled&lt;span class="p"&gt;;&lt;/span&gt; preset:&amp;gt;
     Active: active &lt;span class="o"&gt;(&lt;/span&gt;running&lt;span class="o"&gt;)&lt;/span&gt; since Thu 2025-10-08 12:27:30 +0530&lt;span class="p"&gt;;&lt;/span&gt; 4min 56s ago
   Main PID: 31967 &lt;span class="o"&gt;(&lt;/span&gt;python3&lt;span class="o"&gt;)&lt;/span&gt;
      Tasks: 1 &lt;span class="o"&gt;(&lt;/span&gt;limit: 9563&lt;span class="o"&gt;)&lt;/span&gt;
        CPU: 43ms
     CGroup: /system.slice/hello_python.service
             └─31967 /usr/bin/python3 hello_service.py

Oct 08 12:30:51 sbrsl hello-python[31967]: 2025-10-08 12:30:51,061 INFO: Heartb&amp;gt;
Oct 08 12:31:01 sbrsl hello-python[31967]: 2025-10-08 12:31:01,062 INFO: Heartb&amp;gt;
Oct 08 12:31:11 sbrsl hello-python[31967]: 2025-10-08 12:31:11,063 INFO: Heartb&amp;gt;
Oct 08 12:31:21 sbrsl hello-python[31967]: 2025-10-08 12:31:21,064 INFO: Heartb&amp;gt;
Oct 08 12:31:31 sbrsl hello-python[31967]: 2025-10-08 12:31:31,065 INFO: Heartb&amp;gt;
Oct 08 12:31:41 sbrsl hello-python[31967]: 2025-10-08 12:31:41,068 INFO: Heartb&amp;gt;
Oct 08 12:31:51 sbrsl hello-python[31967]: 2025-10-08 12:31:51,069 INFO: Heartb&amp;gt;
Oct 08 12:32:01 sbrsl hello-python[31967]: 2025-10-08 12:32:01,070 INFO: Heartb&amp;gt;
Oct 08 12:32:11 sbrsl hello-python[31967]: 2025-10-08 12:32:11,070 INFO: Heartb&amp;gt;
Oct 08 12:32:21 sbrsl hello-python[31967]: 2025-10-08 12:32:21,071 INFO: Heartb&amp;gt;
lines 1-19/19 &lt;span class="o"&gt;(&lt;/span&gt;END&lt;span class="o"&gt;)&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The above is the real output from my pi.&lt;/p&gt;

&lt;p&gt;The most important line here is &lt;code&gt;Active: active (running)&lt;/code&gt;. The word &lt;code&gt;enabled&lt;/code&gt; next to &lt;code&gt;Loaded&lt;/code&gt; confirms it'll also start at boot. If you see &lt;code&gt;active (dead)&lt;/code&gt;, the service ran and exited. In this case, if you expected your script exits cleanly this is the right thing you should see. But if you expected it to keep running, this is a problem. If you see &lt;code&gt;failed&lt;/code&gt;, the status output usually includes the last few log lines pointing to the cause. I will show you how to inspect them.&lt;/p&gt;




&lt;h2&gt;
  
  
  Testing Reboot Persistence
&lt;/h2&gt;

&lt;p&gt;The real test of a service is whether it survives a reboot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;reboot
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After the Pi/Linux machine comes back up, check the status again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl status hello_python.service
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If it shows &lt;code&gt;active (running)&lt;/code&gt; with a start time matching the current boot, our service is working correctly as a persistent, auto-starting system service.&lt;/p&gt;




&lt;h2&gt;
  
  
  One-Line Enable + Start Shortcut
&lt;/h2&gt;

&lt;p&gt;On newer versions of systemd (v220 and later, which includes all current Raspberry Pi OS versions), we can combine enable and start with the &lt;code&gt;--now&lt;/code&gt; flag:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="nt"&gt;--now&lt;/span&gt; hello_python.service
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Functionally this is identical to running &lt;code&gt;enable&lt;/code&gt; and &lt;code&gt;start&lt;/code&gt; separately, but just more convenient.&lt;/p&gt;




&lt;h2&gt;
  
  
  This Minimal Service is Still Missing Something
&lt;/h2&gt;

&lt;p&gt;The service you have right now works, but it's missing several things that matter for real-world use. If the application crashes, systemd will leave it dead and never restart it. If you're writing relative file paths in your Python code, they'll likely fail because systemd starts services from the root directory, not your project folder. And if you have any configuration like API keys, database passwords, and server addresses, there's no good place to put them yet. In the next two articles I will address all of these. &lt;/p&gt;

&lt;p&gt;Deliberately stopping or forcefully killing a service isn't just about handling an absolute emergency. In day-to-day administration, there are several practical reasons you will need to step in and shut a process down:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Applying Software Updates: When you modify your code, update a Python package, or download a security patch, the old version of the program keeps running in the Pi's RAM. You must stop the service so systemd can load the fresh code.&lt;/li&gt;
&lt;li&gt;Conserving System Resources: The Raspberry Pi has limited RAM and CPU power. If you are about to run a heavy task like compiling code or processing video, you may need to temporarily kill background services (like a database or web server) to free up hardware memory.&lt;/li&gt;
&lt;li&gt;Testing and Debugging: If your script is misbehaving, it is much easier to stop the systemd service completely and run the script manually in your terminal. This allows us to see live print statements and interactive error messages in real-time.&lt;/li&gt;
&lt;li&gt;Changing Configuration Settings: Many applications only read their configuration or environment files exactly once when they launch. So, if you change a setting, the running service won't notice until you stop and start it again.&lt;/li&gt;
&lt;li&gt;Preventing Data Corruption: If you need to back up a database or copy a live log file from your SD card, stopping the service first ensures that no new data is being written mid-copy. This prevents your backup from becoming corrupted.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Force-kill the Python process with &lt;code&gt;kill &amp;lt;PID&amp;gt;&lt;/code&gt; and then run &lt;code&gt;systemctl status&lt;/code&gt; to see what systemd does. Observe this and the restart policies we will be discussing in the next article would become much more concrete.&lt;/p&gt;

</description>
      <category>systemd</category>
      <category>linux</category>
      <category>devops</category>
      <category>raspberrypi</category>
    </item>
    <item>
      <title>What is systemd? And why should you care?</title>
      <dc:creator>Supun Sriyananda</dc:creator>
      <pubDate>Mon, 21 Sep 2026 14:48:05 +0000</pubDate>
      <link>https://dev.to/ranaweerasupun/what-is-systemd-and-why-should-you-care-5c29</link>
      <guid>https://dev.to/ranaweerasupun/what-is-systemd-and-why-should-you-care-5c29</guid>
      <description>&lt;p&gt;testedOn:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;hardware: Raspberry Pi 5&lt;/li&gt;
&lt;li&gt;os: Raspberry Pi OS Bookworm&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;May it be a sensor pipeline, an MQTT client or a web server you have engineered something that works perfectly.  And then on one day, the device loses power or you reboot it. You get the device powered again but there is only silence. Your MQTT client or the webserver doesn't start. You curse, SSH in, run your script manually, and it all comes back to life. "Well, this is eveyday life!" you say. But it doesn't have to be. You just need to learn how to laverage &lt;code&gt;systemd&lt;/code&gt; to make them run at boot.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Boot Process in Plain English
&lt;/h2&gt;

&lt;p&gt;When you power on a Raspberry Pi (or any Linux computer), a very specific sequence of events unfolds. The processor runs a small piece of firmware stored in ROM, which locates the bootloader on your SD card. The bootloader then loads the Linux kernel into memory and starts it. Now that the kernel is running, it initialises memory, filesystems and device drivers. At the very end of this setup phase, it needs to hand control over to the rest of the operating system.&lt;/p&gt;

&lt;p&gt;The kernel does this by starting exactly one process and giving it process ID 1. This process is the &lt;strong&gt;init system&lt;/strong&gt; and it becomes the parent of everything else that runs on the machine. On virtually every modern Linux distribution such as Raspberry Pi OS, Ubuntu, Debian or Fedora that process is &lt;code&gt;systemd&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This is a common point of confusion for many people at first. I was one of them. The init process is the special PID 1 process that starts and manages the rest of the userspace processes. On modern Linux systems, systemd is the program that serves as the init process, so systemd is PID 1. In other words, “init” describes the role, while “systemd” is the program performing that role.&lt;/p&gt;

&lt;p&gt;So, yes. You thought right! There are other programs out there other than &lt;code&gt;systemd&lt;/code&gt; which performs 'init' role and become PID 1. Some of them are SysVinit, Upstart, OpenRC and runit. Systemd became dominant because it provided a broader and more integrated way to manage a Linux system. It ended up establishing itself as the common standard after major distributions adopted it.&lt;/p&gt;

&lt;p&gt;From the moment systemd starts, it is in charge. It reads configuration files called &lt;strong&gt;unit files&lt;/strong&gt;. 'unit files' describe everything the system needs to do such as mounting filesystems, configuring the network, starting background services and bringing up the desktop environment. It does all of this in a carefully ordered, parallelised way that's much faster and more reliable than the older shell-script-based init systems it replaced.&lt;/p&gt;

&lt;p&gt;This is the key insight you need as a developer: &lt;strong&gt;if you want your application to be part of that startup sequence and be managed, monitored, and restarted by the system itself, you just need to write a unit file that describes it.&lt;/strong&gt; Once systemd knows about your application, it treats it like any other first-class system component.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Feshrv55wpuq82zkal5yc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Feshrv55wpuq82zkal5yc.png" alt="boot-process-from-power-on-to-pid-1" width="800" height="678"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Unit Files: Configuration for Everything
&lt;/h2&gt;

&lt;p&gt;Now you know that systemd manages everything through &lt;strong&gt;unit files&lt;/strong&gt;. They are small, structured text files that follow a consistent format. 'unit files' let us tell systemd these things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;What program should start?&lt;/li&gt;
&lt;li&gt;When should it start?&lt;/li&gt;
&lt;li&gt;What does it depend on?&lt;/li&gt;
&lt;li&gt;Should it restart if it crashes?&lt;/li&gt;
&lt;li&gt;Which user should run it as?&lt;/li&gt;
&lt;li&gt;Where should its output/logs go?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The one you'll work with most is the &lt;strong&gt;service unit&lt;/strong&gt;, which describes a long-running process (a daemon). Other types include &lt;strong&gt;timer units&lt;/strong&gt; (for scheduled tasks, covered in the examples section), &lt;strong&gt;socket units&lt;/strong&gt; (for socket-activated services), and &lt;strong&gt;mount units&lt;/strong&gt; (for filesystem mounts).&lt;/p&gt;

&lt;p&gt;All service unit files share the same three-section structure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[Unit]&lt;/span&gt;
&lt;span class="c"&gt;# Metadata, description, and dependency declarations
&lt;/span&gt;
&lt;span class="nn"&gt;[Service]&lt;/span&gt;
&lt;span class="c"&gt;# How to start, stop, and manage the actual process
&lt;/span&gt;
&lt;span class="nn"&gt;[Install]&lt;/span&gt;
&lt;span class="c"&gt;# How this service integrates into the system boot targets
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You'll become familiar with each of these sections throughout this tutorial. The important thing to know now is that systemd reads these files from &lt;code&gt;/etc/systemd/system/&lt;/code&gt;. So, this is where you'll put your own service files. Files in this directory take precedence over systemd's built-in defaults. And files stored here are completely immune to system upgrades.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffdfqgqn2v8egu049fn5c.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffdfqgqn2v8egu049fn5c.png" alt="the-three-sections-of-a-service-file" width="800" height="432"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The Two Essential Tools: systemctl and journalctl
&lt;/h2&gt;

&lt;p&gt;You interact with systemd through two command-line tools, and you will end up using them often. So, let's learn them first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;systemctl&lt;/code&gt;&lt;/strong&gt; is the control interface for systemd allowing you to start, stop, enable, disable, and inspect services. The most important thing to understand is the difference between these two pairs of concepts: start vs. enable and stop vs. disable.&lt;/p&gt;

&lt;p&gt;The first pair is &lt;strong&gt;start vs enable&lt;/strong&gt;. &lt;code&gt;systemctl start&lt;/code&gt; launches a service &lt;em&gt;right now&lt;/em&gt;, in the current session. If you reboot, it won't start again unless you've also enabled it. &lt;code&gt;systemctl enable&lt;/code&gt; creates a symbolic link that tells systemd to start the service at boot. However, it doesn't start it right now. In everyday use you almost always want to run both commands together after creating a new service.&lt;/p&gt;

&lt;p&gt;The second pair is &lt;strong&gt;stop vs disable&lt;/strong&gt;. &lt;code&gt;systemctl stop&lt;/code&gt; stops a running service immediately, but it'll start again at the next reboot if it's enabled. &lt;code&gt;systemctl disable&lt;/code&gt; removes the boot-time symlink so it won't start at the next reboot, but it doesn't stop the currently running instance. In practice you often want both when removing a service.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;journalctl&lt;/code&gt;&lt;/strong&gt; is how you can look at the logs that systemd collects from every service it manages. Systemd captures everything your service prints to stdout and stderr and stores it in a structured binary database called the &lt;strong&gt;journal&lt;/strong&gt;. So, it makes filtering, searching, and correlating logs across services much more powerful than traditional log files.&lt;/p&gt;

&lt;p&gt;The command you'll end up using most often is &lt;code&gt;journalctl -u my-app.service&lt;/code&gt;. It shows all historical logs for a specific service. Adding &lt;code&gt;-f&lt;/code&gt; follows the log in real time, like &lt;code&gt;tail -f&lt;/code&gt;. Adding &lt;code&gt;-b&lt;/code&gt; shows only logs from the current boot.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why should you care as an Embedded Systems engineer?
&lt;/h2&gt;

&lt;p&gt;If you're running a Raspberry Pi as a headless embedded device such as a sensor node, a home automation hub or an edge computing device, the consequences of your application crashing and never restarting are dire. Which is even worse if the device is in a remote location or inside a piece of equipment, you might not notice for hours, days, or weeks.&lt;/p&gt;

&lt;p&gt;There are alternatives people often reach for first. Putting commands in &lt;code&gt;/etc/rc.local&lt;/code&gt;, using &lt;code&gt;cron @reboot&lt;/code&gt;, or writing shell scripts in &lt;code&gt;/etc/init.d/&lt;/code&gt; are some of them. They all work after a fashion, but they give you almost none of the reliability features systemd provides out of the box. They don't automatically restart crashed processes. They don't provide ordered startup relative to hardware and network dependencies. They don't capture logs in a queryable, structured way. And they don't give you a clean interface for checking whether your application is actually running and healthy.&lt;/p&gt;

&lt;p&gt;systemd gives you all of that, and all it costs is learning how to write a service file. In the rest of this tutorial I will teach you how to write service files building from the simplest possible example up to a production-grade configuration.&lt;/p&gt;

</description>
      <category>raspberrypi</category>
      <category>linux</category>
      <category>systemd</category>
      <category>devops</category>
    </item>
    <item>
      <title>What ioctl(KVM_RUN) Does: The VM Exit Loop Explained</title>
      <dc:creator>Supun Sriyananda</dc:creator>
      <pubDate>Tue, 08 Sep 2026 09:10:18 +0000</pubDate>
      <link>https://dev.to/ranaweerasupun/what-ioctlkvmrun-does-the-vm-exit-loop-explained-4ok5</link>
      <guid>https://dev.to/ranaweerasupun/what-ioctlkvmrun-does-the-vm-exit-loop-explained-4ok5</guid>
      <description>&lt;p&gt;&lt;em&gt;The &lt;a href="https://dev.to/ranaweerasupun/how-qemu-emulates-hardware-device-models-mmio-and-tcg-2b99"&gt;previous article&lt;/a&gt; established that guest code runs natively until it touches a device, at which point control has to reach QEMU.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ioctl(vcpu_fd, KVM_RUN)&lt;/code&gt; is the single most important line in the entire stack. Libvirt, the XML, the device models and VirtIO are just scaffolding around this one call, looped forever.&lt;/p&gt;

&lt;p&gt;We'll build up to it step by step, because the this call only makes sense once you know what a file descriptor is and how &lt;code&gt;ioctl&lt;/code&gt; works generally.&lt;/p&gt;

&lt;h2&gt;
  
  
  File descriptors, briefly
&lt;/h2&gt;

&lt;p&gt;Before I explained what &lt;code&gt;ioctl&lt;/code&gt; is let me refresh your memory about file descriptors. If you don't need a memory refresh, you can skip this section.&lt;/p&gt;

&lt;p&gt;Simply put, a file descriptor (FD) is a non-negative integer that serves as a unique identifier for any resource a process has open.&lt;/p&gt;

&lt;p&gt;The operating system kernel maintains a private lookup table for every running process. Let's say your program opens a resource. The kernel then creates a tracking entry for the underlying object and hands back the index of that slot. From that moment on, your program refers to that resource exclusively by its number.&lt;/p&gt;

&lt;p&gt;By default, three descriptors are pre-allocated when a process spawns: &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;0 for standard input (stdin), &lt;/li&gt;
&lt;li&gt;1 for standard output (stdout), and &lt;/li&gt;
&lt;li&gt;2 for standard error (stderr). &lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The next resource you open grabs the lowest available integer, typically starting at 3. It doesn't matter whether you are interacting with a plain text file, a network socket, a hardware component, or a KVM virtual CPU, the kernel handles them all through file descriptors using uniform system calls like read(), write(), and close(). And the kernel automatically figures out how to route your data based on what that specific integer points to.&lt;/p&gt;

&lt;p&gt;Let me show you the outputs from my raspberry pi 5:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;home1@home1:~ &lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; /proc/self/fd
total 0
lrwx------ 1 home1 home1 64 Sep  7 07:32 0 -&amp;gt; /dev/pts/4
lrwx------ 1 home1 home1 64 Sep  7 07:32 1 -&amp;gt; /dev/pts/4
lrwx------ 1 home1 home1 64 Sep  7 07:32 2 -&amp;gt; /dev/pts/4
lr-x------ 1 home1 home1 64 Sep  7 07:32 3 -&amp;gt; /proc/3311761/fd

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at the numbers on the far right. FDs 0, 1, and 2 are all pointing to &lt;code&gt;/dev/pts/4&lt;/code&gt;. This is the virtual terminal window you are looking at. It says your input, output, and errors are all routed to your screen! Do you see the the number 3 at the end? That is the &lt;code&gt;ls&lt;/code&gt; command itself reading the directory table, grabbing the lowest available integer.&lt;/p&gt;

&lt;h2&gt;
  
  
  ioctl: the escape hatch for everything read and write can't express
&lt;/h2&gt;

&lt;p&gt;Standard &lt;code&gt;read()&lt;/code&gt; and &lt;code&gt;write()&lt;/code&gt; operations excel at shifting raw bytes back and forth but they fall short when you need to &lt;em&gt;configure&lt;/em&gt; the underlying hardware itself.&lt;/p&gt;

&lt;p&gt;For example, you cannot &lt;code&gt;write()&lt;/code&gt; to an CD drive to force it to eject. Similarly, there's no meaningful way to &lt;code&gt;read()&lt;/code&gt; a serial port's baud rate, or to express "change it to 115200" as a byte stream. This is because these are &lt;em&gt;control&lt;/em&gt; operations on a device, not data transfers.        &lt;/p&gt;

&lt;p&gt;The Unix architecture solves this limitation with &lt;code&gt;ioctl&lt;/code&gt; (Input/Output Control). &lt;code&gt;ioctl&lt;/code&gt; is a highly generic system call designed specifically to send arbitrary commands to whatever resource a file descriptor references.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nf"&gt;ioctl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;fd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;unsigned&lt;/span&gt; &lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;   ioctl(fd, REQUEST, argument)
          │     │        │
          │     │        └─ Optional pointer to custom data structures
          │     └────────── The specific command ID (a unique integer)
          └──────────────── The file descriptor you are talking to

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Under the hood, the driver handling the device interprets what each command integer means. This serves as a universal extension mechanism. And it allows any hardware driver to expose custom routines that the standard filesystem interface never planned for. For example, terminals rely on it to adjust window dimensions, network adapters use it to bind IP configurations, and KVM leverages it to orchestrate an entire virtual machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  KVM's three-level ladder of descriptors
&lt;/h2&gt;

&lt;p&gt;KVM exposes itself as &lt;code&gt;/dev/kvm&lt;/code&gt;, a character device. QEMU opens it and gets a descriptor. That descriptor and a stream of &lt;code&gt;ioctl&lt;/code&gt; calls is the &lt;em&gt;entire&lt;/em&gt; QEMU-to-KVM interface.&lt;/p&gt;

&lt;p&gt;To manage complex hardware setups better, KVM organizes this interface into a strict, three-tiered hierarchy.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvjw759cvx6c6mr0bh43y.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvjw759cvx6c6mr0bh43y.png" alt="kvms-three-descriptors"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;When a VM is created, things descent down this ladder:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;System FD (&lt;code&gt;KVM_CREATE_VM&lt;/code&gt; ): Allocates a blank, unconfigured VM instance and hands back a unique VM file descriptor.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;KVM_SET_USER_MEMORY_REGION&lt;/code&gt; on the VM fd — "here's memory I allocated; treat it as the guest's physical RAM." QEMU allocates guest memory as an ordinary &lt;code&gt;mmap&lt;/code&gt; region in its own address space and registers it with KVM. This is the sandbox boundary: the guest can only address what was registered.&lt;/li&gt;
&lt;li&gt;VM FD (&lt;code&gt;KVM_SET_USER_MEMORY_REGION&lt;/code&gt;): Defines the virtual sandbox. The Virtual Machine Monitor (VMM) reserves a normal block of userspace memory via mmap, then registers it here. The KVM kernel module transforms this space into the guest's physical RAM layout. It also ensures the guest can never read or write outside this boundary.&lt;/li&gt;
&lt;li&gt;VM FD (&lt;code&gt;KVM_CREATE_VCPU&lt;/code&gt;): Spawns an execution core inside the VM structure and maps it to a fresh vCPU file descriptor.&lt;/li&gt;
&lt;li&gt;vCPU FD (&lt;code&gt;KVM_RUN&lt;/code&gt;): Instructs the host processor to immediately start executing guest instructions on this specific core. &lt;strong&gt;"run this CPU now."&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What KVM_RUN actually does
&lt;/h2&gt;

&lt;p&gt;Let's zoom in. The &lt;code&gt;KVM_RUN&lt;/code&gt; system call does not behave like a standard, predictable function call. Say a VMM thread(a QEMU vCPU thread) invokes &lt;code&gt;ioctl(vcpu_fd, KVM_RUN)&lt;/code&gt;. This thread immediately &lt;strong&gt;enters the guest&lt;/strong&gt;. And it does not return to your user-space application until the guest does something KVM cannot handle alone.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fich9zfihhw4h0imwzx7t.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fich9zfihhw4h0imwzx7t.png" alt="the-call-that-doesnt-return"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The call blocks on purpose for extended periods.&lt;/strong&gt; Just think about it. From QEMU's perspective, it called a function and that function didn't return for millions of guest instructions. And the entire time the guest is computing, the QEMU thread is parked inside a single &lt;code&gt;ioctl&lt;/code&gt;. QEMU isn't polling or supervising anything. This is elegant, isn't it? It is asleep in a system call and wakes up only when there's work for it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Context switching across the guest boundary is expensive.&lt;/strong&gt; Entering the guest means loading the registers of the guest into the real core; exiting means saving them back out. This is not free and it's part of why an exit is costly!&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Not every VM exit returns to QEMU.&lt;/strong&gt; If the guest triggers an exit that the Linux kernel can settle internally, KVM resets the guest state and jumps right back into guest mode. For example, when servicing a hardware timer or adjusting a local interrupt controller the user-space program QEMU stays asleep, completely unaware that an exit ever occurred in the first place.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cheap exits and expensive exits
&lt;/h2&gt;

&lt;p&gt;There are two tiers of VM exits.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cheap Exits (Kernel-Handled):&lt;/strong&gt; KVM resolves these entirely within the kernel module and immediately re-enters the guest. The &lt;code&gt;ioctl&lt;/code&gt; call never returns. And your VMM thread stays fast asleep. QEMU never learns it happened. Virtual timer ticks fall into this tier. So do accesses to devices KVM emulates in-kernel on ARM64, most importantly the &lt;strong&gt;GIC&lt;/strong&gt;. GIC is the Generic Interrupt Controller. Interrupt handling is far too frequent to route through userspace, so KVM handles it directly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Expensive Exits (User-Space Handled):&lt;/strong&gt; Other exits KVM cannot resolve, because the guest touched a device that exists only as a QEMU device model. So, now the &lt;code&gt;ioctl&lt;/code&gt; genuinely has to return. QEMU wakes up, runs the relevant C function, and calls &lt;code&gt;KVM_RUN&lt;/code&gt; again. &lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwvwe1y8hquv91de3fosj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwvwe1y8hquv91de3fosj.png" alt="cheap-and-expensive-exits"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;When people say "VM exits are expensive," they mean the second kind. Because as I showed you, first is comparatively cheap.&lt;/p&gt;

&lt;p&gt;This distinction is also why the in-kernel GIC exists at all. In the part 2 of the series the interrupt-related warning on Raspberry Pi from &lt;a href="https://dev.to/ranaweerasupun/why-kvm-needs-the-cpu-arm64-el2-vhe-and-the-virtualization-extension-6hm"&gt;article 2&lt;/a&gt; is worth understanding at this point. It generated more exits than a pristine hardware environment would, but the cheap ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  How QEMU learns what happened: the shared kvm_run struct
&lt;/h2&gt;

&lt;p&gt;When &lt;code&gt;KVM_RUN&lt;/code&gt; does return, QEMU needs to know why. Passing that back through the simple numeric return value of &lt;code&gt;ioctl&lt;/code&gt; would be far too limited, so KVM uses shared memory.&lt;/p&gt;

&lt;p&gt;When a vCPU is created, KVM gives QEMU a small region mapped into both QEMU's address space and the kernel's. KVM writes the exit details into this &lt;code&gt;struct kvm_run&lt;/code&gt; before returning. Then QEMU reads them immediately after. There is no need for additional system calls.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fir8afs9bxjq7c0qty97w.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fir8afs9bxjq7c0qty97w.png" alt="the-shared-kvm-run-struct"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;exit_reason&lt;/code&gt; is the dispatch key. &lt;code&gt;KVM_EXIT_MMIO&lt;/code&gt; means the guest accessed a memory-mapped device region. This is the common case on ARM64, and we discussed the mechanism in &lt;a href="https://dev.to/ranaweerasupun/how-qemu-emulates-hardware-device-models-mmio-and-tcg-2b99"&gt;article 4&lt;/a&gt;. QEMU looks up which device model owns that address, calls it, and loops.&lt;/p&gt;

&lt;p&gt;Follow the example concretely. The guest writes the character &lt;code&gt;H&lt;/code&gt; to the UART at &lt;code&gt;0x09000000&lt;/code&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The guest executes a store instruction. It has no idea anything unusual is happening.&lt;/li&gt;
&lt;li&gt;The address is in a device region, so the CPU traps. VM exit.&lt;/li&gt;
&lt;li&gt;KVM sees it's MMIO to an address it doesn't handle in-kernel. It fills in &lt;code&gt;kvm_run&lt;/code&gt; and returns from &lt;code&gt;ioctl&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;QEMU wakes, reads &lt;code&gt;exit_reason&lt;/code&gt;, dispatches to its PL011 UART model.&lt;/li&gt;
&lt;li&gt;The UART model writes &lt;code&gt;H&lt;/code&gt; to whatever the serial console is connected to — your terminal.&lt;/li&gt;
&lt;li&gt;QEMU calls &lt;code&gt;ioctl(KVM_RUN)&lt;/code&gt; again. The guest resumes at the next instruction.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Every character of output from a guest's serial console runs that loop. When you watch an OS installer scroll past over &lt;code&gt;virsh console&lt;/code&gt;, you are watching this cycle execute thousands of times.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb7ubhv6bx9q38i7zrwaj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb7ubhv6bx9q38i7zrwaj.png" alt="one-character-to-the-uart"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  This is the foundation for everything that we will follow
&lt;/h2&gt;

&lt;p&gt;When you can clear your head around this round trip, several later topics becomes easier to grasp.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;VirtIO&lt;/strong&gt; (&lt;a href="https://bittobyteacademy.com/blog/why-virtio-is-faster-virtqueues-and-vhost-net-explained/" rel="noopener noreferrer"&gt;article 6&lt;/a&gt;) is the architectural response to expensive exits. If each round trip costs, then we can make each one carry more work. VirtIO batches many operations behind a single notification, amortizing one exit across dozens of requests.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;vCPU pinning&lt;/strong&gt; Remember that the host thread driving our &lt;code&gt;KVM_RUN&lt;/code&gt; cycle is treated by Linux as an ordinary, everyday thread. So, if you don't intervene, the Linux kernel scheduler will happily shift that thread from one physical core to another to balance host workloads, like it usually does. But, if you pinning your vCPU thread to a single core, you ensure the guest state stays perfectly warm inside that specific core's local cache lines between transitions. Now imagine you let that thread wander across your topology. Then every VM exit drops you onto a cold core. This forces the hardware to reload state variables from scratch and destroys your execution speeds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;QMP versus KVM_RUN&lt;/strong&gt; Don't confuse QMP(QEMU Machine Protocol) with &lt;code&gt;KVM_RUN&lt;/code&gt;. I know I did at first. These are two entirely separate communication paths. QMP is a standard Unix/TCP socket interface that management utilities like &lt;code&gt;libvirt&lt;/code&gt; use to command QEMU from the outside. &lt;code&gt;KVM_RUN&lt;/code&gt; is the internal system call (&lt;code&gt;ioctl&lt;/code&gt;) that QEMU executes to hand control over to the Linux kernel module and physical hardware.&lt;/p&gt;

&lt;p&gt;Keep this in mind: &lt;strong&gt;&lt;code&gt;ioctl(vcpu_fd, KVM_RUN)&lt;/code&gt; commands execution, the guest computes directly on bare-metal hardware until a trap occurs, and the system call unblocks only when QEMU device emulation is required.&lt;/strong&gt; That call, when looped forever, is what constitutes a running virtual machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;file descriptor&lt;/strong&gt; is an integer indexing a per-process table of open kernel objects.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ioctl&lt;/code&gt;&lt;/strong&gt; is the generic system call for device-specific commands that &lt;code&gt;read&lt;/code&gt; and &lt;code&gt;write&lt;/code&gt; can't express.&lt;/li&gt;
&lt;li&gt;KVM is driven entirely through &lt;code&gt;ioctl&lt;/code&gt; on &lt;code&gt;/dev/kvm&lt;/code&gt;, via a three-level ladder: system fd → VM fd → vCPU fd.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;KVM_RUN&lt;/code&gt; on a vCPU fd enters the guest and blocks for as long as the guest runs natively — potentially millions of instructions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cheap exits&lt;/strong&gt; are handled inside KVM (timers, the in-kernel GIC) and never wake QEMU. &lt;strong&gt;Expensive exits&lt;/strong&gt; return to userspace so a QEMU device model can run.&lt;/li&gt;
&lt;li&gt;KVM reports why it exited through the shared &lt;code&gt;kvm_run&lt;/code&gt; struct; &lt;code&gt;exit_reason&lt;/code&gt; tells QEMU which device model to dispatch to.&lt;/li&gt;
&lt;li&gt;Every character on a guest's serial console is one full round trip through this loop.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>kvm</category>
      <category>linux</category>
      <category>devops</category>
      <category>virtualization</category>
    </item>
    <item>
      <title>How QEMU Emulates Hardware: Device Models, MMIO, and TCG</title>
      <dc:creator>Supun Sriyananda</dc:creator>
      <pubDate>Fri, 04 Sep 2026 06:30:00 +0000</pubDate>
      <link>https://dev.to/ranaweerasupun/how-qemu-emulates-hardware-device-models-mmio-and-tcg-2b99</link>
      <guid>https://dev.to/ranaweerasupun/how-qemu-emulates-hardware-device-models-mmio-and-tcg-2b99</guid>
      <description>&lt;p&gt;&lt;em&gt;The &lt;a href="https://dev.to/blog/kvm-vs-qemu-vs-libvirt-who-does-what/"&gt;previous article&lt;/a&gt; established that QEMU emulates the devices KVM deliberately doesn't.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;We constantly hear the phrase, “QEMU emulates devices.” It is treated like a magic trick. A user-space program somehow conjures a virtual disk or a network card out of thin air, and a guest operating system is perfectly fooled.&lt;/p&gt;

&lt;p&gt;But there is no black box. The entire illusion relies on a single mechanism which is applied consistently across every piece of virtual hardware. Once you see this mechanism, every performance characteristic of virtualization makes sense. Why does VirtIO exist, why certain operations feel catastrophically slower in a VM than on bare metal.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before We Begin: QEMU is Two Different Programs
&lt;/h2&gt;

&lt;p&gt;We need to clear up a common point of confusion before looking at the code or going further. QEMU actually does two entirely different jobs depending on how you launch it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Full-system emulation&lt;/strong&gt; (&lt;code&gt;qemu-system-*&lt;/code&gt;) builds a virtual environment from scratch to boot a complete guest operating system. This is what Libvirt launches for every VM, and it is the sole focus of this series. For example, &lt;code&gt;qemu-system-aarch64&lt;/code&gt; constructs a complete, virtualized motherboard from scratch, including the CPU, RAM, disk controllers, network cards, and firmware—allowing a full guest OS to boot.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;User-mode emulation&lt;/strong&gt; (&lt;code&gt;qemu-*&lt;/code&gt;) executes a single binary compiled for a foreign architecture directly on your host OS by translating its system calls on the fly. This engine doesn't build a machine or boot an OS. It's how you run an x86 binary on an ARM machine without a VM. And it is what Docker's &lt;code&gt;binfmt_misc&lt;/code&gt; cross-architecture support uses under the hood. This is useful, but not virtualization.&lt;/p&gt;

&lt;p&gt;Note that, this series is strictly about full-system emulation. If a command begins with &lt;code&gt;qemu-system-&lt;/code&gt;, it is fabricating an entire computer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trick: devices live at memory addresses
&lt;/h2&gt;

&lt;p&gt;The mechanism that makes emulation possible relies on a fundamental truth about real hardware:&lt;/p&gt;

&lt;p&gt;When an operating system wants to send a byte to a serial port, it doesn't call a function. Instead, it &lt;strong&gt;writes to a specific memory address&lt;/strong&gt;. This memory address is physically wired directly to the serial chip on the motherboard. As you can see no RAM is ever involved here. The motherboard's address decoder routes the signal straight to the hardware registers instead.&lt;/p&gt;

&lt;p&gt;This is called &lt;strong&gt;memory-mapped I/O&lt;/strong&gt;, or MMIO, and it's how the CPU talks to essentially every device on a modern ARM system. So, the devices are not special entities the CPU has a private channel to. The devices are just regions of the address space.&lt;/p&gt;

&lt;p&gt;Which means the CPU's entire interface to a device comes down to this: &lt;em&gt;writing to certain addresses and reading from certain addresses.&lt;/em&gt; That's it. Nothing else. And this is an interface software can easily impersonate!&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyv2tl70j0z18fhhir5b4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyv2tl70j0z18fhhir5b4.png" alt="The same write to the same address. Only the far end differs, and the driver never finds out." width="800" height="408"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;QEMU maps out a guest address space where specific regions are intentionally flagged as "device space" rather than RAM. Now, let's imagine this guest attempts to access one of these coordinates. This operation is blocked from executing as a memory task. Instead, it triggers a trap. Then QEMU runs a dedicated software function.&lt;/p&gt;

&lt;p&gt;That software function &lt;em&gt;is&lt;/em&gt; the device. For example, a virtual UART is just a few hundred lines of C code that prints a character to your terminal when a write occurs. A virtual disk controller is C code that intercepts a guest read request and fetches bytes from a &lt;code&gt;.qcow2&lt;/code&gt; file on your host SSD.&lt;/p&gt;

&lt;p&gt;Let's me show you this in code. The the following is a simplified, structural layout of what that C code of this software function looks like. &lt;strong&gt;Don't overwhelm yourself with all the details in the code. Just take a look at it. I just want show you that there is no magic here.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  1. The Handoff Interface (MemoryRegionOps)
&lt;/h3&gt;

&lt;p&gt;First of all, QEMU needs to map the memory region to specific C functions. It registers a structure that tells the engine exactly what code to execute when a guest touches the device's addresses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="cm"&gt;/* This structure acts as the router for guest hardware accesses */&lt;/span&gt;
&lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;MemoryRegionOps&lt;/span&gt; &lt;span class="n"&gt;uart_mmio_ops&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;read&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;uart_mmio_read&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="cm"&gt;/* Function to run when guest reads from an address */&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;write&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;uart_mmio_write&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="cm"&gt;/* Function to run when guest writes to an address */&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;endianness&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;DEVICE_NATIVE_ENDIAN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;valid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;min_access_size&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;valid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;max_access_size&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. The Software Function (uart_mmio_write)
&lt;/h3&gt;

&lt;p&gt;Let's imagine that the guest writes to 0x09000000. When it does, the hardware trap catches it, and QEMU executes this exact type of software function. You can see that it is just a standard C switch statement mapping address offsets to software behaviors.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="cm"&gt;/* The software function that IS the device hardware channel */&lt;/span&gt;
&lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;uart_mmio_write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;opaque&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hwaddr&lt;/span&gt; &lt;span class="n"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; 
                            &lt;span class="kt"&gt;uint64_t&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;unsigned&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;UARTState&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;UARTState&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;opaque&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="cm"&gt;/* The offset is how far past the base address (0x09000000) the guest wrote */&lt;/span&gt;
    &lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="mh"&gt;0x00&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="c1"&gt;// Data Register (TX Buffer)&lt;/span&gt;
            &lt;span class="cm"&gt;/* The guest wants to transmit a character. 
               Instead of triggering silicon, we print it to your host terminal. */&lt;/span&gt;
            &lt;span class="n"&gt;putchar&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="kt"&gt;char&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; 
            &lt;span class="n"&gt;fflush&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stdout&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

            &lt;span class="cm"&gt;/* Tell our software model that an interrupt is ready (Data sent) */&lt;/span&gt;
            &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;uart_status&lt;/span&gt; &lt;span class="o"&gt;|=&lt;/span&gt; &lt;span class="n"&gt;STATUS_TX_EMPTY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; 
            &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="mh"&gt;0x04&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="c1"&gt;// Interrupt Enable Register&lt;/span&gt;
            &lt;span class="cm"&gt;/* The guest is trying to toggle hardware interrupt lines */&lt;/span&gt;
            &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;interrupt_mask&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="mh"&gt;0x08&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="c1"&gt;// Baud Rate Divisor&lt;/span&gt;
            &lt;span class="cm"&gt;/* A real chip would change electrical frequencies here.
               QEMU just updates an integer variable in RAM. */&lt;/span&gt;
            &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;baud_rate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;115200&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="nl"&gt;default:&lt;/span&gt;
            &lt;span class="cm"&gt;/* Guest tried to write to an unmapped register address */&lt;/span&gt;
            &lt;span class="n"&gt;log_bad_hardware_access&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;There is no magic anywhere in this.&lt;/strong&gt; Every device your guest sees is a software model like this. A chunk of code pretending to be a chip. It does this well by responding to the same address accesses the real chip would respond to. And the guest's driver cannot tell the difference, because from the driver's perspective there &lt;em&gt;is&lt;/em&gt; no difference.THe guest driver writes to an address and something happens.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffqguw3bsxewn63en33kg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffqguw3bsxewn63en33kg.png" alt="Every region here is reached the same way. Only one of them is memory." width="800" height="456"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the guest's firmware finds these devices at all
&lt;/h2&gt;

&lt;p&gt;This architecture raises an obvious question: how does the guest OS know that a serial port is exactly at &lt;code&gt;0x09000000&lt;/code&gt; in the first place?&lt;/p&gt;

&lt;p&gt;On a physical ARM board, this layout is explicitly dictated by something called a Device Tree. A device tree is a structured data file compiled into the firmware that outlines exactly what hardware components exist and what memory addresses they occupy. x86 has legacy hardware discovery conventions, but ARM has no equivalent of this. I won't go into details about x86 architecture because the series is about ARM. Just remember that in ARM the entire motherboard topology must be spelled out line-by-line for the kernel at boot.&lt;/p&gt;

&lt;p&gt;QEMU generates a this device tree describing the machine it's emulating, and hands it to the guest at boot. The guest kernel reads it and sees "there's a PL011 UART at &lt;code&gt;0x09000000&lt;/code&gt;," loads the PL011 driver, and starts writing to that address. The driver is the standard, unmodified Linux driver for real PL011 hardware. It has no idea it's talking to C code.&lt;/p&gt;

&lt;h3&gt;
  
  
  The device tree
&lt;/h3&gt;

&lt;p&gt;Here is a highly simplified, annotated snippet of a real Device Tree Source (.dts). Again, just take a look only to recognize there is no magic.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
dts/dts-v1/;

/ {
    interrupt-parent = &amp;lt;0x8001&amp;gt;;
    #address-cells = &amp;lt;0x02&amp;gt;;
    #size-cells = &amp;lt;0x02&amp;gt;;

    /* Tells the Linux kernel this is QEMU's standardized virtual board */
    model = "linux,dummy-virt";              /* Identifies this as the synthetic 'virt' board */
    compatible = "linux,dummy-virt";

    /* Core CPU Configuration (e.g., 2 vCPUs requested) */
    cpus {
        #address-cells = &amp;lt;0x01&amp;gt;;
        #size-cells = &amp;lt;0x00&amp;gt;;

        cpu@0 {
            device_type = "cpu";
            compatible = "arm,cortex-a57";
            reg = &amp;lt;0x00&amp;gt;;
        };

        cpu@1 {
            device_type = "cpu";
            compatible = "arm,cortex-a57";
            reg = &amp;lt;0x01&amp;gt;;
        };
    };

    /* System Memory Layout (e.g., -m 2048M starts at base address 0x40000000) */
    memory@40000000 {
        device_type = "memory";
        reg = &amp;lt;0x00 0x40000000 0x00 0x80000000&amp;gt;;
    };

    /* The Interrupt Controller (GIC) so the CPU can talk to peripherals */
    intc@8000000 {
        compatible = "arm,gic-v3";
        interrupt-controller;
        #interrupt-cells = &amp;lt;0x03&amp;gt;;
        reg = &amp;lt;0x00 0x08000000 0x00 0x010000&amp;gt;,   /* GIC Distributor physical address */
              &amp;lt;0x00 0x080A0000 0x00 0xF60000&amp;gt;;   /* GIC Redistributors physical address */
        phandle = &amp;lt;0x8001&amp;gt;;
    };

    /* A Virtual UART (Serial Port) for console output */
    pl011@9000000 {
        compatible = "arm,pl011", "arm,primecell"; /* Tells Linux which driver binary to load */
        reg = &amp;lt;0x00 0x09000000 0x00 0x1000&amp;gt;;      /* Base address (0x09000000) and memory size (4KB) */
        interrupts = &amp;lt;0x00 0x01 0x04&amp;gt;;            /* Bound to SPI interrupt 1 */
        clocks = &amp;lt;0x8000&amp;gt;;                         /* Tells the guest which hardware interrupt wire it uses */
        clock-names = "uartclk";
    };

    /* ... PCI host bridge, virtio devices, and flash memory nodes continue below ... */
};

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At the very top, QEMU defines the basic structure of our synthetic motherboard. It explicitly states that this is not a real-world chip from Raspberry Pi or Apple, but QEMU’s custom engine.&lt;br&gt;
Take a look at the pl011@9000000 block of the code at the end. This is a Virtual UART.  &lt;/p&gt;

&lt;p&gt;This is also why the &lt;code&gt;virt&lt;/code&gt; machine type matters on ARM64. Because it is not emulating any real-world board. It is a synthetic machine defined by QEMU. On x86, machine types like &lt;code&gt;pc&lt;/code&gt; and &lt;code&gt;q35&lt;/code&gt; emulate actual historical chipsets, complete with their quirks. ARM64 skipped that inheritance. &lt;/p&gt;

&lt;h2&gt;
  
  
  The Dual-Engine Architecture of QEMU
&lt;/h2&gt;

&lt;p&gt;QEMU manages peripheral hardware. It has a device job. This should be clear to you by now. But, it also faces a separate challenge: how to execute the guest's CPU instructions. This is QEMU's CPU job.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fybd305dh6srjsqew3g3z.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fybd305dh6srjsqew3g3z.png" alt="Half 1 has two implementations. Half 2 has one, and always will." width="800" height="436"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Half 2 never changes.&lt;/strong&gt; We discussed in the previous article, that QEMU even without a KVM still able to virtualize. But, it is just slower. The peripheral devices are emulated by QEMU in userspace even if the KVM is missing or inactive. KVM has no built-in device models and never acquires any &lt;a href="https://dev.to/blog/kvm-vs-qemu-vs-libvirt-who-does-what/"&gt;previous article&lt;/a&gt;. (There is a minor exception: KVM implements a few latency-critical components, like the interrupt controller, inside the host kernel to boost performance—but the general rule stands, and that exception is simply an optimization worth remembering.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Half 1 is the part with two implementations.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TCG — the Tiny Code Generator&lt;/strong&gt; — This is QEMU's software CPU. It reads blocks of guest instructions, compiles them into equivalent host instructions on the fly, caches the resulting binaries, and runs them. It is a Just-In-Time (JIT) compiler operating between two processor architectures. This is &lt;em&gt;true software emulation&lt;/em&gt;. And this is your only option when your guest and host architectures don't match. An x86 guest running on an ARM64 host must use TCG; no amount of hardware assistance can force an ARM core to natively parse x86 machine code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;KVM&lt;/strong&gt; is the hardware path from &lt;a href="https://dev.to/blog/why-kvm-needs-the-cpu-el2-vhe-virtualization-extension/"&gt;article 1.2&lt;/a&gt;. Instead of translating instructions in software, QEMU asks KVM to orchestrate the execution. KVM runs the guest code directly on a real, physical core using hardware virtualization extensions. And after that KVM hands control back up to QEMU only when a device trap occurs.&lt;/p&gt;

&lt;p&gt;The choice between them is what &lt;code&gt;&amp;lt;domain type='kvm'&amp;gt;&lt;/code&gt; versus &lt;code&gt;&amp;lt;domain type='qemu'&amp;gt;&lt;/code&gt; selects in libvirt's XML, and it's the single biggest performance factor in the entire stack. We will learn more about these in future posts.&lt;/p&gt;

&lt;h2&gt;
  
  
  The consequence: what a device access costs
&lt;/h2&gt;

&lt;p&gt;When you combine QEMU's two halves, the central performance reality of all virtualization instantly falls into right place.&lt;/p&gt;

&lt;p&gt;Under KVM, guest code runs natively at full speed. Arithmetic, conditional loops, and memory operations inside actual RAM happen directly on the bare metal without any intervention from KVM or QEMU. Millions of instructions can execute back-to-back without either host component ever waking up.&lt;/p&gt;

&lt;p&gt;However, a device access can't complete natively, because there's no physical device. It has no choice but to trap out to QEMU's C code.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgaosd8g5cas5g93bklhe.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgaosd8g5cas5g93bklhe.png" alt="Computation stays in the band at native speed. Every device access leaves it and comes back." width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;So the cost model of a virtual machine is: &lt;strong&gt;native speed for computation, and a relatively expensive round trip for every device interaction.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This single asymmetry explains an enormous amount of real-world behavior. If you have ever worked with VMs you will be able to relate immediately. It is the exact reason why CPU-bound workloads in a VM perform at near-bare-metal speeds while I/O-heavy workloads lag. It is why a virtual serial console is perfectly fine for login shell. Because a human types slowly. So a trap per character goes unnoticed. However, it would be absolutely catastrophic for a virtual network card processing a hundred thousand packets per second.&lt;/p&gt;

&lt;p&gt;And it's the reason VirtIO exists. If device access is the expensive operation, the winning strategy is to make each one carry more work. In other words: you batch. That's &lt;a href="https://dev.to/blog/why-virtio-is-faster-virtqueues-and-vhost-net-explained/"&gt;article 6&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;But you would understand the batching argument only if you know what the round trip actually involves: what "control leaves the guest" means mechanically, what data crosses the boundary, and why some traps are far cheaper than others. That's the next article.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;qemu-system-*&lt;/code&gt; emulates a whole machine and boots an OS. &lt;code&gt;qemu-&amp;lt;arch&amp;gt;&lt;/code&gt; runs a single foreign binary. Only the first is virtualization.&lt;/li&gt;
&lt;li&gt;Devices on real hardware are reached through &lt;strong&gt;memory-mapped I/O&lt;/strong&gt; — the CPU writes to addresses that are wired to chips rather than RAM.&lt;/li&gt;
&lt;li&gt;QEMU exploits this by marking regions of the guest address space as device regions. An access there traps and runs a C function. That function &lt;em&gt;is&lt;/em&gt; the device.&lt;/li&gt;
&lt;li&gt;On ARM64, the guest discovers what exists and where via a &lt;strong&gt;device tree&lt;/strong&gt; QEMU generates. Guest drivers are the standard, unmodified Linux drivers.&lt;/li&gt;
&lt;li&gt;QEMU has two halves. Devices are always emulated in QEMU userspace. The CPU is either translated in software (&lt;strong&gt;TCG&lt;/strong&gt;) or run natively on real hardware (&lt;strong&gt;KVM&lt;/strong&gt;).&lt;/li&gt;
&lt;li&gt;Under KVM, computation runs at native speed but every device access costs a trap out to QEMU. That asymmetry is the foundation of every performance decision in virtualization.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devops</category>
      <category>kvm</category>
      <category>linux</category>
      <category>virtualization</category>
    </item>
    <item>
      <title>KVM vs QEMU vs libvirt: Who Does What</title>
      <dc:creator>Supun Sriyananda</dc:creator>
      <pubDate>Wed, 02 Sep 2026 06:30:00 +0000</pubDate>
      <link>https://dev.to/ranaweerasupun/kvm-vs-qemu-vs-libvirt-who-does-what-3jl4</link>
      <guid>https://dev.to/ranaweerasupun/kvm-vs-qemu-vs-libvirt-who-does-what-3jl4</guid>
      <description>&lt;p&gt;&lt;em&gt;The &lt;a href="https://dev.to/ranaweerasupun/why-kvm-needs-the-cpu-arm64-el2-vhe-and-the-virtualization-extension-6hm"&gt;previous article&lt;/a&gt; covered the CPU virtualization extension and how KVM drives it.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;"What’s the difference between KVM and QEMU?" is one of the most-asked questions in Linux virtualization. If you look around most answers just stack definitions on top of each other. For me, definitions are easily forgotten. I think the best way is to build the causal chain—understanding that each component exists specifically to solve a limitation left behind by the layer beneath it. So, rather than defining everything upfront and hoping you connect the dots, I will start with KVM’s deliberate boundaries and follows the architectural consequences all the way up.&lt;/p&gt;

&lt;h2&gt;
  
  
  KVM only virtualizes the CPU and Memory. Nothing else.
&lt;/h2&gt;

&lt;p&gt;The previous article established what KVM(Kernel-based Virtual Machine) contributes. KVM drives the CPU's virtualization extension so guest code executes natively on real cores, trapping only on privileged operations. It also manages guest memory by mapping the guest's idea of physical addresses onto host pages, and keeping each guest confined to its own.&lt;/p&gt;

&lt;p&gt;KVM is incredibly efficient at executing CPU instructions and managing guest memory at near-native speed. But it only virtualizes the CPU and Memory. Nothing else.&lt;/p&gt;

&lt;p&gt;A naked CPU and raw RAM do not make a computer. A virtual machine needs a motherboard, a PCI bus, a keyboard, a mouse, a graphics card, serial ports, and disk controllers. And KVM deliberately includes none of these hardware simulations. So, if you only had KVM, your virtual machine couldn't boot because it wouldn't have a virtual hard drive to read an OS from, or a virtual screen to display it.&lt;/p&gt;

&lt;p&gt;You need to keep this in mind. This is NOT an oversight. It is a design decision, and &lt;a href="https://dev.to/ranaweerasupun/what-a-hypervisor-is-and-where-kvm-fits-type-1-vs-type-2-53j0"&gt;article 1.1&lt;/a&gt; explained why: because the hypervisor lives inside a full Linux kernel, it inherits everything Linux already does well. Building device emulation into KVM would mean duplicating work the kernel and userspace already handle.&lt;/p&gt;

&lt;p&gt;So KVM leaves an obvious gap. A guest kernel boots, probes for a disk, and finds nothing. We need something that can answer.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fltyio0lepggmnscfza9g.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fltyio0lepggmnscfza9g.png" alt="1–3–1-what-kvm-leaves-out" width="800" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  QEMU Fills the Hardware Gap: It pretends to be all the devices
&lt;/h2&gt;

&lt;p&gt;Because KVM leaves the virtual machine without a "body," it needs a partner. QEMU(Quick Emulator) is a userspace program that emulates an entire machine's worth of devices. When the guest probes for a disk controller, QEMU responds. When the guest writes a byte to a serial port, QEMU receives it. When the guest sends a network packet, QEMU hands it to the host's network stack.&lt;/p&gt;

&lt;p&gt;This reveals the exact handoff between KVM and QEMU. When the guest tries to talk to a hardware device, KVM intercepts the request because KVM doesn't know how to handle hardware. KVM pauses the VM and hands the request up to QEMU. Inside QEMU, a piece of C code pretends to be the chip the guest is looking for, handles the data, and tells KVM to let the guest resume. Every piece of "hardware" the guest sees is just an illusion written in software. &lt;a href="https://bittobyteacademy.com/blog/how-qemu-emulates-hardware-device-models-mmio-tcg/" rel="noopener noreferrer"&gt;Article 1.4&lt;/a&gt; covers how that actually works, since "emulates devices" is doing a lot of quiet work in that sentence.&lt;/p&gt;

&lt;p&gt;QEMU can also emulate a CPU. On its own it's a complete machine emulator. So, even if you don't give it a KVM, it will translate guest instructions in software and boot an OS anyway, slowly. But if you pair it with a KVM, it hands the CPU and memory job to KVM and keeps only the devices.&lt;/p&gt;

&lt;p&gt;That pairing is what a running VM is:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx4ndg7rbflaydik8axki.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx4ndg7rbflaydik8axki.png" alt="1–3–2-how-qemu-and-kvm-split-a-vm" width="800" height="496"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This architecture leads to two structural realities that govern how virtual machines behave on a live Linux system:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One VM is one QEMU process.&lt;/strong&gt; A virtual machine is not a mysterious container or a cluster of workers. It is a single, standard Linux process (like qemu-system-x86_64 or qemu-system-aarch64) running in user space. &lt;code&gt;qemu-system-x86_64&lt;/code&gt; and &lt;code&gt;qemu-system-aarch64&lt;/code&gt; are examples. &lt;code&gt;qemu-system-x86_64&lt;/code&gt; process emulates a 64-bit Intel / AMD (x86_64) architecture and &lt;code&gt;qemu-system-aarch64&lt;/code&gt; emulates 64-bit ARM (ARM64 / AArch64) architecture. They are executable programs that run as processes on your operating system. So, in your task manager (like top or htop on Linux/macOS, or Task Manager on Windows), you will see qemu-system-x86_64 or qemu-system-aarch64 listed as an active process. If you kill that process, the VM instantly dies. If a guest has four vCPUs, that single process simply spins up four ordinary Linux threads. Each thread runs a continuous loop: execute guest code, halt when something traps, handle the intercept, and resume. Because these are standard host threads, the Linux scheduler manages them normally, which is exactly why you can pin specific vCPUs to physical CPU cores using standard Linux tools.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The two halves exist in a constant state of handoff&lt;/strong&gt; Because KVM only handles CPU and memory, it runs guest code at native hardware speed right up until the guest tries to interact with a device (like writing to a disk or sending a network packet). KVM cannot handle this, so it pauses the vCPU thread and yields control back up to QEMU. QEMU’s C-coded device model &lt;em&gt;simulates&lt;/em&gt; the hardware response, updates the virtual state, and tells KVM to resume execution. That handoff is &lt;a href="https://bittobyteacademy.com/blog/what-ioctl-kvm-run-does-the-vm-exit-loop-explained/" rel="noopener noreferrer"&gt;article 1.5&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;So KVM and QEMU together are a complete machine. Which raises the next question: how do you actually start one?&lt;/p&gt;

&lt;h2&gt;
  
  
  libvirt: Turning a Messy Command Into a Permanent Document
&lt;/h2&gt;

&lt;p&gt;There is the problem with QEMU as a user interface. Describing a virtual machine to it means you have to to pass every single hardware detail as a raw command-line flag. &lt;/p&gt;

&lt;p&gt;To launch a standard VM, you have to manually specify each disk's format and bus type, every network interface's model and host backend, the exact firmware paths, the CPU microarchitecture, the serial console configurations, and the explicit memory layout. A realistic, production-ready QEMU invocation easily runs to dozens of flags across multiple lines of unreadable text.&lt;/p&gt;

&lt;p&gt;Nobody wants to type that. More importantly, nobody wants to remember it.&lt;/p&gt;

&lt;p&gt;Libvirt solves this by making the VM a document rather than a command.&lt;/p&gt;

&lt;p&gt;Instead of writing a transient shell script, you describe the machine’s desired state once in a structured XML file. The Libvirt daemon (libvirtd) reads that XML, &lt;em&gt;dynamically&lt;/em&gt; constructs the corresponding, massive QEMU command line behind the scenes, launches the process, and tracks its lifecycle from that moment forward.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsb7tqavgtzxqzmrjdq37.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsb7tqavgtzxqzmrjdq37.png" alt="1–3–3-how-libvirt-starts-a-vm" width="800" height="512"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Broader Scope: Managing the Ecosystem
&lt;/h2&gt;

&lt;p&gt;libvirt's scope is broader than launching processes. It defines and manages virtual networks, storage pools, and snapshots. It exposes a stable API, so tooling written against libvirt keeps working across QEMU versions. And it maintains a control channel to each running QEMU — the QMP socket — which is how a running VM can have a disk hot-plugged or a snapshot taken without restarting.&lt;/p&gt;

&lt;p&gt;The tools you type are all front-ends to that daemon. &lt;code&gt;virsh&lt;/code&gt; is the command-line client and the one this series lives in. &lt;code&gt;virt-manager&lt;/code&gt; is a desktop GUI. &lt;code&gt;virt-install&lt;/code&gt; creates new VMs. Cockpit provides a web interface. None of them talk to QEMU directly — they all send API calls to &lt;code&gt;libvirtd&lt;/code&gt;, which does the work.&lt;/p&gt;

&lt;p&gt;The payoff is that &lt;strong&gt;the XML is the VM.&lt;/strong&gt; There's no hidden state elsewhere. Change a line in the document and you've changed the machine. That's why the domain XML is the central artifact of libvirt work, and it's the subject that opens the next phase of this series.&lt;/p&gt;

&lt;p&gt;Libvirt’s responsibility doesn't end once the QEMU process is running. Its scope is much broader than launching processes. Libvirt defines and manages the entire virtualization ecosystem, including virtual networks, storage pools, and snapshots.&lt;/p&gt;

&lt;p&gt;By exposing a stable, unified API, Libvirt ensures that any tooling written against it keeps working seamlessly across different QEMU versions. This shields you from breaking changes in upstream software.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Frontends: Just Messengers for the Daemon
&lt;/h2&gt;

&lt;p&gt;Because Libvirt acts as the central brain, the tools you actually type or click are just thin frontends to the daemon:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;virsh&lt;/code&gt; – The powerful command-line client (and the core focus of this series).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;virt-manager&lt;/code&gt; – A traditional desktop GUI for visual management.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;virt-install&lt;/code&gt; – A specialized CLI tool dedicated to provisioning new VMs.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Cockpit&lt;/code&gt; – A modern, browser-based web interface for server management.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these tools talk to QEMU directly. They all send standard API calls to libvirtd, the Libvirt daemon. It executes the heavy lifting behind the scenes. &lt;/p&gt;

&lt;p&gt;The ultimate payoff of this architecture is simple: &lt;strong&gt;the XML is the VM&lt;/strong&gt;. There's no hidden state elsewhere. If you change a line in that document, you change the machine. Because the domain XML is the absolute central artifact of Libvirt, it is the exact subject that will open the next phase of this series.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three sentences
&lt;/h2&gt;

&lt;p&gt;Compressed to their essentials:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;KVM&lt;/strong&gt; runs the guest's CPU and memory on real hardware at native speed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;QEMU&lt;/strong&gt; pretends to be all the devices the guest thinks it has.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;libvirt&lt;/strong&gt; stores your VM as XML and drives QEMU so you don't have to.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Definitions are handy, but the causal version is far more useful because it explains exactly why the Linux virtualization stack has this specific shape:&lt;/p&gt;

&lt;p&gt;KVM is lightweight because Linux already provides everything except raw CPU virtualization. That leaves peripheral hardware unhandled. So, QEMU steps in to simulate devices in userspace. However, QEMU’s resulting command-line interface is completely unusable by hand, so Libvirt abstracts it into a clean, permanent document and manages it for you.&lt;/p&gt;

&lt;p&gt;If you want to test your understanding of this stack, imagine pulling a single brick out of the wall. And see what happens:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Without Libvirt: Everything still runs fast and has hardware, but you are stuck hand-writing, troubleshooting, and maintaining enormous, brittle command lines.&lt;/li&gt;
&lt;li&gt;Without QEMU: Your virtual machine has a hyper-fast brain but no body. The guest has no virtual motherboard, network card, or disk to boot from.&lt;/li&gt;
&lt;li&gt;Without KVM: Everything still works perfectly, and your guest has all its devices. But, it just runs painfully slow completely in software emulation.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What this means for the rest of the series
&lt;/h2&gt;

&lt;p&gt;The division of labour is a map for everything that follows.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Storage&lt;/strong&gt; work — disk formats, snapshots, thin provisioning — is about what QEMU's virtual disk points at on the host.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Networking&lt;/strong&gt; work is about what QEMU's virtual NIC plugs into: a NAT bridge, a LAN bridge, a TAP device.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Performance&lt;/strong&gt; work splits across the boundary. Replacing emulated devices with VirtIO ones is QEMU-side. Pinning vCPU threads to physical cores is host-scheduler-side, and possible only because those threads are ordinary Linux threads.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Passthrough&lt;/strong&gt; is the exception that proves the rule: it bypasses QEMU's emulation entirely and hands a guest real hardware.&lt;/p&gt;

&lt;p&gt;Every one of those is a modification to one part of a machine whose shape you now know.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;KVM handles guest CPU and memory only. It has no device emulation at all, by design, because Linux already provides everything else.&lt;/li&gt;
&lt;li&gt;QEMU fills that gap: a userspace program that emulates disks, NICs, consoles, and firmware. It can emulate a CPU too, but hands that job to KVM when available.&lt;/li&gt;
&lt;li&gt;One running VM is one QEMU process, with one host thread per vCPU.&lt;/li&gt;
&lt;li&gt;libvirt exists because QEMU's command line is impractical to write by hand. It stores each VM as XML, builds the command line, launches QEMU, and manages it via a control socket.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;virsh&lt;/code&gt;, &lt;code&gt;virt-manager&lt;/code&gt;, &lt;code&gt;virt-install&lt;/code&gt;, and Cockpit are all front-ends to the &lt;code&gt;libvirtd&lt;/code&gt; daemon.&lt;/li&gt;
&lt;li&gt;The domain XML &lt;em&gt;is&lt;/em&gt; the VM — there's no hidden state elsewhere.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>kvm</category>
      <category>devops</category>
      <category>linux</category>
      <category>virtualization</category>
    </item>
    <item>
      <title>Why KVM Needs the CPU: ARM64 EL2, VHE, and the Virtualization Extension</title>
      <dc:creator>Supun Sriyananda</dc:creator>
      <pubDate>Tue, 01 Sep 2026 09:06:04 +0000</pubDate>
      <link>https://dev.to/ranaweerasupun/why-kvm-needs-the-cpu-arm64-el2-vhe-and-the-virtualization-extension-6hm</link>
      <guid>https://dev.to/ranaweerasupun/why-kvm-needs-the-cpu-arm64-el2-vhe-and-the-virtualization-extension-6hm</guid>
      <description>&lt;p&gt;&lt;em&gt;The &lt;a href="https://dev.to/ranaweerasupun/what-a-hypervisor-is-and-where-kvm-fits-type-1-vs-type-2-53j0"&gt;previous article&lt;/a&gt; established that KVM is a kernel module that turns the running Linux kernel into a hypervisor.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That description is accurate but incomplete, and the gap is where a lot of otherwise-solid mental models come apart. A kernel can't simply decide to run guest operating systems at native speed. It needs the CPU's cooperation — a hardware feature that sits dormant in the silicon until software switches it on.&lt;/p&gt;

&lt;p&gt;This article is about that feature: what problem it solves, what it looks like on ARM64, and how it relates to the kernel module. The relationship between those last two is the part worth getting right, because "is virtualization hardware or software?" has an answer that isn't either.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem: a guest kernel wants to run privileged instructions
&lt;/h2&gt;

&lt;p&gt;Recall the lie from the previous article; "every guest OS believes it owns the hardware". That belief has teeth. A kernel doesn't just &lt;em&gt;think&lt;/em&gt; it's in charge; it executes instructions that only something in charge is allowed to execute. It reconfigures page tables. It writes to device registers. It masks and unmasks interrupts. It sets up the memory management unit.&lt;/p&gt;

&lt;p&gt;Now put that kernel in a VM. It's going to attempt all of those things, because that's what kernels do at boot. Two options present themselves, and both are bad.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option one: let it run them on the real CPU.&lt;/strong&gt; The guest reconfigures the actual MMU, writes to actual device registers, and takes down the host and every other guest with it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option two: catch every instruction in software.&lt;/strong&gt; Inspect each one before it executes, and when it's privileged, emulate its effect against fake state instead of letting it touch hardware. This works. And it is how software-only virtualization operates. However, the cost is enormous. Software is now involved in &lt;em&gt;every single instruction the guest runs&lt;/em&gt;, including the overwhelming majority that are perfectly harmless arithmetic and branches.&lt;/p&gt;

&lt;p&gt;The insight that makes modern virtualization viable is that neither option is necessary if the CPU itself can tell the difference.&lt;/p&gt;

&lt;p&gt;But, think about it for a second. What if the CPU can tell the difference? &lt;/p&gt;

&lt;h2&gt;
  
  
  The fix is in the silicon
&lt;/h2&gt;

&lt;p&gt;Modern CPUs ship with a &lt;strong&gt;virtualization extension&lt;/strong&gt;: a hardware feature that adds a privilege level above the normal kernel level, designed specifically for a hypervisor to occupy.&lt;/p&gt;

&lt;p&gt;With it, the guest kernel runs at its usual privilege level, fully believing it's in charge. Its ordinary instructions — the arithmetic, the branches, the memory accesses — execute directly on a real core at full speed, with no software involvement whatsoever. But the moment it attempts something that would affect real hardware, the CPU &lt;strong&gt;traps&lt;/strong&gt;. It freezes the guest and transfers control up to the hypervisor.&lt;/p&gt;

&lt;p&gt;This is the crucial economic property. Now the expensive interventions happen only on privileged operations, which are rare. Everything else runs native. The software now can stop babysitting and start handling exceptions.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa7e0qa5qfz1jfy8kq4a0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa7e0qa5qfz1jfy8kq4a0.png" alt="kvm-virtualization-on-arm64-software-emulation-vs-hardware-trap" width="799" height="366"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That trap is the &lt;strong&gt;VM exit&lt;/strong&gt;. A VM Exit is a hardware-driven transition where control shifts from the guest virtual machine back to the host operating system hypervisor. This article establishes what causes a VM exit and why it exists; the mechanics of what happens during one — how control actually returns to userspace, what data crosses the boundary, and why some exits are far cheaper than others — are the subject of &lt;a href="https://dev.to/blog/what-ioctl-kvm-run-does-the-vm-exit-loop-explained/"&gt;article 5&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  ARM64's privilege levels, and the x86 equivalents
&lt;/h2&gt;

&lt;p&gt;ARM calls its privilege levels &lt;strong&gt;Exception Levels&lt;/strong&gt;, abbreviated EL. Higher numbers mean more privilege, which is the opposite of x86's ring numbering.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuxe1sn7ydqfc5v5gh0yo.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuxe1sn7ydqfc5v5gh0yo.png" alt="kvm-virtualization-on-arm64-arm64-exception-levels-vs-x86-rings" width="800" height="369"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A guest's applications run at EL0. The guest kernel runs at EL1, where it has always expected to run, and where it can do everything a kernel normally does. Above it sits &lt;strong&gt;EL2&lt;/strong&gt;, the hypervisor level, which the guest cannot see or reach.&lt;/p&gt;

&lt;p&gt;On x86 the same concept is called VT-x on Intel and AMD-V on AMD, and the additional level is described as "root mode" rather than given a number. The naming differs but the structure is the same. If you know x86 virtualization, EL2 is your root mode.&lt;/p&gt;

&lt;p&gt;There's a fourth level on ARM — EL3, for secure-world firmware. This is outside the scope of virtualization and not something KVM touches.&lt;/p&gt;

&lt;h2&gt;
  
  
  VHE: why EL2 alone wasn't enough
&lt;/h2&gt;

&lt;p&gt;The original ARM virtualization design assumed the hypervisor would be a small, purpose-built piece of software living at EL2. EL2 was designed for that: a lean environment with its own register set, deliberately unlike EL1.&lt;/p&gt;

&lt;p&gt;Linux is not that. Linux is a large general-purpose kernel written to run at EL1, and KVM is a module inside it. Under the original design, running KVM meant splitting the kernel. So most of Linux will be at EL1, with a small stub at EL2 handling world switches. It worked, but every transition between the two carried overhead. And this split was structurally unpleasant.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;VHE — Virtualization Host Extensions&lt;/strong&gt; — fixed this. Introduced in ARMv8.1, VHE makes EL2 capable of running an ordinary EL1-style kernel directly, by remapping registers so that a kernel written for EL1 can execute at EL2 without modification. The host kernel, KVM included can run &lt;em&gt;entirely&lt;/em&gt; at EL2. Guests run at EL1 below it. Neither split nor stub is needed now.&lt;/p&gt;

&lt;p&gt;The practical upshot: on VHE-capable hardware, KVM runs in its efficient configuration. On older ARM cores without it, KVM still works, using the split approach. The Cortex-A76 in the Raspberry Pi 5 supports VHE. If we check the kernel boot log we can confirm this.&lt;/p&gt;

&lt;h2&gt;
  
  
  Seeing it on real hardware
&lt;/h2&gt;

&lt;p&gt;Here's the boot log from a Raspberry Pi 5 running Raspberry Pi OS Bookworm, kernel 6.12 aarch64:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;dmesg | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; kvm
&lt;span class="go"&gt;[    0.046684] kvm [1]: nv: 554 coarse grained trap handlers
[    0.046799] kvm [1]: IPA Size Limit: 40 bits
[    0.046811] kvm [1]: GICV region size/alignment is unsafe, using trapping (reduced performance)
[    0.046835] kvm [1]: vgic interrupt IRQ9
[    0.046846] kvm [1]: VHE mode initialized successfully
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every concept in this article is visible in those five lines.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;VHE mode initialized successfully&lt;/code&gt;&lt;/strong&gt; It confirms not just that KVM found a usable virtualization extension, but that it's running in the modern configuration described above. The host kernel is at EL2 without a split. This is the exact moment the dormant silicon feature was switched on, recorded in the boot log.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;IPA Size Limit: 40 bits&lt;/code&gt;&lt;/strong&gt; refers to the Intermediate Physical Address space. This is the guest's view of physical memory. When a guest accesses what it believes is a physical address, that's an IPA(Intermediate Physical Address), which hardware then translates to a real host physical address. A 40-bit tracking system can create (2^{40}) unique address combinations(which is 1 Terabyte (TB) of RAM). The hardware allows the virtual machine to hold drastically more memory than your physical computer actually possesses.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;vgic interrupt IRQ9&lt;/code&gt;&lt;/strong&gt; GIC (Generic Interrupt Controller): This is the physical ARM hardware chip responsible for delivering interrupts from hardware devices directly to the CPU. Guests need interrupts too. Because multiple virtual machines cannot safely share the same physical interrupt chip, KVM creates a simulated, virtual version (Virtual GIC) for each VM.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;GICV region size/alignment is unsafe, using trapping (reduced performance)&lt;/code&gt; line is a Raspberry Pi–specific quirk. One optimization for injecting interrupts directly into guests isn't safely available on this hardware, so KVM falls back to trapping them. It does not affect whether acceleration works. it makes one specific interrupt path slightly slower, that is all. This is a physical trait of the Raspberry Pi chip that cannot be changed. So we ignore this warning and move on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;nv: 554 coarse grained trap handlers&lt;/code&gt;&lt;/strong&gt; refers to nested virtualization support — running a hypervisor inside a guest. Not relevant to in this series.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is virtualization silicon or software?
&lt;/h2&gt;

&lt;p&gt;Now we can take on the question this article exists to answer. If virtualization is a CPU feature, why is KVM a kernel module? And if KVM is a kernel module, what is the CPU actually contributing?&lt;/p&gt;

&lt;p&gt;They are two halves of one mechanism, and neither accomplishes anything alone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The CPU extension is a dormant hardware capability.&lt;/strong&gt; EL2 exists in the Cortex-A76 the moment it leaves the factory. It is baked into the silicon and does nothing on its own. It's a feature waiting for software to claim it. Is is basically an engine with no driver.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The KVM module is the software that claims and drives it.&lt;/strong&gt; When KVM initializes, this is the sequence:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzjtnci0z3q3bpowxf7v7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzjtnci0z3q3bpowxf7v7.png" alt="kvm-virtualization-on-arm64-kvm-init-extension-check" width="799" height="382"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;So the answer to "is virtualization hardware or software" is that it's a partnership with a strict dependency. The extension without KVM is an unused silicon feature. KVM without the extension has no job. It exists solely to drive that hardware, and it will refuse to initialize on a CPU that lacks it.&lt;/p&gt;

&lt;p&gt;This dependency has a useful consequence: &lt;strong&gt;the existence of &lt;code&gt;/dev/kvm&lt;/code&gt; is proof the whole chain worked.&lt;/strong&gt; Beacause, that file is created only when KVM has successfully initialized against a real virtualization extension. If it is there, the silicon feature is present, the software found it, and the door is open. That's why every KVM troubleshooting guide starts by checking for it, and it's the first command in &lt;a href="https://dev.to/blog/how-to-check-kvm-is-enabled-on-raspberry-pi-5/"&gt;article 7&lt;/a&gt;. &lt;/p&gt;

&lt;h2&gt;
  
  
  What happens when the extension is absent
&lt;/h2&gt;

&lt;p&gt;This is worth stating explicitly, because it clarifies the boundary. If a CPU has no virtualization extension, you are not stuck. You can virtualize but you're just slow.&lt;/p&gt;

&lt;p&gt;QEMU(Quick Emulator) is a complete machine emulator independent of KVM. It is a software program that emulates and virtualizes computers.&lt;/p&gt;

&lt;p&gt;QEMU can mimic an entire computer hardware system in software. It lets you run an operating system made for one processor (like an ARM chip) on a completely different processor (like an Intel laptop chip) by translating the code step-by-step instruction by instruction. And QEMU can do this without any hardware support. It works. It's simply far slower and you can guess why. Because software is translating guest instructions rather than letting them run on a real core. &lt;/p&gt;

&lt;p&gt;But, if you pair QEMU with a kernel module like KVM, it drops the slow software translation. Instead, it passes instructions directly to your real CPU cores, letting the virtual machine run at near-native hardware speed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;   QEMU alone   = full machine in software    → works, slow
   QEMU + KVM   = QEMU does devices,          → works, fast
                  KVM rides the CPU extension
                  for CPU and memory
   KVM alone    = impossible — KVM only does CPU and memory.
                  It can't be a disk or a NIC. It always needs QEMU.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That third line is the setup for the next article, and there's a consequence of the second because it's specific to ARM64 hosts.&lt;/p&gt;

&lt;p&gt;KVM can only accelerate guests whose instruction set matches the host's. An &lt;strong&gt;aarch64 guest on an ARM64 host&lt;/strong&gt; runs natively. The Cortex-A76(like in a Raspberry pi 5) executes the guest's instructions directly, so KVM applies and it's fast. An &lt;strong&gt;x86 guest on the same host&lt;/strong&gt; cannot. Because, those aren't ARM instructions, and no extension makes a real ARM core execute them. QEMU falls back to full software translation.&lt;/p&gt;

&lt;p&gt;This isn't a limitation of KVM or of the Raspberry Pi. It's the boundary of what hardware acceleration means. Acceleration requires the guest and host architectures to match; when they don't, you're emulating, and emulation is slow. Anything in this series involving KVM assumes aarch64 guests.&lt;/p&gt;

&lt;p&gt;Remember: Hardware acceleration always requires the guest and host architectures to match, regardless of whether you are using ARM or x86.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A guest kernel executes privileged instructions. Letting them touch real hardware is unsafe; catching every one in software is slow.&lt;/li&gt;
&lt;li&gt;CPU virtualization extensions solve this by adding a privilege level above the kernel. Guest code runs natively until it does something privileged, which traps to the hypervisor. That trap is a VM exit.&lt;/li&gt;
&lt;li&gt;On ARM64 the hypervisor level is &lt;strong&gt;EL2&lt;/strong&gt;, above EL1 (kernel) and EL0 (user). The x86 equivalent is VT-x/AMD-V root mode.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;VHE&lt;/strong&gt; lets a full Linux kernel run at EL2 unmodified, which is the efficient configuration KVM uses on modern ARM cores. &lt;code&gt;dmesg&lt;/code&gt; reports it at boot.&lt;/li&gt;
&lt;li&gt;The silicon extension and the KVM module are partners: the extension is dormant hardware, KVM is the software that switches it on. Neither works alone, and KVM refuses to initialize without it.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/dev/kvm&lt;/code&gt; existing is proof the partnership succeeded.&lt;/li&gt;
&lt;li&gt;KVM only accelerates guests matching the host architecture. x86 guests on ARM64 fall back to slow software emulation.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>kvm</category>
      <category>linux</category>
      <category>virtualization</category>
      <category>arm64</category>
    </item>
    <item>
      <title>I smashed a bug so hard and came out with a Python library</title>
      <dc:creator>Supun Sriyananda</dc:creator>
      <pubDate>Fri, 21 Aug 2026 15:01:47 +0000</pubDate>
      <link>https://dev.to/ranaweerasupun/i-smashed-a-bug-so-hard-and-came-out-with-a-python-library-1fpc</link>
      <guid>https://dev.to/ranaweerasupun/i-smashed-a-bug-so-hard-and-came-out-with-a-python-library-1fpc</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/bugsmash"&gt;DEV's Summer Bug Smash: Smash Stories&lt;/a&gt; powered by &lt;a href="https://sentry.io/" rel="noopener noreferrer"&gt;Sentry&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This is the story of my bug spray.&lt;/p&gt;

&lt;p&gt;I build sensor systems that live outdoors. Things like Raspberry Pi units on 4G cellular, embedded gateways, battery monitoring hardware in places nobody visits twice a year. On those networks, connection reliability isn't a simple nice-to-have.&lt;/p&gt;

&lt;p&gt;I was a junior back then, and I lost data for weeks before I understood why. When I finally went looking for answers, all I found were scattered fixes. Someone had commented on a years-old thread, "this worked for me". Without any consistency anywhere.&lt;/p&gt;

&lt;p&gt;So I patched. Patch after patch, bug after bug, year after year. At some point I thought: why not smash this thing once and for all, for everyone?&lt;/p&gt;

&lt;p&gt;And so I did. Smashed, and smashed, and smashed. The final result? An open source python library.&lt;/p&gt;

&lt;h2&gt;
  
  
  The device that went deaf
&lt;/h2&gt;

&lt;p&gt;Let me tell you about the bug first. So that you know what I smashed and you don't feel pity for it.&lt;/p&gt;

&lt;p&gt;Picture a busy restaurant. You're working the pass out front; the kitchen is through the back, and the two of you talk over a radio link. Every thirty seconds the kitchen shouts, "All dishes on time!" Then the food stops coming out. You check the channel, the power light and the microphone. And Everything is perfect. The kitchen keeps cheerfully reporting they're doing great, and ignores every order you scream back.(Sounds annoying, right?)&lt;/p&gt;

&lt;p&gt;What happened is that the signal hissed for half a second earlier that night. The link dropped and came back instantly. But the radio system had been set up with a rule: when a link drops, wipe everything about it. The kitchen's standing request to be told about incoming orders was part of that everything. So it went ahead and wiped everything!. The kitchen isn't muted and isn't broken. Its request simply isn't on the list anymore, and it has no way of knowing that. So there are no warining lights or warning beeps beacuse there is no failure to report, since nothing failed.&lt;/p&gt;

&lt;p&gt;The system expects the kitchen to re-announce "we're listening on channel four" itself, every single reconnect, forever, unprompted. If it doesn't, it wakes up half alive: still broadcasting to the dining room, deaf to every order coming in. And the only channel it has for telling you something is wrong is the half that still works.&lt;/p&gt;

&lt;p&gt;The radio wasn't defective. It did exactly what it had been told to do. And who was the person told it? Me!. I told it months earlier, when I set it up. It was just never designed for a kitchen four hours away with nobody standing next to it.&lt;/p&gt;

&lt;p&gt;MQTT is a simple messaging system used by smart gadgets to talk to servers, acting like a two-way radio channel where devices must specifically "subscribe" to a frequency to hear messages. By default, if a device loses its connection even for a second, the system resets and forgets what channel it was listening to, requiring the device to manually tune back in. Paho MQTT is an open-source library that gives you this MQTT messaging protocol.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7jpze9fim6n1ybktdncp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7jpze9fim6n1ybktdncp.png" alt="the-bug-from-mqtt" width="800" height="520"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Once I started looking, it wasn't the only bug. A bug spray is required!
&lt;/h2&gt;

&lt;p&gt;The deaf device showed me a buch of other bugs and issues with this whole thing. &lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Reconnection is your problem, not the library's.&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;paho&lt;/code&gt; has &lt;code&gt;reconnect_delay_set&lt;/code&gt;, but only &lt;code&gt;loop_forever()&lt;/code&gt; honours it. With &lt;code&gt;loop_start()&lt;/code&gt; — which is what you want the moment the client isn't the main thread — you're writing the retry loop yourself. I wrote the naive one. Fixed a five-second retry, like everybody's first attempt.&lt;/p&gt;

&lt;p&gt;Now, having fixed the reconnection bug, I wanted to try it on a device. It worked. I was happy. Wasn't so hard, I thought. So a while later I proudly rolled it out to ten devices.&lt;/p&gt;

&lt;p&gt;It died right away. Ten devices, one outage, and every one of them waiting exactly five seconds before knocking again — in perfect unison, forever. The broker came up, took ten simultaneous connections in the same instant, and went straight back down. I waited. It never resurrected itself. It couldn't: each restart just called the stampede back.&lt;/p&gt;

&lt;p&gt;Ten devices were enough to demostrate this bug. So, yes. I fell to a bug from a bug. The fix was exponential backoff with a jitter, so they don't re-synchronise. This is the difference between recovery and a self-inflicted DoS.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;#Backoff needs a cap and jitter.
# And without the jitter, ten devices that dropped together retry together. That's what killed my broker.
&lt;/span&gt;&lt;span class="n"&gt;delay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# cap: never wait over 5 min
&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uniform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;   &lt;span class="c1"&gt;# jitter: don't come back in unison
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  &lt;strong&gt;The bug came back!&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Smash a bug hard enough and it comes back as a phoenix. Karma, probably. My new backoff loop retried forever, which is what I wanted, because a device four hours away has to keep trying. But it had no idea why a connection ended. So when I hit Ctrl-C, my shutdown handler closed the connection, my own retry loop saw a dead connection, and it immediately opened a new one. The process wouldn't exit. I had made it unkillable by making it reliable.&lt;/p&gt;

&lt;p&gt;There is a simple fix, though. When paho tells you the connection dropped, it hands you a number. Zero means you closed it yourself. Anything else means it broke. Only reconnect on "anything else". Nothing does this for you.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;#The bug: any disconnect triggers a reconnect.
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;on_disconnect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;userdata&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rc&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nf"&gt;reconnect&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;   &lt;span class="c1"&gt;# including the disconnect you asked for
&lt;/span&gt;
&lt;span class="c1"&gt;#The fix is one number.
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;on_disconnect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;userdata&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rc&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;rc&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;        &lt;span class="c1"&gt;# you closed it. leave it closed.
&lt;/span&gt;    &lt;span class="nf"&gt;schedule_reconnect&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  &lt;strong&gt;The first connection is a different path, and it usually isn't retried at all.&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;All that backoff logic I just wrote lives in &lt;code&gt;on_disconnect&lt;/code&gt; — the callback that fires when a connection we already had goes away. But a device that boots before its 4G modem is ready never had one. &lt;code&gt;connect()&lt;/code&gt; raises, &lt;code&gt;main()&lt;/code&gt; dies. Because the modem is not ready to communicate. And the process exits before any of my retry code ever runs. When systemd restarts the service if the modem still isn't up, it dies again. That's not a retry loop I designed; it's just a process crashing over and over.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;And the final boss, which is the worst: messages just vanish.&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;My five-minute ceiling had a consequence I hadn't thought about. After a bad drop, the device deliberately just sits there doing nothing for up to five minutes. Which means every reading it takes during that window has nowhere to go. &lt;code&gt;publish()&lt;/code&gt; doesn't complain about this. It hands back a result code saying the message never left. But that is all. Paho can queue messages for you, but that queue lives in memory, so it dies with the process. A power cycle in the field takes your data with it. Gone.&lt;/p&gt;

&lt;p&gt;I tried a few of my own patches over the years for this without ever fixing it properly. Sensor data is the entire reason the device is out there. That's when I stopped patching and started writing a queue that saves messages to disk, so they survive a restart.&lt;/p&gt;

&lt;p&gt;All these four different failure modes had one thing in common: all of them fail &lt;em&gt;quietly&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  So I made the thing I'd been looking for: The bug spray!
&lt;/h2&gt;

&lt;p&gt;Everything above became &lt;a href="https://pypi.org/project/robmqtt/" rel="noopener noreferrer"&gt;&lt;code&gt;robmqtt&lt;/code&gt;&lt;/a&gt; — a resilient MQTT client for edge devices.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Offline queue in SQLite&lt;/strong&gt; — unreachable broker means messages get written to disk, not dropped. They survive restarts and power cycles.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inflight tracking&lt;/strong&gt; — sent-but-unacknowledged messages are tracked separately and re-sent on reconnect.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Priority eviction&lt;/strong&gt; — when the queue fills, low-priority telemetry goes before critical alerts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exponential backoff&lt;/strong&gt; — no stampede.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Subscription registry&lt;/strong&gt; — subscriptions are restored automatically on every reconnect. No &lt;code&gt;on_connect&lt;/code&gt; bookkeeping, no deaf devices. It also means you can call &lt;code&gt;subscribe()&lt;/code&gt; before &lt;code&gt;connect()&lt;/code&gt;, which is how it should have worked in the first place.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Structured logging throughout&lt;/strong&gt; — so the next person doesn't lose days to a problem that leaves no trace.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The application code never has to know whether the broker is reachable. That was the entire goal.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;robmqtt&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ProductionMQTTClient&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ProductionMQTTClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;client_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;field_device_001&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;broker_host&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mqtt.yourdomain.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;broker_port&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1883&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;max_queue_size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;db_path&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;./device.db&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;publish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;topic&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sensors/temperature&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;read_sensor&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
        &lt;span class="n"&gt;qos&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;priority&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. The bug spray!&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell myself at the start
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Silence is not success.&lt;/strong&gt; A green dashboard measures the things you thought to measure. My device was reporting perfect health while it was completely deaf to commands, because I'd never built anything that could notice the difference.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If a library leaves it to you, it will not warn you.&lt;/strong&gt; Reconnection, re-subscription, retry and queueing in &lt;code&gt;paho&lt;/code&gt; isn't broken for leaving these open. It's a protocol client, not a delivery guarantee. But every gap it leaves is a gap that fails in actual production.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Write the log line you'd want at 2am.&lt;/strong&gt; Tests and structured logging exist in &lt;code&gt;robmqtt&lt;/code&gt; for one reason: I spent days chasing something that left no trace, and I refuse to do that to whoever picks this up next.&lt;/p&gt;

&lt;p&gt;The thing that cost me weeks is now one pip install away. That's all I wanted.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;robmqtt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;PyPI:&lt;/strong&gt; &lt;a href="https://pypi.org/project/robmqtt/" rel="noopener noreferrer"&gt;https://pypi.org/project/robmqtt/&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/ranaweerasupun/resilient-edge-mqtt-client" rel="noopener noreferrer"&gt;https://github.com/ranaweerasupun/resilient-edge-mqtt-client&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built by Supun Sriyananda — R&amp;amp;D Engineer working on embedded and IoT systems.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>bugsmash</category>
      <category>python</category>
      <category>iot</category>
    </item>
    <item>
      <title>What a Hypervisor Is, and Where KVM Fits (Type-1 vs Type-2)</title>
      <dc:creator>Supun Sriyananda</dc:creator>
      <pubDate>Sat, 15 Aug 2026 11:36:51 +0000</pubDate>
      <link>https://dev.to/ranaweerasupun/what-a-hypervisor-is-and-where-kvm-fits-type-1-vs-type-2-53j0</link>
      <guid>https://dev.to/ranaweerasupun/what-a-hypervisor-is-and-where-kvm-fits-type-1-vs-type-2-53j0</guid>
      <description>&lt;p&gt;This series builds the KVM/QEMU/libvirt stack starting from the silicon on ARM64. Commands are from a Raspberry Pi 5 (8 GB) running Raspberry Pi OS Bookworm, kernel 6.12 aarch64. Note that, Every command is aarch64-native, and the terminal output is real. The concepts, however, apply anywhere Linux runs. I chose ARM64 deliberately, because almost every KVM tutorial out there is written for x86.&lt;/p&gt;

&lt;p&gt;The first question to start with is: what is a hypervisor, and which kind is KVM?&lt;/p&gt;

&lt;h2&gt;
  
  
  Virtualization works on a lie!
&lt;/h2&gt;

&lt;p&gt;A normal computer runs one operating system, and that OS believes it owns the hardware. It believes all the RAM, every CPU core, the disks, and the network interface belong to it. This belief is not a simplification for beginners. It is baked into how kernels are written. A kernel executes privileged instructions, sets up memory maps, talks directly to devices, and assumes nothing exists above it.&lt;/p&gt;

&lt;p&gt;A hypervisor is what lets several of those operating systems, each believing "I own everything", run on one machine. Each of them keeps believing the lie. The hypervisor's job is to keep that lie convincing enough and quietly share the real hardware underneath.&lt;/p&gt;

&lt;p&gt;Everything else I will be writing about in this series, like VM exits, VirtIO, virtqueues, and passthrough, is a technique for maintaining that lie either more convincingly or more cheaply.&lt;/p&gt;

&lt;h2&gt;
  
  
  The types of hypervisors
&lt;/h2&gt;

&lt;p&gt;The textbook taxonomy divides hypervisors in two:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft9t152h247yk1ysr45hp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft9t152h247yk1ysr45hp.png" alt="types-of-hypervisors" width="800" height="353"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Type 1 hypervisors run directly on the hardware. There is no general-purpose OS underneath them, which means the hypervisor &lt;em&gt;is&lt;/em&gt; the thing the machine boots into. ESXi and Xen are the standard examples. What you get in return is performance and isolation. Because, the hypervisor is the sole layer separating a guest from the hardware, and there is nothing else in the way.&lt;/p&gt;

&lt;p&gt;Type 2 hypervisors run as applications on top of a normal operating system. We boot Linux, macOS, or Windows on our computers, then install a program like VirtualBox and launch it the same way we'd launch a browser. Here, what you get in return is convenience. Your usual desktop environment carries on exactly as before, and a virtual machine also ends up being just another application running alongside everything else.&lt;/p&gt;

&lt;p&gt;People usually summarise the difference as a choice between speed or convenience.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where is KVM located. And is it type-1 or type-2 ?
&lt;/h2&gt;

&lt;p&gt;KVM is a kernel module. Once it is loaded, the Linux kernel itself &lt;em&gt;becomes&lt;/em&gt; the hypervisor. There is no separate hypervisor layer above or below it. The kernel you are already running gains the ability to execute guests directly on the hardware. I meant literally, not as an analogy. There is only one kernel here. It runs the ordinary applications, and it runs virtual machines too.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fae6bkvvr3pkkwazexm1b.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fae6bkvvr3pkkwazexm1b.png" alt="where-is-kvm-located" width="800" height="305"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Now let's try to place that on the Type-1/Type-2 chart.&lt;/p&gt;

&lt;p&gt;It has Type-1's defining property: the hypervisor communicates directly to the silicon, without an intermediary OS between it and the hardware. Guest code runs on real cores at native speed. There is no translation layer and no host kernel being asked to relay requests on the guest's behalf. The Type 1 label was invented to describe hypervisors that let guests run at full speed. KVM does exactly that, so by that reasoning, it belongs in the category.&lt;/p&gt;

&lt;p&gt;But it is &lt;em&gt;inside&lt;/em&gt; a general-purpose operating system that is simultaneously running SSH sessions, your browser, and a few dozen daemons. Your VMs appear in &lt;code&gt;ps&lt;/code&gt; output. They're scheduled by the ordinary Linux scheduler, they are killable with &lt;code&gt;kill&lt;/code&gt;, and constrained by cgroups like any other process. So, by the structural argument, that's unmistakably Type 2.&lt;/p&gt;

&lt;p&gt;This is why the argument never gets settled. People usually call KVM, "Type-1-ish", and that hedge is fair. Because, the two categories were named before anything like KVM existed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The practical consequence of this categorization
&lt;/h2&gt;

&lt;p&gt;Because the hypervisor is part of a full Linux kernel, it inherits everything that kernel already knows how to do. It doesn't need its own scheduler, because Linux has one, and vCPUs become ordinary threads on it. It doesn't need its own memory manager, driver model, filesystem layer, or a network stack. Because linux has all of them. A dedicated Type-1 hypervisor has to build or port every one of those.&lt;/p&gt;

&lt;p&gt;This inheritance is going to shape this whole series. When we pin vCPU threads to physical cores, we'll be using the ordinary Linux scheduler affinity mechanism, because a vCPU is just a thread!. When guest disks turn out to be files in a directory, that's the host filesystem doing its normal job. Later when we bridge guest networking, we'll use the same Linux bridge that would connect any two interfaces.&lt;/p&gt;

&lt;p&gt;The more important point is the flip side: KVM is intentionally minimal. Its responsibilities begin and end with the CPU and memory. It does not know how to present itself as a disk, a network card, a display, or a USB port, and it was never designed to. The reason is that it does not need to. Inside the Linux kernel, mature subsystems already exist for storage, networking, and device management, and above the kernel, in userspace, QEMU takes care of whatever remains.&lt;/p&gt;

&lt;p&gt;That naturally leads to the question of how the work is actually split between these pieces. Article 1.3 in this series answers it. Before we get there, though, one thing needs to be cleared up, because saying that "the kernel becomes the hypervisor" ignores something important. A kernel cannot simply choose to run guest operating systems at native speed. It requires the processor to cooperate, and that cooperation comes from a hardware capability built into the silicon that stays inactive until software explicitly enables it.&lt;/p&gt;

&lt;p&gt;On ARM64 that feature is EL2, and it is where I will start in [the next article].&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A hypervisor lets multiple operating systems each believe they own the hardware, and shares the real hardware underneath.&lt;/li&gt;
&lt;li&gt;Type 1 runs directly on hardware (ESXi, Xen). Type 2 runs as an application on a host OS (VirtualBox).&lt;/li&gt;
&lt;li&gt;KVM is a kernel module that turns the running Linux kernel into a hypervisor. Guest code executes directly on real cores, but guests are also ordinary Linux processes.&lt;/li&gt;
&lt;li&gt;It's Type-1 by the performance argument and Type-2 by the structural one, hence "Type-1-ish". The taxonomy predates the design.&lt;/li&gt;
&lt;li&gt;Because the hypervisor is part of Linux, it reuses Linux's scheduler, memory manager, and drivers. Which is why KVM itself only handles CPU and memory.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>virtualization</category>
      <category>linux</category>
      <category>kvm</category>
      <category>arm</category>
    </item>
    <item>
      <title>MQTT vs HTTP for IoT: What I Learned Using Both in Production</title>
      <dc:creator>Supun Sriyananda</dc:creator>
      <pubDate>Thu, 30 Jul 2026 14:00:26 +0000</pubDate>
      <link>https://dev.to/ranaweerasupun/mqtt-vs-http-for-iot-what-i-learned-using-both-in-production-1n5</link>
      <guid>https://dev.to/ranaweerasupun/mqtt-vs-http-for-iot-what-i-learned-using-both-in-production-1n5</guid>
      <description>&lt;p&gt;Every IoT project eventually reaches the same decision point:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Should the device communicate with the cloud over MQTT or HTTP?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Both protocols are mature, widely supported, and capable of moving device data reliably. The more useful question is not &lt;strong&gt;which protocol is better&lt;/strong&gt;, but &lt;strong&gt;which communication model fits the device&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;I have used MQTT for production edge clients running on deployed robot units and warehouse sensors. I have also used HTTP for a building-management monitoring backend and a smart-entry system built with Flask.&lt;/p&gt;

&lt;p&gt;Both worked. They simply created very different systems.&lt;/p&gt;

&lt;p&gt;This article explains the trade-offs I saw in practice: bandwidth overhead, connection behavior, command latency, power consumption, infrastructure, fan-out, and operational complexity.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architectural difference that drives everything else
&lt;/h2&gt;

&lt;p&gt;HTTP is built around a request-response model. A client sends a request, and a server returns a response.&lt;/p&gt;

&lt;p&gt;MQTT is built around persistent connections and publish-subscribe messaging. A device connects to a broker, publishes messages to topics, and subscribes to topics it wants to receive.&lt;/p&gt;

&lt;p&gt;That difference shapes almost every other trade-off.&lt;/p&gt;

&lt;p&gt;With HTTP, a device usually addresses a specific API endpoint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;POST /api/readings
GET /devices/robot-01/commands
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With MQTT, the device communicates through topics:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;warehouse/sensor-01/temperature
robots/robot-01/commands
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The publisher does not need to know which services consume a message. The broker handles routing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bandwidth and message overhead
&lt;/h2&gt;

&lt;p&gt;The difference becomes easy to see when you compare a small sensor reading.&lt;/p&gt;

&lt;p&gt;An HTTP request might look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="nf"&gt;POST&lt;/span&gt; &lt;span class="nn"&gt;/api/sensors/temperature&lt;/span&gt; &lt;span class="k"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;1.1&lt;/span&gt;
&lt;span class="na"&gt;Host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;api.example.com&lt;/span&gt;
&lt;span class="na"&gt;Content-Type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;application/json&lt;/span&gt;
&lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Bearer &amp;lt;token&amp;gt;&lt;/span&gt;
&lt;span class="na"&gt;Content-Length&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;52&lt;/span&gt;

&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"device_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sensor_01"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"temperature"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;23.5&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The payload is small, but the request also carries headers. Authentication tokens can make those headers much larger than the sensor reading itself.&lt;/p&gt;

&lt;p&gt;An MQTT &lt;code&gt;PUBLISH&lt;/code&gt; packet carries:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Fixed header
Topic name
Packet identifier, when required by QoS
Properties, in MQTT 5
Payload
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a short topic and a small payload, the MQTT packet can be substantially smaller than an equivalent HTTP request. The MQTT fixed header starts at two bytes, although the complete packet is larger once the topic, payload, QoS fields, and any MQTT 5 properties are included.&lt;/p&gt;

&lt;p&gt;The comparison also depends on connection reuse.&lt;/p&gt;

&lt;p&gt;HTTP/1.1 supports persistent connections by default, so a well-configured client does &lt;strong&gt;not&lt;/strong&gt; need to open a new TCP and TLS connection for every request. HTTP/2 can also multiplex many requests over one connection. Poorly configured clients, short-lived processes, network timeouts, and reconnects can still cause repeated handshakes, but “one request equals one new connection” is not an accurate general rule.&lt;/p&gt;

&lt;p&gt;MQTT still has an advantage for frequent, small messages because its application-layer framing is compact and the connection is normally kept open for ongoing messaging.&lt;/p&gt;

&lt;p&gt;For one device publishing every few minutes, the absolute difference may not matter. Across hundreds of devices publishing several times per second, it can become significant.&lt;/p&gt;

&lt;h2&gt;
  
  
  Receiving commands: polling versus subscriptions
&lt;/h2&gt;

&lt;p&gt;The biggest practical difference I encountered was server-to-device communication.&lt;/p&gt;

&lt;p&gt;A basic HTTP API is client-initiated. The device sends a request before the server can return anything. A simple implementation therefore polls for commands:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;poll_for_commands&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;device_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;interval_seconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.example.com/devices/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;device_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/commands&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Authorization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bearer &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;command&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
            &lt;span class="nf"&gt;handle_command&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;command&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;interval_seconds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At a five-second interval, that is 17,280 requests per device per day, including requests that return no commands. A newly created command may also wait almost five seconds before the next poll.&lt;/p&gt;

&lt;p&gt;HTTP-based systems can avoid simple polling with techniques such as long polling, Server-Sent Events, or WebSockets. Those are valid designs, but they add a different connection-management model to the application.&lt;/p&gt;

&lt;p&gt;MQTT supports this pattern directly. The device subscribes once, and the broker forwards matching messages over the existing connection:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;paho.mqtt.client&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;mqtt&lt;/span&gt;

&lt;span class="n"&gt;DEVICE_ID&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;robot-01&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;BROKER_HOST&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mqtt.example.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;on_connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;userdata&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;flags&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reason_code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;properties&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subscribe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;devices/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;DEVICE_ID&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/commands&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;qos&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;on_message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;userdata&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;command&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="nf"&gt;handle_command&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;command&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mqtt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mqtt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CallbackAPIVersion&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;VERSION2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;client_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;DEVICE_ID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;on_connect&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;on_connect&lt;/span&gt;
&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;on_message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;on_message&lt;/span&gt;
&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BROKER_HOST&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1883&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;keepalive&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loop_forever&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For the telepresence robot, this mattered. Camera and movement-related commands needed low and predictable latency. A five-second polling loop was not acceptable, while an MQTT subscription matched the communication pattern naturally.&lt;/p&gt;

&lt;p&gt;For truly hard real-time control, however, neither MQTT nor a general-purpose cloud connection should be treated as a deterministic control bus. Network delay, broker load, retransmission, and connectivity loss still need to be considered.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reliability and QoS
&lt;/h2&gt;

&lt;p&gt;MQTT offers three protocol-level Quality of Service levels:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;QoS 0:&lt;/strong&gt; delivered at most once&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;QoS 1:&lt;/strong&gt; delivered at least once&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;QoS 2:&lt;/strong&gt; delivered exactly once between MQTT protocol peers&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;QoS 1 is useful when losing a message is worse than processing a duplicate. The application must therefore be able to handle duplicate delivery safely.&lt;/p&gt;

&lt;p&gt;QoS 2 reduces duplicate delivery at the MQTT protocol layer, but it adds more packet exchanges and does not automatically make the entire application workflow exactly once. A message can still be processed twice if application state, database writes, retries, or downstream integrations are not idempotent.&lt;/p&gt;

&lt;p&gt;HTTP can also be reliable, but the retry and deduplication behavior is usually designed at the application layer. For example, a device can retry a failed &lt;code&gt;POST&lt;/code&gt; with an idempotency key, while the API stores the key to avoid inserting the same reading twice.&lt;/p&gt;

&lt;p&gt;The important point is that neither protocol removes the need to design failure handling.&lt;/p&gt;

&lt;h2&gt;
  
  
  Power consumption is workload-dependent
&lt;/h2&gt;

&lt;p&gt;It is tempting to say that MQTT always uses less power because its packets are smaller. The real answer is more nuanced.&lt;/p&gt;

&lt;p&gt;Radio state often matters more than payload size. Establishing a cellular or Wi-Fi connection, negotiating TCP and TLS, and waiting for network timers can consume more energy than transmitting a short reading.&lt;/p&gt;

&lt;p&gt;A continuously connected MQTT client can avoid repeated connection setup and receive commands immediately. That can be efficient for devices that communicate frequently or must remain reachable.&lt;/p&gt;

&lt;p&gt;However, maintaining a live MQTT connection requires periodic keepalive traffic and may prevent some devices or modems from entering their deepest sleep states. MQTT &lt;code&gt;PINGREQ&lt;/code&gt; and &lt;code&gt;PINGRESP&lt;/code&gt; packets maintain the connection; they do not allow a device to turn its radio fully off while keeping the same TCP connection alive.&lt;/p&gt;

&lt;p&gt;For a battery-powered sensor that wakes once an hour, uploads one reading, and never receives commands, a short HTTPS request may be simpler and just as efficient. The device must reconnect either way after deep sleep.&lt;/p&gt;

&lt;p&gt;MQTT persistent sessions are still valuable for intermittently connected devices. A broker can preserve subscriptions and queue eligible QoS messages while the client is offline, subject to the broker configuration, protocol version, session settings, and retention limits. When the device reconnects and resumes the session, it can receive the stored messages.&lt;/p&gt;

&lt;p&gt;So the useful rule is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Frequent bidirectional communication often favors MQTT.&lt;/li&gt;
&lt;li&gt;Rare uplink-only communication may favor HTTP.&lt;/li&gt;
&lt;li&gt;Measure on the actual radio, modem, network, and sleep schedule before making a battery-life claim.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Infrastructure and operational complexity
&lt;/h2&gt;

&lt;p&gt;HTTP is easy to start with because almost every platform already supports it.&lt;/p&gt;

&lt;p&gt;A small ingestion API can be only a few lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sqlite3&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;flask&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Flask&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;jsonify&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Flask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;__name__&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="nd"&gt;@app.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/api/readings&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;receive_reading&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;sqlite3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;readings.db&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
            INSERT INTO readings (timestamp, device_id, value)
            VALUES (?, ?, ?)
            &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;device_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;value&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;jsonify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ok&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="mi"&gt;201&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tooling is familiar: &lt;code&gt;curl&lt;/code&gt;, Postman, browser developer tools, reverse proxies, API gateways, serverless functions, status codes, and standard observability platforms.&lt;/p&gt;

&lt;p&gt;MQTT requires a broker. Mosquitto is a popular lightweight open-source option, while managed services such as AWS IoT Core and HiveMQ Cloud reduce the operational burden.&lt;/p&gt;

&lt;p&gt;Running a broker means thinking about:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Device authentication&lt;/li&gt;
&lt;li&gt;TLS certificates&lt;/li&gt;
&lt;li&gt;Topic-level authorization&lt;/li&gt;
&lt;li&gt;Session persistence&lt;/li&gt;
&lt;li&gt;Message expiry and retained messages&lt;/li&gt;
&lt;li&gt;Broker storage&lt;/li&gt;
&lt;li&gt;Monitoring and capacity&lt;/li&gt;
&lt;li&gt;Reconnection storms&lt;/li&gt;
&lt;li&gt;High availability&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A single broker instance can become a single point of failure, but MQTT itself does not require a single-instance deployment. Production systems can use clustered brokers, load-balanced endpoints, replicated state, and managed broker services.&lt;/p&gt;

&lt;p&gt;HTTP is not automatically immune to failure either. A single Flask process is also a single point of failure. HTTP infrastructure is often easier to scale horizontally because stateless APIs and load balancers are so widely supported, but both architectures require deliberate redundancy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fan-out is where MQTT feels fundamentally different
&lt;/h2&gt;

&lt;p&gt;MQTT is especially useful when one message has several consumers.&lt;/p&gt;

&lt;p&gt;A sensor can publish once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sensors/warehouse/temp01
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Several services can subscribe independently:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Dashboard
Alerting service
Time-series database
Analytics pipeline
Anomaly detector
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The sensor does not need to know that these consumers exist. A new consumer can be added without changing device firmware.&lt;/p&gt;

&lt;p&gt;With HTTP, the device can still send one request to an ingestion service, but that service must then distribute the event. This is commonly done with a queue, event bus, stream, or webhook system.&lt;/p&gt;

&lt;p&gt;That architecture can be excellent. It simply means that HTTP handles device ingestion while another system provides asynchronous fan-out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Debugging and developer experience
&lt;/h2&gt;

&lt;p&gt;HTTP is usually easier to inspect manually.&lt;/p&gt;

&lt;p&gt;You can reproduce a request with &lt;code&gt;curl&lt;/code&gt;, view status codes, inspect headers, and test an endpoint without maintaining a long-lived client session.&lt;/p&gt;

&lt;p&gt;MQTT debugging is still manageable, but it requires different tools and concepts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;mosquitto_sub &lt;span class="nt"&gt;-h&lt;/span&gt; broker.example.com &lt;span class="nt"&gt;-t&lt;/span&gt; &lt;span class="s1"&gt;'sensors/#'&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;mosquitto_pub &lt;span class="nt"&gt;-h&lt;/span&gt; broker.example.com &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-t&lt;/span&gt; &lt;span class="s1"&gt;'devices/robot-01/commands'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s1"&gt;'{"action":"stop"}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-q&lt;/span&gt; 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You also need to understand wildcard subscriptions, retained messages, session state, QoS acknowledgements, duplicate delivery, and topic permissions.&lt;/p&gt;

&lt;p&gt;In my experience, HTTP produces a faster first prototype. MQTT often produces a cleaner final design once the system becomes continuously connected, bidirectional, or event-driven.&lt;/p&gt;

&lt;h2&gt;
  
  
  A practical decision framework
&lt;/h2&gt;

&lt;p&gt;Choose &lt;strong&gt;HTTP&lt;/strong&gt; when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The device integrates with an existing REST API.&lt;/li&gt;
&lt;li&gt;Communication is infrequent and mostly device-to-cloud.&lt;/li&gt;
&lt;li&gt;The device uploads large payloads, images, logs, or firmware files.&lt;/li&gt;
&lt;li&gt;Simple debugging and minimal infrastructure matter most.&lt;/li&gt;
&lt;li&gt;Server-to-device messaging is unnecessary or can tolerate polling.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Choose &lt;strong&gt;MQTT&lt;/strong&gt; when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Devices must receive commands or configuration changes quickly.&lt;/li&gt;
&lt;li&gt;Messages are small and frequent.&lt;/li&gt;
&lt;li&gt;Network links are unreliable or intermittent.&lt;/li&gt;
&lt;li&gt;Multiple services need the same device events.&lt;/li&gt;
&lt;li&gt;Session state, topic subscriptions, and QoS are useful.&lt;/li&gt;
&lt;li&gt;Bandwidth efficiency matters across a large fleet.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Use &lt;strong&gt;both&lt;/strong&gt; when the workloads differ.&lt;/p&gt;

&lt;p&gt;That is the design I now prefer for many IoT systems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;MQTT for telemetry, commands, device status, and real-time events&lt;/li&gt;
&lt;li&gt;HTTP for firmware downloads, image uploads, administration, and third-party integrations&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The broker handles operational messaging. The API handles resource-oriented workflows and large transfers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final takeaway
&lt;/h2&gt;

&lt;p&gt;The decision is not really “MQTT or HTTP?”&lt;/p&gt;

&lt;p&gt;It is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Does this device behave more like an API client, or more like a participant in a live event system?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;For an occasional one-way upload, HTTP is often the simplest answer.&lt;/p&gt;

&lt;p&gt;For a connected fleet that publishes continuously, receives commands, survives intermittent networks, and feeds several downstream services, MQTT usually fits better.&lt;/p&gt;

&lt;p&gt;In production, the strongest architecture is often not choosing one protocol everywhere. It is giving each protocol the job it was designed to do.&lt;/p&gt;




&lt;p&gt;Have you used MQTT and HTTP in the same IoT system? I would be interested to hear where you drew the boundary between them.&lt;/p&gt;

</description>
      <category>iot</category>
      <category>mqtt</category>
      <category>http</category>
      <category>architecture</category>
    </item>
  </channel>
</rss>
