Skip to content

Commit a556bac

Browse files
authored
fix(dockerutil): GetImageMetadata: detect correct usr lib dir based on os release (#127)
We had been mounting GPU libraries into the destination /usr/lib inside the inner container by default. This doesn't hold true for Redhat-based distributions, who use /usr/lib64 instead. - Adds the capability to sniff /etc/os-release inside the inner container - Modifies the destination path for bind-mounting GPU libraries to the inner container based on the detected OS release ID - Adds integration testing for Envbox+NVidia GPUs (not run by default in CI) - Attempts to automatically set CODER_USR_LIB_DIR if not specified. - Adds the capability to manually set the inner mount path via CODER_INNER_USR_LIB_DIR.
1 parent 361631d commit a556bac

File tree

10 files changed

+369
-20
lines changed

10 files changed

+369
-20
lines changed

.github/workflows/ci.yaml

+2-2
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ on:
88
pull_request:
99

1010
workflow_dispatch:
11-
11+
1212
permissions:
1313
actions: read
1414
checks: none
@@ -209,7 +209,7 @@ jobs:
209209
category: "Trivy"
210210

211211
- name: Upload Trivy scan results as an artifact
212-
uses: actions/upload-artifact@v3
212+
uses: actions/upload-artifact@v4
213213
with:
214214
name: trivy
215215
path: trivy-results.sarif

Makefile

+1-1
Original file line numberDiff line numberDiff line change
@@ -39,4 +39,4 @@ test:
3939

4040
.PHONY: test-integration
4141
test-integration:
42-
go test -v -count=1 -tags=integration ./integration/
42+
CODER_TEST_INTEGRATION=1 go test -v -count=1 ./integration/

README.md

+53-3
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ The environment variables can be used to configure various aspects of the inner
2121
| `CODER_DOCKER_BRIDGE_CIDR` | The bridge CIDR to start the Docker daemon with. | false |
2222
| `CODER_MOUNTS` | A list of mounts to mount into the inner container. Mounts default to `rw`. Ex: `CODER_MOUNTS=/home/coder:/home/coder,/var/run/mysecret:/var/run/mysecret:ro` | false |
2323
| `CODER_USR_LIB_DIR` | The mountpoint of the host `/usr/lib` directory. Only required when using GPUs. | false |
24+
| `CODER_INNER_USR_LIB_DIR` | The inner /usr/lib mountpoint. This is automatically detected based on `/etc/os-release` in the inner image, but may optionally be overridden. | false |
2425
| `CODER_ADD_TUN` | If `CODER_ADD_TUN=true` add a TUN device to the inner container. | false |
2526
| `CODER_ADD_FUSE` | If `CODER_ADD_FUSE=true` add a FUSE device to the inner container. | false |
2627
| `CODER_ADD_GPU` | If `CODER_ADD_GPU=true` add detected GPUs and related files to the inner container. Requires setting `CODER_USR_LIB_DIR` and mounting in the hosts `/usr/lib/` directory. | false |
@@ -43,7 +44,7 @@ It is not possible to develop `envbox` effectively using a containerized environ
4344

4445
If a login is required to pull images from a private repository, create a secret following the instructions from the [Kubernetes Documentation](https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/#create-a-secret-by-providing-credentials-on-the-command-line) as such:
4546

46-
```
47+
```shell
4748
kubectl -n <coder namespace> create secret docker-registry regcred \
4849
--docker-server=<your-registry-server> \
4950
--docker-username=<your-name> \
@@ -53,7 +54,7 @@ kubectl -n <coder namespace> create secret docker-registry regcred \
5354

5455
Then reference the secret in your template as such:
5556

56-
```
57+
```shell
5758
env {
5859
name = "CODER_IMAGE_PULL_SECRET"
5960
value_from {
@@ -93,11 +94,60 @@ When passing through GPUs to the inner container, you may end up using associate
9394
9495
Envbox will detect these mounts and pass them inside the inner container it creates, so that GPU-aware tools run inside the inner container can still utilize these libraries.
9596
97+
Here's an example Docker command to run a GPU-enabled workload in Envbox. Note the following:
98+
99+
1) The NVidia container runtime must be installed on the host (`--runtime=nvidia`).
100+
2) `CODER_ADD_GPU=true` must be set to enable GPU-specific functionality.
101+
3) When `CODER_ADD_GPU` is set, it is required to also set `CODER_USR_LIB_DIR` to a location where the relevant host directory has been mounted inside the outer container. In the below example, this is `/usr/lib/x86_64-linux-gnu` on the underlying host. It is mounted into the container under the path `/var/coder/usr/lib`. We then set `CODER_USR_LIB_DIR=/var/coder/usr/lib`. The actual location inside the container is not important **as long as it does not overwrite any pre-existing directories containing system libraries**.
102+
4) The location to mount the libraries in the inner container is determined by the distribution ID in the `/etc/os-release` of the inner container. If the automatically determined location is incorrect (e.g. `nvidia-smi` complains about not being able to find libraries), you can set it manually via `CODER_INNER_USR_LIB_DIR`.
103+
104+
> Note: this step is required in case user workloads require libraries from the underlying host that are not added in by the container runtime.
105+
106+
```shell
107+
docker run -it --rm \
108+
--runtime=nvidia \
109+
--gpus=all \
110+
--name=envbox-gpu-test \
111+
-v /tmp/envbox/docker:/var/lib/coder/docker \
112+
-v /tmp/envbox/containers:/var/lib/coder/containers \
113+
-v /tmp/envbox/sysbox:/var/lib/sysbox \
114+
-v /tmp/envbox/docker:/var/lib/docker \
115+
-v /usr/src:/usr/src:ro \
116+
-v /lib/modules:/lib/modules:ro \
117+
-v /usr/lib/x86_64-linux-gnu:/var/coder/usr/lib \
118+
--privileged \
119+
-e CODER_INNER_IMAGE=nvcr.io/nvidia/k8s/cuda-sample:vectoradd-cuda10.2 \
120+
-e CODER_INNER_USERNAME=root \
121+
-e CODER_ADD_GPU=true \
122+
-e CODER_USR_LIB_DIR=/var/coder/usr/lib \
123+
envbox:latest /envbox docker
124+
```
125+
126+
To validate GPU functionality, you can run the following commands:
127+
128+
1) To validate that the container runtime correctly passed the required GPU tools into the outer container, run:
129+
130+
```shell
131+
docker exec -it envbox-gpu-test nvidia-smi
132+
```
133+
134+
2) To validate the same inside the inner container, run:
135+
136+
```shell
137+
docker exec -it envbox-gpu-test docker exec -it workspace_cvm nvidia-smi
138+
```
139+
140+
3) To validate that the sample CUDA application inside the container runs correctly:
141+
142+
```shell
143+
docker exec -it envbox-gpu-test docker exec -it workspace_cvm /tmp/vectorAdd
144+
```
145+
96146
## Hacking
97147

98148
Here's a simple one-liner to run the `codercom/enterprise-minimal:ubuntu` image in Envbox using Docker:
99149

100-
```
150+
```shell
101151
docker run -it --rm \
102152
-v /tmp/envbox/docker:/var/lib/coder/docker \
103153
-v /tmp/envbox/containers:/var/lib/coder/containers \

cli/docker.go

+24-2
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ var (
9898
EnvMemory = "CODER_MEMORY"
9999
EnvAddGPU = "CODER_ADD_GPU"
100100
EnvUsrLibDir = "CODER_USR_LIB_DIR"
101+
EnvInnerUsrLibDir = "CODER_INNER_USR_LIB_DIR"
101102
EnvDockerConfig = "CODER_DOCKER_CONFIG"
102103
EnvDebug = "CODER_DEBUG"
103104
EnvDisableIDMappedMount = "CODER_DISABLE_IDMAPPED_MOUNT"
@@ -135,6 +136,7 @@ type flags struct {
135136
boostrapScript string
136137
containerMounts string
137138
hostUsrLibDir string
139+
innerUsrLibDir string
138140
dockerConfig string
139141
cpus int
140142
memory int
@@ -370,6 +372,7 @@ func dockerCmd() *cobra.Command {
370372
cliflag.StringVarP(cmd.Flags(), &flags.boostrapScript, "boostrap-script", "", EnvBootstrap, "", "The script to use to bootstrap the container. This should typically install and start the agent.")
371373
cliflag.StringVarP(cmd.Flags(), &flags.containerMounts, "mounts", "", EnvMounts, "", "Comma separated list of mounts in the form of '<source>:<target>[:options]' (e.g. /var/lib/docker:/var/lib/docker:ro,/usr/src:/usr/src).")
372374
cliflag.StringVarP(cmd.Flags(), &flags.hostUsrLibDir, "usr-lib-dir", "", EnvUsrLibDir, "", "The host /usr/lib mountpoint. Used to detect GPU drivers to mount into inner container.")
375+
cliflag.StringVarP(cmd.Flags(), &flags.innerUsrLibDir, "inner-usr-lib-dir", "", EnvInnerUsrLibDir, "", "The inner /usr/lib mountpoint. This is automatically detected based on /etc/os-release in the inner image, but may optionally be overridden.")
373376
cliflag.StringVarP(cmd.Flags(), &flags.dockerConfig, "docker-config", "", EnvDockerConfig, "/root/.docker/config.json", "The path to the docker config to consult when pulling an image.")
374377
cliflag.BoolVarP(cmd.Flags(), &flags.addTUN, "add-tun", "", EnvAddTun, false, "Add a TUN device to the inner container.")
375378
cliflag.BoolVarP(cmd.Flags(), &flags.addFUSE, "add-fuse", "", EnvAddFuse, false, "Add a FUSE device to the inner container.")
@@ -523,7 +526,7 @@ func runDockerCVM(ctx context.Context, log slog.Logger, client dockerutil.Client
523526
// of the user so that we can chown directories to the namespaced UID inside
524527
// the inner container as well as whether we should be starting the container
525528
// with /sbin/init or something simple like 'sleep infinity'.
526-
imgMeta, err := dockerutil.GetImageMetadata(ctx, client, flags.innerImage, flags.innerUsername)
529+
imgMeta, err := dockerutil.GetImageMetadata(ctx, log, client, flags.innerImage, flags.innerUsername)
527530
if err != nil {
528531
return xerrors.Errorf("get image metadata: %w", err)
529532
}
@@ -534,6 +537,8 @@ func runDockerCVM(ctx context.Context, log slog.Logger, client dockerutil.Client
534537
slog.F("uid", imgMeta.UID),
535538
slog.F("gid", imgMeta.GID),
536539
slog.F("has_init", imgMeta.HasInit),
540+
slog.F("os_release", imgMeta.OsReleaseID),
541+
slog.F("home_dir", imgMeta.HomeDir),
537542
)
538543

539544
uid, err := strconv.ParseInt(imgMeta.UID, 10, 32)
@@ -614,16 +619,33 @@ func runDockerCVM(ctx context.Context, log slog.Logger, client dockerutil.Client
614619
})
615620
}
616621

622+
innerUsrLibDir := imgMeta.UsrLibDir()
623+
if flags.innerUsrLibDir != "" {
624+
log.Info(ctx, "overriding auto-detected inner usr lib dir ",
625+
slog.F("before", innerUsrLibDir),
626+
slog.F("after", flags.innerUsrLibDir))
627+
innerUsrLibDir = flags.innerUsrLibDir
628+
}
617629
for _, bind := range binds {
618630
// If the bind has a path that points to the host-mounted /usr/lib
619631
// directory we need to remap it to /usr/lib inside the container.
620632
mountpoint := bind.Path
621633
if strings.HasPrefix(mountpoint, flags.hostUsrLibDir) {
622634
mountpoint = filepath.Join(
623-
"/usr/lib",
635+
// Note: we used to mount into /usr/lib, but this can change
636+
// based on the distro inside the container.
637+
innerUsrLibDir,
624638
strings.TrimPrefix(mountpoint, strings.TrimSuffix(flags.hostUsrLibDir, "/")),
625639
)
626640
}
641+
// Even though xunix.GPUs checks for duplicate mounts, we need to check
642+
// for duplicates again here after remapping the path.
643+
if slices.ContainsFunc(mounts, func(m xunix.Mount) bool {
644+
return m.Mountpoint == mountpoint
645+
}) {
646+
log.Debug(ctx, "skipping duplicate mount", slog.F("path", mountpoint))
647+
continue
648+
}
627649
mounts = append(mounts, xunix.Mount{
628650
Source: bind.Path,
629651
Mountpoint: mountpoint,

dockerutil/image.go

+72-9
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"strings"
1010
"time"
1111

12+
"cdr.dev/slog"
1213
dockertypes "github.com/docker/docker/api/types"
1314
"github.com/docker/docker/api/types/container"
1415
"github.com/docker/docker/api/types/filters"
@@ -22,6 +23,23 @@ import (
2223

2324
const diskFullStorageDriver = "vfs"
2425

26+
// Adapted from https://github.com/NVIDIA/libnvidia-container/blob/v1.15.0/src/nvc_container.c#L152-L165
27+
var UsrLibDirs = map[string]string{
28+
// Debian-based distros use a multi-arch directory.
29+
"debian": usrLibMultiarchDir,
30+
"ubuntu": usrLibMultiarchDir,
31+
// Fedora and Redhat use the standard /usr/lib64.
32+
"fedora": "/usr/lib64",
33+
"rhel": "/usr/lib64",
34+
// Fall back to the standard /usr/lib.
35+
"linux": "/usr/lib",
36+
}
37+
38+
// /etc/os-release is the standard location for system identification data on
39+
// Linux systems running systemd.
40+
// Ref: https://www.freedesktop.org/software/systemd/man/latest/os-release.html
41+
var etcOsRelease = "/etc/os-release"
42+
2543
type PullImageConfig struct {
2644
Client Client
2745
Image string
@@ -148,15 +166,16 @@ func processImagePullEvents(r io.Reader, fn ImagePullProgressFn) error {
148166
}
149167

150168
type ImageMetadata struct {
151-
UID string
152-
GID string
153-
HomeDir string
154-
HasInit bool
169+
UID string
170+
GID string
171+
HomeDir string
172+
HasInit bool
173+
OsReleaseID string
155174
}
156175

157176
// GetImageMetadata returns metadata about an image such as the UID/GID of the
158177
// provided username and whether it contains an /sbin/init that we should run.
159-
func GetImageMetadata(ctx context.Context, client Client, img, username string) (ImageMetadata, error) {
178+
func GetImageMetadata(ctx context.Context, log slog.Logger, client Client, img, username string) (ImageMetadata, error) {
160179
// Creating a dummy container to inspect the filesystem.
161180
created, err := client.ContainerCreate(ctx,
162181
&container.Config{
@@ -226,14 +245,58 @@ func GetImageMetadata(ctx context.Context, client Client, img, username string)
226245
return ImageMetadata{}, xerrors.Errorf("no users returned for username %s", username)
227246
}
228247

248+
// Read the /etc/os-release file to get the ID of the OS.
249+
// We only care about the ID field.
250+
var osReleaseID string
251+
out, err = ExecContainer(ctx, client, ExecConfig{
252+
ContainerID: inspect.ID,
253+
Cmd: "cat",
254+
Args: []string{etcOsRelease},
255+
})
256+
if err != nil {
257+
log.Error(ctx, "read os-release", slog.Error(err))
258+
log.Error(ctx, "falling back to linux for os-release ID")
259+
osReleaseID = "linux"
260+
} else {
261+
osReleaseID = GetOSReleaseID(out)
262+
}
263+
229264
return ImageMetadata{
230-
UID: users[0].Uid,
231-
GID: users[0].Gid,
232-
HomeDir: users[0].HomeDir,
233-
HasInit: initExists,
265+
UID: users[0].Uid,
266+
GID: users[0].Gid,
267+
HomeDir: users[0].HomeDir,
268+
HasInit: initExists,
269+
OsReleaseID: osReleaseID,
234270
}, nil
235271
}
236272

273+
// UsrLibDir returns the path to the /usr/lib directory for the given
274+
// operating system determined by the /etc/os-release file.
275+
func (im ImageMetadata) UsrLibDir() string {
276+
if val, ok := UsrLibDirs[im.OsReleaseID]; ok && val != "" {
277+
return val
278+
}
279+
return UsrLibDirs["linux"] // fallback
280+
}
281+
282+
// GetOSReleaseID returns the ID of the operating system from the
283+
// raw contents of /etc/os-release.
284+
func GetOSReleaseID(raw []byte) string {
285+
var osReleaseID string
286+
for _, line := range strings.Split(string(raw), "\n") {
287+
if strings.HasPrefix(line, "ID=") {
288+
osReleaseID = strings.TrimPrefix(line, "ID=")
289+
// The value may be quoted.
290+
osReleaseID = strings.Trim(osReleaseID, "\"")
291+
break
292+
}
293+
}
294+
if osReleaseID == "" {
295+
return "linux"
296+
}
297+
return osReleaseID
298+
}
299+
237300
func DefaultLogImagePullFn(log buildlog.Logger) func(ImagePullEvent) error {
238301
var (
239302
// Avoid spamming too frequently, the messages can come quickly

dockerutil/image_linux_amd64.go

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
package dockerutil
2+
3+
// usrLibMultiarchDir is defined for arm64 and amd64 architectures.
4+
// Envbox is not published for other architectures.
5+
var usrLibMultiarchDir = "/usr/lib/aarch64-linux-gnu"

dockerutil/image_linux_arm64.go

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
package dockerutil
2+
3+
// usrLibMultiarchDir is defined for arm64 and amd64 architectures.
4+
// Envbox is not published for other architectures.
5+
var usrLibMultiarchDir = "/usr/lib/x86_64-linux-gnu"

integration/docker_test.go

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,3 @@
1-
//go:build integration
2-
// +build integration
3-
41
package integration_test
52

63
import (
@@ -24,6 +21,9 @@ import (
2421

2522
func TestDocker(t *testing.T) {
2623
t.Parallel()
24+
if val, ok := os.LookupEnv("CODER_TEST_INTEGRATION"); !ok || val != "1" {
25+
t.Skip("integration tests are skipped unless CODER_TEST_INTEGRATION=1")
26+
}
2727

2828
// Dockerd just tests that dockerd can spin up and function correctly.
2929
t.Run("Dockerd", func(t *testing.T) {

0 commit comments

Comments
 (0)