DEV Community

Viacheslav Poturaev
Viacheslav Poturaev

Posted on

Building a portable face recognition application with Go and dlib

TL;DR We're going to build a portable facial recognition microservice for Linux using static linker in Go build with CGO dependencies.

One of the great things about Go is portability of applications built with it.

You can build a binary for different platforms and run it without installing any dependencies on the target machine. This becomes especially important if such a machine is an older low-end server with limited resources that would struggle to build an app or install needed shared libraries.

Building static apps is easy when all code is in Go, but it can become more complicated with CGO dependencies.

Davis King has made an awesome C++ dlib library, that can efficiently detect people faces in a photo. Kagami Hiiragi built a go-face library that allows using dlib from a Go application.

The installation guide mentions a few dependencies needed for a successful build:

sudo apt-get install libdlib-dev libblas-dev libatlas-base-dev liblapack-dev libjpeg-turbo8-dev
Enter fullscreen mode Exit fullscreen mode

By default, the build will depend on shared libraries and then, if you try to run the resulting binary somewhere else, it may fail due to missing file, like here:

./faces: error while loading shared libraries: libdlib.so.19: cannot open shared object file: No such file or directory
Enter fullscreen mode Exit fullscreen mode

Linux has an ldd tool to inspect binary dependencies, most common problems when you try to run a binary built on another Linux machine are version mismatch of GLIBC and missing libraries.

ldd ./faces
./faces: /usr/lib/x86_64-linux-gnu/libstdc++.so.6: version `GLIBCXX_3.4.26' not found (required by ./faces)
    linux-vdso.so.1 (0x00007fff871b5000)
    libdlib.so.19 => not found
    libblas.so.3 => /usr/lib/x86_64-linux-gnu/libblas.so.3 (0x00007f35f50b0000)
    liblapack.so.3 => /usr/lib/x86_64-linux-gnu/liblapack.so.3 (0x00007f35f47f1000)
    libjpeg.so.8 => /usr/lib/x86_64-linux-gnu/libjpeg.so.8 (0x00007f35f4589000)
    libresolv.so.2 => /lib/x86_64-linux-gnu/libresolv.so.2 (0x00007f35f436f000)
    libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (0x00007f35f4150000)
    libstdc++.so.6 => /usr/lib/x86_64-linux-gnu/libstdc++.so.6 (0x00007f35f3dc7000)
    libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x00007f35f3a29000)
    libgcc_s.so.1 => /lib/x86_64-linux-gnu/libgcc_s.so.1 (0x00007f35f3811000)
    libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f35f3420000)
    /lib64/ld-linux-x86-64.so.2 (0x00007f35f5672000)
    libgfortran.so.4 => /usr/lib/x86_64-linux-gnu/libgfortran.so.4 (0x00007f35f3041000)
    libquadmath.so.0 => /usr/lib/x86_64-linux-gnu/libquadmath.so.0 (0x00007f35f2e01000)
Enter fullscreen mode Exit fullscreen mode

I'm hosting my personal photo-blog (github) on a free VM in Oracle Cloud, and because I've set this machine up quite a while ago, it is stuck at Ubuntu 18.04 with only older libraries available by default. This was a reason I've tried to enable face recognition with a statically built app.

To separate fast-paced development with easy builds from complicated builds, I decided to implement facial recognition as a standalone microservice: https://github.com/vearutop/faces.

Let's see if we can get rid of dynamic dependencies and improve portability with static build.

Dlib already has everything needed for a build in isolation, but by default it would dynamically link with installed libs if they are available. Because of that, I'll try to build in a clean docker environment.

Let's create a playground Dockerfile.

./docker/Dockerfile:

FROM ubuntu:22.04 as builder

RUN apt-get update
RUN apt-get install -y build-essential cmake curl
RUN curl -sLO https://go.dev/dl/go1.21.6.linux-amd64.tar.gz && tar -C /usr/local -xzf go1.21.6.linux-amd64.tar.gz && rm -rf go1.21.6.linux-amd64.tar.gz
RUN mkdir /dlib && cd /dlib && curl -sLO http://dlib.net/files/dlib-19.24.tar.bz2 && tar xf dlib-19.24.tar.bz2
RUN cd /dlib/dlib-19.24 && mkdir build && cd build && cmake .. && cmake --build . --config Release && make install && rm -rf /dlib
Enter fullscreen mode Exit fullscreen mode
docker build -f ./docker/Dockerfile -t builder .
Enter fullscreen mode Exit fullscreen mode

Once the build is ready, we can get into a container with our app code mounted.

docker run --rm -v $PWD:/app -w /app -it builder /bin/bash
Enter fullscreen mode Exit fullscreen mode
root@f08033dcccbc:/app# ls
LICENSE  Makefile  README.md  bin  dev_test.go  docker  faces.go  go.mod  go.sum  models  unit.coverprofile  vendor

Enter fullscreen mode Exit fullscreen mode

I've downloaded all dependencies with go mod vendor to simplify operations in the container.

In order to build statically, we need to set CGO_LDFLAGS="-static" for the go build.

root@f08033dcccbc:/app# CGO_LDFLAGS="-static" /usr/local/go/bin/go build .
# github.com/Kagami/go-face
jpeg_mem_loader.cc:3:10: fatal error: jpeglib.h: No such file or directory
    3 | #include <jpeglib.h>
      |          ^~~~~~~~~~~
compilation terminated.
Enter fullscreen mode Exit fullscreen mode

Build failed on a missing header file that was supposed to be installed with one of the dependencies. Let's see if we have that file somewhere in container.

root@f08033dcccbc:/app# find / -name '*jpeglib.h'
/usr/local/include/dlib/external/libjpeg/jpeglib.h
Enter fullscreen mode Exit fullscreen mode

Header files are looked up in /usr/include/ by default. For a quick and dirty fix, we can copy the missing file(s) there.

root@f08033dcccbc:/app# cp /usr/local/include/dlib/external/libjpeg/*.h /usr/include/
Enter fullscreen mode Exit fullscreen mode

Let's run the build again!

root@f08033dcccbc:/app# CGO_LDFLAGS="-static" /usr/local/go/bin/go build .
# github.com/vearutop/faces
/usr/local/go/pkg/tool/linux_amd64/link: running g++ failed: exit status 1
/usr/bin/ld: cannot find -lblas: No such file or directory
/usr/bin/ld: cannot find -lcblas: No such file or directory
/usr/bin/ld: cannot find -llapack: No such file or directory
/usr/bin/ld: cannot find -ljpeg: No such file or directory
collect2: error: ld returned 1 exit status
Enter fullscreen mode Exit fullscreen mode

Bad luck, it failed with another error now. Linker complains that it cannot link against a few missing libs, but all of them should already be included in dlib build.

If we search our codebase (including vendor), we'll find this line in face.go:

// #cgo LDFLAGS: -ldlib -lblas -lcblas -llapack -ljpeg
Enter fullscreen mode Exit fullscreen mode

This is a linker instruction with list of libs that's causing the problem now. Fortunately, with vendored deps it is super easy to change code of dependencies, so let's change that line to this:

// #cgo LDFLAGS: -ldlib
Enter fullscreen mode Exit fullscreen mode

Let's run the build one more time.

root@f08033dcccbc:/app# CGO_LDFLAGS="-static" /usr/local/go/bin/go build .
# github.com/vearutop/faces
/usr/bin/ld: /tmp/go-link-3191459515/000010.o: in function `_cgo_9c8efe9babca_C2func_getaddrinfo':
/tmp/go-build/cgo-gcc-prolog:58: warning: Using 'getaddrinfo' in statically linked applications requires at runtime the shared libraries from the glibc version used for linking
root@f08033dcccbc:/app# ./faces -help
Usage of ./faces:
  -listen string
        listen address (default "localhost:8011")
root@f08033dcccbc:/app# 
Enter fullscreen mode Exit fullscreen mode

It complained about something, but worked!
Let's check the dependencies now.

root@f08033dcccbc:/app# ldd ./faces
        not a dynamic executable
Enter fullscreen mode Exit fullscreen mode

This looks like a nice statically built binary! 😌

Let's check if it actually works. In the same container I can run the app in background with:

root@f08033dcccbc:/app# ./faces &
[1] 1739
root@f08033dcccbc:/app# 
Enter fullscreen mode Exit fullscreen mode

And then invoke request with curl:

root@f08033dcccbc:/app# curl -X 'POST' \
'http://localhost:8011/image' \
-H 'accept: application/json' \
-H 'Content-Type: multipart/form-data' \
-F 'image=@person.jpg;type=image/jpeg'
Enter fullscreen mode Exit fullscreen mode
{"elapsedSec":0.258315,"found":1,"faces":[{"Rectangle":{"Min":{"X":352,"Y":185},"Max":{"X":567,"Y":400}},"Descriptor":[-0.16648848,-0.05050624,0.106586605,-0.0867105,-0.09123391,-0.097584575,-0.046739854,-0.103373915,0.074457884,-0.105268024,0.20660394,-0.13579035,-0.2745444,0.0005242062,-0.03232275,0.18681777,-0.13113116,-0.17754263,-0.052810747,-0.05957584,0.0062686643,-0.03621107,0.011501403,0.1487859,-0.06366991,-0.33826596,-0.06331841,-0.08673793,-0.010200936,-0.0629237,0.027267495,0.11619936,-0.2607339,-0.04982499,0.01518264,0.12889145,0.02307811,-0.118307345,0.1285096,-0.048686076,-0.24529652,-0.12607978,0.136835,0.26203102,0.162219,0.034145266,0.018228233,-0.0061597005,0.040899806,-0.295318,0.0031301053,0.06259319,0.0745079,0.049838964,0.00964687,-0.27123472,0.07631222,0.060989577,-0.12530015,0.03486493,0.035399184,-0.04188027,0.04090107,-0.051638283,0.36773872,0.10492739,-0.14495152,-0.087634355,0.21060707,-0.16210485,-0.00697436,0.04431132,-0.16566163,-0.12653385,-0.31701985,-0.06338993,0.31295794,0.03408507,-0.17158867,0.076981254,-0.09508267,0.073756054,0.02041351,0.14637248,-0.0001675617,0.10626993,-0.08162568,-0.01661037,0.23682739,-0.021808863,-0.006492801,0.22029987,-0.01065092,-0.044090617,0.09562777,0.039906204,-0.05015147,-0.061895538,-0.21429531,0.028714905,-0.07911338,-0.017555084,-0.02431442,0.106665134,-0.20538758,0.08050651,0.017503517,-0.0074621206,-0.057238452,0.036879964,-0.08754097,-0.09878489,0.111212455,-0.24645737,0.15643074,0.21560076,0.10718059,0.13916788,0.05442419,0.053753562,0.024602186,-0.011599961,-0.13366313,-0.02042818,0.062051836,-0.0836075,-0.010100439,0.07831607],"Shapes":[{"X":529,"Y":253},{"X":492,"Y":251},{"X":399,"Y":237},{"X":436,"Y":245},{"X":457,"Y":309}]}]}
Enter fullscreen mode Exit fullscreen mode

To wrap up, let's add our findings in app codebase.

If we now try to build the app outside a container with dynamic linking, the build will fail because we've removed linker instructions in vendored code. To make it work for both cases we can guard behavior with build flags. Let's remove the // #cgo LDFLAGS: ... line from face.go and create two new files instead.

face_static.go:

//go:build static
package face

// #cgo LDFLAGS: -ldlib
import "C"
Enter fullscreen mode Exit fullscreen mode

face_dynamic.go:

//go:build !static
package face

// #cgo LDFLAGS: -ldlib -lblas -lcblas -llapack -ljpeg
import "C"
Enter fullscreen mode Exit fullscreen mode

Now we'll need to add a build tag in order to build statically, but the default build will use dynamic linking.

root@f08033dcccbc:/app# CGO_LDFLAGS="-static" /usr/local/go/bin/go build -tags static .
Enter fullscreen mode Exit fullscreen mode

Let's update our Dockerfile with more instructions to perform the actual application build.

FROM ubuntu:22.04 as builder

RUN apt-get update
RUN apt-get install -y build-essential cmake curl
RUN curl -sLO https://go.dev/dl/go1.21.6.linux-amd64.tar.gz && tar -C /usr/local -xzf go1.21.6.linux-amd64.tar.gz && rm -rf go1.21.6.linux-amd64.tar.gz
RUN mkdir /dlib && cd /dlib && curl -sLO http://dlib.net/files/dlib-19.24.tar.bz2 && tar xf dlib-19.24.tar.bz2
RUN cd /dlib/dlib-19.24 && mkdir build && cd build && cmake .. && cmake --build . --config Release && make install && rm -rf /dlib

# Missing header file.
RUN cp /usr/local/include/dlib/external/libjpeg/*.h /usr/include/

# Building app.
WORKDIR /app
ADD . .
RUN CGO_LDFLAGS="-static" /usr/local/go/bin/go build -tags static .

# Exporting minimal docker image with pre-built binary.
FROM alpine
WORKDIR /root
CMD ["/bin/faces", "-listen", "0.0.0.0:80"]
COPY --from=builder /app/faces /bin/faces
Enter fullscreen mode Exit fullscreen mode

After that we can run a clean build.

docker build -f ./docker/Dockerfile -t faces .
Enter fullscreen mode Exit fullscreen mode

Now, if you need resulting binary to deploy it somewhere, you can copy it from docker image.

docker run -v $PWD:/opt/mount --rm faces cp /bin/faces /opt/mount/faces
Enter fullscreen mode Exit fullscreen mode

As a result you'll have ./faces in your current directory.

Or you can start the service on http://localhost:8000 with docker.

docker run --rm -p 8000:80 faces
Enter fullscreen mode Exit fullscreen mode

Top comments (0)