저는 그냥 제 서버에 봇을 배포하고 싶었을 뿐인데 - 1

RanolP·2022년 10월 3일
1
post-thumbnail

내가 원하는 기능을 구현하는 비즈니스 로직을 짜는 건 늘 즐겁습니다! 가끔 오버엔지니어링도 하고, 퍼즐을 맞추듯 우아한 코드를 만들어내는 건 아주 뿌듯한 일입니다. 하지만 현실은 때때로 그보다 훨씬 귀찮은 과정을 거쳐야 내게 결과를 가져다 줍니다.

봇은 간단한 명령어 하나만을 싣고

저는 봇을 만들기 위해 프레임워크부터 새로 만드는 야크 털 깎기를 하고 있는데요. 뭐, 그건 그거고, 제가 배포하고 싶은 건 바로 아래 명령어를 실행할 수 있는 봇입니다.

use bot_any::message::MessageWrite;
use kal::{lex::TransformHintProvider, Command};

/// Pong!
#[derive(Command, TransformHintProvider)]
pub struct Ping {}

impl Ping {
    pub async fn execute(&self) -> MessageWrite {
        MessageWrite::new().push_text("Pong!".to_string())
    }
}

명령어 자체는 아주 간단합니다. 어떻게 저 명령어를 부르는가는 조금 복잡하긴 합니다만, 보일러플레이트가 중요하지는 않으니 과정을 짧게 설명하자면 다음과 같습니다.

  1. 무한 루프를 돌며 HTTP 요청으로 새 이벤트가 발생했음을 확인한다.
  2. 새 이벤트가 메시지 이벤트인지 확인한다.
  3. 메시지가 커맨드 형식에 알맞는지 확인한다.
  4. 커맨드가 ping인지 확인한다.
  5. execute()한다.

자 그럼 코드도 제대로 짰으니 배포를 한 번 해보려고 했습니다.

컨테이너? Docker? 일단 해보자!

Cloudflare Workers같은 서버리스 환경에 배포하는 방법도 있겠지만, 현재 놀고 있는 제 개인 클라우드 서버가 있었기 때문에 Docker 이미지를 만들어 배포하기로 했습니다. 이전에 팀 작업을 할 때 한 팀원 분께서 Dockerfile을 작성하시는 모습을 봤기에 간단한 개념 정도는 알고 있었습니다.

베이스 이미지를 설정하고, 지정한 명령을 순서대로 실행하고, 진입점을 지정해 하나의 이미지를 만드는 과정. 그 정도로 생각하고 간단히 Dockerfile을 작성해서 로컬 환경에서 빌드해봐야지라는 나이브한 생각으로 접근하고 만 것입니다.

작고 잽싸면 좋다! 아닌가요?

제 작고 귀여운 클라우드 서비스의 무료 제공 디스크 용량에는 제한이 있기 때문에 이미지 크기는 작으면 작을수록 좋습니다. 그리고, 작다면 군더더기가 필연적으로 적을테니 잽싸고 좋다! 아닌가요? 그래서 작은 이미지를 만들기 위해 무엇을 해야 하나 하다가 How to create small Docker images for Rust를 읽게 되었습니다. 해당 글에서는 아래와 같은 결과를 보여주는데요. 제가 FROM scratch를 할 자신은 없고, alpine은 할만해보이기도 하고, 많이들 쓰는 배포판이었기 때문에 건드려보기로 했습니다.

$ docker images
REPOSITORY    TAG           IMAGE ID       CREATED          SIZE
myip     scratch       795604e74501   9 minutes ago    15.9MB
myip     alpine        9a26400587a2   2 minutes ago    21.6MB
myip     distroless    b789c964a680   19 seconds ago   33MB
myip     debian        c388547b9486   12 seconds ago   79.4MB

한편, 빌드할 때 필요한 의존성들을 굳이 배포할 때까지 가져갈 이유는 없습니다. Docker에서는 여러 단계 빌드(Multi-Stage Build) 기법을 활용해 빌드한 후 나온 바이너리만 홀라당 낚아채 실행할 베이스 이미지에 넣어줘 용량을 또다시 줄입니다.

재사용은 대부분의 성능 이슈를 해결하죠

흔히 쉽게 변하지 않는 의존성을 미리 설치해두고 캐시해 내 프로젝트의 변경점만 빌드하는 방식으로 도커 이미지 빌드를 가속합니다. 저 역시 그러한 방식에 대해 익히 알고 있었던지라 빈 프로젝트 만들어 의존성 정보 복사하고 의존성 설치하기 같은 일을 하려고 했는데, cargo-chef라는 걸출한 도구가 있어 다행히 그런 이상한 방법을 동원하지 않을 수 있었습니다.

링크의 모험

그보다 제가 쓰는 언어가 네이티브 언어이다보니 libc나 링킹에 주의를 기울일 필요가 있습니다. Alpine Linux의 경우 musl libc를 사용해 작고 귀여운 크기를 유지할 수 있었다고 하네요. 그래서 musl libc랑 호환되려면 어떻게 해야 하나 베이스 이미지를 여럿 찾아다녔습니다.

첫 시도는 clux/muslrust를 바탕으로 만드는 것이었는데요. 대체로 잘 작동했지만 아주 큰 문제가 하나 있었습니다. 제가 배포해야 하는 타겟 머신은 arm64 기기이고, 해당 이미지는 amd64 이미지만 제공했으니까요.

그래서 다른 이미지를 찾으며 이것저것 둘러보다 maplibre/martin이라는 프로젝트의 Dockerfile을 보고, rust 이미지가 alpine 태그를 붙여 이미지를 배포하고 있다는 사실을 알아냈습니다. 야호! 이제 고민 안해도 된다. 공식 이미지를 써야지! 그런데... 과연 이걸로 끝이었을까요? 아닙니다. 아니었습니다.

OpenSSL의 벼랑 끝에서

저는 정상적으로 빌드한 것 같은데 이미지를 실행한 컨테이너는 켜지자마자 종료 코드 139번, 세그멘테이션 오류로 죽어버리고 맙니다. 대체 왜 그런 것이었을까요? 분명히 헬로 월드는 잘 돌아갔는데.... 어쩔 수 없이 타노스식 디버깅을 선택하고 맙니다. 그러니까, 의존성 절반을 죽였다가, 세그멘테이션 오류가 나나 확인하고, 나머지 절반을 죽이고, 아. 이분 탐색이라고도 부르던가요? 하여튼 말입니다.

그리고 결과는, 네. 제목에서 눈치채셨겠지만 HTTP 요청을 처리하기 위해 들여온 라이브러리가 OpenSSL을 불러오지 못하고 죽는군요. 대체 왜?? 검색해보니 이유는 간단했습니다. Issue #1462: Segmentation fault when initializing tls on 1.51.0-x86_64-unknown-linux-musl #1462 · sfackler/rust-openssl. 네, OpenSSL이 의존하는 musl libc와 제 Rust 실행 파일이 의존하는 musl libc가 충돌한다는 이야기군요.

어라? 그런데 앞서 clux/muslrust를 쓸 땐 잘 작동했는데?? 한번 Dockerfile을 읽어봅시다. 오, 이런. 이 이미지는 아래와 같이 OpenSSL을 직접 빌드해서 Rust 실행 파일에 직접 링킹시켰습니다. 저는 별로 그러고 싶지 않은데요.

RUN curl -sSL https://www.openssl.org/source/openssl-$SSL_VER.tar.gz | tar xz && \
    cd openssl-$SSL_VER && \
    ./Configure no-zlib no-shared -fPIC --prefix=$PREFIX --openssldir=$PREFIX/ssl linux-x86_64 && \
    env C_INCLUDE_PATH=$PREFIX/include make depend 2> /dev/null && \
    make -j$(nproc) && make install && \
    cd .. && rm -rf openssl-$SSL_VER

그럼 반대로 모든 라이브러리를 동적 링킹해볼까요? RUSTFLAGS=-Ctarget-feature=-crt-static? 일단 써보면.... 저런 panic handler부터 못 찾고 있군요. 건드리고 싶지 않아졌어요.

rustls에 착지

별로 OpenSSL로 해결하고 싶지 않아졌습니다. 그냥 Rust로 구현한 rustls를 쓰면 정적 링킹이 되니 만사형통 아닌가요? 저는 그렇게 배웠습니다. h1-client-rustls를 사용하도록 설정해 rustls에 안착. 편안하네요.

결론적으로 나온 Dockerfile은

FROM --platform=${TARGETARCH} rust:alpine AS chef

RUN apk update
RUN apk add --no-cache musl-dev

WORKDIR /app
RUN cargo install cargo-chef

FROM chef AS planner

COPY . .
RUN cargo chef prepare --recipe-path recipe.json

FROM chef AS builder

COPY --from=planner /app/recipe.json .
RUN cargo chef cook --release --recipe-path recipe.json

COPY . .
RUN cargo build --release

FROM alpine:latest

COPY --from=builder \
    /app/target/release/ranol-bot \
    /usr/bin/ranol-bot

ENTRYPOINT [ "/usr/bin/ranol-bot" ]

조금 복잡하지만 그럭저럭 잘 읽히는 것 같습니다. 다행이에요.

자동으로 빌드하기

자 그럼, 도커 이미지는 빌드할 수 있습니다. 하지만 자동으로 빌드하고 배포까지 뿅 진행되는 것이 편하지 않겠습니까. GitHub Actions를 사용할 순간입니다. 그런데 어... 저는 별로 Docker Hub에까지 올리고 싶지는 않은데요. 그냥 저 혼자 쓸 거니까... GitHub Container Registry면 충분하지 않을까요? 한번 뚝딱거려 봅시다.

엥 대문자면 안된다고요?

ERROR: invalid tag "ghcr.io/RanolP/ranol-bot:latest": repository name must be lowercase

...네? 아니 도대체 왜요. 그냥 ${{ github.repository_owner }}를 넣으면 안되는 거에요? 이 글에 난 속았어! 그래서 그냥 아래와 같이 단계를 하나 만들어서 땜빵하고 있었습니다.

- name: Make informations
  id: docker_info
  run: |
    echo ::set-output name=OWNER::`echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]'`
    echo ::set-output name=YMD_TAG::`date +"%Y.%m.%d-\`git rev-parse --short ${{ github.sha }}\`"`

docker/metadata-action을 사용해야 한다는 건 한참 뒤에 알아챘고, 아 이렇게나 예쁘게 정의할 수 있는 것을.... 한탄만 합니다. 아니 그런데 대체 뭔 일을 할지 이름만 보곤 감이 안 잡힌단 말이에요.

- name: Docker meta
  id: meta
  uses: docker/metadata-action@v4
  with:
    images: ghcr.io/${{ github.repository }}
    tags: |
      {{date 'YYYY.MM.DD'}}-{{sha}}
      latest

잡다한 일들, 그리고 종합해서 보자면

QEMU 설정하고, Docker Buildx 설정하고, type=gha로 캐시 설정하고, 멀티 아키텍처 빌드하려고 platforms 설정해주고... 그렇게 나온 GitHub Actions Workflow는 이렇습니다.


permissions:
  packages: write

jobs:
  docker:
    needs: []
    runs-on: ubuntu-latest
    steps:
      - name: Docker meta
        id: meta
        uses: docker/metadata-action@v4
        with:
          images: ghcr.io/${{ github.repository }}
          tags: |
            {{date 'YYYY.MM.DD'}}-{{sha}}
            latest
      - name: Setup QEMU
        uses: docker/setup-qemu-action@v2
      - name: Setup Docker Buildx
        uses: docker/setup-buildx-action@v2
      - name: Checkout repository
        uses: actions/checkout@v3

      - name: Login to GitHub Container Registry
        uses: docker/login-action@v2
        if: ${{ !github.event.pull_request.head.repo.fork }}
        with:
          registry: ghcr.io
          username: ${{ github.repository_owner }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - name: Build (and optionally push) the Docker image
        uses: docker/build-push-action@v3
        with:
          platforms: linux/amd64,linux/arm64/v8
          push: ${{ !github.event.pull_request.head.repo.fork }}
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

아아... 예쁘다... 드디어 도커 이미지를 자동으로 배포할 수 있게 되었어요....

다음 이야기는?

도커 이미지는 있는데... 이거 이미지 푸시할 때마다 자동으로 받아와서 블루-그린 배포할 수 없나? 시크릿 값이랑 볼륨 정보는 어디에 적어두지? 아... Bash 스크립트를 짜고 웹훅 받아서 자동으로 돌리게 하는 HTTP 서버 짜도 되지만 결국 Kubernetes를 재발명하고 말 거라고요? 이런 맙소사. 마침내 내가 Kubernetes를 배우고야 한단 말인가! 클라우드 서버에 Kubernetes 깔고 이것저것 설정해보도록 하겠습니다. 지금은... 쉴래요.

profile
사람과 컴퓨터 사이를 이어주는 소프트웨어를 만듭니다

0개의 댓글