Podman 활용 기록 - Rootless 로 Jenkins 의 노예 만들기

Podman 활용 기록 - Rootless 로 Jenkins 의 노예 만들기
💡
이 글을 읽지 않아도 되는 사람 🥱
- 뭔가 대단한 걸 하는 줄 알고 들어온 당신

내면의 힙스터와 불편한 동거

최근 들어 이놈의 Podman 을 좀 쌈박하게 써먹을 방법이 없을까 싶었다.

한 달 전에 글 싸질러 놓은거도 있고, 지금 와서 Docker 로 다시 돌아가자니 이게 찌질한 자존심 때문인지 아니면 꼴아박은 시간이 아까워서 그런지 마냥 지워버릴수는 없었는데. 그냥 언젠가는 떡상한다는 존버 마인드로 계속 이거저거 써보고 있었다

내 마음속의 힙스터가 "트렌드에 맞게" 살아가라고 하고있다

최근 Terraform, Ansible 등을 뒤져보다가 번뜩 드는 생각이, 최근에 Jenkins 를 안만져본거 같았다. 전 직장에서는 맨날 켜놓고 있던 Jenkins 였는데, 정작 자동화하거나 테스트 돌릴게 따로 없다 보니 깔아놓지도 않고서 Github Action 한테 찝적거리고나 있었다.


드디어 찾은 변명거리

전 직장에서는 Jenkins 를 Docker 로 올려 사용하지는 않았고, 직접 WAR 에 OPENJDK 를 가져다가 썼는데, 이게 나름의 이유가 있었다

  1. 컨테이너로 올리면 볼륨 관리가 귀찮음
    이게 은근 짜증나는게, 한두번씩 이미지 업데이트 하다가 볼륨이 깨진적이 있어서 watchtower 에서 업데이트를 exclude 해야한다
  2. 플러그인이 자주 오류 남
    꼭 컨테이너로 돌릴때만 오류가 나는 플러그인들이 있다, alpine 환경에 맞지 않거나, 아니면 Jenkins 버젼이 지원하지 않는지 일일히 확인해야 했다.
  3. Jenkins 로 Docker 돌릴 수 있다
    사실 이게 핵심, Jenkins 에 따로 플러그인 주렁주렁 달 필요 없이 컨테이너 돌리면 된다는 기적의 마인드

Jenkins 로 Docker 돌리는게 은근 괜찮고, 이미지랑 쉘 스크립트만 잘 짜놓으면 커스터마이징이 무궁무진하기 때문에 (사실 플러그인 공부하기 귀찮음) 전 직장에서 이거저거 잘 써먹었던 기억이 난다

문제는 Docker 는 rootless 가 아니기 때문에 standalone 으로 돌아가는 Jenkins 도 Root 권한이 필요하다는 점.
이게 사실 제일 문제인게, Docker 수행을 위한 ssh 접속, 네트워크 설정까지 전부 root 이기 때문에 한번 잘못 걸리면 다 털려먹히는 구조였다. 그래서 정작 외부 접속은 죄다 막아두고 내부 네트워크에서만 간간히 돌려먹고 있었는데, podman 은 기본이 rootless 이다 보니, 이거 일반 사용자 계정으로 Jenkins 올려서 써먹을 수 있겠다는 생각이 번뜩 든 것이다.

이제야 찾은 podman 활용법

설치 및 초기화

💡
- Java 17, OpenJDK-jre (OpenLogic)
- Jenkins 2.440.2 LTS
- podman 3.4.4
jenkins 가 사용할 일반 계정을 만들어 준다
생성된계정에 접속 후 podman 을 사용 가능한지 확인
jenkins.war, java 만 준비하면 된다

jenkins 디렉터리는 ~/.jenkins 디렉터리가 따로 생기는게 싫어서 만듬, logs 는 로그파일 폴더.

  • startup-jenkins.sh
#!/bin/bash

# 경로를 합치는 함수
join_paths() {
    local path1=$1
    local path2=$2

    # 슬래시 추가 및 중복 제거
    echo "$path1/$path2" | sed 's#/\+#/#g'
}

# 파일 존재여부 확인
is_file_exists() {
    local file_path=$1

    if [ -f "$file_path" ]; then
        echo "true"
    else
        echo "false"
    fi
}

# 디렉터리 존재여부 확인
is_dir_exists() {
    local dir_path=$1

    if [ -d "$dir_path" ]; then
        echo "true"
    else
        echo "false"
    fi
}

echo "UID : $UID | USER : $USER"

# linger 설정, 사용자 로그인 없이 서비스가 실행되도록 설정
loginctl enable-linger "$UID"


# 현재 경로
script_file_path=$(readlink -f "$0")
script_dir_path=$(dirname "$script_file_path")

jenkins_file_name="jenkins.war"
jenkins_file_path=$(join_paths "$script_dir_path" "$jenkins_file_name")
# jenkins.war 이 존재하지 않으면 종료
if [ $(is_file_exists "$jenkins_file_path") = "false" ]; then
    echo "File not found: $jenkins_file_path"
    exit 1
fi

java_home_path=$(join_paths "$script_dir_path" "/java/bin")
# java_home_path 이 존재하지 않으면 종료
if [ $(is_dir_exists "$java_home_path") = "false" ]; then
    echo "File not found: $java_home_path"
    exit 1
fi

jenkins_home_path=$(join_paths "$script_dir_path" "/jenkins")
# jenkins_home_path 이 존재하지 않으면 생성
if [ $(is_dir_exists "$java_home_path") = "false" ]; then
    mkdir -p "$jenkins_home_path"
fi


jenkins_logs_path=$(join_paths "$script_dir_path" "/logs")
# jenkins_logs_path 이 존재하지 않으면 생성
if [ $(is_dir_exists "$jenkins_logs_path") = "false" ]; then
    mkdir -p "$jenkin$jenkins_logs_path"
fi

# 경로 확인
echo "script_file_path : $script_file_path | script_dir_path : $script_dir_path"
echo "jenkins_file_path : $jenkins_file_path | jenkins_home_path : $jenkins_home_path"
echo "java_home_path : $java_home_path"

# jenkins 실행
nohup $java_home_path/java -DJENKINS_HOME=$jenkins_home_path \
-server -Djava.net.preferIPv4Stack=true -Dlog4j2.formatMsgNoLookups=true \
-Xms64m -Xmx512m -XX:MaxNewSize=128m \
-XX:+HeapDumpOnOutOfMemoryError -XX:ParallelGCThreads=2 \
-jar $jenkins_file_path \
--httpPort=18080 --logfile=$jenkins_logs_path/jenkins.log > /dev/null 2>&1 &

exit 0

jenkins 실행 부분에 들어갈 JAVA 옵션 및 JENKINS 옵션만 잘 잡아주면 된다.

  • shutdown-jenkins.sh
#!/bin/bash

# Jenkins 프로세스를 찾아서 PID를 가져옴
jenkins_pid=$(ps -ef | grep jenkins.war | grep -v grep | awk '{print $2}')

echo $jenkins_pid
# PID가 존재하는지 확인
if [ -z "$jenkins_pid" ]; then
    echo "Jenkins 프로세스를 찾을 수 없습니다."
else
    # Jenkins 프로세스를 강제로 종료
    kill -9 "$jenkins_pid"
    echo "Jenkins 프로세스 (PID: $jenkins_pid)를 중지했습니다."
fi

그냥 kill 해버리면 작업중에 문제가 생길까 싶을 수도 있지만, 스케쥴이나 웹 훅 관련된 작업만 하고 실제 작업은 Podman 에서 수행하기 때문에 걱정하지 않아도 된다.

스크립트 실행
jenkins 프로세스 확인
초기 비밀번호 입력 후 기본 플러그인 설치

테스트

freestyle 프로젝트를 만들어 준다, 나는 내 Repository 에 있는 Cookbook 프로젝트의 Writerside 를 빌드하는게 목적이므로 cookbook 프로젝트로 명명했

쉘 스크립트를 실행하여 podman 이 정상 호출되는지, 기본 경로 정보도 확인한다

#!/bin/bash

echo "hello, today is $(date)"

podman ps -a

pwd

jenkins 가 실행중인 jenkins-ssh 유저의 podman 호출은 정상적이다.
workpath 도 JENKINS_HOME 의 workspace/cookbook 으로 정상 출력된다.

실전

JENKINS_HOME 은 .gitignore 를 걸어두었기 때문에 내가 작업할 스크립트를 관리하기 어려우니, 심볼릭 링크를 걸어둔다

ln -s ~/jenkins/jenkins/workspace/cookbook/ ~/jenkins/cookbook

cookbook 프로젝트는 내가 따로 관리하는 Writerside 로 작성된 위키 프로젝트다.

리눅스 명령어나, 자주 쓰는데 까먹는 명령어들, 설치 가이드 등을 정리한 도큐먼트인데, JetBrain 놈들이 만들어 놓고 잘 관리를 안하는지, 사용하는 사람들이 별로 없다.

  • Dockerfile : Writerside 프로젝트를 빌드할 도커파일
  • git-clone.sh : github Repository 에서 소스 불러오는 쉘
  • id_ed25519, id_ed25519.pub : github 에서 코드를 clone 하기 위한 ssh 키 쌍
  • build.sh : Dockerfile 빌드 시 copy 되어 실제 git clone 과 Writerside 빌드를 수행할 쉘 스크립트
  • Dockerfile
FROM registry.jetbrains.team/p/writerside/builder/writerside-builder:233.14938

# agt-get
RUN apt-get update && apt-get install -y \
  git ssh \
  && rm -rf /var/lib/apt/lists/* /var/cache/debconf/*

# .ssh 생성 및 github.com > known_hosts 저장
RUN mkdir -p /root/.ssh && \
  chmod 0700 /root/.ssh && \
  ssh-keyscan github.com > /root/.ssh/known_hosts

ARG DISABLE_CACHE

# Github 에 저장된 public 키와 대응하는 private 키 추가
COPY ./id_ed25519 /root/.ssh/id_rsa
RUN chmod 600 /root/.ssh/id_rsa

# 빌드 스크립트 복사
COPY ./git-clone.sh /root/git-clone.sh
RUN chmod +x /root/git-clone.sh
COPY ./build.sh /root/build.sh
RUN chmod +x /root/build.sh

ARG BUILD_PATH=/usr/local/cookbook
WORKDIR ${BUILD_PATH}

ENV REPO_PATH=/usr/local/cookbook
ENV GIT_REPO_GROUP=NeedPainkiller
ENV GIT_REPO_NAME=CookBook
ENTRYPOINT ["/bin/bash","/root/build.sh"]
  • git-clone.sh
#!/bin/bash
set -e

function help {
cat <<EOF
Usage:
  REPO_PATH=/usr/local/rpa-portal-build GIT_REPO_GROUP=grouop-name GIT_REPO_NAME=repository-name ./git-clone.sh
EOF
exit 1
}
[ -z "${REPO_PATH}" ] && echo "ERROR: REPO_PATH not defined" && help
[ -z "${GIT_REPO_GROUP}" ] && echo "ERROR: GIT_REPO_GROUP not defined" && help
[ -z "${GIT_REPO_NAME}" ] && echo "ERROR: GIT_REPO_NAME not defined" && help

SOURCE_PATH=${REPO_PATH}/${GIT_REPO_NAME}

pushd ${REPO_PATH}

echo "========== git clone =========="
if [ ! -d ${GIT_REPO_NAME} ]; then
  git clone git@github.com:${GIT_REPO_GROUP}/${GIT_REPO_NAME}.git
fi
pushd ${SOURCE_PATH}

if [ -d ".git" ]; then
  echo "========== git pull =========="
  rm -f ./.git/index.lock
  git checkout .
  git pull --force
fi
exit 0
  • build.sh
#!/bin/bash
set -e

function help {
cat <<EOF
Usage:
 REPO_PATH=/usr/local/rpa-portal-repo GIT_REPO_GROUP=rainbow-brain GIT_REPO_NAME=repository-name ./build.sh
EOF
exit 1
}
[ -z "${REPO_PATH}" ] && echo "ERROR: REPO_PATH not defined" && help
[ -z "${GIT_REPO_GROUP}" ] && echo "ERROR: GIT_REPO_GROUP not defined" && help
[ -z "${GIT_REPO_NAME}" ] && echo "ERROR: GIT_REPO_NAME not defined" && help

# 경로를 합치는 함수
join_paths() {
    local path1=$1
    local path2=$2

    # 슬래시 추가 및 중복 제거
    echo "$path1/$path2" | sed 's#/\+#/#g'
}

# 파일 존재여부 확인
is_file_exists() {
    local file_path=$1

    if [ -f "$file_path" ]; then
        echo "true"
    else
        echo "false"
    fi
}

mkdir -p ${REPO_PATH}

chmod +x /root/git-clone.sh

REPO_PATH=${REPO_PATH} GIT_REPO_GROUP=${GIT_REPO_GROUP} GIT_REPO_NAME=${GIT_REPO_NAME} /bin/bash /root/git-clone.sh

SOURCE_PATH=${REPO_PATH}/${GIT_REPO_NAME}
pushd ${SOURCE_PATH}

echo "========== build =========="
#writerside-cli generate CookBook/cookbook --output ${SOURCE_PATH}

export DISPLAY=:99 &&
Xvfb :99 &
 /opt/builder/bin/idea.sh helpbuilderinspect \
--source-dir ${SOURCE_PATH} \
--product Writerside/cookbook \
--runner github \
--output-dir ${SOURCE_PATH}

echo "========== copy =========="
RESULT_PATH=${SOURCE_PATH}/webHelpCOOKBOOK2-all.zip

mkdir -p /usr/local/build/
COPY_PATH=/usr/local/build/result.zip

rm -rf ${COPY_PATH}
yes | cp -rf ${RESULT_PATH} ${COPY_PATH}
exit 0

수행 로직은 아래 차트로 설명할 수 있겠다

  1. jenkins 는 build.sh, git-clone.sh 와 github clone 을 위한 id_ed25518 private 키를 copy 하여 Jetbrains Writerside 기반의 이미지를 만든다
  2. 빌드된 이미지는 run 과 동시에 build.sh 를 실행하며 build.sh 는 git-clone 으로 Writerside 프로젝트를 clone 한 뒤 빌드를 수행한다
  3. 빌드된 Writerside HTML 파일은 ZIP 압축파일로 저장된다

일단 Writerside 패키지 자체가 Jetbrains 플러그인과 몇몇 라이브러리에 종속되어 있어 상당히 무겁다, podman 을 통해 이미지화 한다면 일일히 패키지를 관리할 필요 없이 사용할 수 있을 것이다.

Writerside 도 Docker용 이미지를 제공한다.

Build with Docker | Writerside
docker pull registry.jetbrains.team/p/writerside/builder/writerside-builder:233.14938

이미지 이름, 빌드가 근본없는걸 보아하니 내 9년 따리 짬으로 예상하건데 제대로 빌드가 안될 거 같다.

늬들 컨테이너 만드는 거 맞지?

노근본 도메인, 노근본 버젼, 노근본 용량 이거 삼박자가 딱딱 맞아 떨어지는게, 관리안하는 게 명백하다.

테스트용 run.sh 를 만들어서 테스트를 진행해 보자

#!/bin/bash

# 이미지 삭제
podman rmi localhost/cookbook-build
# 이미지 빌드
podman build . --tag cookbook-build
# 빌드 결과 디렉터리
mkdir -p build
# 실행
podman run --rm \
    -v ./build:/usr/local/build \
    localhost/cookbook-build

첫 실행

씨발 니들이 그럼 그렇지

솔직히 예상은 했는데, 윈도우, 리눅스에서 잘만 되던게 컨테이너 환경에서만 안된다? 이건 그냥 컨테이너 만든 애들이 트러블슈팅에 별 관심이 없다는 얘기다.

로그도 보아하니 Writerside 에서 사용하는 xml 태그를 읽어서 역직렬화 하는 과정에서 class 캐스팅에 실패한 것이 보인다

최신 버젼의 Writerside 라서 문제인건지는 몰라도 xml 태그를 제대로 못 읽는다는거 부터가 내가 옵션을 건든다고 해결 될 문제가 아닌 것 같았다. github 이슈트래커에도 비슷한 에러가 보고되고 있는데, 별 관심이 없는건지 github actions 쪽에도 이슈가 있는것으로 보인다.

하 시팔 힙스터 인생이 뭐 그렇지 암

일단은 빌드를 해야 한다

일단 3시간의 try-catch 와중에 4GB 나 되는 혹부리달린 이미지를 계속 써야 하는지 의문이 들었다, 정작 Writerside 의 빌드 zip 파일은 10mb 이하인데다가

그냥 압축 풀어다가 nginx 에서 static 파일로 읽어가는게 다인데 굳이 이런짓을 해야 하는가?

결론적으로는 빌드 가능한 환경에서 zip 파일을 빌드해서 repository 에 올려버리고 그걸 가져다 쓰는게 낫겠다 싶었다

역시 개발자는 기술보다 잔머리로 먹고 살아야 한다

zip 파일을 repository 에 올릴거였으면 뭐하러 podman 을 쓰나 싶기는 한데, 아 몰라 지금 Writerside 가 병신인거지 podman 이랑 Jenkins 가 잘못한게 아니잖아?

그냥 나중에 gradle 이나 npm 빌드할 일 있으면 각각 이미지 가져다가 빌드하면 되겠지.

더럽고 추하게 빤스런하기

일단 build.sh 의 Writerside 빌드 부분을 제외한다

#!/bin/bash
set -e

function help {
cat <<EOF
Usage:
 REPO_PATH=/usr/local/rpa-portal-repo GIT_REPO_GROUP=rainbow-brain GIT_REPO_NAME=repository-name ./build.sh
EOF
exit 1
}
[ -z "${REPO_PATH}" ] && echo "ERROR: REPO_PATH not defined" && help
[ -z "${GIT_REPO_GROUP}" ] && echo "ERROR: GIT_REPO_GROUP not defined" && help
[ -z "${GIT_REPO_NAME}" ] && echo "ERROR: GIT_REPO_NAME not defined" && help

# 경로를 합치는 함수
join_paths() {
    local path1=$1
    local path2=$2

    # 슬래시 추가 및 중복 제거
    echo "$path1/$path2" | sed 's#/\+#/#g'
}

# 파일 존재여부 확인
is_file_exists() {
    local file_path=$1

    if [ -f "$file_path" ]; then
        echo "true"
    else
        echo "false"
    fi
}

mkdir -p ${REPO_PATH}

chmod +x /root/git-clone.sh

REPO_PATH=${REPO_PATH} GIT_REPO_GROUP=${GIT_REPO_GROUP} GIT_REPO_NAME=${GIT_REPO_NAME} /bin/bash /root/git-clone.sh

SOURCE_PATH=${REPO_PATH}/${GIT_REPO_NAME}
pushd ${SOURCE_PATH}

echo "========== copy =========="
RESULT_PATH=${SOURCE_PATH}/webHelpCOOKBOOK2-all.zip

mkdir -p /usr/local/build/
COPY_PATH=/usr/local/build/result.zip

rm -rf ${COPY_PATH}
yes | cp -rf ${RESULT_PATH} ${COPY_PATH}

mkdir -p /usr/local/build/result
unzip ${COPY_PATH} -d /usr/local/build/result

exit 0
결과 zip 파일을 git add 해준다

Dockerfile 의 Writerside 이미지도 ubuntu:22.04 으로 변경해준다

FROM ubuntu:22.04

# agt-get
RUN apt-get update && apt-get install -y \
  git ssh zip \
  && rm -rf /var/lib/apt/lists/* /var/cache/debconf/*

# .ssh 생성 및 github.com > known_hosts 저장
RUN mkdir -p /root/.ssh && \
  chmod 0700 /root/.ssh && \
  ssh-keyscan github.com > /root/.ssh/known_hosts

ARG DISABLE_CACHE

# Github 에 저장된 public 키와 대응하는 private 키 추가
COPY ./id_ed25519 /root/.ssh/id_rsa
RUN chmod 600 /root/.ssh/id_rsa

# 빌드 스크립트 복사
COPY ./git-clone.sh /root/git-clone.sh
RUN chmod +x /root/git-clone.sh
COPY ./build.sh /root/build.sh
RUN chmod +x /root/build.sh

ARG BUILD_PATH=/usr/local/cookbook
WORKDIR ${BUILD_PATH}

ENV REPO_PATH=/usr/local/cookbook
ENV GIT_REPO_GROUP=NeedPainkiller
ENV GIT_REPO_NAME=CookBook
ENTRYPOINT ["/bin/bash","/root/build.sh"]

앞서 빌드된 이미지와 Wirterside 이미지도 각각 4GB 가량 차지하니 지워두도록 한다.

이미 repo 에 올라온 zip 파일을 긁어오고 unzip 까지 한다

한결 행복해지는 용량

Jenkins 구성

빌드한 cookbook 리소스를 /usr/local/share 에 복사하여 다른 계정의 podman 에서 실행중인 nginx 가 읽을 수 있도록 조치한다.

정상 수행 확인
테스트를 위해 모두 777 로 적용

Nginx 구성

nginx 설정을 추가한다

호스팅 및 결과 확인

나는 AWS Route53 에서 도메인, 네임서버를 직접 관리하기 때문에 cookbook 도메일을 추가하여 진행한다

Readme | COOKBOOK

Github WebHook 을 쓸까?

단순 podman 싸게로만 쓸거면 jenkins 를 쓸 이유가 없다 Github 에서 Repo 의 commit 을 받아 빌드하는 로직을 추가해야 한다.

젠킨스(Jenkins) GitHub Webhooks 연동
<br /><br />

웹훅 가이드는 위 링크를 참조하자, 설명을 아주 잘해 주셨다

근데 webhook 을 걸려면 pipeline 으로 프로젝트를 재구성 해야하고, 정작 podman 으로 작성한 Dockerfile 이 무색하게, workspace 에 repo 가 clone 된다.

repository 를 jenkins workspace 에 그대로 노출시키지 않고, 컨테이너로 감싸면서 빌드 결과파일만 꺼내 오는게 목적인데, 배보다 배꼽이 커지고, 주객이 전도되어 버린다.

내가 WebHook 을 쓰지 않는 이유.

X발 이건 또 뭔 힙스터 같은 소리야 싶겠지만, 난 전 직장에서 webhook 으로 트리거된 수많은 job 들을 관리했었다. 단순한 로그성 commit 에서도 요란하게 알림을 울려대는 Slack 에 노이로제가 걸렸는데, 브랜치를 관리해도 불필요한 job 이 우후죽순 생기기 시작했다.

짤부탁한데이 #6] 오늘은 피곤할 때 쓰는 짤들은 넣어뒀습니다.
선임님, 커밋 7개를 한번에 push 하셨군요?

정작 필요한 테스트는 밀려 돌아가지 않는 경우가 생겼고, 너무나도 많은 Job 과 에러 메시지에 불감증이 생겨버려서, 미처 확인하지 못한 이슈가 방치되기도 했다.

결과적으로 나는 현재 production 단계인 repository 와 Branch 는 수동으로 Job 을 수행하도록 했다. 커밋과 diff 를 직접 확인하고, 내가 빌드해도 좋겠다는 판단을 내린뒤에 직접 Jenkins 에서 Job 을 실행했다.

development 단계에 있는 repository 는 호스팅이 필요한 웹 프로젝트는 테스트만 수행, 그 외의 프로젝트는 모두 쳐내버렸다

결론

podman 으로 writerside 빌드는 결과적으로는 실패 했지만, 소스코드 캡슐화, 그리고 jenkins 프로젝트의 간소화는 확보할 수 있었다. 개인적으로는 pipeline 이라는 개념이 맘에 들지 않기도 하고, 직접 쉘을 돌리는게 테스트하기에도 편했기 때문에 Jenkins 를 제대로 활용했다고는 하기 어려운 작업이었다.

어찌됐던간에 rootless 상태로 컨테이너를 빌드하고 실행하는것이 목적이었으니, podman 을 사용할 명분 하나는 갖춘셈이다.

물론 jenkins 자체에서 Docker 를 실행하는 더 좋은 방법은 넘친다. 그중에는 rootless 하게 실행가능한 부분도 있고, 더 쉬운 방향도 제공한다.

하지만 직접 쉘을 쓰는게 편한 내 입장 상, 앞으로는 이런 방식으로 사용하지 않을 까 싶다.