Calling a containerized Golang function using RPC
This article demonstrates how to call an exported function of a go program running inside a docker container from the host PC using RPC.
The Demo
The demo code involves:
- RPC server that receives & executes shell commands inside a docker container & writes results back to a client
//server.go (not valid code)
//CmdExecutor cmd process response
type CmdExecutorRes struct{
Stdout string
Stderr string
Err error
}
//CmdExecutor exported RPC object for executing client commands
type CmdExecutor interface{
//Run executes client cmd in blocking mode
//then returns cmd process stdout/stderr & ret code
Run(cmd []string, res *CmdExecutorRes) error
}
func RunServer() {
//test code
cmdE := new(CmdExecutorImpl)
//register CmdExecutor objecy
rpc.Register(cmdE)
rpc.HandleHTTP()
rpc.Serve(":3000")
}
- A client that spawns the containerized RPC server & calls its Run function to execute shell commands in the docker container.
//client.go
//CmdExecutorContainer for starting CmdExecutor RPC server
// in a docker container
type CmdExecutorContainer interface{
//Start CmdExecutor rpc server container
Start()(err error)
//Stop CmdExecutor container
Stop()(err error)
//GetIP of RPC server container
GetIP()(ip string, err error);
}
func RunClient() {
cmdExecutorContainer := &CmdExecutorContainerImpl{}
cmd := []string{"echo", "Hello From Container"}
cmdERes := CmdExecutorRes{}
//spawn container
cmdExecutorContainer.Start()
//get ip of container
containerIP, err := cmdExecutorContainer.GetIP();
//make RPC call
rpc.Conn("tcp", containerIP+":3000")
rpc.Call("CmdExecutorImpl.Run", cmd, &cmdERes)
//log response
log.Printf(cmdERes.Stdout,cmdERes.Stderr,cmdERes.Err)
cmdExecutorContainer.Stop()
}
Even though this demo is in golang it should be possible to reproduce this in any language that has RPC support (for example gRPC) & docker engine sdk. You could write a wrapper around docker binaries if no SDK is available.
This article does not explain how RPC or docker works, you can see some links at the end of this article for those.
Project Structure / Files
server.go
will contain our CmdExecutor RPC Server
package main
func RunServer() {
}
client.go
will spawn CmdExecutor in a container & call its Run function via RPC to execute commands inside the container
package main
func RunClient() {
}
main.go
package main
import (
"log"
"os"
)
func main() {
if len(os.Args) != 2 || (os.Args[1] != "server" && os.Args[1] != "client") {
log.Fatal("Usage: ./main <server | client>")
}
if os.Args[1] == "server" {
RunServer()
} else {
RunClient()
}
}
Dockerfile
An ubuntu based image for our CMDExecutor RPC server. Make sure your compiled binary can run in your base image platform.
FROM ubuntu:20.04
COPY cmdExecutor ./
EXPOSE 3000
CMD [ "./cmdExecutor", "server"]
#init project
go mod init
#build go binary
go build -o cmdExecutor
#build docker image
docker build -t "cmdexecutor" .
Executing Shell Commands from Golang
We will first need to write a simple command executor that will live inside our docker container to execute client’s commands and return response, I will use golang’s OS package to execute the command in blocking mode then write the response back to the client’s struct.
package main
import (
"bytes"
"os/exec"
"log"
)
//CmdExecutor exported RPC object for executing client commands
type CmdExecutor int
//CmdExecutor cmd process response
type CmdExecutorRes struct{
Stdout string
Stderr string
Err error
}
//Run executes client cmd in blocking mode then returns cmd process stdout/stderr & ret code
func (r *CmdExecutor) Run(cmdStr []string, res *CmdExecutorRes) error{
log.Print("Received RPC: ", cmdStr)
cmd := exec.Command(cmdStr[0], cmdStr[1:]...)
stdout := bytes.Buffer{}
stderr := bytes.Buffer{}
cmd.Stdout = &stdout
cmd.Stderr = &stderr
res.Err = cmd.Run() //blocking
if res.Err != nil{
return res.Err
}
res.Stdout = stdout.String()
res.Stderr = stderr.String()
return res.Err;
}
func RunServer() {
//test code
cmdE := new(CmdExecutor)
cmdERes := CmdExecutorRes{}
cmd := []string{"echo", "Hello From Container"}
cmdE.Run(cmd, &cmdERes)
log.Printf("stdout: %v\nstderr: %v\nerr: %v\n", cmdERes.Stdout,cmdERes.Stderr,cmdERes.Err)
}
#build
go build -o cmdExecutor
#rebuild docker image
docker build -t "cmdexecutor" .
#run cmdExecutor
docker run cmdexecutor
Add RPC Server
Golang has an inbuilt net RPC package that enables us to call exported functions of an object remotely. In our case the object would be CmdExecutor that would enable a client to execute commands inside the container.
package main
import (
...
"net"
"net/http"
"net/rpc"
)
type CmdExecutor int
type CmdExecutorRes struct{
...
}
func (r *CmdExecutor) Run(cmdStr []string, res *CmdExecutorRes) error{
...
return res.Err;
}
func runRPCServer(cmdE *CmdExecutor) error{
rpc.Register(cmdE)
rpc.HandleHTTP()
listener, err := net.Listen("tcp", ":3000")
if err != nil{
return err;
}
log.Println("RPC Server Running on :3000")
err = http.Serve(listener, nil)
return err;
}
func RunServer() {
//test code
cmdE := new(CmdExecutor)
if err := runRPCServer(cmdE); err != nil{
log.Fatal("RPC Server Error: ", err);
}
}
# rebuild & run RPC server
go build -o cmdExecutor
docker build -t "cmdexecutor" .
docker run cmdexecutor
Calling Run function via RPC
Now that we have our RPC server running inside the container its time we wrote the client that connects to it and calls the Run function to execute commands inside the docker container.
package main
import (
"log"
"net/rpc"
)
func RunClient() {
client, err := rpc.DialHTTP("tcp", "127.0.0.1:3000")
if err != nil{
log.Fatal("rpc.DialHTTP: ", err)
}
cmd := []string{"echo", "Hello From Container"}
cmdERes := CmdExecutorRes{}
err = client.Call("CmdExecutor.Run", &cmd, &cmdERes)
if err != nil{
log.Fatal("client.Call: ", err)
}
log.Printf("stdout: %v\nstderr: %v\nerr: %v\n", cmdERes.Stdout,cmdERes.Stderr,cmdERes.Err)
}
You will have to run the server on the host PC for now, till we have a way of getting its docker IP (hint: docker inspect <container_id>
)
#build
go build -o cmdExecutor
#run server on host pc
./cmdExecutor server
# on another terminal
./cmdExecutor client
Spawning RPC container from client & getting its IP
Since our cmd executor will live in a docker container, we need a way to spawn the container & get its IP so we can be able to connect to the RPC server. For this I used docker golang’s SDK
package main
import (
"log"
"net/rpc"
"time"
"errors"
"context"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/client"
)
type CmdExecutorContainer struct{
ctx context.Context
client *client.Client
ct container.ContainerCreateCreatedBody
}
func (a *CmdExecutorContainer) Start()(err error){
a.ctx = context.Background()
a.client, err = client.NewClientWithOpts(client.FromEnv)
if err != nil{
return
}
a.ct, err = a.client.ContainerCreate(
a.ctx,
&container.Config{
Image: "cmdexecutor:latest",
},
nil, nil, nil, "",
)
if err != nil{
return
}
err = a.client.ContainerStart(a.ctx, a.ct.ID, types.ContainerStartOptions{})
return
}
func (a *CmdExecutorContainer) Stop()(err error){
err = a.client.ContainerStop(a.ctx, a.ct.ID, nil)
return
}
func (a *CmdExecutorContainer) GetIP()(ip string, err error){
res, err := a.client.ContainerInspect(a.ctx, a.ct.ID)
if err != nil{
return
}
if res.NetworkSettings == nil{
return ip, errors.New("NetworkSettings nil")
}
ip = res.NetworkSettings.IPAddress
return
}
func RunClient() {
cmdExecutorContainer := CmdExecutorContainer{}
cmd := []string{"echo", "Hello From Container"}
cmdERes := CmdExecutorRes{}
if err := cmdExecutorContainer.Start(); err != nil{
log.Fatal("ContainerStart ", err)
}
defer cmdExecutorContainer.Stop()
time.Sleep(time.Second * 1) // let container start, @todo poll docker inspect
containerIP, err := cmdExecutorContainer.GetIP();
if err != nil{
log.Println("Err GetIP ", err)
return
}
log.Println("CmdExecutorContainer IP ", containerIP)
client, err := rpc.DialHTTP("tcp", containerIP+":3000")
if err != nil{
log.Println("rpc.DialHTTP: ", err)
return
}
if err = client.Call("CmdExecutor.Run", cmd, &cmdERes); err != nil{
log.Println("Err client.Call: ", err)
return
}
log.Printf("stdout: %v\nstderr: %v\nerr: %v\n", cmdERes.Stdout,cmdERes.Stderr,cmdERes.Err)
}
#install docker SDK
go mod tidy
#build
go build -o cmdExecutor
#rebuild image
docker build -t "cmdexecutor" .
#spawn container & call function via RPC demo
./cmdExecutor client
I get this results on my PC
$ ./cmdExecutor client
CmdExecutorContainer IP 172.17.0.2
stdout: Hello From Container
stderr:
err: <nil>
An interesting exercise to attempt would be to execute the command in non-blocking mode then stream the results (stdout/stderr) back to client as the command executes. Or calling the function from another language (see rpcjson or grpc).
Resources & Further reading
Its important to note that a docker process is not a sandbox and should not be used to execute untrusted user code, see projects like gVisor or firecracker to see how to execute untrusted code in a container.
- https://en.wikipedia.org/wiki/Remote_procedure_call
- https://docs.docker.com/
- https://pkg.go.dev/net/rpc
You can find the sources here https://github.com/jakhax/myblog/tree/master/src/rpc_into_container