Running Node Apps Locally and in the Cloud with Docker and Azure

Endless thanks to my colleague Aaron Schlesinger for this unbelievably helpful article on running Node apps via Docker and Azure.

Lots of people have heard success stories about docker in production, like how adopting it sped up deployments or improved uptime.

Docker isn't just for production, though. At its core, it's a tool that lets you containerize any running process and run the container on almost any host, including your local machine. That means containers can help us twice! Once during local development and testing, and then again in production.

Pre-Requisites

I'll be taking you on a journey from using docker on your local machine all the way to deploying your docker image to the public internet on Azure. Before we start, you'll need to have docker installed on your local machine. See the installation instructions below for your system:

You'll also need to have the Azure az command line interface (CLI) installed on your machine. Follow these instructions to do the install.

Finally, you'll need to have a basic idea of how node.js apps work, and how to use the command line.

Let's get started!

A Quick Primer on Containers & Docker

Before we start, let's spend two paragraphs learning a little bit and getting some terminology down. When you're containerizing your app, you first create an image, which is a package of everything your app needs to run - code, dependencies, assets and everything else. Once you have the image, any system that has docker installed on it can run your app, without any dependencies.

The "no dependencies" part is the key, and the huge benefit for developers. docker images are completely self-contained and isolated, so the systems that run your apps only know about docker, not about your code.

How to Create an Image

To create an image, you write some commands into a Dockerfile. These are step-by-step instructions that docker follows to assemble everything your app needs to run. I won't go into too much detail here about Dockerfiles, so check out the Dockerfile reference to learn everything you need to know.

Here's an example Dockerfile for an app that uses yarn for its dependencies:

    FROM node:11
    LABEL appname="JSJanuary"
    LABEL author="Aaron Schlesinger"
    LABEL twitter="@arschles"

    # Install Dependencies and Copy Source Files
    RUN mkdir /node
    WORKDIR /node
    COPY package.json .
    COPY yarn.lock .
    RUN yarn install
    COPY . .

    # Set Up the App to Run
    EXPOSE 8080
    CMD node index.js

Running Locally

When you have a Dockerfile, you use docker build to build your image, and then docker run to run it. For example, here's how you would build and run the app from the above Dockerfile:

    $ docker build -t jsjanuary ./
    $ docker run --rm -p8080:8080 jsjanuary

A few notes about the above commands:

  • The -t in docker build tells docker what to name the image
  • The ./ in docker build tells docker the "build context" - the files that should be available to the Dockerfile when it does the COPY instructions above
  • The --rm after docker run tells docker to clean up the running container when the process ends. By default, containers are not cleaned up so you can inspect them
  • The -p8080:8080 in docker run tells docker to map port 8080 in the container to port 8080 on the host

docker build and docker run are really useful to build and test a final product that will be deployed. Since the container runs in the same environment locally as it will in production, you can have all kinds of confidence that if you change something locally and it works, the new change will work in production too.

And I mean any change - imagine how much work it would be to upgrade the version of node without containers! In the containers world, you just change the FROM line in your Dockerfile.

Running in Production

So it's dead simple to build and run your image locally, and you can use that technique to test locally, and you get tons of control over your app's environment. We'll need some good ways to run containers in production, though, so we can actually use these benefits everywhere.

There are actually two pieces we need to run images in production:

  1. A docker image repository: this is a hosted service (you can host a repository yourself if you're feeling brave :P) for you to store your images
  2. A docker host: this is the hosted service that downloads the image from the repository and does the docker run for you, in the cloud. Most hosts let you scale up the number of running containers, and automatically restarts them if they crash.

Here are some of the more popular registries and hosts: I usually use for my repository and for the host, but there are a ton of other options out there for both. To give you a glimpse of how many there are, here are 9 more popular options:

I'm going to be showing how to push my image up to ACR and get it running on ACI.

Pushing My Image to ACR

I'll need to push my image to ACR before I deploy it, so that ACI can download and run it.

First, I do my docker build locally as before, but with a different image name that's compatible with ACR:

    $ docker build -t jsjanuary.azurecr.io/arschles/jsjanuary .

Next, I create a new ACR registry:

    $ az acr create -n jsjanuary -g jsjanuary --sku Basic

I should see some output like the following after this runs:

    {
      "adminUserEnabled": false,
      "creationDate": "2018-12-26T22:43:00.472618+00:00",
      "id": "/subscriptions/5ea9ae04-3601-468a-ba84-cb7e82ae1e48/resourceGroups/jsjanuary/providers/Microsoft.ContainerRegistry/registries/jsjanuary",
      "location": "eastus",
      "loginServer": "jsjanuary.azurecr.io",
      "name": "jsjanuary",
      "provisioningState": "Succeeded",
      "resourceGroup": "jsjanuary",
      "sku": {
        "name": "Basic",
        "tier": "Basic"
      },
      "status": null,
      "storageAccount": null,
      "tags": {},
      "type": "Microsoft.ContainerRegistry/registries"
    }

Now that I have a registry, I authorize my docker CLI to be able to push images to it:

    $ az acr login -n jsjanuary -g jsjanuary

And after that succeeds, I should see the following:

    Login Succeeded

And now, I'm ready to push my image to ACR:

    $ docker push jsjanuary.azurecr.io/arschles/jsjanuary

I should see something like this:

    The push refers to repository [jsjanuary.azurecr.io/arschles/jsjanuary]
    f137dca15c8a: Pushed
    1a65cfe9e082: Pushed
    b74103ddace2: Pushed
    866fa6cbec18: Pushed
    64c1171b24e7: Pushed
    146c4607adc2: Pushed
    609507c6e8f0: Pushed
    4a6166f16a0e: Pushed
    e02b32b1ff99: Pushed
    f75e64f96dbc: Pushed
    8f7ee6d76fd9: Pushed
    c23711a84ad4: Pushed
    90d1009ce6fe: Pushed
    latest: digest: sha256:238c1858a2a968e172031cdb35ff77bfe0181d6d711ef10e7acd55bd597e7527 size: 3049

Interlude: Giving ACI Permissions to Pull from ACR

By default, ACR has strict permissions and ACI won't be able to pull from the registry we created. We'll need to run the below script (it may take a few minutes to run) to open up permissions for it. Make sure to save the SP_PASSWD and SP_APP_ID variables when you run these commands; we'll need them in the next section.

    export ACR_NAME=jsjanuary
    export SERVICE_PRINCIPAL_NAME=jsjanuary-acr-service-principal
    
    # get the registry's ID
    export ACR_REGISTRY_ID=$(az acr show --name $ACR_NAME --query id --output tsv)
    
    # create a service principle that has the acrpull permissions
    export SP_PASSWD=$(az ad sp create-for-rbac --name http://$SERVICE_PRINCIPAL_NAME --scopes $ACR_REGISTRY_ID --role acrpull --query password --output tsv)
    export SP_APP_ID=$(az ad sp show --id http://$SERVICE_PRINCIPAL_NAME --query appId --output tsv)

Deploying the Image to ACI

Now that the image is in our ACR registry and ACI has permissions to pull the image from our registry, we can run our image in ACI with this command:

    $ az container create --resource-group jsjanuary --name jsjanuary --image jsjanuary.azurecr.io/arschles/jsjanuary --registry-login-server jsjanuary.azurecr.io --registry-username ${SP_APP_ID} --registry-password ${SP_PASSWD} --ip-address=Public --dns-name=jsjanuary-arschles --ports 8080 --location eastus

This command may take a few minutes to run as ACI starts the docker image up. When it finishes and the docker image is running, you'll see the following long output:

    {
      "containers": [
        {
          "command": null,
          "environmentVariables": [],
          "image": "jsjanuary.azurecr.io/arschles/jsjanuary",
          "instanceView": {
            "currentState": {
              "detailStatus": "",
              "exitCode": null,
              "finishTime": null,
              "startTime": "2018-12-26T23:13:35+00:00",
              "state": "Running"
            },
            "events": [
              {
                "count": 1,
                "firstTimestamp": "2018-12-26T23:12:37+00:00",
                "lastTimestamp": "2018-12-26T23:12:37+00:00",
                "message": "pulling image \"jsjanuary.azurecr.io/arschles/jsjanuary\"",
                "name": "Pulling",
                "type": "Normal"
              },
              {
                "count": 1,
                "firstTimestamp": "2018-12-26T23:13:33+00:00",
                "lastTimestamp": "2018-12-26T23:13:33+00:00",
                "message": "Successfully pulled image \"jsjanuary.azurecr.io/arschles/jsjanuary\"",
                "name": "Pulled",
                "type": "Normal"
              },
              {
                "count": 1,
                "firstTimestamp": "2018-12-26T23:13:35+00:00",
                "lastTimestamp": "2018-12-26T23:13:35+00:00",
                "message": "Created container",
                "name": "Created",
                "type": "Normal"
              },
              {
                "count": 1,
                "firstTimestamp": "2018-12-26T23:13:35+00:00",
                "lastTimestamp": "2018-12-26T23:13:35+00:00",
                "message": "Started container",
                "name": "Started",
                "type": "Normal"
              }
            ],
            "previousState": null,
            "restartCount": 0
          },
          "livenessProbe": null,
          "name": "jsjanuary",
          "ports": [
            {
              "port": 8080,
              "protocol": "TCP"
            }
          ],
          "readinessProbe": null,
          "resources": {
            "limits": null,
            "requests": {
              "cpu": 1.0,
              "memoryInGb": 1.5
            }
          },
          "volumeMounts": null
        }
      ],
      "diagnostics": null,
      "id": "/subscriptions/5ea9ae04-3601-468a-ba84-cb7e82ae1e48/resourceGroups/jsjanuary/providers/Microsoft.ContainerInstance/containerGroups/jsjanuary",
      "identity": null,
      "imageRegistryCredentials": [
        {
          "password": null,
          "server": "jsjanuary.azurecr.io",
          "username": "16362c6a-03bb-4f8b-a130-e580c3c0c445"
        }
      ],
      "instanceView": {
        "events": [],
        "state": "Running"
      },
      "ipAddress": {
        "dnsNameLabel": "jsjanuary-arschles",
        "fqdn": "jsjanuary-arschles.eastus.azurecontainer.io",
        "ip": "104.45.188.199",
        "ports": [
          {
            "port": 8080,
            "protocol": "TCP"
          }
        ],
        "type": "Public"
      },
      "location": "eastus",
      "name": "jsjanuary",
      "networkProfile": null,
      "osType": "Linux",
      "provisioningState": "Succeeded",
      "resourceGroup": "jsjanuary",
      "restartPolicy": "Always",
      "tags": {},
      "type": "Microsoft.ContainerInstance/containerGroups",
      "volumes": null
    }

After this, you can run a curl command:

    $ curl jsjanuary-arschles.eastus.azurecontainer.io:8080

And you'll see the Hello World! expected output. Our container is running on the internet in the Azure cloud!

Conclusion

We've gone from creating and running images on our local machine to uploading them to ACR and finally running them in ACI. If you don't use Docker now, following this same path, and choosing the hosted options that are right for you, is a great way to get started.

Have fun!

The contributors to JavaScript January are passionate engineers, designers and teachers. Emily Freeman is a developer advocate at Kickbox and curates the articles for JavaScript January.