OPTiM IoT Agent を GitLab CIでクロスコンパイルとパッケージング自動化

こんにちは。AI・IoTサービス開発部の青木です。

前回はチームリーダとしてのMTG運用方法などを投稿しました。

tech-blog.optim.co.jp

OPTiM IoTではIoTデバイス関連のデバイスエージェントの開発も行っていまして、 デバイスのアーキテクチャやディストリビューションの違いからビルド&パッケージングのパターンが多く困っていたこともあり、CIで簡単にビルド出来るようにしました。

AgentはGo言語で書かれているので、各OSとアーキテクチャ向けのクロスコンパイルの様子とOS毎のバージョン管理に利用するためのパッケージングについて記事にします。

目次 

各アーキテクチャ毎に go build

Golangにはクロスコンパイル機能が搭載されています。 Golangの公式にクロスコンパイルができるOSとアーキテクチャの組み合わせ表があります。

今回はAgent自体をLinux向けに開発し、Raspberry PiNVIDIA Jetson 用の arm,arm64OPTiM Edge や amd64,i386 の計4種類のクロスコンパイルを行います。

以下が設定する $GOOS$GOARCHです

$GOOS $GOARCH
linux 386
linux amd64
linux arm
linux arm64

クロスコンパイルを行う際の注意点

クロスコンパイルは上記の環境変数を以下のように指定すれば簡単にビルドが可能です。

GOOS=linux GOARCH=arm GOARM=7 go build -o main main.go

# use fish shell
env GOOS=linux GOARCH=arm GOARM=7 go build -o main main.go

簡単なプログラムであれば大抵は動作するでしょう。 しかし、C,C++のライブラリを利用している場合などは.soライブラリに対して動的リンクになってしまっているケースがあります。

file dist/linux_armv7
dist/linux_armv7: ELF 32-bit LSB executable, ARM, EABI5 version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux-armhf.so.3,

dynamically となっているのがその部分です。

file main
main: ELF 32-bit LSB executable, ARM, EABI5 version 1 (SYSV), statically linked, not stripped

statically は静的リンクとなります。

この場合、リンクされているライブラリがプログラムを実行する端末にインストールされている必要があります。 対処法は以下となります。

  • 静的リンクでビルドする
    • 注意: LGPLライセンスなどの制約でライセンス違反になる可能性があります。
  • 端末に必要なライブラリをインストールする

開発を行う際は必ず確認することではありますが、しっかりOSSのライセンスやそれぞれの利用規約などを確認するようにしましょう。 しらずしらずのうちに違反してしまっているかも知れません。。

アーキテクチャ毎にDockerfileを準備

ビルドを簡単にかつ環境依存が無いようにDocker上でビルドを行おうと思います。 今回はDocker Buildを行いアーキテクチャ毎にビルドしバイナリファイルをイメージごと保存します。

  • arm の例
FROM golang:1.16.11-bullseye AS ubuntu

ARG BINARY_NAME=sample
ARG VERSION=testing

COPY . /app
WORKDIR /app

# 32bit arm
RUN apt-get update && apt-get install -y gcc-arm-linux-gnueabihf
RUN CC=arm-linux-gnueabihf-gcc CGO_ENABLED=1 GOPROXY=direct GOSUMDB=off GOARCH=arm GOOS=linux GOARM=5 go build -o /dist/${BINARY_NAME}_linux_armv5 main/main.go
RUN CC=arm-linux-gnueabihf-gcc CGO_ENABLED=1 GOPROXY=direct GOSUMDB=off GOARCH=arm GOOS=linux GOARM=6 go build -o /dist/${BINARY_NAME}_linux_armv6 main/main.go
RUN CC=arm-linux-gnueabihf-gcc CGO_ENABLED=1 GOPROXY=direct GOSUMDB=off GOARCH=arm GOOS=linux GOARM=7 go build -o /dist/${BINARY_NAME}_linux_armv7 main/main.go

FROM scratch
COPY --from=ubuntu /dist /dist

これらを4種類用意します。(ビルド部分のみ抜粋)

  • amd64
RUN CGO_ENABLED=1 GOPROXY=direct GOSUMDB=off GOARCH=amd64 GOOS=linux go build -o /dist/${BINARY_NAME}_linux_amd64 main/main.go
  • i386
RUN apt-get update && apt-get install -y libc6-dev-i386
RUN CGO_ENABLED=1 GOPROXY=direct GOSUMDB=off GOARCH=386 GOOS=linux go build -o /dist/${BINARY_NAME}_linux_i386 main/main.go
  • arm64
RUN apt-get update && apt-get install -y gcc-aarch64-linux-gnu
RUN CC=aarch64-linux-gnu-gcc CGO_ENABLED=1 GOPROXY=direct GOSUMDB=off GOARCH=arm64 GOOS=linux go build -o /dist/${BINARY_NAME}_linux_arm64 main/main.go

GitLab CI 構築

docs.gitlab.com

ビルドを毎回手動で行うのは大変面倒なので、CIにおまかせしようと思います。

構築後はこのようにパイプラインでビルドされ、弊社のDocker Image Registryに格納されます。

https://cdn-ak.f.st-hatena.com/images/fotolife/o/optim-tech/20211220/20211220021548.png

ここはArtifactなどでもいいですね。今回は他のリポジトリのDocker Multi Stage Build を利用して COPY --from= でファイルを引っ張って来たいのでイメージ状態にしておきます。

  • .gitlab-ci.yml
stages:
  - 1st
  - 2nd

include:
  - local: '.gitlab/ci/amd64.gitlab-ci.yml'
  - local: '.gitlab/ci/arm.gitlab-ci.yml'
  - local: '.gitlab/ci/i386.gitlab-ci.yml'
  - local: '.gitlab/ci/arm64.gitlab-ci.yml'

yamlファイル内がごちゃつかないように分けておきます。 タグを切った時にビルドが走り、Registryに格納されるようにしておきます。

  • .gitlab/ci/amd64.gitlab-ci.yml
build:amd64:
  image: docker
  stage: 2nd
  script:
    - docker login -u ${CI_REGISTRY_USER} -p ${CI_REGISTRY_PASSWORD} ${CI_REGISTRY}
    - docker build
      --build-arg VERSION=${CI_COMMIT_TAG}
      -f docker/amd64.docker
      -t $CI_REGISTRY_IMAGE/amd64:${CI_COMMIT_TAG} .
    - docker push $CI_REGISTRY_IMAGE/amd64:${CI_COMMIT_TAG}
    - docker rmi $CI_REGISTRY_IMAGE/amd64:${CI_COMMIT_TAG}
  rules:
    - if: $CI_COMMIT_TAG

ビルドしたバイナリをOSごとにパッケージング

今度はOSごとに提供されているパッケージマネージャでインストールやアップデートが出来るようにパッケージ化を行いたいと思います。 パッケージ化する際は、OSのバージョンもそうですがCPUのアーキテクチャも合わせないとだめなケースがあるためそれぞれのパターン毎に用意します。

  • CentOS
  • Ubuntu/Debian
  • Raspberry Pi OS(arm用)

Ubuntu Linux の場合

Ubuntuは18.04LTS20.04LTSを用意します。 どちらもほとんど同じパッケージング内容の為、同じDockerfileを利用してパッケージング出来るように作ります。

ARG MONITORING_DAEMON_IMAGE
ARG MONITORING_DAEMON_VERSION
ARG UBUNTU_IMAGE=ubuntu
ARG UBUNTU_TAG="18.04"

# Load Daemon Binary Image
FROM ${MONITORING_DAEMON_IMAGE}:${MONITORING_DAEMON_VERSION} AS monitoring_daemon

# Main Build Stage
FROM $UBUNTU_IMAGE:$UBUNTU_TAG AS build_stage

RUN apt-get update && apt-get install -y tzdata
ENV TZ=Asia/Tokyo

ARG MONITORING_DAEMON_VERSION

ARG MONITORING_PACKAGE_NAME=cios-monitor
ARG MONITORING_BINARY_NAME=monitord
ARG BINARY_ARCH=amd64

COPY packaging/package_settings /packaging/package_settings
COPY packaging/docker/package_scripts/package-deb.sh /package-deb.sh

COPY --from=monitoring_daemon /dist/${MONITORING_BINARY_NAME}_linux_${BINARY_ARCH} /dist/${MONITORING_BINARY_NAME}_linux_${BINARY_ARCH}

RUN apt-get install -y build-essential devscripts cdbs dh-make quilt debhelper devscripts apt-utils

# /debへのパッケージングスクリプトを実行
RUN chmod +x /package-deb.sh
RUN /package-deb.sh ${MONITORING_PACKAGE_NAME} ${MONITORING_DAEMON_VERSION} ${MONITORING_BINARY_NAME} /dist/${MONITORING_BINARY_NAME}_linux_${BINARY_ARCH} /packaging/package_settings

FROM alpine

COPY --from=build_stage /*.deb /

DockerのBuild Argを利用し、様々なイメージに対応しました。(と言ってもUbuntuに最適化しているのでUbuntuのTagバージョンを変更する程度ですが...)

パッケージングをCIで構築

GitLab CIでパッケージングします。 ここでポイントなのが、CIのRunnerで実行されているCPUアーキテクチャと異なったアーキテクチャでDockerContainerを立ち上げることが出来る multiarch/qemu-user-static を実行しています。 クロスコンパイルの場合は、コンパイルに必要なライブラリ等のインストールで行えましたが、パッケージングに関しては異なるアーキテクチャの環境を再現しなければいけません。

github.com

それらをふまえてまず .gitlab-ci.yml を作成します。

  • .gitlab-ci.yml
image: docker:latest

stages:
  - build
  - packaging

before_script:
  - docker run --rm --privileged multiarch/qemu-user-static:register || true

include:
  - local: '.gitlab/ci/packaging/ubuntu_amd64.gitlab-ci.yml'
  - local: '.gitlab/ci/packaging/centos_amd64.gitlab-ci.yml'
  - local: '.gitlab/ci/packaging/ubuntu_i386.gitlab-ci.yml'
  - local: '.gitlab/ci/packaging/raspberrypios_arm.gitlab-ci.yml'
  - local: '.gitlab/ci/packaging/debian_arm64v8.gitlab-ci.yml'

前回と同じように include を利用して別の定義ファイルにしておきます。

  • .gitlab/ci/packaging/ubuntu_amd64.gitlab-ci.yml
p:ubuntu:18.04_amd64:
  stage: packaging
  variables:
    ARCH: amd64
  script:
    - docker login -u ${CI_REGISTRY_USER} -p ${CI_REGISTRY_PASSWORD} ${CI_REGISTRY}
    - docker build
      -t $CI_REGISTRY_IMAGE/ubuntu_18.04_${ARCH}:${CI_BUILD_REF_SLUG}
      --build-arg MONITORING_DAEMON_IMAGE=${MONITORING_IMAGE_PATH}/${ARCH}
      --build-arg MONITORING_DAEMON_VERSION=${MONITORING_DAEMON_VERSION}
      --build-arg UBUNTU_IMAGE=ubuntu
      --build-arg UBUNTU_TAG=18.04
      -f packaging/docker/ubuntu.docker .
    - docker push $CI_REGISTRY_IMAGE/ubuntu_18.04_${ARCH}:${CI_BUILD_REF_SLUG}
    - docker rmi $CI_REGISTRY_IMAGE/ubuntu_18.04_${ARCH}:${CI_BUILD_REF_SLUG}
  rules:
    - if: '$CI_PIPELINE_SOURCE == "push"'
      when: manual
      allow_failure: true
    # - if: $CI_COMMIT_TAG

p:ubuntu:20.04_amd64:
  stage: packaging
  variables:
    ARCH: amd64
  script:
    - docker login -u ${CI_REGISTRY_USER} -p ${CI_REGISTRY_PASSWORD} ${CI_REGISTRY}
    - docker build
      -t $CI_REGISTRY_IMAGE/ubuntu_20.04_${ARCH}:${CI_BUILD_REF_SLUG}
      --build-arg MONITORING_DAEMON_IMAGE=${MONITORING_IMAGE_PATH}/${ARCH}
      --build-arg MONITORING_DAEMON_VERSION=${MONITORING_DAEMON_VERSION}
      --build-arg UBUNTU_IMAGE=ubuntu
      --build-arg UBUNTU_TAG=20.04
      -f packaging/docker/ubuntu.docker .
    - docker push $CI_REGISTRY_IMAGE/ubuntu_20.04_${ARCH}:${CI_BUILD_REF_SLUG}
    - docker rmi $CI_REGISTRY_IMAGE/ubuntu_20.04_${ARCH}:${CI_BUILD_REF_SLUG}
  rules:
    - if: '$CI_PIPELINE_SOURCE == "push"'
      when: manual
      allow_failure: true
    # - if: $CI_COMMIT_TAG

前回はTAGにしていましたが、デバッグや動作確認も含めてどのブランチで任意のタイミングで実行したいので rules にその記述を行っています。

無事設定が完了すると以下のようになります。

https://cdn-ak.f.st-hatena.com/images/fotolife/o/optim-tech/20211220/20211220021545.png

Ubuntu20.04 の対応が未完了の為CIがコケていますが、それ以外はクリアしています。

完了

実際は2つのプログラムがインストールされるような仕組みになっているためもう少し複雑になっていますが、手作業で行っていたパッケージング作業をほぼ自動化することに成功しました。 また、今まではクロスコンパイルではなく実機でコンパイルしていたりとエンジニアとしても辛い部分であったため、かなり効率化できたと思います。

ちなみにですが、Golangだとクロスコンパイル時でも30秒程でビルドが終わるため全くストレスがありません。Runnerとして稼働しているマシンスペックもそこまで高くなくても良いので、便利だなと感じています。

さいごに

私個人ではGitHub Actionsをよく利用していたのですが、GitLab CIもかなり優秀でGitHub Actionsより自由度があるような気がします。 GitLabにPackageRegistryやContainerRegistryが導入されているので、CIで提供一歩手間まで簡単になりました。(特に認証周り)

CIは最初に構築しておく派で、これまで様々なCIによる効率化と最適化を行ってきましたので、その一部を紹介できて個人的に満足しています😄
CIでゴニョゴニョする話を連載にしてもいいかもしれませんね。 また紹介できるものがあれば紹介しようかと思います。

それでは、また今度。


オプティムでは効率化が大好きなエンジニアを募集しています。

www.optim.co.jp