It’s the details that matter when you operate and run a piece of software. One of the most important details I find when operating applications is how it respects SIGHUP signals. Why? Because SIGHUP makes it easy to do configuration changes without disruption and config changes are the source of most major incidents. So what is SIGHUP and why should you care?

SIGHUP is a signal sent to a process when its controlling terminal is closed. However, when we are running a daemonized process (which we do a lot if you work below the application layers), we don’t ever expect this signal to get sent. Somewhere along the way it became common practice for daemonized processes to use SIGHUP for configuration reload. This allows us to not terminate the process while making changes!

In this article, I will show you a simple way to respect SIGHUP and “hot reload” (doing it live, its hot!) a config. We are going to create a simple web server that uses a config value to display a message. During this process we are going to send a SIGHUP and have the message update without downtime or restarting our process.

Setup

First, let’s setup a simple http server. I am just working in a directory and have a single file called main.go. Nothing crazy here, just a single endpoint that says “Hello, World!”

package main

import (
	"log"
	"net/http"
)

func main() {
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte("Hello, World!"))
    })
    log.Fatal(http.ListenAndServe(":8080", nil))
}

To test this open two terminals and run go run main.go to start the server and in the other window run

➜ curl localhost:8080
Hello, World!

Wow. Much amaze.

Signal Handling

At this point we have a server but we only want our application to respond to one OS signal, SIGHUP. Go provides an os/signals package to help us with this! Let’s rub some of that on it.

package main

import (
	"log"
	"net/http"
+   "os"
+   "os/signals"
+   "syscall"
)

func main() {
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte("Hello, World!"))
    })
+   sigs := make(chan os.Signal)
+   signal.Notify(sigs, syscall.SIGHUP)
    log.Fatal(http.ListenAndServe(":8080", nil))
}

Cool. Wait, what just happened? First, we load the os and syscall packages. In the main function we create a new channel that accepts values of the type os.Signal. We then pass that channel to signal.Notify as well as define the types of signals we want to notify on, in this case only SIGHUP.

There is a problem with this though. We aren’t doing anything with the signal. Let’s incrementally approach this. First goal is to log when a signal is thrown, that’s all. To do this, we need to check the values on the channel.

func main() {
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte("Hello, World!"))
    })
+   sigs := make(chan os.Signal)
+   signal.Notify(sigs, syscall.SIGHUP)
    log.Fatal(http.ListenAndServe(":8080", nil))

+    for {
+        select {
+        case <-sigs:
+            log.Println("hot reload")
+        }
+    }
}

This is great but if we run this it still doesn’t work. Why? Because the http server is running before the select statement and so it never reaches that point until we exit because its blocking. We need the server to run in parallel so we can operate around it. To do this we will put this in its own goroutine.

func main() {
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte("Hello, World!"))
    })
+   sigs := make(chan os.Signal)
+   signal.Notify(sigs, syscall.SIGHUP)

+   go func() {
+       log.Println("starting up....")
+       log.Fatal(http.ListenAndServe(":8080", nil))
+   }()

+   for {
+       select {
+       case <-sigs:
+           log.Println("hot reload")
+       }
+   }
}

Now we are getting somewhere. If we run this, we should be able to send SIGHUP signals to it and get the message “hot reload”. Let’s test it out! In one terminal start your server (go run main.go) and in another run ps to find your process number

# in terminal one
➜ go run main.go
2021/03/30 15:44:36 starting up....

Once the application is running, we need to find the process of the compiled executable. In the below output process 21371 is the one we want to send signals to not 21351. This will kill the go run main.go process but leave your server running.

# in terminal two
➜ ps
  PID TTY           TIME CMD
18507 ttys000    0:00.62 -zsh
 3162 ttys001    0:00.95 -zsh
21351 ttys001    0:00.92 go run main.go
21371 ttys001    0:00.01 /var/folders/ny/sp_8f52x4hj_lrkl5cqz4ndw0000gp/T/go-build725126617/b001/exe/main

# send the signal!
➜ kill -SIGHUP 21371

In terminal one you should see something similar to this

2021/03/30 15:44:36 starting up....
2021/03/30 15:46:06 hot reload

Configuration

Now all we need is a configuration value to reload! Let’s add on to our criteria now that logging is working. Next, we expect when a SIGHUP signal is received for the configuration to be reloaded. Let’s add some configuration values!

package main

import (
    "log"
    "net/http"
    "os"
    "os/signal"
    "syscall"
)

+var conf = &Config{Message: "Hello, World!"}

+type Config struct {
+    Message string
+}

func main() {
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+        w.Write([]byte(conf.Message))
    })

    sigs := make(chan os.Signal, 1)
    signal.Notify(sigs, syscall.SIGHUP)

    go func() {
        log.Println("starting up....")
        log.Fatal(http.ListenAndServe(":8080", nil))
    }()

    for {
        select {
        case <-sigs:
            log.Println("hot reload")
+            conf.Message = "Go To Hell!"
        }
    }
}

So above we have added a Config struct, set a default with the Message value of Hello World! and we have altered our handler to use this configuration value. We have also altered our select area to set a new message whenever we receive a SIGHUP. This message is less welcoming.

Let’s run this!

# in terminal one
➜ go run main.go
2021/03/30 15:44:36 starting up....

In terminal two, test the endpoint to make sure our default settings work

# in terminal two
➜ curl localhost:8080
Hello, World!

Now send a SIGHUP just like we did before and then attempt to curl again

➜ curl localhost:8080
Go To Hell!

Conclusion

Congratulations, you just learned one of my favorite patterns in computers! This is a powerful pattern that scales really well. If I was going to continue working on this I would load configuration values from a file or a datastore (or both!). You could then use any number of tools to send a SIGHUP to all of your processes to do a config reload.