Serverless function cold start is too slow, let's dockerize our app
Tags: javascript, tutorial, devops, astro
Published: 2023-06-28 19:41:31

I developed an application that I used to deploy on Vercel. 
But, to deliver the best image based on the user's device, I decided to add runtime image processing, which makes it impossible for me to deploy on **edge functions**.
So I tried on **serverless function** but **cold start** is really really slow. Sometimes, I would experience a 5-second delay in loading my page even though I didn't have a heavy resource load. This was mainly due to the fact that I didn't have enough traffic on my website to avoid cold starts.

> **Note:** "Cold start" typically refers to the initial startup time of a serverless function or application when there has been no recent traffic.


The solution I found was to dockerize my application and deploy it on my own server.

----

## Dockerfile

I won't explain what Docker is since there are numerous excellent tutorials available on the web. However, to dockerize an application, the first step is to create a Dockerfile where we specify the necessary instructions. Personally, I use Astro, so here's an example of its content:

```docker
FROM node:18.16.0 AS runtime
WORKDIR /app

COPY . .

RUN npm install
RUN npm run build

ENV HOST=0.0.0.0
ENV PORT=8888
EXPOSE 8888
CMD node ./dist/server/entry.mjs
```

> **Note:** You can find this code on the astro doc [Build your Astro Site with Docker](https://docs.astro.build/fr/recipes/docker/).

----

And that's not all.

My application requires some environment variables, so let's add one named DB_URL. The first thing to do is to declare that we will receive an ARG from outside:

```docker
ARG DB_URL
```

Then, we pass this argument as an environment variable:

```docker
ENV DB_URL=${DB_URL}
```

Now, let's talk about the deployment workflow.

----

## Deployment workflow

The deployment is done through a **Github Action Workflow** named `deploy`. I trigger this workflow each time I push to the master branch, which performs the following steps:

1. Get the previous version and increment it.
2. Build and push the Docker image.
3. Create a Github tag with the version and push it.
4. Deploy the Docker image to my server.

----

### Get the previous version and increment it

Instead of retrieving the latest image name from my registry, I push the version as a git tag. This way, I only need to fetch the latest tag, which is much easier and more efficient. Furthermore, I know the content each docker image thanks to that.

The git command to achieve this is straightforward:

```sh
LATEST_TAG=git describe --abbrev=0 --tags
```

And now let's increment it. But before doing it, let's see how is structured my tag.

As I push a new version each time I push on master I decided not to use semver but just have `vVersionNumber`.

> **Note:** If I have released my app manually, I would have chose to use semver, and chose the right version `major` / `minor` / `patch` when launching the Github Action Workflow.

To extract the number from the tag, I simply remove the leading 'v':

```sh
LATEST_VERSION=${LATEST_TAG#v}
```

Then, the new version is calculated by incrementing the latest version:

```sh
NEW_VERSION=$(($LATEST_VERSION+1))
```

{% details Whole version step %} 

```yaml
- name: Get new version
  id: newVersion
  run: |
    PREVIOUS_VERSION=$(git describe --abbrev=0 --tags)
    echo "Previous Version: $PREVIOUS_VERSION"
    LATEST_VERSION=${LATEST_TAG#v}
    NEW_VERSION=$(($LATEST_VERSION+1))
    echo "New Version: $NEW_VERSION"
    echo "NEW_VERSION=$NEW_VERSION" >> "$GITHUB_OUTPUT"
```

{% enddetails %}

Now it's time to build the image and push it on the registry.

----

## Build and push docker image

I use Docker Hub as my registry, but you can use the one you prefer. 
To log in to Docker Hub, I utilize the `docker/login-action@v2` Github Action and retrieve my credentials from **Github Secrets**:

```yaml
- name: Login to Docker Hub
  uses: docker/login-action@v2
  with:
    username: ${{ secrets.DOCKERHUB_USERNAME }}
    password: ${{ secrets.DOCKERHUB_TOKEN }}
```

I then set up Docker using `docker/setup-buildx-action@v2`:

```yaml
- name: Set up Docker
  uses: docker/setup-buildx-action@v2
```

An now let's build our image thanks to `docker/build-push-action@v4`: 

```yaml
- name: Build and push Docker image
        uses: docker/build-push-action@v4
        with:
          context: .
          build-args: |
            "DB_URL=${{ secrets.DB_URL }}"
          push: true
          tags: astroApp:${{ steps.newVersion.outputs.NEW_VERSION }}
```

> **Note:** As you can see, I didn't forget to pass my `DB_URL` env variable that I get from **Github secrets**.

Now that the docker image is created and pushed, I can create the git tag and push it for future releases.

----

## Create and push git tag

The first step is to set up the git config. Personally, I've chosen to use a fake "GitHub Actions" user for this purpose:

```yaml
- name: Setup git config
  run: |
    git config --local user.name "GitHub Action"
    git config --local user.email "githubAction@users.noreply.github.com"
```

And now I can easily create and push the tag.

```yaml
- name: Create and push git tag
  run: |
    git tag v${{ steps.newVersion.outputs.NEW_VERSION }}
    git push origin v${{ steps.newVersion.outputs.NEW_VERSION }}
```

We are nearing the end of this workflow. Now it's time to deploy the Docker image to the server.

----

## Deploy the docker image on my server

My server is already configured with a Docker registry, eliminating the need for logging in during the GitHub Actions workflow.

To accomplish this, I utilize the `appleboy/ssh-action@v0.1.10` action, which connects to the server and performs the following steps:
1. Pulls the latest image.
2. Stops the existing container (if any).
3. Deletes the previous container (if any).
4. Runs a new container with the freshly pulled image.


```yaml
- name: Deploy docker image to server
  uses: appleboy/ssh-action@v0.1.10
  with:
    host: ${{ secrets.SERVER_HOST }}
    username: ${{ secrets.SERVER_USERNAME }}
    key: ${{ secrets.SSH_PRIVATE_KEY }}
    script: |
      docker pull astroApp:${{ steps.newVersion.outputs.NEW_VERSION }}
      docker stop web || true
      docker rm web || true
      docker run -d -p 8888:8888 --restart unless-stopped --name web astroApp:${{ steps.newVersion.outputs.NEW_VERSION }}
```

> **Note:** This step is not perfect, as it lacks error recovery. However, for my specific use case, this limitation is not problematic since it involves a small website. In the event of any issues during deployment, I can address them manually.

----

## Conclusion

As I mentioned previously, while my workflow may not be perfect, it is perfectly suited for my needs. With this workflow, I have eliminated cold starts and achieved an incredibly fast **First Contentful Paint**. It has been a significant improvement for my website.

In the future, I may consider using **Ansible** to enhance the process and make it more robust. Ansible's configuration management capabilities would allow me to automate and standardize the deployment process further. This would provide better error recovery and ensure consistency across deployments.

Overall, I'm satisfied with the current workflow, but I'm always open to exploring new tools and techniques to optimize my development and deployment processes.

If you want to see the whole **Github Action Workflow**, you can see [my gist](https://gist.github.com/romain-trotard/8de7a37ffe07e2cddcf9e6b7ebd70a04).

----

Feel free to comment and provide feedback. If you'd like to stay updated, you can follow me on [Twitch](https://twitch.tv/rainbowapps ) or visit my [Website](https://www.romaintrotard.com). If you appreciate my work, you can also support me by buying me a coffee using this [link](https://ko-fi.com/romaintrotard) :coffee: