Repository: prometheus/blackbox_exporter

Issue:
Support automatically reload config
Related to https://github.com/prometheus/prometheus/issues/9783

It would be really useful to have this feature available in the nodes (at least the official ones). It would avoid having to set up mechanisms that vary depending on the installation method during deployments.

Hints from issue comments:
@electron0zero: I would be happy to review a PR that adds this in a similar way as https://github.com/prometheus/prometheus/pull/14769@endesapt: Hi! I would like to try work on this feature, is it still relevant? 
@roidelapluie @mem @electron0zero @RasmusLauritsen: Would love to see this as well!  Even better if there was support for multiple config files - so blackbox-exporter keeps running even if someone added a file with syntax error in their modules.

Solution patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 23d8b93..3571d75 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,8 @@ Changes:
 * [FEATURE]
 * [ENHANCEMENT]
 * [BUGFIX]
+* [FEATURE] Support config reload automatically - feature flag `config.enable-auto-reload`, `config.auto-reload-interval`
+* [CHANGE] Config is not reloaded if the file content didn't change when using all reload methods
 
 ## 0.27.0 / 2025-06-26
 
diff --git a/README.md b/README.md
index 8e0edc8..eb8a2f1 100644
--- a/README.md
+++ b/README.md
@@ -160,6 +160,10 @@ Blackbox exporter is configured via a [configuration file](CONFIGURATION.md) and
 Blackbox exporter can reload its configuration file at runtime. If the new configuration is not well-formed, the changes will not be applied.
 A configuration reload is triggered by sending a `SIGHUP` to the Blackbox exporter process or by sending a HTTP POST request to the `/-/reload` endpoint.
 
+Blackbox exporter also supports automatic configuration reloading. You can enable this feature using the `--config.enable-auto-reload` flag. 
+When enabled, the exporter will automatically check for changes to its configuration file at a specified interval in seconds. 
+The interval can be customized with the `--config.auto-reload-interval` flag, which is set to 30 (which is 30 seconds) by default.
+
 To view all available command-line flags, run `./blackbox_exporter -h`.
 
 To specify which [configuration file](CONFIGURATION.md) to load, use the `--config.file` flag.
diff --git a/config/config.go b/config/config.go
index 7e14a7a..9a75a39 100644
--- a/config/config.go
+++ b/config/config.go
@@ -87,6 +87,7 @@ type SafeConfig struct {
 	C                   *Config
 	configReloadSuccess prometheus.Gauge
 	configReloadSeconds prometheus.Gauge
+	configChecksum      string
 }
 
 func NewSafeConfig(reg prometheus.Registerer) *SafeConfig {
@@ -115,6 +116,21 @@ func (sc *SafeConfig) ReloadConfig(confFile string, logger *slog.Logger) (err er
 		}
 	}()
 
+	var currentConfigChecksum string
+	currentConfigChecksum, err = GenerateChecksum(confFile)
+	if err != nil {
+		return fmt.Errorf("failed to generate initial checksum for configuration file: %s", err)
+	}
+	if currentConfigChecksum == sc.configChecksum {
+		if logger != nil {
+			logger.Info("Configuration checksum check passed. No changes detected.")
+		}
+		return nil
+	}
+	if logger != nil {
+		logger.Info("Configuration file change detected, reloading the configuration.")
+	}
+
 	yamlReader, err := os.Open(confFile)
 	if err != nil {
 		return fmt.Errorf("error reading config file: %s", err)
@@ -145,6 +161,7 @@ func (sc *SafeConfig) ReloadConfig(confFile string, logger *slog.Logger) (err er
 
 	sc.Lock()
 	sc.C = c
+	sc.configChecksum = currentConfigChecksum
 	sc.Unlock()
 
 	return nil
diff --git a/config/reload.go b/config/reload.go
new file mode 100644
index 0000000..3d7de12
--- /dev/null
+++ b/config/reload.go
@@ -0,0 +1,34 @@
+// Copyright 2025 The Prometheus Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package config
+
+import (
+	"crypto/sha256"
+	"encoding/hex"
+	"fmt"
+	"os"
+)
+
+func GenerateChecksum(yamlFilePath string) (string, error) {
+	hash := sha256.New()
+	yamlContent, err := os.ReadFile(yamlFilePath)
+	if err != nil {
+		return "", fmt.Errorf("error reading YAML file: %w", err)
+	}
+	_, err = hash.Write(yamlContent)
+	if err != nil {
+		return "", fmt.Errorf("error writing YAML file to hash: %w", err)
+	}
+
+	return hex.EncodeToString(hash.Sum(nil)), nil
+}
diff --git a/main.go b/main.go
index 84cebfd..c41a0ec 100644
--- a/main.go
+++ b/main.go
@@ -27,6 +27,7 @@ import (
 	"strconv"
 	"strings"
 	"syscall"
+	"time"
 
 	"github.com/alecthomas/kingpin/v2"
 	"github.com/prometheus/client_golang/prometheus"
@@ -47,14 +48,16 @@ import (
 var (
 	sc = config.NewSafeConfig(prometheus.DefaultRegisterer)
 
-	configFile     = kingpin.Flag("config.file", "Blackbox exporter configuration file.").Default("blackbox.yml").String()
-	timeoutOffset  = kingpin.Flag("timeout-offset", "Offset to subtract from timeout in seconds.").Default("0.5").Float64()
-	configCheck    = kingpin.Flag("config.check", "If true validate the config file and then exit.").Default().Bool()
-	logLevelProber = kingpin.Flag("log.prober", "Log level for probe request logs. One of: [debug, info, warn, error]. Please see the section `Controlling log level for probe logs` in the project README for more information.").Default("info").String()
-	historyLimit   = kingpin.Flag("history.limit", "The maximum amount of items to keep in the history.").Default("100").Uint()
-	externalURL    = kingpin.Flag("web.external-url", "The URL under which Blackbox exporter is externally reachable (for example, if Blackbox exporter is served via a reverse proxy). Used for generating relative and absolute links back to Blackbox exporter itself. If the URL has a path portion, it will be used to prefix all HTTP endpoints served by Blackbox exporter. If omitted, relevant URL components will be derived automatically.").PlaceHolder("<url>").String()
-	routePrefix    = kingpin.Flag("web.route-prefix", "Prefix for the internal routes of web endpoints. Defaults to path of --web.external-url.").PlaceHolder("<path>").String()
-	toolkitFlags   = webflag.AddFlags(kingpin.CommandLine, ":9115")
+	configFile         = kingpin.Flag("config.file", "Blackbox exporter configuration file.").Default("blackbox.yml").String()
+	timeoutOffset      = kingpin.Flag("timeout-offset", "Offset to subtract from timeout in seconds.").Default("0.5").Float64()
+	configCheck        = kingpin.Flag("config.check", "If true validate the config file and then exit.").Default().Bool()
+	logLevelProber     = kingpin.Flag("log.prober", "Log level for probe request logs. One of: [debug, info, warn, error]. Please see the section `Controlling log level for probe logs` in the project README for more information.").Default("info").String()
+	enableAutoReload   = kingpin.Flag("config.enable-auto-reload", "When enabled, Blackbox exporter will automatically reload its configuration file at a specified interval. The interval is defined by the `--config.auto-reload-interval` flag, which defaults to `30s`").Default().Bool()
+	autoReloadInterval = kingpin.Flag("config.auto-reload-interval", "Specifies the interval in seconds for checking and automatically reloading configuration file upon detecting changes.").Default("30").Uint()
+	historyLimit       = kingpin.Flag("history.limit", "The maximum amount of items to keep in the history.").Default("100").Uint()
+	externalURL        = kingpin.Flag("web.external-url", "The URL under which Blackbox exporter is externally reachable (for example, if Blackbox exporter is served via a reverse proxy). Used for generating relative and absolute links back to Blackbox exporter itself. If the URL has a path portion, it will be used to prefix all HTTP endpoints served by Blackbox exporter. If omitted, relevant URL components will be derived automatically.").PlaceHolder("<url>").String()
+	routePrefix        = kingpin.Flag("web.route-prefix", "Prefix for the internal routes of web endpoints. Defaults to path of --web.external-url.").PlaceHolder("<path>").String()
+	toolkitFlags       = webflag.AddFlags(kingpin.CommandLine, ":9115")
 
 	moduleUnknownCounter = promauto.NewCounter(prometheus.CounterOpts{
 		Name: "blackbox_module_unknown_total",
@@ -156,7 +159,17 @@ func run() int {
 					logger.Info("Reloaded config file")
 					rc <- nil
 				}
+			case <-time.Tick(time.Duration(*autoReloadInterval) * time.Second):
+				if !*enableAutoReload {
+					continue
+				}
+				if sc.ReloadConfig(*configFile, logger); err != nil {
+					logger.Error("Error reloading config", "err", err)
+					continue
+				}
+				logger.Info("Reloaded config file")
 			}
+
 		}
 	}()
 



Test patch:
diff --git a/config/reload_test.go b/config/reload_test.go
new file mode 100644
index 0000000..c70a027
--- /dev/null
+++ b/config/reload_test.go
@@ -0,0 +1,109 @@
+// Copyright 2025 The Prometheus Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package config
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+)
+
+func TestGenerateChecksum(t *testing.T) {
+	// Create a temporary file and establish the "original" state.
+	// All subsequent tests will measure against this state.
+	originalContent := []byte("modules:\n  http_2xx:\n    prober: http\n")
+
+	tempDir := t.TempDir()
+	filePath := filepath.Join(tempDir, "config.yaml")
+
+	if err := os.WriteFile(filePath, originalContent, 0644); err != nil {
+		t.Fatalf("Could not write initial file: %v", err)
+	}
+
+	originalChecksum, err := GenerateChecksum(filePath)
+	if err != nil {
+		t.Fatalf("Could not generate initial checksum: %v", err)
+	}
+	if originalChecksum == "" {
+		t.Fatal(" Initial checksum should not be empty")
+	}
+
+	t.Run("when content is appended", func(t *testing.T) {
+		testModification(t, filePath, originalContent, originalChecksum, []byte("modules:\n  http_2xx:\n    prober: http\n  new_module:\n"))
+	})
+
+	t.Run("when content is completely replaced", func(t *testing.T) {
+		testModification(t, filePath, originalContent, originalChecksum, []byte("completely: different\n"))
+	})
+
+	t.Run("when content is cleared (file becomes empty)", func(t *testing.T) {
+		testModification(t, filePath, originalContent, originalChecksum, []byte(""))
+	})
+
+	// These tests do not fit the modify-restore pattern and are handled independently.
+	t.Run("should return an error for a non-existent file", func(t *testing.T) {
+		nonExistentPath := filepath.Join(tempDir, "this-file-does-not-exist.yaml")
+
+		checksum, err := GenerateChecksum(nonExistentPath)
+
+		if err == nil {
+			t.Fatal("Expected an error for a non-existent file, but got nil")
+		}
+		if checksum != "" {
+			t.Errorf("Checksum should be empty on error, but got: %s", checksum)
+		}
+	})
+
+	t.Run("should return an error when path is a directory", func(t *testing.T) {
+		checksum, err := GenerateChecksum(tempDir)
+
+		if err == nil {
+			t.Fatal("Expected an error when the path is a directory, but got nil")
+		}
+		if checksum != "" {
+			t.Errorf("Checksum should be empty on error, but got: %s", checksum)
+		}
+	})
+}
+
+// testModification is a helper function that encapsulates the user's requested test logic:
+// 1. Write new content.
+// 2. Check that the checksum is different.
+// 3. Write the original content back.
+// 4. Check that the checksum is the same as the original.
+func testModification(t *testing.T, filePath string, originalContent []byte, originalChecksum string, newContent []byte) {
+	t.Helper()
+
+	if err := os.WriteFile(filePath, newContent, 0644); err != nil {
+		t.Fatalf("Failed to write new content to file: %v", err)
+	}
+
+	modifiedChecksum, err := GenerateChecksum(filePath)
+	if err != nil {
+		t.Fatalf("Failed to generate checksum for modified file: %v", err)
+	}
+	if modifiedChecksum == originalChecksum {
+		t.Error("Checksum did not change after modifying file content")
+	}
+	if err := os.WriteFile(filePath, originalContent, 0644); err != nil {
+		t.Fatalf("Failed to restore original content to file: %v", err)
+	}
+
+	restoredChecksum, err := GenerateChecksum(filePath)
+	if err != nil {
+		t.Fatalf("Failed to generate checksum for restored file: %v", err)
+	}
+	if restoredChecksum != originalChecksum {
+		t.Errorf("Checksum should be restored to original value, but it was not.\nOriginal: %s\nRestored: %s", originalChecksum, restoredChecksum)
+	}
+}