Jul 26, 2023

swift server + dev containers

In this post you will learn how to configure your Swift development environment for Linux using Dev Containers VSCode extension. This unlocks the ability to build, run, and debug Swift apps on Linux.

pre-reqs

Before starting, be sure these items are installed on your local development machine.

hello app

This example shows how to set up a dev container for the most basic swift server app from the Vapor template. This is a good place to start if a database nor a template language is needed.

Start with the base Vapor template without Leaf or Fluent. Issue the following command in Terminal.app:

vapor new hello -n

This command creates a 'Hello, World' Vapor app. Passing the argument -n tells the toolbox to not include Leaf or Fluent. Next, open the resulting package in VSCode, which shows something similar to:

Perfect, now to set up the dev container.

devcontainer.json

Create a directory and file in the project root called .devcontainer/devcontainer.json and copy the following in to the file:

{
    "name": "Swift",
    "image": "swift:5.8.1", // <- current version of swift
    "features": {
        "ghcr.io/devcontainers/features/common-utils:2": {
            "installZsh": "false",
            "username": "vscode",
            "userUid": "1000",
            "userGid": "1000",
            "upgradePackages": "false"
        },
        "ghcr.io/devcontainers/features/git:1": {
            "version": "os-provided",
            "ppa": "false"
        }
    },
    "runArgs": [
        "--cap-add=SYS_PTRACE",
        "--security-opt",
        "seccomp=unconfined"
    ],
    // Configure tool-specific properties.
    "customizations": {
        // Configure properties specific to VS Code.
        "vscode": {
            // Set *default* container specific settings.json values on container create.
            "settings": {
                "lldb.library": "/usr/lib/liblldb.so"
            },
            // Add the IDs of extensions you want installed when the container is created.
            "extensions": [
                "sswg.swift-lang"
            ]
        }
    },
    // Use 'forwardPorts' to make a list of ports inside the container available locally.
    // "forwardPorts": [],

    // Use 'postCreateCommand' to run commands after the container is created.
    "postCreateCommand": "swift --version",

    // Set `remoteUser` to `root` to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
    "remoteUser": "vscode"
}

This is pulled directly from the Swift Server team's repo. The only thing different is the image attribute which specifies the docker image of the latest Swift version (5.8.1 atm). If necessary, you can update this to the specific image you require.

Next, be sure Docker is running on your local machine and select F1 in VSCode, then select the option that says 'Dev Containers: Rebuild and Reopen in Container'.

This builds the Docker container, mounts the project, and opens a new VSCode window showing the project files. The cool thing here is that, since the project directory is mounted in the Docker container, all changes are actually made in the local project directory and can be source controlled as normal.

And that is it. Press F5. This is the shortcut to build and run the debug configuration of the app.

Now let's have a look at something a little more complicated... like when the app needs a database.

todo app

This example shows how to set up a dev container for an app that needs a database (postgres). This is done with Docker Compose.

Start with the base Vapor template with Leaf and Fluent. Issue the following command in Terminal.app:

vapor new todo

Type 'y' for the subsequent two questions...

The resulting project looks very similar to the previous example, however the code is slightly different; it integrates Fluent, the default Vapor database driver. For Fluent to do its job, it needs a database to connect to.

devcontainer.json

As before, create a directory and file in the project root called .devcontainer/devcontainer.json and copy the following in to the file:

{
    "name": "Swift",
    "dockerComposeFile": "docker-compose.yml",  // 1
    "service": "app",                           // 2
    "workspaceFolder": "/todo",                 // 3
    "features": {
        "ghcr.io/devcontainers/features/common-utils:2": {
            "installZsh": "false",
            "username": "vscode",
            "userUid": "1000",
            "userGid": "1000",
            "upgradePackages": "false"
        },
        "ghcr.io/devcontainers/features/git:1": {
            "version": "os-provided",
            "ppa": "false"
        }
    },
    // Configure tool-specific properties.
    "customizations": {
        // Configure properties specific to VS Code.
        "vscode": {
            // Set *default* container specific settings.json values on container create.
            "settings": {
                "lldb.library": "/usr/lib/liblldb.so"
            },
            // Add the IDs of extensions you want installed when the container is created.
            "extensions": [
                "sswg.swift-lang"
            ]
        }
    },
    // Use 'forwardPorts' to make a list of ports inside the container available locally.
    // "forwardPorts": [ ],

    // Use 'postCreateCommand' to run commands after the container is created.
    "postCreateCommand": "swift --version",

    // Set `remoteUser` to `root` to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
    "remoteUser": "vscode"
}

It is very similar to the previous example except the image attribute has been replaced with three new attributes:

  1. dockerComposeFile - This is the relative path to the Docker Compose file that will be used
  2. service - The service name of the primary (application) container
  3. workspaceFolder - The path of the workspace directory inside the container. Note this the same as the package's name (in Package.swift) for reasons described later.

docker-compose.yml

Next create the Docker Compose configuration file at the path .devcontainer/docker-compose.yml:

version: '3.7'
services:
  app:
    depends_on:
      - postgres
    image: swift:5.8.1
    volumes:
      - ..:/todo:cached
    command: sleep infinity
    environment:
      - POSTGRES_HOST=postgres
      - POSTGRES_PORT=5432
      - POSTGRES_DB=vapor_database
      - POSTGRES_USER=vapor_username
      - POSTGRES_PASSWORD=vapor_password
    privileged: true
  postgres:
    image: "postgres"
    environment:
      - POSTGRES_DB=vapor_database
      - POSTGRES_USER=vapor_username
      - POSTGRES_PASSWORD=vapor_password

This is all pretty standard stuff; it sets up the services and configures env variables. Press F1 in VSCode and the container will build and a new window launch, just like the previous example. The real value add here is the postgres service; which builds and boots a Postgres container.

Finally it is time for the glorious part, aka the entire point, run the app in the dev container.

run the app

So far the dev container has been set up and opened. If this all works and there are no errors then 👏. Now just press F5.

If that works, the debug console will show:

Launching: /todo/.build/debug/App
Launched process 10440

Open up a browser and go to 127.0.0.1:8080, the text 'Hello Vapor!' should be displayed.

Now let's debug something... in VSCode, navigate to routes.swift and add a breakpoint to line #6

Go back to the web browser and reload the page...

From here all the normal lldb commands will work...

po req

GET / HTTP/1.1
Host: 127.0.0.1:8080
Sec-Fetch-Site: none
Connection: keep-alive
Upgrade-Insecure-Requests: 1
Sec-Fetch-Mode: navigate
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5.1 Safari/605.1.15
Accept-Language: en-US,en;q=0.9
Sec-Fetch-Dest: document
Accept-Encoding: gzip, deflate
thread backtrace

* thread #73, name = 'NIO-ELT-0-#1', stop reason = breakpoint 1.1
  * frame #0: 0x0000aaaaaad1282c App`closure #1 in routes(req=0x0000aaaaadbd74a0) at routes.swift:6:23
    frame #1: 0x0000fffff7332e08 libswift_Concurrency.so`swift::runJobInEstablishedExecutorContext(swift::Job*) + 252
    frame #2: 0x0000fffff7333684 libswift_Concurrency.so`swift_job_run + 92
    frame #3: 0x0000fffff7e4abf8 libdispatch.so`_dispatch_continuation_pop + 380
    frame #4: 0x0000fffff7e4a960 libdispatch.so`_dispatch_async_redirect_invoke + 220
    frame #5: 0x0000fffff7e56c04 libdispatch.so`_dispatch_worker_thread + 544
    frame #6: 0x0000fffff68cd5c8 libc.so.6`___lldb_unnamed_symbol3291 + 724
    frame #7: 0x0000fffff6935d1c libc.so.6`___lldb_unnamed_symbol3659 + 12

wrapping up

So that is pretty cool. Developing on the same platform you are deploying to is very powerful. It unlocks faster iterations and more confidence through working directly on your production environment platform.

Check out the example repo for the code in post:

jagreenwood/examples