One of the feature that macOS known for is the fast boot times. According to Wikipedia, this was achieved by using on-demand activation in launchd, the service manager for macOS. Rather than launching daemon on start up, launchd listen to the defined port and start the daemon when needed.
In Linux with systemd, we can achieve the same behaviour by using systemd.socket(5).
In this journal, we take a look on how to implement it on Go program that listen and serve HTTP.
The Program
The program itself is this website, run in my machine on port 10000.
This website is build using
ciigo,
which use
lib/http
that wrap the standard HTTP package.
The code for starting HTTP server looks like this in
lib/http#Server.Start,
func (srv *Server) Start() (err error) {
if srv.Server.TLSConfig == nil {
err = srv.Server.ListenAndServe()
} else {
err = srv.Server.ListenAndServeTLS("", "")
}
...
}
I run it in my machine using systemd user service behind a HAProxy.
[Unit] Description=local.kilabit [Service] Restart=on-failure RestartSec=10 WorkingDirectory=/home/ms/kilabit.info ExecStartPre=/home/ms/local/share/go/bin/go build ./cmd/www-kilabit ExecStart=/home/ms/kilabit.info/www-kilabit -dev -address=127.0.0.1:10000 [Install] WantedBy=default.target
j Snippet of HAProxy configuration,
...
frontend https
acl host_kilabit hdr(host) -i kilabit.local
use_backend be_kilabit if host_kilabit
...
backend be_kilabit
server kilabit 127.0.0.1:10000
If I want to publish a new page, I create a new directory, start writing
index.adoc, and open the page at kilabit.local/<path to new page>/.
Most of the time it just run in the background doing nothing.
Implementation in the Code
After reading the blog (written by the founder of systemd itself) and the code, the logic for socket-based activation quite simple:
-
Read the environment variable
LISTEN_PID.This environment variable contains the PID of our program that launched by systemd. We need to compare the program PID with this value first. If its empty or not match, we can tell that its not run by systemd.
-
Read the environment variable
LISTEN_FDS.This environment variable contains the file descriptor (fd) number opened by systemd start from 3 (The fd 0 is for input, 1 for stdout, and 2 for stderr).
-
For each fd from 3 until
LISTEN_FDS, convert them tonet.Listener.
Here is the full code, more or less (subject to changes in the future),
func Listeners(unsetEnv bool) (list []net.Listener, err error) {
logp := `Listeners`
if unsetEnv {
defer func() {
_ = os.Unsetenv(envListenPID)
_ = os.Unsetenv(envListenFDS)
}()
}
v := os.Getenv(envListenPID)
if v == `` {
return nil, nil
}
listenPid, err := strconv.Atoi(v)
if err != nil {
return nil, fmt.Errorf(`%s: %w`, logp, err)
}
pid := os.Getpid()
if listenPid != pid {
return nil, fmt.Errorf(`%s: mismatch PID, got %d, want %d`,
logp, listenPid, pid)
}
v = os.Getenv(envListenFDS)
if v == `` {
return nil, nil
}
n, err := strconv.Atoi(v)
if err != nil {
return nil, fmt.Errorf(`%s: invalid LISTEN_FDS value %s: %w`, logp, v, err)
}
if n < 0 {
return nil, fmt.Errorf(`%s: invalid LISTEN_FDS value %d`, logp, n)
}
const listenFDSStart = 3
if n > math.MaxInt-listenFDSStart {
return nil, fmt.Errorf(`%s: invalid LISTEN_FDS value %d`, logp, n)
}
for fd := listenFDSStart; fd < listenFDSStart+n; fd++ {
fdptr := uintptr(fd)
flags, err := unix.FcntlInt(fdptr, unix.F_GETFD, 0)
if err != nil {
return nil, fmt.Errorf(`%s: %w`, logp, err)
}
newflags := flags | unix.FD_CLOEXEC
if flags != newflags {
_, err = unix.FcntlInt(fdptr, unix.F_SETFD, newflags)
if err != nil {
return nil, fmt.Errorf(`%s: %w`, logp, err)
}
}
file := os.NewFile(fdptr, ``)
fileListener, err := net.FileListener(file)
if err != nil {
return nil, fmt.Errorf(`%s: %w`, logp, err)
}
list = append(list, fileListener)
}
return list, nil
}
Now, we need to changes an implementation of HTTP server in the lib/http.
In the ServerOptions we add new field Listener as an instance of
[net.Listener].
If it set, the HTTP server will use it to accepting new connection instead
of creating new one from [ServerOptions.Address].
diff --git a/lib/http/server.go b/lib/http/server.go
index 4c89032b..76713308 100644
--- a/lib/http/server.go
+++ b/lib/http/server.go
@@ -352,9 +352,17 @@ func (srv *Server) ServeHTTP(res http.ResponseWriter, req *http.Request) {
// Start the HTTP server.
func (srv *Server) Start() (err error) {
if srv.Server.TLSConfig == nil {
- err = srv.Server.ListenAndServe()
+ if srv.Options.Listener == nil {
+ err = srv.Server.ListenAndServe()
+ } else {
+ err = srv.Server.Serve(srv.Options.Listener)
+ }
} else {
- err = srv.Server.ListenAndServeTLS("", "")
+ if srv.Options.Listener == nil {
+ err = srv.Server.ListenAndServeTLS(``, ``)
+ } else {
+ err = srv.Server.ServeTLS(srv.Options.Listener, ``, ``)
+ }
}
if errors.Is(err, http.ErrServerClosed) {
err = nil
diff --git a/lib/http/server_options.go b/lib/http/server_options.go
index 9ccb3e65..d51fe381 100644
--- a/lib/http/server_options.go
+++ b/lib/http/server_options.go
@@ -6,6 +6,7 @@ package http
import (
"io"
"log"
+ "net"
"net/http"
"git.sr.ht/~shulhan/pakakeh.go/lib/memfs"
@@ -13,6 +14,11 @@ import (
// ServerOptions define an options to initialize HTTP server.
type ServerOptions struct {
+ // Listener define the network listener to be used for serving HTTP
+ // connection.
+ // The Listener can be activated using systemd socket.
+ Listener net.Listener
+
// Memfs contains the content of file systems to be served in memory.
// The MemFS instance to be served should be already embedded in Go
// file, generated using memfs.MemFS.GoEmbed().
Back to the program, we changes the main function to call [systemd.Listeners] and pass the first Listener to [lib/http.ServerOptions].
diff --git a/cmd/www-kilabit/main.go b/cmd/www-kilabit/main.go
index d1bbaba..56a5acd 100644
--- a/cmd/www-kilabit/main.go
+++ b/cmd/www-kilabit/main.go
@@ -10,6 +10,7 @@ import (
"git.sr.ht/~shulhan/ciigo"
"git.sr.ht/~shulhan/pakakeh.go/lib/memfs"
+ "git.sr.ht/~shulhan/pakakeh.go/lib/systemd"
)
const (
@@ -55,6 +56,21 @@ func main() {
}
default:
+ listeners, err := systemd.Listeners(true)
+ if err != nil {
+ log.Fatal(err)
+ }
+ if len(listeners) > 1 {
+ log.Fatal(`too many listeners received for activation`)
+ }
+ if len(listeners) == 1 {
+ serveOpts.Listener = listeners[0]
+ gotAddr := serveOpts.Listener.Addr().String()
+ if gotAddr != serveOpts.Address {
+ log.Fatalf(`invalid Listener address, got %s, want %s`,
+ gotAddr, serveOpts.Address)
+ }
+ }
err = ciigo.Serve(serveOpts)
if err != nil {
log.Fatal(err)
One thing to note here, is that, we must check if the Listener address is actually match with the address that we want to listen.
That’s it for the code. Next, we need to create and modify the unit files for systemd.
systemd Unit Files
In the systemd, we need to tell it to listen on the same port as our program by creating the systemd.socket file,
[Socket] ListenStream=127.0.0.1:10000 [Install] WantedBy=sockets.target
And modify the service file by removing the Install section,
[Unit] Description=local.kilabit [Service] Restart=on-failure RestartSec=10 WorkingDirectory=/home/ms/kilabit.info ExecStartPre=/home/ms/local/share/go/bin/go build ./cmd/www-kilabit ExecStart=/home/ms/kilabit.info/www-kilabit -dev -address=127.0.0.1:10000
Disable and stop the old service and start the new socket unit file,
$ systemctl --user daemon-reload $ systemctl --user disable --now local.kilabit.service # You may need to re-symlink or copy the service file back. $ systemctl --user enable local.kilabit.socket $ systemctl --user restart local.kilabit.socket $
When we open the website in the browser, https://kilabit.local, the service now should start automatically:
Feb 02 15:45:58 systemd[825]: Starting local.kilabit... Feb 02 15:46:00 systemd[825]: Started local.kilabit. Feb 02 15:46:00 www-kilabit[86615]: 2026/02/02 15:46:00 convertFileMarkups: converting _content/journal/2026/systemd_socket_with_go/index.adoc Feb 02 15:46:00 www-kilabit[86615]: 2026/02/02 15:46:00 ciigo: starting HTTP server at http://127.0.0.1:10000 for "_content"
After thought
The systemd.socket is a great way to reduce the machine boot times and save the system resources. The service will run only when we need it. It should only be used for local development not in production.
After implementing the above code, I am thinking the way to stop the service automatically when its inactive after N seconds or minutes. Can systemd do it or will it require additional code changes in the program?
If you want the same behaviour without modifying the code, take a look at systemd-socket-proxyd(8) which we will cover in the next journal.