app/main.go
package main
import (
"fmt"
"time"
)
func main() {
for {
.Println("Hello, World!")
fmt.Sleep(1 * time.Second)
time}
}
Sparrow
July 14, 2024
Creating a satisfying development experience is essential for fostering a productive and motivated engineering team. A poor development environment can quickly lead to frustration and inefficiency. To address this, I’ve been exploring the use of Skaffold to streamline the development process, enabling rapid and consistent iteration across various services. In this example, I will demonstrate how Skaffold can be utilized to improve the workflow for Go (Golang) applications. The key objectives are:
All files mentioned in this post can be found in this repo
Our approach involves using Skaffold to build our Docker image. This image will include all the necessary tools for building and running the application, as well as any additional utilities required for tasks like debugging (e.g., standard CLI tools such as ps, less, etc.). Since this image is not intended for production deployment, security is not a primary concern so this does not need to be a locked down image.
When a code change is made, Skaffold will detect the change and restart the Go application without restarting the container, thus minimizing iteration overhead. In subsequent posts, I will delve into further optimizations to enhance our iteration speed even more.
Before we begin, let’s talk about init
processes. The popular, lightweight tini is great, but is missing some functionality that we’ll need. Primarily, as mentioned above, we want to restart our application without the container restarting. We have a few options: We could use something like Air or gowatch, but those tools will sit and watch for file changes. That’s redundant since skaffold is already watching for file changes. So what we really need is for skaffold to tell our application to restart. We could build that functionality in our app, but that requires a code change of our production grade app that will only be used for development, so again, isn’t the best idea (not to mention if you have dozens or hundreds of services, changing all of them will not be a fun experience).
So instead, let’s build something ourselves to handle our use case. I’ve built a very simple shell script called devinit.
The script works as following:
./app
or go run .
)kill -USR1 <PID>
) it restarts the applicationkill -TERM <PID>
) it stops the application and shuts downStraightforward enough. Pull the repo and try it out locally. Let’s make a simple go application:
Add a main.go
Run with devinit
Now keep the application running and make a change to main.go (I changed it to say Goodbye, World) and run the following in a different terminal:
And the results:
Now, when we build our Dockerfile, we’ll just need to start using the devinit
process. Then we’ll tell skaffold to signal the script to restart when we’ve made changes.
Let’s create our base dockerfile. This will be changed in future blog posts, but should be a good start.
app/Dockerfile
ARG GO_VERSION=1.22
FROM golang:${GO_VERSION}
ARG GO_VERSION
# init process that enables us to tell go to rebuild/rerun when files have been synced
RUN curl https://raw.githubusercontent.com/sparrowengine/devinit/main/devinit -o /usr/local/bin/devinit && \
chmod 755 /usr/local/bin/devinit
# You can add any other tools you need here (remember, this is only used for dev so image size/security/etc shouldn't be a concern)
# RUN apt update && apt install -y curl vim
# Set up where our current working dir, where our app code will be stored
ARG WORKDIR=/workspace
WORKDIR ${WORKDIR}
# Copy all files (Make sure you have a good .dockerignore to avoid unnecessary rebuilds!)
COPY * ${WORKDIR}/
# Use signaler to call go run. We could have also built the app and run the binary, but this is simpler
ENTRYPOINT ["sh", "-c", "devinit /usr/local/go/bin/go run ."]
The dockerfile is commented and should be fairly straightforward.
It’s important to make sure your .dockerignore
is very restrictive. You don’t want your app to restart or rebuilds to happen when you change README files or files that have no impact on your go app (alternatively, you can be more granular in your Dockerfile COPY - But if you find you’re modifying the Dockerfile frequently, you might be doing something wrong).
Here’s mine:
We’ll start simple here. Create a directory called k8s
and add an app.yaml
file. We’ll be deploying our app as a pod rather than a deployment or something that makes more sense, but this is just for this tutorial. This should match your actual production application as closely as possible (And would ideally be using the same exact files to keep things DRY).
And finally, the skaffold.yaml
file. We’ll only use the build and deploy steps to start
app/skaffold.yaml
The build step is the most important. We give it the Dockerfile and the context (cwd), which is enough just to build the image. In addition, we tell skaffold to infer which files are needed for syncing (hence the importance of .dockerignore). And finally, we tell skaffold that after a file sync happens, to signal our devinit process and tell it to restart the application.
Now spin it up and making file changes to your .go
files will sync the files and restart the application.
Of course, there are still problems to be solved. The next step is to get debugging to work, but that will be part 2.
We’ll use the simple kubectl
deploy method. This is great for small applications, but once you outgrow this, take a look at the helm
deploy method and other options.
Now that we have all the pieces together, let’s try it out.
output
Generating tags...
- blog-skaffold-go/app -> blog-skaffold-go/app:1958d06-dirty
Checking cache...
- blog-skaffold-go/app: Found Locally
Tags used in deployment:
- blog-skaffold-go/app -> blog-skaffold-go/app:2db85477c26067b58cf6b84289fb14f113b354a8cf7f8ac371154ae5c4480543
Starting deploy...
- pod/app created
Waiting for deployments to stabilize...
- pods is ready.
Deployments stabilized in 3.075 seconds
Listing files to watch...
- blog-skaffold-go/app
Press Ctrl+C to exit
Watching for changes...
[app] Hello, World!
[app] Hello, World!
Now change the main.go file and you should see:
output
This is a relatively simple set up, but can easily be used to scale to multiple services. But, we’re not done yet. This handles the happy path well, in that changing a single file will refresh the cluster quickly. But we still need to handle the scenarios where we need to modify dependencies efficiently. In addition, we want to make sure that our debugger can connect to the service so we step through our live code with breakpoints and all the other tools we’re used to. I will save that for the next posts!