Top
Best
New

Posted by khazit 12/7/2025

Go is portable, until it isn't(simpleobservability.com)
155 points | 134 comments
mmulet 12/13/2025|
I ran into this issue when porting term.everything[0] from typescript to go. I had some c library dependencies that I did need to link, so I had to use cgo. My solution was to do the build process on alpine linux[1] and use static linking[2]. This way it statically links musl libc, which is much friendlier with static linking than glibc. Now, I have a static binary that runs in alpine, Debian, and even bare containers.

Since I have made the change, I have not had anyone open any issues saying they had problems running it on their machines. (Unlike when I was using AppImages, which caused much more trouble than I expected)

[0] https://github.com/mmulet/term.everything look at distribute.sh and the makefile to see how I did it.

[1]in a podman or docker container

[2] -ldflags '-extldflags "-static"'

jchw 12/13/2025||
IMO this is the best approach, but it is worth noting that musl libc is not without its caveats. I'd say for most people it is best to tread carefully and make sure that differences between musl libc and glibc don't cause additional problems for the libraries you are linking to.

There is a decent list of known functional differences on the musl libc wiki:

https://wiki.musl-libc.org/functional-differences-from-glibc...

Overall, though, the vast majority of software works perfectly or near perfectly on musl libc, and that makes this a very compelling option indeed, especially since statically linking glibc is not supported and basically does not work. (And obviously, if you're already using library packages that are packaged for Alpine Linux in the first place, they will likely already have been tested on musl libc, and possibly even patched for better compatibility.)

nickcw 12/13/2025|||
That is a nice approach. I'll have to give that a try with rclone. I tried lots of things in the past but not using Alpine which is a great idea

Another alternative is

https://github.com/ebitengine/purego

You can use this to dynamic load shared objects / DLLs so in the OP example they could disable systemd support if the systemd shared object did not load.

This technique is used in the cgofuse library ( https://github.com/winfsp/cgofuse ) rclone uses which means rclone can run even if you don't have libfuse/winfsp installed. However the rclone mount subcommand won't work.

The purego lib generalizes this idea. I haven't got round to trying this yet but it looks very promising.

kokada 12/13/2025||
I am using purego indirectly in two pet projects of mine. While it has its own issues it definitely solves the issue of cross-compilation.

In this particular case it may be that they will need to write a wrapper to abstract differences between the systemd C API if it is not stable, but at least they still can compile a binary from macOS to Linux without issues.

The other issue as other said is to use journalctl and just parse the JSON format. Very likely that this would be way more stable, but not sure if it is performant enough.

johnisgood 12/13/2025|||
I use `-ldflags '-extldflags "-static"` as well.

From the .go file, you just do `// #cgo LDFLAGS: -L. -lfoo`.

You definitely do not need Alpine Linux for this. I have done this on Arch Linux. I believe I did not even need musl libc for this, but I potentially could have used it.

I did not think I was doing something revolutionary!

In fact, let me show you a snippet of my build script:

  # Build the Go project with the static library
  if go build -o $PROG_NAME -ldflags '-extldflags "-static"'; then
    echo "Go project built with static library linkage"
  else
    echo "Error: Failed to build the Go project with static library"
    exit 1
  fi

  # Check if the executable is statically linked
  if nm ./$PROG_NAME | grep -q "U "; then
    echo "Error: The generated executable is dynamically linked"
    exit 1
  else
    echo "Successfully built and verified static executable '$PROG_NAME'"
  fi
And like I said, the .go file in question has this:

  // #cgo LDFLAGS: -L. -lfoo
It works perfectly, and should work on any Linux distribution.
mmulet 12/13/2025||
I use alpine for this [1] reason, but I will admit that this is a premature-optimization. I haven’t actually ran into the problem myself.

——

Your code is great, I do basically the same thing (great minds think alike!). The only thing I want to add is that cgo supports pkg-config directly [2] via

  // #cgo pkg-config: $lib

So you don’t have to pass in linker flags manually. It’s incredibly convenient.

[1]https://stackoverflow.com/questions/57476533/why-is-statical...

[2]https://github.com/mmulet/term.everything/blob/def8c93a3db25...

johnisgood 12/19/2025||
Thanks! I did not use pkg-config because the compiled .c is the one I have written specifically for this Go program. :D I did it all on my own system as well (Void Linux).
pansa2 12/13/2025|||
> do the build process on alpine linux and […] statically link musl libc

IIRC it used to be common to do builds on an old version of RHEL or CentOS and dynamically link an old version of glibc. Binaries would then work on newer systems because glibc is backwards compatible.

Does anyone still use that approach?

Xylakant 12/13/2025||
If you need glibc for any kind of reason, that approach is still used. But that won’t save you if no glibc is available. And since the folks here want to produce a musl build anyways for alpine, the easier approach is to just go for musl all the way.
apitman 12/13/2025|||
Note that you don't have to compile on an Alpine system to achieve this. These instructions should work on most distros:

https://www.arp242.net/static-go.html

nly 12/13/2025|||
> and even bare containers.

Strange, i thought the whole point of containers was to solve this problem.

vrighter 12/15/2025|||
The whole point of containers is to ship almost the whole OS with the application (It is a technical implementation of the "works on my machine" concept). If the OS you put in your container (by just pulling in a prebuilt image from somewhere) doesn't have the necessary things, then the application would fail to work just the same as if you ran it on the bare operating system with the the same missing libraries.
jdub 12/13/2025|||
Depends how much you care about the size and security footprint of your container images.
nly 12/15/2025||
Static linking doesn't solve security issues either.
jdub 12/17/2025||
Static linking can be a layer of defence against some security issues, depending on your circumstances.

But what I said was "reduced security footprint", considering the trade offs between a single statically linked binary and a full (or even cut down) Linux distribution.

imcritic 12/13/2025|||
What troubles did you have with AppImages?
mmulet 12/13/2025||
List of troubles:

[1]https://github.com/mmulet/term.everything/issues/28

[2]https://github.com/mmulet/term.everything/issues/18 (although this issue later gets sidetracked to a build issue)

[3]https://github.com/mmulet/term.everything/issues/14

[4]https://github.com/mmulet/term.everything/issues/7

tasuki 12/13/2025|||
Huh. Does term.everything just work, or are there some gotchas? This seems like it could be supremely useful!
mmulet 12/13/2025||
It works so far! No major gotchas that I know of yet. From the perspective of the apps, they are just talking to a normal Wayland compositor, so everything works as expected. Just try it for your workflow, and if you run into any problems just open an issue and I’ll fix it.
cxr 12/13/2025||
I didn't see an explanation in the README that part of what the first GIF[1] shows is an effect created by video editing software (and not a screencapture that's just demonstrating the program actually running). "Screen images simulated" are the words usually chosen to start off the disclaimers in fine print shown at the bottom of the screen when similar effects appear in commercials. I think that it would make sense to adopt a similar explanation wrt the effect used for the GIF.

1. <https://github.com/mmulet/term.everything/blob/main/resource...>

plufz 12/13/2025|||
Why would an open source project need to have any disclaimer? They are not selling anything.
nofriend 12/13/2025||
Because lying is wrong even when open source projects do it.
plufz 12/13/2025|||
I think it is a big stretch calling this visual effect lying.

I don’t know if it is a cultural American thing or just difference in interpretation but I had no difficulty understanding that this was a visual effect. But in my country ads don’t come with disclaimers. Do you feel like these disclaimers are truly helpful?

cxr 12/13/2025|||
I don't feel that the person I responded to is lying or being intentionally deceptive.
nebezb 12/13/2025||||
> “in commercials where such effects appear”

Good thing this isn’t a commercial then.

hmans 12/13/2025|||
[dead]
davvid 12/13/2025||
> We did not want to spend time maintaining a backward compatible parser or doing code archaeology. So this option was discarded.

Considering all of the effort and hoop-jumping involved in the route that was chosen, perhaps this decision might be worth revisiting.

In hindsight, maintaining a parser might be easier and more maintainable when compared to the current problems that were overcome and the future problems that will arise if/when the systemd libraries decide to change their C API interfaces.

One benefit of a freestanding parser is that it could be made into a reusable library that others can use and help maintain.

khazit 12/13/2025||
There is an existing pure Go library [1] written by someone else. The issue is that we weren’t confident we could ship a reliable parser. We even included an excerpt from the systemd documentation, which didn’t exactly reassure us:

> Note that the actual implementation in the systemd codebase is the only ultimately authoritative description of the format, so if this document and the code disagree, the code is right

This required a lot of extra effort and hoop-jumping, but at least it’s on our side rather than something users have to deal with at deploy time.

[1]: https://github.com/Velocidex/go-journalctl

bb88 12/13/2025||
That's what I was thinking too. A go native library is 10 times better in the go ecosystem than a c library linked to a go executable.

Also in the age of AI it seems possible to have it do the rewrite for you, for which you can iterate on further.

bitbasher 12/13/2025||
Once you use CGO, portability is gone. Your binary is no longer staticly compiled.

This can happen subtley without you knowing it. If you use a function in the standard library that happens to call into a CGO function, you are no longer static.

This happens with things like os.UserHomeDir or some networking things like DNS lookups.

You can "force" go to do static compiling by disabling CGO, but that means you can't use _any_ CGO. Which may not work if you require it for certain things like sqlite.

swills 12/13/2025||
You can definitely use CGO and still build statically, but you do need to set ldflags to include -static.
tptacek 12/13/2025||
You can even cross-compile doing that.
swills 12/13/2025||
Yes, indeed, I do.
hiAndrewQuinn 12/13/2025|||
You don't need CGO for SQLite in most cases; I did a deep dive into it here.

https://til.andrew-quinn.me/posts/you-don-t-need-cgo-to-use-...

PunchyHamster 12/13/2025|||
> Which may not work if you require it for certain things like sqlite.

there is cgo-less sqlite implementation https://github.com/glebarez/go-sqlite it seems to not be maintained much tho

IceWreck 12/13/2025||
You're linking to a different version - this is the one that most people use https://github.com/modernc-org/sqlite
debugnik 12/13/2025||
Yes and no, the package above is a popular `database/sql` driver for the same SQLite port you linked.
silverwind 12/13/2025|||
> This happens with things like os.UserHomeDir or some networking things like DNS lookups.

The docs do not mention this CGO dependency, are you sure?

https://pkg.go.dev/os#UserHomeDir

purpleidea 12/13/2025||
I was surprised too, that I had to check the docs, so I assume the user was misinformed.
bitbasher 12/13/2025||
Perhaps I misremembered or things changed? For instance, the os/user results in a dynamicly linked executable: https://play.golang.com/p/7QsmcjJI4H5

There are multiple standard library functions that do it.. I recall some in "net" and some in "os".

telotortium 12/13/2025||
os.UserHomeDir is specified to read the HOME environment variable, so it doesn’t require CGo. os/user does, but only to support NSS and LDAP, which are provided by libc. That’s also why net requires CGo- for getaddrinfo using resolv.conf
ncruces 12/13/2025||
There are at least a couple of ways to run SQLite without CGO.
tptacek 12/13/2025||
I think the standard answer here is modernc.org/sqlite.
apitman 12/13/2025||
Careful, you're responding to the author of a wasm-based alternative.
ncruces 12/13/2025||
No need to be careful. I won't bite. ;)
combiBean 12/13/2025||
Was there not a third option: Calling the journalctl CLI as a child process and consume the parsed logs from the standard output? This might have avoided both the requirement to use CGO and also to write a custom parser. But I guess I am missing something.
tkone 12/13/2025||
Ironically this is EXACTLY what the journald receiver for OpenTelemetry does, which, as they noted, is written in go.

Specifically because you're only supposed to use that OR the c bindings by design because they want the ability to change in the internal format when it's necessary.

redrove 12/13/2025|||
Yeah looks like they missed the forest for the trees.

I see this kind of thing in our industry quite often; some Rube Goldberg machine being invented and kept on life support for years because of some reason like this, where someone clearly didn’t do the obvious thing and everyone now just assumes it’s the only solution and they’re married to it.

But I’m too grumpy, work me is leaking into weekend me. I had debates around crap like this all week and I now see it everywhere.

cookiengineer 12/13/2025|||
Also there is a --json (or -o json) flag for journalctl which will output line based json log entries. And it can simply be called with a Command as you pointed out.
dardeaup 12/13/2025|||
This was the first thought that occurred to me too when I saw this post.
IshKebab 12/13/2025||
It's generally less robust to run CLI tools and scrape the output. Usually it isn't intended to be machine readable, and you have to handle extra failure modes, like incompatible tool versions, missing tools, incorrect parsers, etc.

It's the lazy-but-bad solution.

jeremyjh 12/13/2025|||
journalctl is designed for these use cases and has options to solve those issues. The lazy part here is you not doing any research about this tool before dismissing it as "not best practice", which is exactly what the fuckups who wrote this article did.
khazit 12/13/2025||
We dismissed using journalctl at the very start. We’ve had similar experiences with other CLI tools: the moment you start embedding them inside a program, you introduce a whole new class of problems. What if journalctl exits? What if it outputs an error? What if it hangs? On top of that, you have to manage the subprocess lifecycle yourself. It’s not as easy as it may seem.

You can also argue that sd_journal (the C API) exists for this exact reason, rather than shelling out to journalctl. These are technical trade-offs, doesn't mean we're fuckups

mxey 12/14/2025|||
> You can also argue that sd_journal (the C API) exists for this exact reason, rather than shelling out to journalctl.

Quoting from https://systemd.io/JOURNAL_FILE_FORMAT/

> If you need access to the raw journal data in serialized stream form without C API our recommendation is to make use of the Journal Export Format, which you can get via journalctl -o export or via systemd-journal-gatewayd.

Certainly sounds like running journalctl, or using the gateway, is a supported option.

jeremyjh 12/13/2025|||
Does Go really not have any libraries capable of supervising an external program? If you'd considered journalctl, why didn't you mention it in the article? As many have pointed out here, it is the obvious and intended way to do this, and the path you chose was harder for reasons that seemed to surprise you but were entirely foreseeable.
mxey 12/14/2025||
JFTR, of course it has a library for it https://pkg.go.dev/os/exec
zbentley 12/13/2025||||
I think a lot is riding on that “generally”. You’re right that the default approach/majority of cases should avoid shelling out wherever possible, but there are a large minority of situations where doing that does make sense, including:

Calling a CLI tool which will be present everywhere your program might reasonably be installed (e.g. if your program is a MySQL extension, it can probably safely assume the existence of mysqld).

The CLI tool you want to call is vendored into or downloaded by your wrapper program, reducing installation requirements overhead (this is not always a good idea for other reasons, but it does address a frequently cited reason not to shell out).

The CLI tool’s functionality is both disjoint with the rest of your program and something that you have a frequent need to hard-kill. (Forking is much more error prone than running a discrete subprocess; you can run your own program as a subprocess too, but in that case the functionality is probably not disjointed).

Talking to POSIX CLI tools in a POSIX compatible way (granted most things those tools do are easier/faster in a language’s stdlib).

mxey 12/14/2025||
I recently wrote some Go code for running containers and chose to use the docker CLI instead of an API client. The CLI is more well known and better documented, and this is what replacements like Podman support. When there’s a problem, it’s easier to reproduce it by running the same CLI command. It also meant I wouldn’t need a whole lot of dependencies, and we needed the docker CLI anyway.

Obviously you shouldn’t try to parse human-readable output.

sigwinch 12/13/2025||||
journalctl with -o export produces a binary interchange format. Would you rather have bugs or API rot from that, or in an internal tool?
1718627440 12/14/2025|||
> Usually it isn't intended to be machine readable

It usually is, because that is the UNIX philosophy and programs that intermingle output with layout often stop doing that, when they don't write to a terminal.

orochimaaru 12/13/2025||
So you can’t pull in c libraries built for different distributions and expect this to work.

If you use pure go, things are portable. The moment you use C API, that portability doesn’t exist. This should be apparent.

jeremyjh 12/13/2025|
My assumption was that they were using a C API just from reading the headline. I don't use Go but these sorts of problems are common to any project doing that in just about any language.
larusso 12/13/2025||
I think this is true for nearly all compiled languages. I had the same fun with rust and openSSL and glibC. OP didn’t mentioned the fun with glib-c when compiling on a fairly recent distro and trying it to run on an older one. There is the “many Linux” project which provides docker images with a minimum glib c version installed so it’s compatible with newer ones. The switch to a newer open ssl version on Debian/Ubuntu created some issues for my tool. I replaced it with rust tls to remove the dynamic linked library. I prefer complete statically linked binaries though. But that is really hard to do and damn near impossible on Apple systems.
nunez 12/13/2025||
You hit this real quick when trying to build container images from the scratch. Theoretically you can drop a Go binary into a blank rootfs and it will run. This works most of the time, but anything that depends on Go's Postgres client requires libpq which requires libc. Queue EFILE runtime errors after running the container.
nateb2022 12/13/2025|
> anything that depends on Go's Postgres client requires libpq which requires libc

Try https://github.com/lib/pq

AlbinoDrought 12/13/2025|||
I've also seen https://github.com/jackc/pgx used in many projects
mxey 12/13/2025|||
> For users that require new features or reliable resolution of reported bugs, we recommend using pgx which is under active development.
regularfry 12/13/2025||
All of this, every last bit of complexity and breakage and sweat, is downstream of this:

> Journal logs are not stored in plain text. They use a binary format

And it was entirely predictable and predicted that this sort of problem would be the result when that choice was made.

dwattttt 12/14/2025||
That's why I hexify all binary files, to make it easier to understand them.
valbaca 12/13/2025||
Unix philosophy strikes again
pjmlp 12/13/2025||
And a set of people rediscovered why cross compiling only works up to certain extent, regardless of the marketing on the tin.

The point one needs to touch APIs that only exists on the target system, the fun starts, regardless of the programming language.

Go, Zig, whatever.

dwattttt 12/13/2025|
You're thinking of cross platform codebases. There's nothing about cross compilation that stops the toolchain from knowing what APIs are present & not present on a target system.
pjmlp 12/13/2025||
Cross compilation and cross platform are synonymous in compiled languages, in regards of many issues that one needs to care about.

Cross platform goes beyond in regards to UI, direction locations, user interactions,...

Yeah, if you happen to have systemd Linux libraries on macOS to facilitate cross compilation into a compatible GNU/Linux system than it works, that is how embedded development has worked for ages.

What doesn't work is pretending that isn't something to care about.

IshKebab 12/13/2025||
> Cross compilation and cross platform are synonymous in compiled languages

Err, no. Cross-platform means the code can be compiled natively on each platform. Cross-compilation is when you compile the binaries on one platform for a different platform.

pjmlp 12/13/2025||
Not at all, cross platform means executing the same application in many platforms, regardless of the hardware and OS specific features of each platform.

Cross-compilation is useless if you don't actually get to executed the created binaries in the target platform.

Now, how do you intend to compile from GNU/Linux into z/OS, so that we can execute the generated binary out from the C compiler ingesting the code written in GNU/Linux platform, in the z/OS language environment inside an enclave, not configured in POSIX mode?

Using z/OS, if you're feeling more modern, it can be UWP sandboxed application with identity in Windows.

IshKebab 12/13/2025||
> cross platform means executing the same application in many platforms, regardless of the hardware and OS specific features of each platform.

That is a better definition yes. But it's still not synonymous with cross-compilation, obviously. Most cross-platform apps are not cross-compiled because it's usually such a pain.

colonwqbang 12/13/2025|
Has nothing to do with go. You added a dependency which is not portable. It is well known that systemd project only targets Linux.

Vendorise systemd and compile only the journal parts, if they are portable and can be isolated from the rest. Otherwise just shell out to journalctl.

More comments...