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.