Use Services in Dagger
Dagger v0.9.0 includes a breaking change for binding service containers. The Container.withServiceBinding
API now takes a Service
instead of a Container
, so you must call Container.asService
on its argument. See the section on binding service containers for examples.
Introduction
Dagger v0.4.0 introduced service containers, aka container-to-container networking. This feature enables users to spin up additional long-running services (as containers) and communicate with those services from their Dagger pipelines. Dagger v0.9.0 further improved this implementation, enabling support for container-to-host networking and host-to-container networking.
Some common use cases for services and service containers are:
- Run a test database
- Run end-to-end integration tests
- Run sidecar services
This guide teaches you the basics of using services and service containers in Dagger.
Requirements
This guide assumes that:
- You have a Go, Python, or Node.js development environment. If not, install Go, Python, or Node.js.
- You have a Dagger SDK installed for one of the above languages. If not, follow the installation instructions for the Dagger Go, Python, or Node.js SDK.
- You have Docker installed and running on the host system. If not, install Docker.
Key concepts
Dagger's service containers have the following characteristics:
- Each service container has a canonical, content-addressed hostname and an optional set of exposed ports
- Service containers can bind to other containers as services
Service containers come with the following built-in features:
- Service containers are started just-in-time, de-duplicated, and stopped when no longer needed
- Service containers are health checked prior to running clients
- Service containers are given an alias for the client container to use as its hostname
Working with service hostnames and ports
Each service container has a canonical, content-addressed hostname and an optional set of exposed ports.
- Go
- Node.js
- Python
You can query a service container's canonical hostname by calling the Service.Hostname()
SDK method.
package main
import (
"context"
"fmt"
"os"
"dagger.io/dagger"
)
func main() {
ctx := context.Background()
// create Dagger client
client, err := dagger.Connect(ctx, dagger.WithLogOutput(os.Stderr))
if err != nil {
panic(err)
}
defer client.Close()
// get hostname of service container via API
val, err := client.Container().
From("python").
WithExec([]string{"python", "-m", "http.server"}).
AsService().
Hostname(ctx)
if err != nil {
panic(err)
}
fmt.Println(val)
}
You can query a service container's canonical hostname by calling the Service.hostname()
SDK method.
import { connect, Client } from "@dagger.io/dagger"
connect(
async (client: Client) => {
// get hostname of service container
const val = await client
.container()
.from("python")
.withExec(["python", "-m", "http.server"])
.asService()
.hostname()
console.log(val)
},
{ LogOutput: process.stderr },
)
You can query a service container's canonical hostname by calling the Service.hostname()
SDK method.
import sys
import anyio
import dagger
async def main():
# create Dagger client
async with dagger.Connection(dagger.Config(log_output=sys.stderr)) as client:
# get hostname of service container via API
val = await (
client.container()
.from_("python")
.with_exec(["python", "-m", "http.server"])
.as_service()
.hostname()
)
print(val)
anyio.run(main)
You can also define the ports on which the service container will listen. Dagger checks the health of each exposed port prior to running any clients that use the service, so that clients don't have to implement their own polling logic.
- Go
- Node.js
- Python
This example uses the WithExposedPort()
method to set ports on which the service container will listen. Note also the Endpoint()
helper method, which returns an address pointing to a particular port, optionally with a URL scheme. You can either specify a port or let Dagger pick the first exposed port.
package main
import (
"context"
"fmt"
"os"
"dagger.io/dagger"
)
func main() {
ctx := context.Background()
// create Dagger client
client, err := dagger.Connect(ctx, dagger.WithLogOutput(os.Stderr))
if err != nil {
panic(err)
}
defer client.Close()
// create HTTP service container with exposed port 8080
httpSrv := client.Container().
From("python").
WithDirectory("/srv", client.Directory().WithNewFile("index.html", "Hello, world!")).
WithWorkdir("/srv").
WithExec([]string{"python", "-m", "http.server", "8080"}).
WithExposedPort(8080).
AsService()
// get endpoint
val, err := httpSrv.Endpoint(ctx)
if err != nil {
panic(err)
}
fmt.Println(val)
// get HTTP endpoint
val, err = httpSrv.Endpoint(ctx, dagger.ServiceEndpointOpts{
Scheme: "http",
})
if err != nil {
panic(err)
}
fmt.Println(val)
}
This example uses the withExposedPort()
method to set ports on which the service container will listen. Note also the endpoint()
helper method, which returns an address pointing to a particular port, optionally with a URL scheme. You can either specify a port or let Dagger pick the first exposed port.
import { connect, Client } from "@dagger.io/dagger"
connect(
async (client: Client) => {
// create HTTP service container with exposed port 8080
const httpSrv = client
.container()
.from("python")
.withDirectory(
"/srv",
client.directory().withNewFile("index.html", "Hello, world!"),
)
.withWorkdir("/srv")
.withExec(["python", "-m", "http.server", "8080"])
.withExposedPort(8080)
.asService()
// get HTTP endpoint
let val = await httpSrv.endpoint()
console.log(val)
val = await httpSrv.endpoint({ scheme: "http" })
console.log(val)
},
{ LogOutput: process.stderr },
)
This example uses the with_exposed_port()
method to set ports on which the service container will listen. Note also the endpoint()
helper method, which returns an address pointing to a particular port, optionally with a URL scheme. You can either specify a port or let Dagger pick the first exposed port.
import sys
import anyio
import dagger
async def main():
# create Dagger client
async with dagger.Connection(dagger.Config(log_output=sys.stderr)) as client:
# create HTTP service container with exposed port 8080
http_srv = (
client.container()
.from_("python")
.with_directory(
"/srv",
client.directory().with_new_file("index.html", "Hello, world!"),
)
.with_workdir("/srv")
.with_exec(["python", "-m", "http.server", "8080"])
.with_exposed_port(8080)
.as_service()
)
# get endpoint
val = await http_srv.endpoint()
# get HTTP endpoint
val_scheme = await http_srv.endpoint(scheme="http")
print(val)
print(val_scheme)
anyio.run(main)
In practice, you are more likely to set your own hostname aliases with service bindings, which are covered in the next section.
Working with services
You can use services in Dagger in three ways:
- Bind service containers
- Expose service containers to the host
- Expose host services to client containers
Services are automatically started when needed and stopped when no longer needed. Dagger cancels each service run after a 10 second grace period to avoid frequent restarts. For more information, read how service binding works or start/stop services explicitly if you need more control.
Bind service containers
Dagger v0.9.0 includes a breaking change for binding service containers. The examples below have been updated.
Dagger enables users to bind a service running in a container to another (client) container with an alias that the client container can use as a hostname to communicate with the service.
Binding a service to a container or the host creates a dependency in your Dagger pipeline. The service container needs to be running when the client container runs. The bound service container is started automatically whenever its client container runs.
Here's an example of an HTTP service automatically starting in tandem with a client container. The service binding enables the client container to access the HTTP service using the alias www
.
- Go
- Node.js
- Python
package main
import (
"context"
"fmt"
"os"
"dagger.io/dagger"
)
func main() {
ctx := context.Background()
// create Dagger client
client, err := dagger.Connect(ctx, dagger.WithLogOutput(os.Stderr))
if err != nil {
panic(err)
}
defer client.Close()
// create HTTP service container with exposed port 8080
httpSrv := client.Container().
From("python").
WithDirectory("/srv", client.Directory().WithNewFile("index.html", "Hello, world!")).
WithWorkdir("/srv").
WithExec([]string{"python", "-m", "http.server", "8080"}).
WithExposedPort(8080).
AsService()
// create client container with service binding
// access HTTP service and print result
val, err := client.Container().
From("alpine").
WithServiceBinding("www", httpSrv).
WithExec([]string{"wget", "-O-", "http://www:8080"}).
Stdout(ctx)
if err != nil {
panic(err)
}
fmt.Println(val)
}
import { connect, Client } from "@dagger.io/dagger"
connect(
async (client: Client) => {
// create HTTP service container with exposed port 8080
const httpSrv = client
.container()
.from("python")
.withDirectory(
"/srv",
client.directory().withNewFile("index.html", "Hello, world!"),
)
.withWorkdir("/srv")
.withExec(["python", "-m", "http.server", "8080"])
.withExposedPort(8080)
.asService()
// create client container with service binding
// access HTTP service and print result
const val = await client
.container()
.from("alpine")
.withServiceBinding("www", httpSrv)
.withExec(["wget", "-qO-", "http://www:8080"])
.stdout()
console.log(val)
},
{ LogOutput: process.stderr },
)
import sys
import anyio
import dagger
async def main():
# create Dagger client
async with dagger.Connection(dagger.Config(log_output=sys.stderr)) as client:
# create HTTP service container with exposed port 8080
http_srv = (
client.container()
.from_("python")
.with_directory(
"/srv",
client.directory().with_new_file("index.html", "Hello, world!"),
)
.with_workdir("/srv")
.with_exec(["python", "-m", "http.server", "8080"])
.with_exposed_port(8080)
.as_service()
)
# create client container with service binding
# access HTTP service and print result
val = await (
client.container()
.from_("alpine")
.with_service_binding("www", http_srv)
.with_exec(["wget", "-O-", "http://www:8080"])
.stdout()
)
print(val)
anyio.run(main)
Services in service containers should be configured to listen on the IP address 0.0.0.0 instead of 127.0.0.1. This is because 127.0.0.1 is only reachable within the container itself, so other services (including the Dagger health check) won't be able to connect to it. Using 0.0.0.0 allows connections to and from any IP address, including the container's private IP address in the Dagger network.
When a service is bound to a container, it also conveys to any outputs of that container, such as files or directories. The service will be started whenever the output is used, so you can also do things like this:
- Go
- Node.js
- Python
package main
import (
"context"
"fmt"
"os"
"dagger.io/dagger"
)
func main() {
ctx := context.Background()
// create Dagger client
client, err := dagger.Connect(ctx, dagger.WithLogOutput(os.Stderr))
if err != nil {
panic(err)
}
defer client.Close()
// create HTTP service container with exposed port 8080
httpSrv := client.Container().
From("python").
WithDirectory("/srv", client.Directory().WithNewFile("index.html", "Hello, world!")).
WithWorkdir("/srv").
WithExec([]string{"python", "-m", "http.server", "8080"}).
WithExposedPort(8080).
AsService()
// create client container with service binding
// access HTTP service, write to file and retrieve contents
val, err := client.Container().
From("alpine").
WithServiceBinding("www", httpSrv).
WithExec([]string{"wget", "http://www:8080"}).
File("index.html").
Contents(ctx)
if err != nil {
panic(err)
}
fmt.Println(val)
}
import { connect, Client } from "@dagger.io/dagger"
connect(
async (client: Client) => {
// create HTTP service container with exposed port 8080
const httpSrv = client
.container()
.from("python")
.withDirectory(
"/srv",
client.directory().withNewFile("index.html", "Hello, world!"),
)
.withWorkdir("/srv")
.withExec(["python", "-m", "http.server", "8080"])
.withExposedPort(8080)
.asService()
// create client container with service binding
// access HTTP service, write to file and retrieve contents
const val = await client
.container()
.from("alpine")
.withServiceBinding("www", httpSrv)
.withExec(["wget", "http://www:8080"])
.file("index.html")
.contents()
console.log(val)
},
{ LogOutput: process.stderr },
)
import sys
import anyio
import dagger
async def main():
# create Dagger client
async with dagger.Connection(dagger.Config(log_output=sys.stderr)) as client:
# create HTTP service container with exposed port 8080
http_srv = (
client.container()
.from_("python")
.with_directory(
"/srv",
client.directory().with_new_file("index.html", "Hello, world!"),
)
.with_workdir("/srv")
.with_exec(["python", "-m", "http.server", "8080"])
.with_exposed_port(8080)
.as_service()
)
# create client container with service binding
# access HTTP service, write to file and retrieve contents
val = await (
client.container()
.from_("alpine")
.with_service_binding("www", http_srv)
.with_exec(["wget", "http://www:8080"])
.file("index.html")
.contents()
)
print(val)
anyio.run(main)
Expose service containers to the host
Starting with Dagger v0.9.0, you can expose service container ports directly to the host. This enables clients on the host to communicate with services running in Dagger.
One use case is for testing, where you need to be able to spin up ephemeral databases to run tests against. You might also use this to access a web UI in a browser on your desktop.
Here's an example of how to use Dagger services on the host. In this example, the host makes HTTP requests to an HTTP service running in a container.
- Go
- Node.js
- Python
package main
import (
"context"
"fmt"
"io"
"net/http"
"os"
"dagger.io/dagger"
)
func main() {
ctx := context.Background()
// create Dagger client
client, err := dagger.Connect(ctx, dagger.WithLogOutput(os.Stderr))
if err != nil {
panic(err)
}
defer client.Close()
// create HTTP service container with exposed port 8080
httpSrv := client.Container().
From("python").
WithDirectory("/srv", client.Directory().WithNewFile("index.html", "Hello, world!")).
WithWorkdir("/srv").
WithExec([]string{"python", "-m", "http.server", "8080"}).
WithExposedPort(8080).
AsService()
// expose HTTP service to host
tunnel, err := client.Host().Tunnel(httpSrv).Start(ctx)
if err != nil {
panic(err)
}
defer tunnel.Stop(ctx)
// get HTTP service address
srvAddr, err := tunnel.Endpoint(ctx)
if err != nil {
panic(err)
}
// access HTTP service from host
res, err := http.Get("http://" + srvAddr)
if err != nil {
panic(err)
}
defer res.Body.Close()
// print response
body, err := io.ReadAll(res.Body)
if err != nil {
panic(err)
}
fmt.Println(string(body))
}
The Dagger pipeline calls Host.Tunnel(service).Start()
to create a new Service
. By default, Dagger lets the operating system randomly choose which port to use based on the available ports on the host's side. Finally, a call to Service.Endpoint()
gets the final address with whichever port is bound.
import { connect, Client } from "@dagger.io/dagger"
import fetch from "node-fetch"
connect(
async (client: Client) => {
// create HTTP service container with exposed port 8080
const httpSrv = client
.container()
.from("python")
.withDirectory(
"/srv",
client.directory().withNewFile("index.html", "Hello, world!"),
)
.withWorkdir("/srv")
.withExec(["python", "-m", "http.server", "8080"])
.withExposedPort(8080)
.asService()
// expose HTTP service to host
const tunnel = await client.host().tunnel(httpSrv).start()
// get HTTP service address
const srvAddr = await tunnel.endpoint()
// access HTTP service from host
// print response
await fetch("http://" + srvAddr)
.then((res) => res.text())
.then((body) => console.log(body))
},
{ LogOutput: process.stderr },
)
The Dagger pipeline calls Host.Tunnel(service).Start()
to create a new Service
. By default, Dagger lets the operating system randomly choose which port to use based on the available ports on the host's side. Finally, a call to Service.Endpoint()
gets the final address with whichever port is bound.
import sys
import anyio
import httpx
import dagger
async def main():
# create Dagger client
async with dagger.Connection(dagger.Config(log_output=sys.stderr)) as client:
# create HTTP service container with exposed port 8080
http_srv = (
client.container()
.from_("python")
.with_directory(
"/srv",
client.directory().with_new_file("index.html", "Hello, world!"),
)
.with_workdir("/srv")
.with_exec(["python", "-m", "http.server", "8080"])
.with_exposed_port(8080)
.as_service()
)
# expose HTTP service to host
tunnel = await client.host().tunnel(http_srv).start()
# get HTTP service address
endpoint = await tunnel.endpoint()
# access HTTP service from host
async with httpx.AsyncClient() as http:
r = await http.get(f"http://{endpoint}")
print(r.status_code)
print(r.text)
anyio.run(main)
The Dagger pipeline calls host.tunnel(service).start()
to create a new Service
. By default, Dagger lets the operating system randomly choose which port to use based on the available ports on the host's side. Finally, a call to Service.endpoint()
gets the final address with whichever port is bound.
Expose host services to containers
Starting with Dagger v0.9.0, you can bind containers to host services. This enables client containers in Dagger pipelines to communicate with services running on the host.
This implies that a service is already listening on a port on the host, out-of-band of Dagger.
Here's an example of how a container running in a Dagger pipeline can access a service on the host. In this example, a container in a Dagger pipeline queries a MariaDB database service running on the host. Before running the pipeline, use the following command to start a MariaDB database service on the host:
docker run --rm --detach -p 3306:3306 --name my-mariadb --env MARIADB_ROOT_PASSWORD=secret mariadb:10.11.2
- Go
- Node.js
- Python
package main
import (
"context"
"fmt"
"os"
"dagger.io/dagger"
)
func main() {
ctx := context.Background()
// create Dagger client
client, err := dagger.Connect(ctx, dagger.WithLogOutput(os.Stderr))
if err != nil {
panic(err)
}
defer client.Close()
// expose host service on port 3306
hostSrv := client.Host().Service([]dagger.PortForward{
{Frontend: 3306, Backend: 3306},
})
// create MariaDB container
// with host service binding
// execute SQL query on host service
out, err := client.Container().
From("mariadb:10.11.2").
WithServiceBinding("db", hostSrv).
WithExec([]string{"/bin/sh", "-c", "/usr/bin/mysql --user=root --password=secret --host=db -e 'SELECT * FROM mysql.user'"}).
Stdout(ctx)
if err != nil {
panic(err)
}
fmt.Println(out)
}
import { connect, Client } from "@dagger.io/dagger"
connect(
async (client: Client) => {
// expose host service on port 3306
const hostSrv = client.host().service([{ frontend: 3306, backend: 3306 }])
// create MariaDB container
// with host service binding
// execute SQL query on host service
const out = await client
.container()
.from("mariadb:10.11.2")
.withServiceBinding("db", hostSrv)
.withExec([
"/bin/sh",
"-c",
"/usr/bin/mysql --user=root --password=secret --host=db -e 'SELECT * FROM mysql.user'",
])
.stdout()
console.log(out)
},
{ LogOutput: process.stderr },
)
import sys
import anyio
import dagger
async def main():
# create Dagger client
async with dagger.Connection(dagger.Config(log_output=sys.stderr)) as client:
# expose host service on port 3306
host_srv = client.host().service(
[
dagger.PortForward(
backend=3306, frontend=3306, protocol=dagger.NetworkProtocol.TCP
)
]
)
# create MariaDB container
# with host service binding
# execute SQL query on host service
out = await (
client.container()
.from_("mariadb:10.11.2")
.with_service_binding("db", host_srv)
.with_exec(
[
"/bin/sh",
"-c",
"/usr/bin/mysql --user=root --password=secret --host=db -e 'SELECT * FROM mysql.user'",
]
)
.stdout()
)
print(out)
anyio.run(main)
This Dagger pipeline creates a service that proxies traffic through the host to the configured port. It then sets the service binding on the client container to the host.
To connect client containers to Unix sockets on the host instead of TCP, see Host.unixSocket
.
Persist service state
Dagger cancels each service run after a 10 second grace period to avoid frequent restarts. To avoid relying on the grace period, use a cache volume to persist a service's data, as in the following example:
- Go
- Node.js
- Python
package main
import (
"context"
"fmt"
"os"
"dagger.io/dagger"
)
func main() {
ctx := context.Background()
// create Dagger client
client, err := dagger.Connect(ctx, dagger.WithLogOutput(os.Stderr))
if err != nil {
panic(err)
}
defer client.Close()
// create Redis service container
redisSrv := client.Container().
From("redis").
WithExposedPort(6379).
WithMountedCache("/data", client.CacheVolume("my-redis")).
WithWorkdir("/data").
AsService()
// create Redis client container
redisCLI := client.Container().
From("redis").
WithServiceBinding("redis-srv", redisSrv).
WithEntrypoint([]string{"redis-cli", "-h", "redis-srv"})
// set and save value
redisCLI.
WithExec([]string{"set", "foo", "abc"}).
WithExec([]string{"save"}).
Stdout(ctx)
// get value
val, err := redisCLI.
WithExec([]string{"get", "foo"}).
Stdout(ctx)
if err != nil {
panic(err)
}
fmt.Println(val)
}
import { connect, Client } from "@dagger.io/dagger"
connect(
async (client: Client) => {
const redisSrv = client
.container()
.from("redis")
.withExposedPort(6379)
.withMountedCache("/data", client.cacheVolume("my-redis"))
.withWorkdir("/data")
.asService()
// create Redis client container
const redisCLI = client
.container()
.from("redis")
.withServiceBinding("redis-srv", redisSrv)
.withEntrypoint(["redis-cli", "-h", "redis-srv"])
// set and save value
await redisCLI.withExec(["set", "foo", "abc"]).withExec(["save"]).stdout()
// get value
const val = await redisCLI.withExec(["get", "foo"]).stdout()
console.log(val)
},
{ LogOutput: process.stderr },
)
import sys
import anyio
import dagger
async def main():
# create Dagger client
async with dagger.Connection(dagger.Config(log_output=sys.stderr)) as client:
# create Redis service container
redis_srv = (
client.container()
.from_("redis")
.with_exposed_port(6379)
.with_mounted_cache("/data", client.cache_volume("my-redis"))
.with_workdir("/data")
.as_service()
)
# create Redis client container
redis_cli = (
client.container()
.from_("redis")
.with_service_binding("redis-srv", redis_srv)
.with_entrypoint(["redis-cli", "-h", "redis-srv"])
)
# set and save value
await redis_cli.with_exec(["set", "foo", "abc"]).with_exec(["save"]).stdout()
# get value
val = await redis_cli.with_exec(["get", "foo"]).stdout()
print(val)
anyio.run(main)
This example uses Redis's SAVE
command to ensure data is synced. By default, Redis flushes data to disk periodically.
Start and stop services
Services are designed to be expressed as a Directed Acyclic Graph (DAG) with explicit bindings allowing services to be started lazily, just like every other DAG node. But sometimes, you may need to explicitly manage the lifecycle. Starting with Dagger v0.9.0, you can explicitly start and stop services in your pipelines.
Here's an example which demonstrates explicitly starting a Docker daemon for use in a test suite:
- Go
- Node.js
- Python
package main_test
import (
"context"
"testing"
"dagger.io/dagger"
"github.com/stretchr/testify/require"
)
func TestFoo(t *testing.T) {
ctx := context.Background()
c, err := dagger.Connect(ctx)
require.NoError(t, err)
dockerd, err := c.Container().From("docker:dind").AsService().Start(ctx)
require.NoError(t, err)
// dockerd is now running, and will stay running
// so you don't have to worry about it restarting after a 10 second gap
// then in all of your tests, continue to use an explicit binding:
_, err = c.Container().From("golang").
WithServiceBinding("docker", dockerd).
WithEnvVariable("DOCKER_HOST", "tcp://docker:2375").
WithExec([]string{"go", "test", "./..."}).
Sync(ctx)
require.NoError(t, err)
// or, if you prefer
// trust `Endpoint()` to construct the address
//
// note that this has the exact same non-cache-busting semantics as WithServiceBinding,
// since hostnames are stable and content-addressed
//
// this could be part of the global test suite setup.
dockerHost, err := dockerd.Endpoint(ctx, dagger.ServiceEndpointOpts{
Scheme: "tcp",
})
require.NoError(t, err)
_, err = c.Container().From("golang").
WithEnvVariable("DOCKER_HOST", dockerHost).
WithExec([]string{"go", "test", "./..."}).
Sync(ctx)
require.NoError(t, err)
// Service.Stop() is available to explicitly stop the service if needed
}
import { connect, Client } from "@dagger.io/dagger"
connect(
async (client: Client) => {
const dockerd = await client
.container()
.from("docker:dind")
.asService()
.start()
// dockerd is now running, and will stay running
// so you don't have to worry about it restarting after a 10 second gap
// then in all of your tests, continue to use an explicit binding:
const test = await client
.container()
.from("golang")
.withServiceBinding("docker", dockerd)
.withEnvVariable("DOCKER_HOST", "tcp://docker:2375")
.withExec(["go", "test", "./..."])
.sync()
console.log("test: ", test)
// or, if you prefer
// trust `endpoint()` to construct the address
//
// note that this has the exact same non-cache-busting semantics as withServiceBinding,
// since hostnames are stable and content-addressed
//
// this could be part of the global test suite setup.
const dockerHost = await dockerd.endpoint({ scheme: "tcp" })
const testWithEndpoint = await client
.container()
.from("golang")
.withEnvVariable("DOCKER_HOST", dockerHost)
.withExec(["go", "test", "./..."])
.sync()
console.log("testWithEndpoint: ", testWithEndpoint)
// service.stop() is available to explicitly stop the service if needed
},
{ LogOutput: process.stderr },
)
import sys
import anyio
import dagger
async def main():
# create Dagger client
async with dagger.Connection(dagger.Config(log_output=sys.stderr)) as client:
dockerd = await client.container().from_("docker:dind").as_service().start()
# dockerd is now running, and will stay running
# so you don't have to worry about it restarting after a 10 second gap
test = await (
client.container()
.from_("golang")
.with_service_binding("docker", dockerd)
.with_env_variable("DOCKER_HOST", "tcp://docker:2375")
.with_exec(["go", "test", "./..."])
.sync()
)
print("test: " + test)
# or, if you prefer
# trust `endpoint()` to construct the address
#
# note that this has the exact same non-cache-busting semantics as with_service_binding,
# since hostnames are stable and content-addressed
#
# this could be part of the global test suite setup.
docker_host = await dockerd.endpoint(scheme="tcp")
test_with_endpoint = await (
client.container()
.from_("golang")
.with_env_variable("DOCKER_HOST", docker_host)
.with_exec(["go", "test", "./..."])
.sync()
)
print("test_with_endpoint: " + test_with_endpoint)
# service.stop() is available to explicitly stop the service if needed
anyio.run(main)
Example: MariaDB database service for application tests
The following example demonstrates service containers in action, by creating and binding a MariaDB database service container for use in application unit/integration testing.
The application used in this example is Drupal, a popular open-source PHP CMS. Drupal includes a large number of unit tests, including tests which require an active database connection. All Drupal 10.x tests are written and executed using the PHPUnit testing framework. Read more about running PHPUnit tests in Drupal.
- Go
- Node.js
- Python
package main
import (
"context"
"fmt"
"os"
"dagger.io/dagger"
)
func main() {
ctx := context.Background()
// create Dagger client
client, err := dagger.Connect(ctx, dagger.WithLogOutput(os.Stderr))
if err != nil {
panic(err)
}
defer client.Close()
// get MariaDB base image
mariadb := client.Container().
From("mariadb:10.11.2").
WithEnvVariable("MARIADB_USER", "user").
WithEnvVariable("MARIADB_PASSWORD", "password").
WithEnvVariable("MARIADB_DATABASE", "drupal").
WithEnvVariable("MARIADB_ROOT_PASSWORD", "root").
WithExposedPort(3306).
AsService()
// get Drupal base image
// install additional dependencies
drupal := client.Container().
From("drupal:10.0.7-php8.2-fpm").
WithExec([]string{"composer", "require", "drupal/core-dev", "--dev", "--update-with-all-dependencies"})
// add service binding for MariaDB
// run kernel tests using PHPUnit
test, err := drupal.
WithServiceBinding("db", mariadb).
WithEnvVariable("SIMPLETEST_DB", "mysql://user:password@db/drupal").
WithEnvVariable("SYMFONY_DEPRECATIONS_HELPER", "disabled").
WithWorkdir("/opt/drupal/web/core").
WithExec([]string{"../../vendor/bin/phpunit", "-v", "--group", "KernelTests"}).
Stdout(ctx)
if err != nil {
panic(err)
}
fmt.Println(test)
}
import { connect, Client } from "@dagger.io/dagger"
connect(
async (client: Client) => {
// get MariaDB base image
const mariadb = client
.container()
.from("mariadb:10.11.2")
.withEnvVariable("MARIADB_USER", "user")
.withEnvVariable("MARIADB_PASSWORD", "password")
.withEnvVariable("MARIADB_DATABASE", "drupal")
.withEnvVariable("MARIADB_ROOT_PASSWORD", "root")
.withExposedPort(3306)
.asService()
// get Drupal base image
// install additional dependencies
const drupal = client
.container()
.from("drupal:10.0.7-php8.2-fpm")
.withExec([
"composer",
"require",
"drupal/core-dev",
"--dev",
"--update-with-all-dependencies",
])
// add service binding for MariaDB
// run unit tests using PHPUnit
const test = await drupal
.withServiceBinding("db", mariadb)
.withEnvVariable("SIMPLETEST_DB", "mysql://user:password@db/drupal")
.withEnvVariable("SYMFONY_DEPRECATIONS_HELPER", "disabled")
.withWorkdir("/opt/drupal/web/core")
.withExec(["../../vendor/bin/phpunit", "-v", "--group", "KernelTests"])
.stdout()
// print ref
console.log(test)
},
{ LogOutput: process.stderr },
)
import sys
import anyio
import dagger
async def main():
# create Dagger client
async with dagger.Connection(dagger.Config(log_output=sys.stderr)) as client:
# get MariaDB base image
mariadb = (
client.container()
.from_("mariadb:10.11.2")
.with_env_variable("MARIADB_USER", "user")
.with_env_variable("MARIADB_PASSWORD", "password")
.with_env_variable("MARIADB_DATABASE", "drupal")
.with_env_variable("MARIADB_ROOT_PASSWORD", "root")
.with_exposed_port(3306)
.as_service()
)
# get Drupal base image
# install additional dependencies
drupal = (
client.container()
.from_("drupal:10.0.7-php8.2-fpm")
.with_exec(
[
"composer",
"require",
"drupal/core-dev",
"--dev",
"--update-with-all-dependencies",
]
)
)
# add service binding for MariaDB
# run unit tests using PHPUnit
test = await (
drupal.with_service_binding("db", mariadb)
.with_env_variable("SIMPLETEST_DB", "mysql://user:password@db/drupal")
.with_env_variable("SYMFONY_DEPRECATIONS_HELPER", "disabled")
.with_workdir("/opt/drupal/web/core")
.with_exec(["../../vendor/bin/phpunit", "-v", "--group", "KernelTests"])
.stdout()
)
print(test)
anyio.run(main)
This example begins by creating a MariaDB service container and initializing a new MariaDB database. It then creates a Drupal container (client) and installs required dependencies into it. Next, it adds a binding for the MariaDB service (db
) in the Drupal container and sets a container environment variable (SIMPLETEST_DB
) with the database DSN. Finally, it runs Drupal's kernel tests (which require a database connection) using PHPUnit and prints the test summary to the console.
Explicitly specifying the service container port with WithExposedPort()
(Go), withExposedPort()
(Node.js) or with_exposed_port()
(Python) is particularly important here. Without it, Dagger will start the service container and immediately allow access to service clients. With it, Dagger will wait for the service to be listening first.
Reference: How service binding works for container services
If you're not interested in what's happening in the background, you can skip this section and just trust that services are running when they need to be. If you're interested in the theory, keep reading.
Consider this example:
- Go
- Node.js
- Python
package main
import (
"context"
"fmt"
"os"
"dagger.io/dagger"
)
func main() {
ctx := context.Background()
// create Dagger client
client, err := dagger.Connect(ctx, dagger.WithLogOutput(os.Stderr))
if err != nil {
panic(err)
}
defer client.Close()
// create Redis service container
redisSrv := client.Container().
From("redis").
WithExposedPort(6379).
AsService()
// create Redis client container
redisCLI := client.Container().
From("redis").
WithServiceBinding("redis-srv", redisSrv).
WithEntrypoint([]string{"redis-cli", "-h", "redis-srv"})
// send ping from client to server
ping := redisCLI.WithExec([]string{"ping"})
val, err := ping.
Stdout(ctx)
if err != nil {
panic(err)
}
fmt.Println(val)
}
Here's what happens on the last line:
- The client requests the
ping
container's stdout, which requires the container to run. - Dagger sees that the
ping
container has a service binding,redisSrv
. - Dagger starts the
redisSrv
container, which recurses into this same process. - Dagger waits for health checks to pass against
redisSrv
. - Dagger runs the
ping
container with theredis-srv
alias magically added to/etc/hosts
.
import { connect, Client } from "@dagger.io/dagger"
connect(
async (client: Client) => {
// create Redis service container
const redisSrv = client
.container()
.from("redis")
.withExposedPort(6379)
.asService()
// create Redis client container
const redisCLI = client
.container()
.from("redis")
.withServiceBinding("redis-srv", redisSrv)
.withEntrypoint(["redis-cli", "-h", "redis-srv"])
// send ping from client to server
const val = await redisCLI.withExec(["ping"]).stdout()
console.log(val)
},
{ LogOutput: process.stderr },
)
Here's what happens on the last line:
- The client requests the
ping
container's stdout, which requires the container to run. - Dagger sees that the
ping
container has a service binding,redisSrv
. - Dagger starts the
redisSrv
container, which recurses into this same process. - Dagger waits for health checks to pass against
redisSrv
. - Dagger runs the
ping
container with theredis-srv
alias magically added to/etc/hosts
.
import sys
import anyio
import dagger
async def main():
# create Dagger client
async with dagger.Connection(dagger.Config(log_output=sys.stderr)) as client:
# create Redis service container
redis_srv = (
client.container().from_("redis").with_exposed_port(6379).as_service()
)
# create Redis client container
redis_cli = (
client.container()
.from_("redis")
.with_service_binding("redis-srv", redis_srv)
.with_entrypoint(["redis-cli", "-h", "redis-srv"])
)
# send ping from client to server
ping = await redis_cli.with_exec(["ping"]).stdout()
print(ping)
anyio.run(main)
Here's what happens on the last line:
- The client requests the
ping
container's stdout, which requires the container to run. - Dagger sees that the
ping
container has a service binding,redis_srv
. - Dagger starts the
redis_srv
container, which recurses into this same process. - Dagger waits for health checks to pass against
redis_srv
. - Dagger runs the
ping
container with theredis-srv
alias magically added to/etc/hosts
.
Dagger cancels each service run after a 10 second grace period to avoid frequent restarts.
Services are based on containers, but they run a little differently. Whereas regular containers in Dagger are de-duplicated across the entire Dagger Engine, service containers are only de-duplicated within a Dagger client session. This means that if you run separate Dagger sessions that use the exact same services, they will each get their own "instance" of the service. This process is carefully tuned to preserve caching at each client call-site, while prohibiting "cross-talk" from one Dagger session's client to another Dagger session's service.
Content-addressed services are very convenient. You don't have to come up with names and maintain instances of services; just use them by value. You also don't have to manage the state of the service; you can just trust that it will be running when needed and stopped when not.
If you need multiple instances of a service, just attach something unique to each one, such as an instance ID.
Here's a more detailed client-server example of running commands against a Redis service:
- Go
- Node.js
- Python
package main
import (
"context"
"fmt"
"os"
"dagger.io/dagger"
)
func main() {
ctx := context.Background()
// create Dagger client
client, err := dagger.Connect(ctx, dagger.WithLogOutput(os.Stderr))
if err != nil {
panic(err)
}
defer client.Close()
// create Redis service container
redisSrv := client.Container().
From("redis").
WithExposedPort(6379).
AsService()
// create Redis client container
redisCLI := client.Container().
From("redis").
WithServiceBinding("redis-srv", redisSrv).
WithEntrypoint([]string{"redis-cli", "-h", "redis-srv"})
// set value
setter, err1 := redisCLI.
WithExec([]string{"set", "foo", "abc"}).
Stdout(ctx)
if err1 != nil {
panic(err1)
}
fmt.Println(setter)
// get value
getter, err2 := redisCLI.
WithExec([]string{"get", "foo"}).
Stdout(ctx)
if err2 != nil {
panic(err2)
}
fmt.Println(getter)
}
import { connect, Client } from "@dagger.io/dagger"
connect(
async (client: Client) => {
// create Redis service container
const redisSrv = client
.container()
.from("redis")
.withExposedPort(6379)
.asService()
// create Redis client container
const redisCLI = client
.container()
.from("redis")
.withServiceBinding("redis-srv", redisSrv)
.withEntrypoint(["redis-cli", "-h", "redis-srv"])
// set value
const setter = await redisCLI.withExec(["set", "foo", "abc"]).stdout()
console.log(setter)
const getter = await redisCLI.withExec(["get", "foo"]).stdout()
console.log(getter)
},
{ LogOutput: process.stderr },
)
import sys
import anyio
import dagger
async def main():
# create Dagger client
async with dagger.Connection(dagger.Config(log_output=sys.stderr)) as client:
# create Redis service container
redis_srv = (
client.container().from_("redis").with_exposed_port(6379).as_service()
)
# create Redis client container
redis_cli = (
client.container()
.from_("redis")
.with_service_binding("redis-srv", redis_srv)
.with_entrypoint(["redis-cli", "-h", "redis-srv"])
)
# set value
setter = await redis_cli.with_exec(["set", "foo", "abc"]).stdout()
# get value
getter = await redis_cli.with_exec(["get", "foo"]).stdout()
print(setter)
print(getter)
anyio.run(main)
Note that this example relies on the 10-second grace period, which you should try to avoid. It would be better to chain both commands together, which ensures that the service stays running for both:
- Go
- Node.js
- Python
package main
import (
"context"
"fmt"
"os"
"dagger.io/dagger"
)
func main() {
ctx := context.Background()
// create Dagger client
client, err := dagger.Connect(ctx, dagger.WithLogOutput(os.Stderr))
if err != nil {
panic(err)
}
defer client.Close()
// create Redis service container
redisSrv := client.Container().
From("redis").
WithExposedPort(6379).
AsService()
// create Redis client container
redisCLI := client.Container().
From("redis").
WithServiceBinding("redis-srv", redisSrv).
WithEntrypoint([]string{"redis-cli", "-h", "redis-srv"})
// set and get value
val, err := redisCLI.
WithExec([]string{"set", "foo", "abc"}).
WithExec([]string{"get", "foo"}).
Stdout(ctx)
if err != nil {
panic(err)
}
fmt.Println(val)
}
import { connect, Client } from "@dagger.io/dagger"
connect(
async (client: Client) => {
// create Redis service container
const redisSrv = client
.container()
.from("redis")
.withExposedPort(6379)
.asService()
// create Redis client container
const redisCLI = client
.container()
.from("redis")
.withServiceBinding("redis-srv", redisSrv)
.withEntrypoint(["redis-cli", "-h", "redis-srv"])
// set and get value
const val = await redisCLI
.withExec(["set", "foo", "abc"])
.withExec(["get", "foo"])
.stdout()
console.log(val)
},
{ LogOutput: process.stderr },
)
import sys
import anyio
import dagger
async def main():
# create Dagger client
async with dagger.Connection(dagger.Config(log_output=sys.stderr)) as client:
# create Redis service container
redis_srv = (
client.container().from_("redis").with_exposed_port(6379).as_service()
)
# create Redis client container
redis_cli = (
client.container()
.from_("redis")
.with_service_binding("redis-srv", redis_srv)
.with_entrypoint(["redis-cli", "-h", "redis-srv"])
)
# set and get value
val = await (
redis_cli.with_exec(["set", "foo", "abc"])
.with_exec(["get", "foo"])
.stdout()
)
print(val)
anyio.run(main)
Depending on the 10-second grace period is risky because there are many factors which could cause a 10-second delay between calls to Dagger, such as excessive CPU load, high network latency between the client and Dagger, or Dagger operations that require a variable amount of time to process.
Conclusion
This tutorial walked you through the basics of using service containers with Dagger. It explained how container-to-container networking and the service lifecycle is implemented in Dagger. It also provided examples of exposing service containers to the host, exposiing host services to containers and persisting service state using Dagger.
Use the API Key Concepts page and the Go, Node.js and Python SDK References to learn more about Dagger.