FishFinger is a Dockerfile and Docker-Compose lightweight programmatic library written in Go. This project provides easy to use abstractions of official Docker libraries with no other dependency than official Go library and official Docker libraries.
FishFinger also provides a one possible solution to deal with container initialization delay and makes room for further improvement through user-defined solutions.
This project is fully tested and comes with an extensive documentation.
First of all, you need to install and configure the latest Docker version on your machine.
Then go get this repository:
go get -d github.com/timtosi/fishfinger
Finally, check how to use the components provided:
- Use one or more services from a Compose file
- Use one or more services from a Compose file with a Backoff
The fishfinger.Compose
component represents an abstraction of the libcompose
library. It provides functions allowing the user to use a Compose file
programmatically.
An extensive documentation of this component is available here.
Let's say you have a basic Compose file where you defined three redis services.
For instance, you could want to be able to write a test suite that will directly make use of your containers instead of simply mocking their behaviour through burdensome to write functions.
First of all, let's create a new Compose file handler by using the
fishfinger.NewCompose
function that takes the path to your Compose file as an
argument.
func main() {
c, err := fishfinger.NewCompose("./docker-compose.yaml")
if err != nil {
log.Fatal(err)
}
}
Time to start services.
func main() {
c, err := fishfinger.NewCompose("./docker-compose.yaml")
if err != nil {
log.Fatal(err)
}
if err := c.Start(); err != nil {
log.Fatal(err)
}
}
As no argument is provided, all services will be started, but Compose.Start
function accepts a variable number of service name in argument and will start
them in the order specified.
At this stage, you should have a neat redis cluster running. The Redis client
ports you previously set for each of your services are the 6379
but you had to
specify different available ports on your host machine. You can hard code the
different ports OR you can use the Compose.Port
function to find the correct
port to use from a service name and the combination of the port exposed and the
protocol used such as 6379/tcp
. This function returns the full address in the
following form <host>:<port>
.
func main() {
c, err := fishfinger.NewCompose("./docker-compose.yaml")
if err != nil {
log.Fatal(err)
}
if err := c.Start(); err != nil {
log.Fatal(err)
}
addr, err := c.Port("redis-01", "6379/tcp")
if err != nil {
log.Fatal(err)
}
}
The Compose.Port
function will return the full address in the following form
<host>:<port>
. Now you can instanciate your redis client and do whatever stuff
you want.
In the end, you maybe want to clean the containers created. Just use the
Compose.Stop
function just as you would use the Compose.Start
function.
func main() {
c, err := fishfinger.NewCompose("./docker-compose.yaml")
if err != nil {
log.Fatal(err)
}
if err := c.Start(); err != nil {
log.Fatal(err)
}
addr, err := c.Port("redis-01", "6379/tcp")
if err != nil {
log.Fatal(err)
}
if err := c.Stop(); err != nil {
log.Fatal(err)
}
}
As no argument is provided, all services will be stopped.
Complete working code can be found here.
When you want to use Docker in a programmatic way, you can encounter a particular
issues regarding the definition of a container's ready
state: the container state
and the state of the software running inside the container are decoupled.
That means a container will be considered ready
regardless of the software
state, leading to programmatic errors. Moreover, that is not because the main
dockerized software is running that you consider your container ready.
Let's say you have a basic Compose file where you defined a service running a SQL database. In this case, you are using it for testing purposes because you do not want to use a mocking driver. Basically, you could build an image with all your data preloaded but currently you do not want to rebuild your image each time you update your dataset because it's evolving frequently.
As you can see at line #40, in this example, you find a solution by using a container that mounts a volume where you put all .sql scripts you use for populating the database at initialization.
Here is a visualisation of the three ready
states this Docker container has :
+--------------------+ +-------------+ +-------------------+
| CONTAINER IS READY | --> | MYSQL IS UP | --> | DATA ARE INSERTED |
+--------------------+ +-------------+ +-------------------+
You can't rely on Docker to know when your container is ready and you can't rely on a time constant because the data insertion step is variable depending on the data you will have to set.
There are several ways to tackle this problem and FishFinger allow you to resolve
this easily by using the Compose.StartBackoff
function.
First of all, let's create a new Compose file handler by using the
fishfinger.NewCompose
function that takes the path to your Compose file as an
argument.
func main() {
c, err := fishfinger.NewCompose("./docker-compose.yaml")
if err != nil {
log.Fatal(err)
}
}
Now, time to start the MySQL service with the Compose.StartBackoff
function.
func main() {
c, err := fishfinger.NewCompose("./docker-compose.yaml")
if err != nil {
log.Fatal(err)
}
if err := c.StartBackoff(fishfinger.SocketBackoff, "datastore:9090/tcp"); err != nil {
log.Fatal(err)
}
}
This function takes two arguments: the Backoff function used and a variable
number of strings
composed of the service name, a port and a protocol used
such as redis:6379/tcp
.The Backoff function used here is the one provided by
default by the Fishfinger project but you are expected to provide another one
that suits your needs.
func SocketBackoff(c *Compose, service, port string) error {
var (
msg string
conn net.Conn
)
addr, err := c.Port(service, port)
if err != nil {
return err
}
for ; msg != "ready\n"; time.Sleep(5 * time.Second) {
if conn, err = net.Dial("tcp", addr); err == nil {
fmt.Fprintf(conn, "ready\n")
msg, _ = bufio.NewReader(conn).ReadString('\n')
conn.Close()
}
fmt.Printf("Retry connection in 5s.\n")
}
return nil
}
It's only keep trying to connect to a specific port exposed by the container. The fact is the function will not find any remote listener until all data is correctly loaded, as you can see here. In this way, you are assured everything is ready to be processed by the rest of your program.
Complete working code can be found here.
Every file provided here is available under the MIT License.
The logo is a derivative of "BLUE_GOPHER.png" by Ashley McNamara, used under CC BY. This logo is licensed under CC BY by Tim Tosi.
If you encouter any issue by using what is provided here, please let me know ! Help me to improve by sending your thoughts to timothee.tosi@gmail.com !