제 목 :
우리집 비서 만들기 프로젝트 #10 - 서버편: 컨테이너와 배포 파일

9편에서 가짜 모듈로 허브를 끝까지 확인했다. 이제 NAS에 올릴 차례다. 이번에는 컨테이너 이미지와 docker-compose.yml, Jenkinsfile을 만들어 배포를 준비한다. 갈길이 멀다. 어서 시작하자.
Step 1. Docker 이미지
이미지는 서비스마다 하나씩 만든다. 허브의 Dockerfile이다. 세 단계로 나눴다.
# 허브 이미지. 빌드 컨텍스트는 저장소 루트다 (예제 클라이언트 scripts/ 를 테스트에 쓰기 때문).# docker build -f services/hub/Dockerfile --target runtime -t secretary .FROM python:3.13-slim AS baseENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ PIP_DISABLE_PIP_VERSION_CHECK=1WORKDIR /appCOPY services/hub/requirements.txt .RUN pip install --no-cache-dir -r requirements.txt
# 테스트 단계: 테스트가 하나라도 실패하면 이미지 빌드가 실패한다.FROM base AS testCOPY services/hub/requirements-dev.txt .RUN pip install --no-cache-dir -r requirements-dev.txtCOPY services/hub/app ./appCOPY services/hub/tests ./testsCOPY scripts ./scriptsRUN python -m pytest -q
# 실행 이미지: 앱 코드만 담는다.FROM base AS runtimeENV SECRETARY_DATA_DIR=/dataCOPY services/hub/app ./app
# root가 아닌 전용 사용자로 실행한다. NAS의 data 폴더 소유자를 이 UID로 맞춘다.RUN useradd --uid 10001 --no-create-home --shell /usr/sbin/nologin secretary \ && mkdir -p /data && chown secretary /dataUSER secretary
EXPOSE 8080HEALTHCHECK --interval=15s --timeout=3s --start-period=10s --retries=3 \ CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/healthz', timeout=2)"
# 워커는 1개. Nonce와 승인 대기열을 SQLite 하나로 관리하기 때문이다.CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080", "--workers", "1", "--no-server-header"]- base: 실행에 필요한 패키지만 설치
- test: base 위에 테스트 도구와 테스트 코드를 얹고
pytest를 돌린다. 테스트가 하나라도 실패하면 이미지 빌드 자체가 실패한다. 덕분에 빌드하는 곳에 Python이 없어도 된다. - runtime: base 위에 앱 코드만 얹는다. 테스트 코드도, 테스트 도구도 없다.
나중에 만들 모듈의 Dockerfile도 모양은 같다. Dockerfile은 빌드 컨텍스트가 저장소 루트다. 그래서 COPY services/hub/app ./app처럼 경로를 적는다. 허브 테스트가 저장소 루트의 예제 클라이언트(scripts/)도 쓰기 때문이다.
그리고 실행은 root가 아닌 secretary(UID 10001) 사용자로 한다. 혹시 앱에 구멍이 나도 컨테이너 안에서 root가 되지는 않는다.
.dockerignore도 챙긴다. .env가 이미지 안에 실수로 들어가는 사고를 막는다.
.git.env.env.*!.env.exampledata__pycache__*.pyc.pytest_cache.venvdocker build ... .의 마지막 .이 빌드 컨텍스트다. 빌드 컨텍스트는 Dockerfile이 있는 배포경로를 의미한다. 빌드를 시작할 루트 디렉토리의 경로를 의미한다고 생각하면 된다. 중간의 ...은 빌드 옵션이니 신경쓰지 말자.
암튼, Docker는 빌드를 시작하기 전에 이 폴더(여기서는 저장소 루트)를 통째로 빌더에 넘기고, Dockerfile의 COPY는 그 안에서만 파일을 가져올 수 있는 명령어다. 다시 이야기하자면 Docker는 빌드 시작 전 Dockerfile이 있는 곳을 기준으로 안에 있는 모든 내용물을 통째로 빌더에 넘기는 과정을 거친다. Dockerfile에 있는 COPY 명령은 빌더에 넘어간 내용물에서 복사를 한다는 이야기.
그런데 빌더에 넘기고 싶지 않은 파일이 있다면 .dockerignore에 미리 넣어두면 된다. .dockerignore는 이미지 안으로 넘기지 않을 파일 목록이다. .gitignore랑 같다. Git 서버에 안 올리는 것, 빌더에 안 넣어주는 것. 여기 적힌 파일은 빌더에 아예 안 가니까, 누가 나중에 COPY . .을 써도 이미지에 들어갈 수 없다.
쓰는 법은 앞에서 이야기 했다시피 .gitignore와 거의 같다. 한 줄에 패턴 하나, *는 아무 글자, 앞에 !를 붙이면 “이건 빼지 마라”다. 그래서 .env.*로 환경설정 파일은 전부 막고 !.env.example 파일만 살렸다.
.gitignore는 저장소에 안 올라가게, .dockerignore는 이미지에 안 들어가게 막는다. 덤으로 .git처럼 큰 폴더를 빼면 빌드도 빨라진다.
Step 2. docker-compose.yml
2편에서 만든 compose에서 whoami를 빼고, 허브(secretary)를 넣었다. 모듈은 아직 없다. 모듈 자리는 비워 두고, 네트워크만 미리 나눠 둔다.
# Jenkins가 배포할 때 쓰는 compose 파일. 직접 손으로 올릴 때도 같은 파일을 쓴다.## 설정값은 --env-file 로 넘긴 .env 에서 읽는다 (Jenkins는 Credentials의 Secret file을 넘긴다).# Jenkins 배포 시 셸 환경으로 넘어오는 값:# IMAGE_TAG Git 커밋 해시 앞 12자리# SECRETARY_DATA_DIR_HOST NAS의 데이터 폴더 절대 경로 (예: /volume1/docker/secretary/data)## 네트워크는 세 개로 나눈다.# edge cloudflared ↔ 허브. 바깥에서 들어오는 길은 이것 하나# modules 허브 ↔ 모듈. internal: true 라서 이 네트워크로는 인터넷에 나갈 수 없다# egress 인터넷이 필요한 모듈만 붙인다 (예: 메일 모듈 → AWS SES)# 모듈은 edge에 없으므로 cloudflared(= 바깥)에서 모듈로 바로 갈 길이 없다.## 새 모듈을 붙이려면: 아래에 서비스를 추가하고, 허브의 SECRETARY_MODULES / SECRETARY_MODULE_TOKENS 에 한 줄씩.# 허브 코드는 고치지 않는다 (docs/module-contract.md).
services: cloudflared: image: cloudflare/cloudflared:latest container_name: cloudflared restart: unless-stopped command: tunnel --no-autoupdate run environment: - TUNNEL_TOKEN=${TUNNEL_TOKEN:?TUNNEL_TOKEN is required} networks: [edge]
# 허브: 인증, 사용자, 권한, 승인, 감사 로그, 명령 전달 secretary: image: secretary:${IMAGE_TAG:-latest} build: context: . dockerfile: services/hub/Dockerfile target: runtime container_name: secretary restart: unless-stopped # env_file로 .env 전체를 넘기지 않는다. 컨테이너마다 필요한 값만 골라 넘긴다. # 허브에는 AWS 키 같은 모듈의 비밀값이 들어가지 않는다. environment: - SECRETARY_DATA_DIR=/data - SECRETARY_MAX_USERS=${SECRETARY_MAX_USERS:-4} - SECRETARY_REQUIRE_CF_ACCESS=${SECRETARY_REQUIRE_CF_ACCESS:-true} - SECRETARY_CLOCK_SKEW_SECONDS=${SECRETARY_CLOCK_SKEW_SECONDS:-60} - SECRETARY_APPROVAL_TTL_SECONDS=${SECRETARY_APPROVAL_TTL_SECONDS:-300} # 모듈을 붙이면 여기에 한 줄씩 늘어난다 (12편) - SECRETARY_MODULES= - SECRETARY_MODULE_TOKENS= volumes: - ${SECRETARY_DATA_DIR_HOST:-./data}:/data # 바깥으로 여는 포트는 없다. cloudflared만 같은 네트워크 안에서 secretary:8080 으로 닿는다. read_only: true tmpfs: - /tmp cap_drop: [ALL] security_opt: - no-new-privileges:true networks: [edge, modules]
# 모듈은 여기 아래에 붙인다 (12편의 secretary-mail 부터)
networks: edge: name: secretary_edge modules: name: secretary_modules internal: true egress: name: secretary_egress네트워크를 세 개로 나눈 게 핵심이다.
| 네트워크 | 붙은 것 | 역할 |
|---|---|---|
edge |
cloudflared, 허브 | 바깥에서 들어오는 길. 이것 하나뿐 |
modules |
허브, 모듈들 | 허브와 모듈 사이. internal: true라서 이 네트워크로는 인터넷에 못 나간다 |
egress |
인터넷이 필요한 모듈만 | 예: 12편의 메일 모듈 → AWS SES |
모듈은 edge에 없으니, cloudflared(그러니까 바깥)에서 모듈로 바로 가는 길이 아예 없다. 모듈에 닿을 수 있는 건 허브뿐이다. 나중에 붙일 Tapo 모듈처럼 인터넷이 필요 없는 모듈은 modules에만 붙이면 된다.
컨테이너 보안 설정이 꽤 붙었다.
ports가 없다. 바깥에서는 못 들어오고, 같은edge네트워크의 cloudflared만secretary:8080으로 닿는다.read_only: true: 파일시스템을 읽기 전용으로. 허브가 쓸 수 있는 곳은/data볼륨과/tmp뿐이다.cap_drop: [ALL]: 리눅스 권한(capability)을 전부 뺀다. 웹 서버 하나 돌리는 데 특별한 권한은 필요 없다.no-new-privileges: 컨테이너 안에서 권한 상승을 막는다.
Docker 네트워크는 기본으로 바깥(인터넷)으로 나가는 길이 뚫려 있다. 컨테이너가 보낸 요청을 Docker가 NAS의 IP로 바꿔서 내보내 주기 때문이다(NAT). 네트워크에 internal: true를 붙이면 이 길이 없어진다. 같은 네트워크에 붙은 컨테이너끼리는 여전히 이야기할 수 있지만, 그 네트워크를 통해서는 인터넷에 나갈 수도, 바깥에서 들어올 수도 없다.
그래서 modules에만 붙은 모듈은 허브(와 같은 네트워크의 다른 모듈)하고만 이야기할 수 있다. 혹시 모듈 하나가 털려도, 그 모듈로 데이터를 바깥으로 빼돌리거나 해킹 도구를 내려받을 길이 없다. 컨테이너는 여러 네트워크에 동시에 붙을 수 있고, 붙은 네트워크 중 하나라도 바깥으로 열려 있으면 그쪽으로 나간다. 메일 모듈처럼 인터넷이 꼭 필요한 모듈만 egress에 같이 붙이는 이유다.
Step 3. Jenkinsfile
마지막은 자동 배포 파이프라인이다. Gitea에 push하면 Jenkins가 받아서 NAS에 배포한다. 우리집 Jenkins는 NAS 안의 Docker 컨테이너라서, Jenkins가 직접 docker를 다루지 않고 SSH로 NAS에 접속해서 빌드와 배포를 시킨다. 왜 이렇게 했는지는 배포편에서 자세히 이야기한다.
// Secretary 빌드·배포 파이프라인//// Jenkins 컨테이너에는 docker가 없다. 빌드와 배포는 NAS가 직접 한다.// Gitea push → webhook → Jenkins// 1) Upload : 이 커밋의 소스를 SSH로 NAS에 올린다 (git archive)// 2) Test : NAS에서 테스트 이미지 빌드 + pytest// 3) Deploy : main 브랜치면 .env를 올리고 NAS에서 빌드·배포·헬스체크 (실패하면 직전 이미지로)//// 필요한 Jenkins Credentials:// nas-deploy-ssh (SSH Username with private key) NAS의 배포 전용 계정 키// secretary-env (Secret file) .env 파일 전체//// 설치마다 다른 값은 저장소에 적지 않고 Jenkins 환경 변수로 넣는다// (Manage Jenkins → System → Global properties → Environment variables, 또는 작업 설정):// SECRETARY_NAS_HOST 필수. 예: deploy@192.168.0.10// SECRETARY_NAS_BASE 선택. 기본 /volume1/docker/secretary (src/, data/ 가 여기 생긴다)// SECRETARY_NAS_DOCKER 선택. 기본 docker. 배포 계정이 docker 그룹이 아니면 "sudo docker"
pipeline { agent any
options { disableConcurrentBuilds() timeout(time: 20, unit: 'MINUTES') buildDiscarder(logRotator(numToKeepStr: '20')) }
environment { NAS_HOST = "${env.SECRETARY_NAS_HOST ?: ''}" NAS_BASE = "${env.SECRETARY_NAS_BASE ?: '/volume1/docker/secretary'}" NAS_DOCKER = "${env.SECRETARY_NAS_DOCKER ?: 'docker'}" }
stages { stage('Prepare') { steps { script { if (!env.NAS_HOST?.trim()) { error('Jenkins 환경 변수 SECRETARY_NAS_HOST 를 설정하세요. 예: deploy@192.168.0.10') } env.IMAGE_TAG = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim() currentBuild.description = "secretary:${env.IMAGE_TAG}" } } }
stage('Upload') { steps { withCredentials([sshUserPrivateKey(credentialsId: 'nas-deploy-ssh', keyFileVariable: 'SSH_KEY')]) { // 작은따옴표 셸 스크립트: Groovy가 아니라 셸이 변수를 해석하게 해서 비밀값이 새지 않게 한다. sh ''' SSH="ssh -i $SSH_KEY -o BatchMode=yes -o StrictHostKeyChecking=yes" git archive --format=tar HEAD \ | $SSH "$NAS_HOST" "rm -rf '$NAS_BASE/src' && mkdir -p '$NAS_BASE/src' && tar -x -C '$NAS_BASE/src'" ''' } } }
stage('Test') { steps { withCredentials([sshUserPrivateKey(credentialsId: 'nas-deploy-ssh', keyFileVariable: 'SSH_KEY')]) { sh ''' SSH="ssh -i $SSH_KEY -o BatchMode=yes -o StrictHostKeyChecking=yes" $SSH "$NAS_HOST" "IMAGE_TAG='$IMAGE_TAG' DOCKER='$NAS_DOCKER' bash '$NAS_BASE/src/deploy/deploy.sh' test" ''' } } }
stage('Deploy') { when { expression { env.GIT_BRANCH in ['main', 'origin/main'] } } steps { withCredentials([ sshUserPrivateKey(credentialsId: 'nas-deploy-ssh', keyFileVariable: 'SSH_KEY'), file(credentialsId: 'secretary-env', variable: 'ENV_FILE'), ]) { sh ''' SSH="ssh -i $SSH_KEY -o BatchMode=yes -o StrictHostKeyChecking=yes" # .env는 표준 입력으로 보낸다. 명령줄이나 로그에 값이 남지 않는다. 배포 스크립트가 끝나면 지운다. $SSH "$NAS_HOST" "umask 077 && cat > '$NAS_BASE/src/.env'" < "$ENV_FILE" $SSH "$NAS_HOST" "IMAGE_TAG='$IMAGE_TAG' DOCKER='$NAS_DOCKER' SECRETARY_DATA_DIR_HOST='$NAS_BASE/data' bash '$NAS_BASE/src/deploy/deploy.sh' deploy" ''' } } } }
post { success { echo "완료: secretary:${env.IMAGE_TAG}" } }}시큐어 코딩 글에서 Jenkins Credentials 이야기를 했었는데, 여기서 그대로 쓴다.
nas-deploy-ssh: NAS 배포 계정의 SSH 키.sshUserPrivateKey로 꺼내 쓴다.secretary-env:.env파일 통째로 Secret file로 올려 둔다. 저장소에는 절대 들어가지 않는다.- 셸 스크립트는 작은따옴표(
''')로. 큰따옴표를 쓰면 Groovy가 먼저 문자열에 비밀값을 끼워 넣으면서 노출될 수 있다. .env는 SSH 표준 입력으로 보낸다. 명령줄 인자로 넘기면 프로세스 목록이나 로그에 남을 수 있다.StrictHostKeyChecking=yes. 처음 한 번 NAS의 호스트 키를 등록해 두고, 그 뒤로는 다른 서버로 바꿔치기돼도 접속하지 않는다.main브랜치만 배포한다. 다른 브랜치는 테스트까지만 돈다.- NAS 주소는 저장소에 없다.
SECRETARY_NAS_HOST같은 값은 Jenkins 환경 변수로 넣는다. 이건 바로 다음 Step에서 다시 이야기한다.
Jenkinsfile이 부르는 deploy/deploy.sh는 NAS에서 실제로 테스트 이미지 빌드, 실행 이미지 빌드, docker compose up, 헬스체크를 하고, 헬스체크가 실패하면 직전 이미지로 되돌린다. 서비스가 여러 개가 됐으니 맨 위에 서비스 목록을 두고, 서비스마다 테스트하고, 빌드하고, 헬스체크한다.
# 배포할 서비스: "services/ 아래 폴더:이미지·컨테이너 이름". 모듈을 추가하면 여기에도 한 줄.SERVICES=( "hub:secretary")지금은 허브 하나뿐이다. 12편에서 메일 모듈을 붙이면 "mail:secretary-mail" 한 줄이 늘어난다.
모듈이 늘어나도 모든 서비스는 같은 태그(커밋 해시)로 함께 배포된다. 하나라도 헬스체크에 실패하면 전부 직전 태그로 되돌린다. .env는 배포하는 동안에만 존재하고 끝나면 지운다. 이 스크립트로 처음 배포하면서 겪은 일들은 배포편에서 이야기한다.
Step 4. 다른 사람도 가져다 쓸 수 있게
언젠가 이 소스를 다른 사람과 나누고 싶다. 그러려면 저장소 안에 우리집만의 값이 하나도 없어야 한다. 정리하면서 확인한 것들이다.
- 비밀값:
.env는.gitignore로 막고, 운영 값은 Jenkins Credentials(secretary-env)에만 있다. - 우리집 주소: 처음에는 Jenkinsfile에 NAS IP를 직접 적어 두었다. 이걸 Jenkins 환경 변수로 뺐다. 값이 없으면 빌드가 친절하게 멈춘다.
environment {NAS_HOST = "${env.SECRETARY_NAS_HOST ?: ''}"NAS_BASE = "${env.SECRETARY_NAS_BASE ?: '/volume1/docker/secretary'}"NAS_DOCKER = "${env.SECRETARY_NAS_DOCKER ?: 'docker'}"}
- 집마다 다른 설정: 최대 인원(
SECRETARY_MAX_USERS), 배포 경로, 모듈마다의 설정(12편 메일 모듈의 수신자 허용 목록 같은)은 전부 환경 변수다. - 모듈은 골라 쓰게: 메일이 필요 없는 집은 메일 모듈을 안 붙이면 된다. 반대로 자기만의 모듈을 만들어 붙이려면 module-contract.md의 약속만 지키면 된다. 허브 코드는 건드릴 필요가 없다.
사실 버전이 1.0정도로 남들에게 보여줄 수 있을 정도가 된다면 Github 공개 저장소에 소스를 올릴 생각이다. 집에서 하는건 Nightly버전이고, Github에 올리는건 공개버전이 되겠지. Secretary 서버가 잘 돌아간다면 허브들만 계속 올려서 확장해 나가는 플랫폼으로 한번 만들어볼 생각이다.
정리하면
7편부터 여기까지 한 일을 정리하면 이렇다. 여기까지가 허브다.
- 약속: 허브와 모듈은
/manifest,/actions/{액션}/validate,/actions/{액션},/healthz네 개로만 이어진다. 승인 여부와 역할 제한은 모듈이 선언하고 허브가 집행한다 - 명령: 모든 모듈은
POST /v1/commands하나로. 모듈이 늘어도 클라이언트는 그대로 - 승인 대기열: 모듈이 “승인 필요”라고 한 명령은 붙잡아 두고, 승인되면 그대로 모듈에 넘긴다
- 이벤트: 모듈이 먼저 알려야 할 일은
/internal/events로. 바깥에서는 막혀 있다 - 승인 규칙: 내 요청은 내 다른 기기가, 아이 요청은 관리자가. 다른 가족 요청은 없는 것처럼 404. 두 번 눌러도 한 번만 실행
- 감사 로그: 누가(사람, 기기) 언제 무엇을 했는지 JSONL로. 비밀값과 입력값은 남기지 않는다
- 컨테이너: non-root, 읽기 전용, 권한 전부 제거, 포트 없음, 컨테이너마다 필요한 값만. 네트워크를 나눠 모듈은 허브만 닿게
- 배포 파일: Jenkins는 지휘만, NAS가 직접 빌드.
.env는 배포 중에만 존재하고, 실패하면 되돌린다 - 공유: 우리집만의 값은 저장소 밖으로. 누가 가져가도 자기 집 값만 넣으면 돌아가게
허브는 이제 다 준비됐다. 다음 11편 배포편에서는 이 파일들로 진짜 NAS에 배포해 본다. 그리고 12편에서 이 약속대로 첫 번째 모듈, 메일을 붙인다. 코드는 한 줄도 안 고쳤는데 몇 번이나 넘어졌는지는 거기서.
그럼 안녕!

꼬리말을 불러오는 중...