Reproducible environments with `devbox` and `nix`

8 minute read Published: 2023-07-18

Install devbox and nix using

sh <(curl -L https://nixos.org/nix/install) --no-daemon
curl -fsSL https://get.jetpack.io/devbox | bash

Create new devbox environment

> devbox init

This will create a file named devbox.json with contents

{
  "packages": [],
  "shell": {},
  "nixpkgs": {
    "commit": null
  }
}

Let's create a Python environment that can interact with Cloud Spanner but using an emulator. This will work only on Linux for now.

To create a bare minimal Python env, you can use

devbox add python311
devbox add python311Packages.pip

These will update devbox.json with this and update devbox.lock.

"packages": [
    "python311",
    "python311Packages.pip",
],
# (omitted)

Let's also add requirements.txt with google-cloud-spanner. Change init in devbox.json with

"init_hook": [
    "source $VENV_DIR/bin/activate",
    "pip install -r requirements.txt"
]

When you execute devbox shell, this will download nix packages necessary and create a shell with the installed packages, and then run the commands from init_hook.

Spanner emulator

You can download the spanner emulator as a standalone binary and run it for your testing, but this time, let's use nix to package it, and run it using process-compose

Nix flakes

Let's create a folder named emulator, and create a nix flake using nix templates with nix flake init (after you enable --experimental options from ~/nix.conf, for details go here). You can use this flake in your devbox.json with

    "packages": [
        "python311",
        "python311Packages.pip",
        "path:emulator"
    ],
# (omitted)

Empty nix flake looks like this

{
  description = "A very basic flake";
  outputs = { self, nixpkgs }: {
    packages.x86_64-linux.hello = nixpkgs.legacyPackages.x86_64-linux.hello;
    packages.x86_64-linux.default = self.packages.x86_64-linux.hello;
  };
}

We need to make a derivation for the spanner emulator. This is a bare minimum cross platform nix package/dev shell for any project.

{
  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
    flake-utils.url = "github:numtide/flake-utils"; # use flake-utils to make it easier to use nix flake for cross platform, even though we are not using it here
  };
  outputs = { self, nixpkgs, flake-utils }:
    flake-utils.lib.eachDefaultSystem (system:
      let
        name = "spanner-emulator";
        src = "./.";
        pkgs = nixpkgs.legacyPackages.${system};
      in rec {
        packages = { 
            # your packages here
        };
        devShells = {  };
      }
    );
}

We will make packages only for x86_64-linux, so we will use this pattern (these are sets in nix, so you can use // to merge them, and right one is prioritized on conflict)

      let
        name = "spanner-emulator";
        src = "./.";
        pkgs = nixpkgs.legacyPackages.${system};
      in rec {
        packages = { } // (if system == "x84_64-linux" then {
          # your packages here for x86_64-linux
        } else {});
        devShells = {  } // (if system == "x84_64-linux" then {
          # your dev shells here for x86_64-linux
        } else {});
      }

Onto creating spanner-emulator package,

spanner-emulator = 
    let 
      version = "1.5.2";
      inherit (pkgs) stdenv lib;
    in
    stdenv.mkDerivation rec
    {
      name = "spanner-emulator-${version}";
      src = pkgs.fetchurl {
        url =
            "https://storage.googleapis.com/cloud-spanner-emulator/releases/${version}/cloud-spanner-emulator_linux_amd64-${version}.tar.gz";
        sha256 = "e02e53776f36865dd581234c0c21a54add77d88fb956023aa47f99d96c0af788";
    };
    sourceRoot = ".";
    unpackPhase = '' # it's necessary to unpack tar.gz and put them in $out, in our case we use $out/bin
        mkdir -p $out/bin
        tar -xzf $src -C $out/bin # src is the path to the downloaded tar.gz file
    '';
    meta = with nixpkgs.lib; { # this section is optional, but it's nice to have
        homepage = "https://github.com/GoogleCloudPlatform/cloud-spanner-emulator";
        description =
            "Cloud Spanner Emulator is a local emulator for the Google Cloud Spanner database service.";
        platforms = platforms.linux;
    }; 
    }

You can build the spanner-emulator with nix build .#spanner-emulator and run it with ./result/bin/emulator_main. You can also use nix develop .#spanner-emulator to enter a dev shell with spanner-emulator in your path.

When you try to see what's being linked here

> ldd /nix/store/pz5gz7b6hnrkyilakr4sbzclmhrlv1cf-spanner-emulator/bin/emulator_main

    linux-vdso.so.1 (0x00007ffcd0797000)
    libpthread.so.0 => /nix/store/yzjgl0h6a3qh1mby405428f16xww37h0-glibc-2.35-224/lib/libpthread.so.0 (0x00007f146b31b000)
    libm.so.6 => /nix/store/yzjgl0h6a3qh1mby405428f16xww37h0-glibc-2.35-224/lib/libm.so.6 (0x00007f146b23b000)
    libstdc++.so.6 => not found
    libgcc_s.so.1 => /nix/store/yzjgl0h6a3qh1mby405428f16xww37h0-glibc-2.35-224/lib/libgcc_s.so.1 (0x00007f146b221000)
    libc.so.6 => /nix/store/yzjgl0h6a3qh1mby405428f16xww37h0-glibc-2.35-224/lib/libc.so.6 (0x00007f146b018000)
    /lib64/ld-linux-x86-64.so.2 => /nix/store/yzjgl0h6a3qh1mby405428f16xww37h0-glibc-2.35-224/lib64/ld-linux-x86-64.so.2 (0x00007f146e5ae000)

libstdc++.so.6 is not found. This is because spanner-emulator first calls the loader ld and then some libraries to work, these need to be referenced from Nix (/nix/store).

Solution(1)

A manual way to solve this issue is to override $LD_LIBRARY_PATH to include gcc from /nix/store. Use ld available from /nix/store and use the built spanner-emulator. You can get these paths by checking the path of emulator_main after you build the package and exec into shell with nix develop .#spanner-emulator.

LD_LIBRARY_PATH=/nix/store/7ls5xhx6kqpjgpg67kdd4pmbkhna4b6c-gcc-12.2.0-lib/lib/:$LD_LIBRARY_PATH \
    /nix/store/x33pcmpsiimxhip52mwxbb5y77dhmb21-glibc-2.37-8/lib/ld-linux-x86-64.so.2  \
    /nix/store/k9ywsqknwpyn88idr92ppjfw1n0dkayp-spanner-emulator/bin/emulator_main --host_port localhost:1234

The above command uses glibc and ld-linux-x86-64.so.2 that is available from /nix/store not the system ones, this one is important, otherwise you might get GLIBC_2.xx not found errors. This will start the emulator on port 1234.

Solution(2)

There is also a way to patch the dynamic libraries to use the ones from nix. This is done using patchelf. You can use patchelf to patch the dynamic libraries to use the ones from nix in postFixUp phase. I will ignore this.

Solution(3)

Nix provides a convenient way to patch these dynamic libraries using autoPatchelfHook. All we need to do is include this in our derivation.


  nativeBuildInputs = [
    pkgs.autoPatchelfHook
  ];
  buildInputs = [
    pkgs.gcc-unwrapped
  ];

Final flake

{
  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
    flake-utils.url = "github:numtide/flake-utils";
  };
  outputs = { self, nixpkgs, flake-utils }:
    flake-utils.lib.eachDefaultSystem (system:
      let
        pname = "spanner-emulator";
        src = ./.;
        pkgs = nixpkgs.legacyPackages.${system};
      in
      rec {
        packages = { } // (if system == "x86_64-linux" then {
          ${pname} =
            let
              version = "1.5.2";
              inherit (pkgs) stdenv lib;
            in
            stdenv.mkDerivation
              rec {
                name = "${pname}";
                src = pkgs.fetchurl {
                  url =
                    "https://storage.googleapis.com/cloud-spanner-emulator/releases/${version}/cloud-spanner-emulator_linux_amd64-${version}.tar.gz";
                  sha256 = "e02e53776f36865dd581234c0c21a54add77d88fb956023aa47f99d96c0af788";
                };
                sourceRoot = ".";
                nativeBuildInputs = [
                  pkgs.autoPatchelfHook
                ];
                buildInputs = [
                  pkgs.gcc-unwrapped
                ];
                unpackPhase = ''
                  mkdir -p $out/bin
                  tar -xzf $src -C $out/bin
                '';
                buildPhase = ":";
                meta = with nixpkgs.lib; {
                  homepage = "https://github.com/GoogleCloudPlatform/cloud-spanner-emulator";
                  description =
                    "Cloud Spanner Emulator is a local emulator for the Google Cloud Spanner database service.";
                  platforms = platforms.linux;
                };
              };
        } else { });
        defaultPackage = self.packages.spanner-emulator;
        # You can ignore devShells here
        devShells = pkgs.mkShell
          { } // (if system == "x86_64-linux" then {
          default = pkgs.mkShell {
            buildInputs = with pkgs; [
              self.packages.${system}.spanner-emulator
            ];
            shellHook = ''
              echo HI THERE
            '';
          };
        } else { });
      }
    );
}

Using devbox now

Now that flake is ready, we can use devbox for creating a shell with spanner-emulator available. With devbox.json now available, we can start the shell with devbox shell

{
  "packages": [
    "path:emulator",
    "python311Packages.pip@latest",
    "python311@latest"
  ],
  "shell": {
    "init_hook": [
      "source $VENV_DIR/bin/activate",
      "pip install -r requirements.txt"
    ]
  },
  "nixpkgs": {
    "commit": "f80ac848e3d6f0c12c52758c0f25c10c97ca3b62"
  }
}

Starting the emulator

After all the packages are installed (provided in init), you have emulator_main binary in your $PATH. You can start the emulator with emulator_main --host_port localhost:1234. Open another devbox shell and run example python script to connect to the emulator.

With this main.py in the root folder,

from google.cloud import spanner
import argparse
import os

os.environ["SPANNER_EMULATOR_HOST"] = "localhost:1234"

OPERATION_TIMEOUT_SECONDS = 240


def create_instance(instance_id):
    """Creates an instance."""
    spanner_client = spanner.Client(project="test-project")
    config_name = "{}/instanceConfigs/regional-us-central1".format(
        spanner_client.project_name
    )

    instance = spanner_client.instance(
        instance_id,
        configuration_name=config_name,
        display_name="This is a display name.",
        node_count=1,
        labels={
            "cloud_spanner_samples": "true",
            "sample_name": "snippets-create_instance-explicit",
        },
    )

    operation = instance.create()
    operation.result(OPERATION_TIMEOUT_SECONDS)
    print("Created instance {}".format(instance_id))


def create_database(instance_id, database_id):
    """Creates a database and tables for sample data."""
    spanner_client = spanner.Client(project="test-project")
    instance = spanner_client.instance(
        instance_id,
    )

    database = instance.database(
        database_id,
        ddl_statements=[
            """CREATE TABLE Singers (
            SingerId     INT64 NOT NULL,
            FirstName    STRING(1024),
            LastName     STRING(1024),
            SingerInfo   BYTES(MAX),
            FullName   STRING(2048) AS (
                ARRAY_TO_STRING([FirstName, LastName], " ")
            ) STORED
        ) PRIMARY KEY (SingerId)""",
            """CREATE TABLE Albums (
            SingerId     INT64 NOT NULL,
            AlbumId      INT64 NOT NULL,
            AlbumTitle   STRING(MAX)
        ) PRIMARY KEY (SingerId, AlbumId),
        INTERLEAVE IN PARENT Singers ON DELETE CASCADE""",
        ],
    )

    operation = database.create()
    operation.result(OPERATION_TIMEOUT_SECONDS)
    print("Created database {} on instance {}".format(database_id, instance_id))

parser = argparse.ArgumentParser(description="Switch between two functions")

parser.add_argument("--instance", action="store_true", help="create instance")
parser.add_argument("--database", action="store_true", help="create database")

args = parser.parse_args()
if args.instance:
    create_instance("test-instance")
elif args.database:
    create_database("test-instance", "test-database")
else:
    print("Please specify --instance or --database")

you can run python main.py --instance to create an instance and python main.py --database to create a database. But this will give you an error

ImportError: libstdc++.so.6: cannot open shared object file: No such file or directory

To avoid this, you need to add stdenv.cc.cc.lib to the list of packages in devbox.json (for some reason devbox add stdenv.cc.cc.lib doesn't work) to add libstdc++.so.6 to your $PATH. Now you can run the script without any errors.

Using process compose

A better way is to create a process-compose.yml file that provides a way to start the emulator with devbox itself.

# version: "0.5"
processes:
  emulator:
    command: emulator_main --host_port localhost:1234
    availability:
      restart: "always"

With this configuration, you can start with devbox services up -b (-b is for background). This will start the emulator in the background. Then you can run the python script to create the instance and database.

Final result

(.venv) (devbox) user@ww:~/Projects/spanner-emulator$ devbox services up -b
Starting all services: emulator 
(.venv) (devbox) user@ww:~/Projects/spanner-emulator$ python main.py --instance
Waiting for operation to complete...
Created instance test-instance
(.venv) (devbox) user@ww:~/Projects/spanner-emulator$ python main.py --database
Waiting for operation to complete...
Created database test-database on instance test-instance
(.venv) (devbox) user@ww:~/Projects/spanner-emulator$