unlock unlimited code runs for $79, limited time →
NOSSLETTER.techLIVE
back to OSSpedia
build-systemsdependency-managementrefactoring

Switch golangci plugins to compiled-in single-binary modules

Project: kubernetes/kubernetes | Language: Go | Concepts: build-systems, dependency-management, refactoring | Difficulty: mid
Link: https://github.com/kubernetes/kubernetes/pull/141832

The problem

Kubernetes used custom golangci-lint plugins (for code linting) that were built as shared object (.so) files using Go's plugin system. This approach became problematic due to a regression in Go 1.27 that broke plugin builds, and it also added complexity to the build and configuration process.

Why it's non-trivial

A naive fix might just try to patch around the Go 1.27 plugin regression or adjust build flags. However, Go plugins are inherently fragile, platform-dependent, and complicate reproducibility and developer onboarding. Migrating to a single-binary, compiled-in module approach requires changes to configuration, build scripts, and plugin registration code to ensure the linters are still discoverable and configurable by golangci-lint.

The approach

1. Change linter configuration from .so plugin path to type: module

Before:
Linters were configured with a path pointing to a built .so file:

-        path: _output/local/bin/logcheck.so

After:
Now they are marked as type: module, signaling golangci-lint to use a compiled-in module:

+        type: module

This change appears in all relevant YAML config files (hack/golangci-hints.yaml, hack/golangci.yaml, hack/golangci.yaml.in, and example configs).

2. Remove plugin build/install steps from the build script

Before:
The install script built each linter as a Go plugin:

-  GOTOOLCHAIN="$(kube::golang::hack_tools_gotoolchain)" go -C "${KUBE_ROOT}/hack/tools/golangci-lint" build -o "${GOBIN}/logcheck.so" -buildmode=plugin sigs.k8s.io/logtools/logcheck/plugin
-  GOTOOLCHAIN="$(kube::golang::hack_tools_gotoolchain)" go -C "${KUBE_ROOT}/hack/tools/golangci-lint" build -o "${GOBIN}/kube-api-linter.so" -buildmode=plugin sigs.k8s.io/kube-api-linter/pkg/plugin
-  GOTOOLCHAIN="$(kube::golang::hack_tools_gotoolchain)" go -C "${KUBE_ROOT}/hack/tools/golangci-lint" build -o "${GOBIN}/sorted.so" -buildmode=plugin k8s.io/kubernetes/hack/tools/golangci-lint/sorted/plugin

After:
Now, only a single binary is built, with all linters compiled in:

+GOTOOLCHAIN="$(kube::golang::hack_tools_gotoolchain)" go -C "${KUBE_ROOT}/hack/tools/golangci-lint" build -o "${GOBIN}/golangci-lint" .

This simplifies the build process and avoids plugin-related pitfalls.

3. Add a custom main.go to register and run the linters as modules

A new entrypoint is added at hack/tools/golangci-lint/main.go:

+	// These imports register the custom linters.
+	_ "sigs.k8s.io/kube-api-linter"
+	_ "sigs.k8s.io/logtools/logcheck/gclplugin"
+	_ "k8s.io/kubernetes/hack/tools/golangci-lint/sorted/plugin"

By importing these packages for their side effects, the linters self-register with golangci-lint's module system.

The main function also wires up version reporting and error handling:

+	if err := commands.Execute(commands.BuildInfo{GoVersion: runtime.Version(), Version: golangCIVersion}); err != nil {
+		_, _ = fmt.Fprintf(os.Stderr, "The command is terminated due to an error: %v\n", err)
+		os.Exit(exitcodes.Failure)
+	}

4. Refactor plugin registration code to use the module plugin API

The plugin code for the sorted linter is refactored to use the plugin-module-register API:

Before:
Used Go plugin entrypoints:

-type analyzerPlugin struct{}
-
-func (*analyzerPlugin) GetAnalyzers() []*analysis.Analyzer {
-	return []*analysis.Analyzer{pkg.NewAnalyzer()}
-}
-
-// AnalyzerPlugin is the entry point for golangci-lint.
-var AnalyzerPlugin analyzerPlugin

After:
Registers as a module plugin:

+import (
+	"github.com/golangci/plugin-module-register/register"
+)
...
+func New(pluginSettings interface{}) (register.LinterPlugin, error) {
+    ...
+    return linterPlugin{config: config}, nil
+}
+
+func init() {
+	register.Plugin("sorted", New)
+}

This enables the linter to be discovered as a module rather than a plugin.

5. Update documentation and example configs

References to building plugins are replaced with instructions to ensure the linter is imported in the main module, and example configs use type: module instead of path: ...so.

-2. **Check plugin build**: Ensure the plugin is correctly built with `go build -buildmode=plugin`
+2. **Check the build**: Ensure `hack/tools/golangci-lint/main.go` imports the `plugin` package

From the review thread

No notable review discussion on this PR.

Takeaways

  • Prefer static linking or module-based integration over Go plugins for custom tooling, as plugins are fragile and have limited support across Go versions and platforms.
  • Simplifying build pipelines by reducing the number of custom artifacts (like plugins) improves maintainability and reproducibility.
  • When refactoring build or integration approaches, ensure configuration files and documentation are updated to match the new model, preventing confusion for future maintainers.