diff --git a/Jenkinsfile b/Jenkinsfile index 7632810..83c1bc5 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -2,10 +2,12 @@ // golang 컨테이너에서 빌드·테스트 → EC2(systemd)로 단일 정적 바이너리 배포. // // 필요한 Jenkins 구성 (자세한 건 deploy/README.md): -// - 플러그인: Go Plugin, SSH Agent -// - Global Tool Configuration → Go 에 1.26 등록(이름 = GO_TOOL 값과 일치) -// - 자격증명: "SSH Username with private key" 를 만들고 ID 를 SSH_CRED_ID 와 일치시킴 (EC2 접속 키) -// - 대상 EC2 1회 셋업: /opt/slack-notifier, .env.prod, systemd 서비스, Caddy (deploy/README.md) +// - 플러그인: Go Plugin, Credentials Binding +// - Global Tool Configuration → Go 에 'go-1.26.4' 등록(이름이 tools 블록과 일치해야 함) +// - 빌드 에이전트에 sshpass 설치 (아이디/비번 SSH 접속용) +// - 자격증명: "Username with password" 를 'palnet-dev-ops' ID 로 등록 (유저·비번 모두 여기서 가져옴) +// - 대상 EC2: 비밀번호 SSH 허용, 배포 계정이 APP_DIR 소유, APP_DIR 에 .env.prod 미리 배치, Caddy(HTTPS) +// - 앱 기동은 start.sh(백그라운드, sudo/systemd 불필요)로 관리 — 배포 시 start.sh 도 함께 전송됨 // // 호스트/유저/아키텍처는 파라미터로 조정. main 브랜치 + DEPLOY_HOST 지정 시에만 배포. @@ -21,18 +23,17 @@ pipeline { parameters { // parameters 를 바꿀 때마다 이 기본값과 environment.PARAMS_VERSION 을 함께 +1 한다. // (파라미터는 1빌드 늦게 등록되므로, 아래 Guard 가 stale 빌드를 막는다) - string(name: 'PARAMS_VERSION', defaultValue: '2', description: '파라미터 동기화 가드용 — 직접 바꾸지 말 것') - string(name: 'DEPLOY_HOST', defaultValue: '52.79.193.239', description: 'EC2 호스트(DNS 또는 IP). 비우면 배포 스킵(빌드/테스트만).') - string(name: 'DEPLOY_USER', defaultValue: 'ec2-user', description: 'SSH 사용자 (Ubuntu=ubuntu, Amazon Linux=ec2-user)') + string(name: 'PARAMS_VERSION', defaultValue: '3', description: '파라미터 동기화 가드용 — 직접 바꾸지 말 것') string(name: 'APP_DIR', defaultValue: '/data/app/notifier', description: '원격 설치 경로') choice(name: 'ARCH', choices: ['amd64', 'arm64'], description: 'EC2 아키텍처 (Graviton/t4g=arm64, x86=amd64)') } environment { - SSH_CRED_ID = 'slack-notifier-ec2' // Jenkins SSH 자격증명 ID (실제 ID로 교체 가능) + DEPLOY_CRED_ID = 'palnet-dev-ops' // Jenkins "Username with password" 자격증명 ID (EC2 접속 비번) + DEPLOY_HOST = '52.79.193.239' // 'EC2 호스트(DNS 또는 IP). 비우면 배포 스킵(빌드/테스트만).') BIN = 'slack-notifier' GOFLAGS = '-buildvcs=false' // .git 소유권 경고 회피 - PARAMS_VERSION = '2' // ← parameters 변경 시 위 기본값과 함께 +1 + PARAMS_VERSION = '3' // ← parameters 변경 시 위 기본값과 함께 +1 } stages { @@ -75,26 +76,34 @@ pipeline { agent any steps { unstash 'binary' - sshagent(credentials: [env.SSH_CRED_ID]) { + // 아이디/비번 SSH (sshpass). 유저(CRED_USER)·비번(PASSWORD) 모두 palnet-dev-ops 자격증명에서. + // sshpass -e 로 비번을 SSHPASS 환경변수로 전달 → 커맨드라인/ps 에 노출 안 됨. + withCredentials([usernamePassword(credentialsId: env.DEPLOY_CRED_ID, + usernameVariable: 'CRED_USER', passwordVariable: 'PASSWORD')]) { sh ''' set -eu - H="${DEPLOY_USER}@${DEPLOY_HOST}" - SSH="ssh -o StrictHostKeyChecking=accept-new" + export SSHPASS="$PASSWORD" + H="${CRED_USER}@${DEPLOY_HOST}" # 유저·비번 모두 palnet-dev-ops 자격증명에서 + # scp 는 포트 옵션이 대문자 -P, ssh 는 소문자 -p + SCP="sshpass -e scp -P 22 -o StrictHostKeyChecking=no" + SSH="sshpass -e ssh -p 22 -o StrictHostKeyChecking=no" - # 1) 새 바이너리를 임시 위치로 전송 - scp -o StrictHostKeyChecking=accept-new "${BIN}" "$H:/tmp/${BIN}.new" + # 0) 배포 디렉터리 보장 + $SSH "$H" "mkdir -p '${APP_DIR}'" - # 2) 원자적 교체 후 재시작. - # 실행 중 바이너리 덮어쓰기는 ETXTBSY 가 나므로, 같은 파일시스템에서 rename(mv) 으로 교체한다. - # (running 프로세스는 옛 inode 를 유지 → 안전) - $SSH "$H" "set -e; \ - sudo mkdir -p '${APP_DIR}' '${APP_DIR}/logs' '${APP_DIR}/data'; \ - sudo mv '/tmp/${BIN}.new' '${APP_DIR}/${BIN}.staged'; \ - sudo chmod 0755 '${APP_DIR}/${BIN}.staged'; \ - sudo mv -f '${APP_DIR}/${BIN}.staged' '${APP_DIR}/${BIN}'; \ - sudo systemctl restart slack-notifier; \ - sleep 1; \ - sudo systemctl is-active slack-notifier" + # 1) 바이너리(임시명)와 start.sh 전송 + $SCP "${BIN}" "$H:${APP_DIR}/${BIN}.new" + $SCP deploy/start.sh "$H:${APP_DIR}/start.sh" + + # 2) 프로세스 정지 후 바이너리 교체(정지 상태라 ETXTBSY 없음) → start.sh 로 재기동. + # sudo/systemd 불필요 — 배포 계정이 APP_DIR 소유. .env.prod 는 서버에 미리 두어야 함. + $SSH "$H" "set -e; cd '${APP_DIR}'; \ + chmod +x start.sh; \ + ./start.sh stop || true; \ + mv -f '${BIN}.new' '${BIN}'; \ + chmod 0755 '${BIN}'; \ + ./start.sh start prod; \ + ./start.sh status" ''' } } diff --git a/deploy/README.md b/deploy/README.md index 27dc047..21ab64e 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -1,14 +1,16 @@ -# 배포 가이드 (EC2 + systemd + Jenkins) +# 배포 가이드 (EC2 + start.sh + Jenkins) -slack-notifier 를 **EC2(리눅스)** 에 단일 정적 바이너리로 올리고, **systemd** 로 운영하며, -**Jenkins** 로 빌드·배포를 자동화한다. 도커 레지스트리 불필요(바이너리 직접 배포). +slack-notifier 를 **EC2(리눅스)** 에 단일 정적 바이너리로 올리고, **start.sh**(백그라운드, sudo/systemd 불필요) +로 운영하며, **Jenkins** 로 빌드·배포를 자동화한다. 도커 레지스트리 불필요(바이너리 직접 배포). ``` -[git push main] → Jenkins (golang:1.26 컨테이너) → go test/build → scp 바이너리 → EC2 - → systemctl restart slack-notifier +[git push] → Jenkins (네이티브 Go) → go vet/test/build → sshpass scp(바이너리+start.sh) → EC2 + → start.sh restart [Gitea/Notion] ──webhook(HTTPS)──→ Caddy(:443) ──→ slack-notifier(:8000) ──→ Slack ``` +기본 경로: `APP_DIR=/data/app/notifier`, 유저 `ec2-user`, 아키텍처 `amd64` (Jenkins 파라미터로 조정). + --- ## 1. EC2 최초 1회 셋업 @@ -16,11 +18,12 @@ slack-notifier 를 **EC2(리눅스)** 에 단일 정적 바이너리로 올리 ### 1-1. 설치 경로 / 환경파일 ```bash -sudo mkdir -p /opt/slack-notifier/{logs,data} -sudo chown -R $USER:$USER /opt/slack-notifier # Jenkins 배포 유저가 쓸 수 있게(또는 sudo 권한) +# 배포 계정(ec2-user)이 소유 → sudo 없이 배포/기동 가능 +sudo mkdir -p /data/app/notifier +sudo chown -R ec2-user:ec2-user /data/app/notifier # 비밀값/설정 — 서버에만 두고 절대 커밋하지 않음 -sudo vi /opt/slack-notifier/.env.prod +vi /data/app/notifier/.env.prod # SLACK_BOT_TOKEN=xoxb-... # DEFAULT_SLACK_CHANNEL=C... (브로드캐스트 채널, GITEA_BROADCAST=on 일 때) # GITEA_BROADCAST=off @@ -31,64 +34,65 @@ sudo vi /opt/slack-notifier/.env.prod # LOG_RETENTION_DAYS=7 ``` -> systemd 서비스는 `-env prod` 로 실행되어 `.env.prod` 를 읽고, 로그는 `/opt/slack-notifier/logs/app-YYYY-MM-DD.log` 로 날짜별 회전된다. +> 앱은 `-env prod` 로 실행되어 `.env.prod` 를 읽고, 로그는 `/data/app/notifier/logs/app-YYYY-MM-DD.log` +> 로 날짜별 회전된다(7일 보존). stdout/패닉은 `logs/console.log` 에 남는다. + +### 1-2. 실행 스크립트(start.sh) -### 1-2. systemd 서비스 등록 +`start.sh` 는 **Jenkins 배포 시 바이너리와 함께 자동 전송**되므로 수동 복사는 보통 불필요하다. +최초 수동 기동이 필요하면: ```bash -sudo cp deploy/slack-notifier.service /etc/systemd/system/slack-notifier.service -sudo systemctl daemon-reload -sudo systemctl enable --now slack-notifier -sudo systemctl status slack-notifier +# 바이너리·start.sh·.env.prod 를 /data/app/notifier 에 둔 뒤 +cd /data/app/notifier +./start.sh start prod # 시작 (start|stop|restart|status) +./start.sh status ``` -서비스는 `/opt/slack-notifier/slack-notifier` 바이너리를 실행한다(첫 배포 전이면 아직 없음 → Jenkins 첫 배포 후 정상 기동). - ### 1-3. HTTPS 리버스 프록시(Caddy) 웹훅은 공개 HTTPS 가 필수. Caddy 가 Let's Encrypt 인증서를 자동 발급한다. ```bash sudo cp deploy/Caddyfile /etc/caddy/Caddyfile -sudo vi /etc/caddy/Caddyfile # your-domain.com 을 실제 도메인으로 교체 +sudo vi /etc/caddy/Caddyfile # your-domain.com 을 실제 도메인으로 교체 (reverse_proxy localhost:8000) sudo systemctl restart caddy ``` - 도메인 A 레코드 → EC2 퍼블릭 IP -- 보안그룹 인바운드 **80, 443** 개방(ACME 검증 + 웹훅 수신) +- 보안그룹 인바운드 **80, 443**(ACME + 웹훅 수신), 그리고 **Jenkins → EC2 22(SSH)** 허용 - 웹훅 등록 주소: `https://your-domain.com/webhooks/{gitea,notion}` --- ## 2. Jenkins 셋업 -### 2-1. 플러그인 / Go 도구 -- 플러그인: **Go Plugin**, **SSH Agent** -- **Manage Jenkins → Tools → Go installations** 에서 Go 추가: - - Name: **`go-1.26`** (Jenkinsfile 의 `GO_TOOL` 과 일치, 다르면 Jenkinsfile 수정) - - "Install automatically" 로 1.26.x 선택(또는 에이전트에 설치된 Go 경로 지정) -- 컨테이너 없이 에이전트의 네이티브 Go 로 빌드 → 이미지 풀/디스크 오버헤드 없음 - -### 2-2. 자격증명(SSH 키) -- Jenkins → Credentials → **SSH Username with private key** 추가 - - Username: EC2 SSH 유저(`ubuntu` 등) - - Private key: EC2 접속 키(.pem) - - **ID: `slack-notifier-ec2`** (Jenkinsfile 의 `SSH_CRED_ID` 와 일치, 다르면 Jenkinsfile 수정) -- 배포 유저는 `/opt/slack-notifier` 쓰기 + `systemctl restart slack-notifier` 가능해야 함 - (sudoers 에 무암호 허용 권장): - ``` - ubuntu ALL=(root) NOPASSWD: /bin/systemctl restart slack-notifier, /bin/systemctl is-active slack-notifier, /bin/mv, /bin/mkdir, /bin/chmod - ``` +### 2-1. 플러그인 / Go 도구 / sshpass +- 플러그인: **Go Plugin**, **Credentials Binding** +- **Manage Jenkins → Tools → Go installations** 에 Go 추가: + - Name: **`go-1.26.4`** (Jenkinsfile 의 `tools { go '...' }` 와 일치) + - "Install automatically" 로 1.26.x (또는 에이전트의 Go 경로) +- **빌드 에이전트에 `sshpass` 설치** (아이디/비번 SSH): `sudo yum install -y sshpass` / `apt install -y sshpass` + +### 2-2. 자격증명(아이디/비번) +- Jenkins → Credentials → **Username with password** 추가 + - **ID: `palnet-dev-ops`** (Jenkinsfile 의 `DEPLOY_CRED_ID` 와 일치) + - **Username = EC2 SSH 유저(ec2-user), Password = 접속 비밀번호** — 둘 다 파이프라인이 자격증명에서 가져옴 + - (호스트 IP 는 자격증명에 없으므로 `DEPLOY_HOST` 파라미터로 받음) +- 대상 EC2 는 **비밀번호 SSH 허용**(`/etc/ssh/sshd_config` 의 `PasswordAuthentication yes`) 필요 +- 배포 계정이 `APP_DIR` 소유 → **sudo 불필요** ### 2-3. 파이프라인 잡 - **Pipeline script from SCM** → 이 저장소 → `Jenkinsfile` - 파라미터(첫 실행 시 생성됨): - `DEPLOY_HOST` = EC2 DNS/IP (비우면 빌드·테스트만, 배포 스킵) - - `DEPLOY_USER` = `ubuntu`(또는 `ec2-user`) - - `APP_DIR` = `/opt/slack-notifier` - - `ARCH` = `arm64`(Graviton/t4g) 또는 `amd64`(x86) ← **인스턴스 아키텍처와 반드시 일치** + - `APP_DIR` = `/data/app/notifier` + - `ARCH` = `amd64`(x86) 또는 `arm64`(Graviton/t4g) ← **인스턴스 아키텍처와 반드시 일치** + - (SSH 유저는 `palnet-dev-ops` 자격증명의 username 사용 → 별도 파라미터 없음) 배포는 **main 브랜치 + DEPLOY_HOST 지정** 시에만 수행된다. +(일반 Pipeline 잡은 `BRANCH_NAME` 이 비어 `branch 'main'` 매칭이 안 되므로, Multibranch Pipeline 권장 — +단일 잡으로 쓸 거면 Jenkinsfile 의 `when` 에서 `branch 'main'` 을 제거할 것.) ### 2-4. 파라미터 변경 시 (stale 가드) @@ -100,22 +104,20 @@ Jenkins 의 `parameters` 는 변경해도 **1빌드 늦게** 등록된다(step/e 2. **`parameters` 의 `PARAMS_VERSION` 기본값**과 **`environment.PARAMS_VERSION`** 을 **둘 다 +1** 3. 커밋/푸시 → 첫 빌드는 `Guard` 에서 **의도적으로 실패**(파라미터 재등록만 됨) → **한 번 더 빌드**하면 정상 진행 -즉 "파라미터가 실제로 적용된 빌드"에서만 배포가 일어나도록 강제된다. - --- ## 3. 배포 동작 / 롤백 -- 무중단 교체: 새 바이너리를 임시 전송 후 **같은 파일시스템에서 rename** 으로 교체(실행 중 `ETXTBSY` 회피) → `systemctl restart`. -- 로그 확인: `journalctl -u slack-notifier -f` 또는 `tail -f /opt/slack-notifier/logs/app-$(date +%F).log` -- 롤백: 이전 빌드의 바이너리로 다시 배포(Jenkins "Rebuild" 또는 이전 커밋으로 빌드). 필요하면 배포 전 `cp slack-notifier slack-notifier.bak` 단계를 추가해도 됨. +- 교체 순서: 바이너리(임시명)·start.sh 전송 → `start.sh stop` → `mv` 로 교체(정지 상태라 `ETXTBSY` 없음) → `start.sh start prod` → `status`. +- 로그 확인: `tail -f /data/app/notifier/logs/app-$(date +%F).log` (구조적 로그) / `logs/console.log` (stdout·패닉) +- 롤백: 이전 커밋으로 다시 빌드/배포. 필요하면 배포 전 `cp slack-notifier slack-notifier.bak` 단계를 추가해도 됨. --- ## 4. 수동 배포(참고, Jenkins 없이) ```bash -make build-linux ARCH=arm64 # 정적 바이너리 생성 -scp slack-notifier ubuntu@EC2:/tmp/sn.new -ssh ubuntu@EC2 'sudo mv /tmp/sn.new /opt/slack-notifier/slack-notifier && sudo systemctl restart slack-notifier' +make build-linux ARCH=amd64 # 정적 바이너리 생성 +sshpass -e scp -P 22 slack-notifier deploy/start.sh ec2-user@EC2:/data/app/notifier/ # SSHPASS 환경변수 사용 +ssh ec2-user@EC2 'cd /data/app/notifier && chmod +x start.sh && ./start.sh restart prod && ./start.sh status' ``` diff --git a/deploy/start.sh b/deploy/start.sh new file mode 100755 index 0000000..994e045 --- /dev/null +++ b/deploy/start.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# +# slack-notifier 실행 스크립트 — 스크립트가 위치한 디렉터리에서 백그라운드로 운영. +# (배포 경로 예: /data/app/notifier/start.sh) +# +# ./start.sh start [dev|prod] # 백그라운드 시작 (기본 prod) +# ./start.sh stop # 종료 +# ./start.sh restart [dev|prod] # 재시작 +# ./start.sh status # 상태 +# +# 같은 디렉터리에 바이너리(slack-notifier)와 .env.{env} 가 있어야 한다. +set -euo pipefail + +APP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$APP_DIR" + +BIN="./slack-notifier" +ENV="${2:-prod}" +PID_FILE="$APP_DIR/run/slack-notifier.pid" +CONSOLE_LOG="$APP_DIR/logs/console.log" # stdout/stderr (기동 전 출력·패닉 포함) + +mkdir -p "$APP_DIR/run" "$APP_DIR/logs" + +is_running() { + [[ -f "$PID_FILE" ]] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null +} + +start() { + if is_running; then + echo "이미 실행 중 (pid $(cat "$PID_FILE"))" + return 0 + fi + if [[ ! -x "$BIN" ]]; then + echo "바이너리가 없거나 실행 권한 없음: $BIN" + exit 1 + fi + nohup "$BIN" -env "$ENV" >>"$CONSOLE_LOG" 2>&1 & + echo $! >"$PID_FILE" + sleep 1 + if is_running; then + echo "시작됨 (pid $(cat "$PID_FILE"), env=$ENV)" + else + echo "시작 실패 — 마지막 로그:" + tail -n 20 "$CONSOLE_LOG" + rm -f "$PID_FILE" + exit 1 + fi +} + +stop() { + if ! is_running; then + echo "실행 중이 아님" + rm -f "$PID_FILE" + return 0 + fi + local pid + pid="$(cat "$PID_FILE")" + kill "$pid" 2>/dev/null || true + for _ in $(seq 1 10); do + is_running || break + sleep 0.5 + done + if is_running; then + kill -9 "$pid" 2>/dev/null || true + fi + rm -f "$PID_FILE" + echo "종료됨" +} + +case "${1:-}" in +start) start ;; +stop) stop ;; +restart) + stop + start + ;; +status) + if is_running; then + echo "running (pid $(cat "$PID_FILE"))" + else + echo "stopped" + fi + ;; +*) + echo "사용법: $0 {start|stop|restart|status} [dev|prod]" + exit 1 + ;; +esac