Using Go as a scripting language in Linux
Ignat Korchagin
Posted on April 18, 2020
This is a repost of my post from the Cloudflare Blog
At Cloudflare we like Go. We use it in many in-house software projects as well as parts of bigger pipeline systems. But can we take Go to the next level and use it as a scripting language for our favourite operating system, Linux?
gopher image CC BY 3.0 Renee French
Tux image CC0 BY OpenClipart-Vectors
Why consider Go as a scripting language
Short answer: why not? Go is relatively easy to learn, not too verbose and there is a huge ecosystem of libraries which can be reused to avoid writing all the code from scratch. Some other potential advantages it might bring:
- Go-based build system for your Go project:
go build
command is mostly suitable for small, self-contained projects. More complex projects usually adopt some build system/set of scripts. Why not have these scripts written in Go then as well? - Easy non-privileged package management out of the box: if you want to use a third-party library in your script, you can simply
go get
it. And because the code will be installed in yourGOPATH
, getting a third-party library does not require administrative privileges on the system (unlike some other scripting languages). This is especially useful in large corporate environments. - Quick code prototyping on early project stages: when you're writing the first iteration of the code, it usually takes a lot of edits even to make it compile and you have to waste a lot of keystrokes on "edit->build->check" cycle. Instead you can skip the "build" part and just immediately execute your source file.
- Strongly-typed scripting language: if you make a small typo somewhere in the middle of the script, most scripts will execute everything up to that point and fail on the typo itself. This might leave your system in an inconsistent state. With strongly-typed languages many typos can be caught at compile time, so the buggy script will not run in the first place.
Current state of Go scripting
At first glance Go scripts seem easy to implement with Unix support of shebang lines for scripts. A shebang line is the first line of the script, which starts with #!
and specifies the script interpreter to be used to execute the script (for example, #!/bin/bash
or #!/usr/bin/env python
), so the system knows exactly how to execute the script regardless of the programming language used. And Go already supports interpreter-like invocation for .go
files with go run
command, so it should be just a matter of adding a proper shebang line, something like #!/usr/bin/env go run
, to any .go
file, setting the executable bit and we're good to go.
However, there are problems around using go run
directly. This great post describes in detail all the issues around go run
and potential workarounds, but the gist is:
-
go run
does not properly return the script error code back to the operating system and this is important for scripts, because error codes are one of the most common ways multiple scripts interact with each other and the operating system environment. - you can't have a shebang line in a valid
.go
file, because Go does not know how to process lines starting with#
. Other scripting languages do not have this problem, because for most of them#
is a way to specify comments, so the final interpreter just ignores the shebang line, but Go comments start with//
andgo run
on invocation will just produce an error like:
package main:
helloscript.go:1:1: illegal character U+0023 '#'
The post describes several workarounds for above issues including using a custom wrapper program gorun as an interpreter, but all of them do not provide an ideal solution. You either:
- have to use non-standard shebang line, which starts with
//
. This is technically not even a shebang line, but the way howbash
shell processes executable text files, so this solution isbash
specific. Also, because of the specific behaviour ofgo run
, this line is rather complex and not obvious (see original post for examples). - have to use a custom wrapper program gorun in the shebang line, which works well, however, you end up with
.go
files, which are not compilable with standardgo build
command because of the illegal#
character.
How Linux executes files
OK, it seems the shebang approach does not provide us with an all-rounder solution. Is there anything else we could use? Let's take a closer look how Linux kernel executes binaries in the first place. When you try to execute a binary/script (or any file for that matter which has executable bit set), your shell in the end will just use Linux execve
system call passing it the filesystem path of the binary in question, command line parameters and currently defined environment variables. Then the kernel is responsible for correct parsing of the file and creating a new process with the code from the file. Most of us know that Linux (and many other Unix-like operating systems) use ELF binary format for its executables.
However, one of the core principles of Linux kernel development is to avoid "vendor/format lock-in" for any subsystem, which is part of the kernel. Therefore, Linux implements a "pluggable" system, which allows any binary format to be supported by the kernel - all you have to do is to write a correct module, which can parse the format of your choosing. And if you take a closer look at the kernel source code, you'll see that Linux supports more binary formats out of the box. For example, for the recent 4.14
Linux kernel we can see that it supports at least 7 binary formats (in-tree modules for various binary formats usually have binfmt_
prefix in their names). It is worth to note the binfmt_script module, which is responsible for parsing above mentioned shebang lines and executing scripts on the target system (not everyone knows that the shebang support is actually implemented in the kernel itself and not in the shell or other daemon/process).
Extending supported binary formats from userspace
But since we concluded that shebang is not the best option for our Go scripting, seems we need something else. Surprisingly Linux kernel already has a "something else" binary support module, which has an appropriate name binfmt_misc
. The module allows an administrator to dynamically add support for various executable formats directly from userspace through a well-defined procfs
interface and is well-documented.
Let's follow the documentation and try to setup a binary format description for .go
files. First of all the guide tells you to mount special binfmt_misc
filesystem to /proc/sys/fs/binfmt_misc
. If you're using relatively recent systemd-based Linux distribution, it is highly likely the filesystem is already mounted for you, because systemd by default installs special mount and automount units for this purpose. To double-check just run:
$ mount | grep binfmt_misc
systemd-1 on /proc/sys/fs/binfmt_misc type autofs (rw,relatime,fd=27,pgrp=1,timeout=0,minproto=5,maxproto=5,direct)
Another way is to check if you have any files in /proc/sys/fs/binfmt_misc
: properly mounted binfmt_misc
filesystem will create at least two special files with names register
and status
in that directory.
Next, since we do want our .go
scripts to be able to properly pass the exit code to the operating system, we need the custom gorun wrapper as our "interpreter":
$ go get github.com/erning/gorun
$ sudo mv ~/go/bin/gorun /usr/local/bin/
Technically we don't need to move gorun
to /usr/local/bin
or any other system path as binfmt_misc
requires full path to the interpreter anyway, but the system may run this executable with arbitrary privileges, so it is a good idea to limit access to the file from security perspective.
At this point let's create a simple toy Go script helloscript.go
and verify we can successfully "interpret" it. The script:
package main
import (
"fmt"
"os"
)
func main() {
s := "world"
if len(os.Args) > 1 {
s = os.Args[1]
}
fmt.Printf("Hello, %v!", s)
fmt.Println("")
if s == "fail" {
os.Exit(30)
}
}
Checking if parameter passing and error handling works as intended:
$ gorun helloscript.go
Hello, world!
$ echo $?
0
$ gorun helloscript.go gopher
Hello, gopher!
$ echo $?
0
$ gorun helloscript.go fail
Hello, fail!
$ echo $?
30
Now we need to tell binfmt_misc
module how to execute our .go
files with gorun
. Following the documentation we need this configuration string: :golang:E::go::/usr/local/bin/gorun:OC
, which basically tells the system: "if you encounter an executable file with .go
extension, please, execute it with /usr/local/bin/gorun
interpreter". The OC
flags at the end of the string make sure, that the script will be executed according to the owner information and permission bits set on the script itself, and not the ones set on the interpreter binary. This makes Go script execution behaviour same as the rest of the executables and scripts in Linux.
Let's register our new Go script binary format:
$ echo ':golang:E::go::/usr/local/bin/gorun:OC' | sudo tee /proc/sys/fs/binfmt_misc/register
:golang:E::go::/usr/local/bin/gorun:OC
If the system successfully registered the format, a new file golang
should appear under /proc/sys/fs/binfmt_misc
directory. Finally, we can natively execute our .go
files:
$ chmod u+x helloscript.go
$ ./helloscript.go
Hello, world!
$ ./helloscript.go gopher
Hello, gopher!
$ ./helloscript.go fail
Hello, fail!
$ echo $?
30
That's it! Now we can edit helloscript.go
to our liking and see the changes will be immediately visible the next time the file is executed. Moreover, unlike the previous shebang approach, we can compile this file any time into a real executable with go build
.
Posted on April 18, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.