9 changed files with 444 additions and 25 deletions
@ -0,0 +1,108 @@
|
||||
// slack-notifier CI/CD |
||||
// 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) |
||||
// |
||||
// 호스트/유저/아키텍처는 파라미터로 조정. main 브랜치 + DEPLOY_HOST 지정 시에만 배포. |
||||
|
||||
pipeline { |
||||
agent none |
||||
|
||||
options { |
||||
timestamps() |
||||
disableConcurrentBuilds() |
||||
timeout(time: 20, unit: 'MINUTES') |
||||
} |
||||
|
||||
parameters { |
||||
// parameters 를 바꿀 때마다 이 기본값과 environment.PARAMS_VERSION 을 함께 +1 한다. |
||||
// (파라미터는 1빌드 늦게 등록되므로, 아래 Guard 가 stale 빌드를 막는다) |
||||
string(name: 'PARAMS_VERSION', defaultValue: '1', description: '파라미터 동기화 가드용 — 직접 바꾸지 말 것') |
||||
string(name: 'DEPLOY_HOST', defaultValue: '', description: 'EC2 호스트(DNS 또는 IP). 비우면 배포 스킵(빌드/테스트만).') |
||||
string(name: 'DEPLOY_USER', defaultValue: 'ubuntu', description: 'SSH 사용자 (Ubuntu=ubuntu, Amazon Linux=ec2-user)') |
||||
string(name: 'APP_DIR', defaultValue: '/opt/slack-notifier', description: '원격 설치 경로') |
||||
choice(name: 'ARCH', choices: ['arm64', 'amd64'], description: 'EC2 아키텍처 (Graviton/t4g=arm64, x86=amd64)') |
||||
} |
||||
|
||||
environment { |
||||
SSH_CRED_ID = 'slack-notifier-ec2' // Jenkins SSH 자격증명 ID (실제 ID로 교체 가능) |
||||
BIN = 'slack-notifier' |
||||
GOFLAGS = '-buildvcs=false' // .git 소유권 경고 회피 |
||||
PARAMS_VERSION = '1' // ← parameters 변경 시 위 기본값과 함께 +1 |
||||
} |
||||
|
||||
stages { |
||||
// 파라미터 정의가 갱신되기 전(=stale)에 빌드/배포되는 것을 막는다. |
||||
// Jenkinsfile 의 step/environment 는 즉시 반영되지만 parameters 는 1빌드 늦게 등록되므로, |
||||
// "현재 코드의 PARAMS_VERSION(즉시 반영)" 과 "등록된 params.PARAMS_VERSION(지난 등록)" 이 다르면 중단. |
||||
stage('Guard: params sync') { |
||||
agent any |
||||
steps { |
||||
script { |
||||
if (params.PARAMS_VERSION != env.PARAMS_VERSION) { |
||||
error("파라미터가 아직 갱신되지 않음(stale): 등록=${params.PARAMS_VERSION}, 현재=${env.PARAMS_VERSION}. " + |
||||
"이 빌드가 파라미터를 재등록했으니, 한 번 더 빌드하면 정상 진행됩니다.") |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
stage('Build & Test') { |
||||
agent any |
||||
// 'go-1.26' = Global Tool Configuration 의 Go 도구 이름(다르면 여기와 일치시킬 것). 컨테이너 없음 — 경량. |
||||
tools { go 'go-1.26' } |
||||
steps { |
||||
sh 'go version' |
||||
sh 'go vet ./...' |
||||
sh 'go test ./...' |
||||
sh "CGO_ENABLED=0 GOOS=linux GOARCH=${params.ARCH} go build -ldflags='-s -w' -o ${BIN} ." |
||||
sh "ls -lh ${BIN}" |
||||
stash name: 'binary', includes: "${BIN}" |
||||
} |
||||
} |
||||
|
||||
stage('Deploy') { |
||||
when { |
||||
allOf { |
||||
branch 'main' |
||||
expression { return params.DEPLOY_HOST?.trim() } |
||||
} |
||||
} |
||||
agent any |
||||
steps { |
||||
unstash 'binary' |
||||
sshagent(credentials: [env.SSH_CRED_ID]) { |
||||
sh ''' |
||||
set -eu |
||||
H="${DEPLOY_USER}@${DEPLOY_HOST}" |
||||
SSH="ssh -o StrictHostKeyChecking=accept-new" |
||||
|
||||
# 1) 새 바이너리를 임시 위치로 전송 |
||||
scp -o StrictHostKeyChecking=accept-new "${BIN}" "$H:/tmp/${BIN}.new" |
||||
|
||||
# 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" |
||||
''' |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
post { |
||||
success { echo "✅ ${env.BRANCH_NAME} 빌드 성공" } |
||||
failure { echo "❌ 빌드 실패 — 콘솔 로그 확인" } |
||||
} |
||||
} |
||||
@ -0,0 +1,121 @@
|
||||
# 배포 가이드 (EC2 + systemd + Jenkins) |
||||
|
||||
slack-notifier 를 **EC2(리눅스)** 에 단일 정적 바이너리로 올리고, **systemd** 로 운영하며, |
||||
**Jenkins** 로 빌드·배포를 자동화한다. 도커 레지스트리 불필요(바이너리 직접 배포). |
||||
|
||||
``` |
||||
[git push main] → Jenkins (golang:1.26 컨테이너) → go test/build → scp 바이너리 → EC2 |
||||
→ systemctl restart slack-notifier |
||||
[Gitea/Notion] ──webhook(HTTPS)──→ Caddy(:443) ──→ slack-notifier(:8000) ──→ Slack |
||||
``` |
||||
|
||||
--- |
||||
|
||||
## 1. EC2 최초 1회 셋업 |
||||
|
||||
### 1-1. 설치 경로 / 환경파일 |
||||
|
||||
```bash |
||||
sudo mkdir -p /opt/slack-notifier/{logs,data} |
||||
sudo chown -R $USER:$USER /opt/slack-notifier # Jenkins 배포 유저가 쓸 수 있게(또는 sudo 권한) |
||||
|
||||
# 비밀값/설정 — 서버에만 두고 절대 커밋하지 않음 |
||||
sudo vi /opt/slack-notifier/.env.prod |
||||
# SLACK_BOT_TOKEN=xoxb-... |
||||
# DEFAULT_SLACK_CHANNEL=C... (브로드캐스트 채널, GITEA_BROADCAST=on 일 때) |
||||
# GITEA_BROADCAST=off |
||||
# MONITOR_SLACK_CHANNEL=C... (에러 알림) |
||||
# GITEA_WEBHOOK_SECRET=... |
||||
# NOTION_VERIFICATION_TOKEN=... |
||||
# NOTION_API_TOKEN=... |
||||
# LOG_RETENTION_DAYS=7 |
||||
``` |
||||
|
||||
> systemd 서비스는 `-env prod` 로 실행되어 `.env.prod` 를 읽고, 로그는 `/opt/slack-notifier/logs/app-YYYY-MM-DD.log` 로 날짜별 회전된다. |
||||
|
||||
### 1-2. systemd 서비스 등록 |
||||
|
||||
```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 |
||||
``` |
||||
|
||||
서비스는 `/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 systemctl restart caddy |
||||
``` |
||||
|
||||
- 도메인 A 레코드 → EC2 퍼블릭 IP |
||||
- 보안그룹 인바운드 **80, 443** 개방(ACME 검증 + 웹훅 수신) |
||||
- 웹훅 등록 주소: `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-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) ← **인스턴스 아키텍처와 반드시 일치** |
||||
|
||||
배포는 **main 브랜치 + DEPLOY_HOST 지정** 시에만 수행된다. |
||||
|
||||
### 2-4. 파라미터 변경 시 (stale 가드) |
||||
|
||||
Jenkins 의 `parameters` 는 변경해도 **1빌드 늦게** 등록된다(step/environment 는 즉시 반영). |
||||
그 사이 옛 파라미터로 빌드·배포되는 사고를 막기 위해 `Guard: params sync` 스테이지가 있다. |
||||
|
||||
`parameters {}` 를 바꿀 때 규칙: |
||||
1. 파라미터를 수정하고 |
||||
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` 단계를 추가해도 됨. |
||||
|
||||
--- |
||||
|
||||
## 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' |
||||
``` |
||||
@ -0,0 +1,100 @@
|
||||
package logging |
||||
|
||||
import ( |
||||
"os" |
||||
"path/filepath" |
||||
"strings" |
||||
"sync" |
||||
"time" |
||||
) |
||||
|
||||
// dailyWriter는 날짜별 로그 파일(<dir>/<prefix>-YYYY-MM-DD<ext>)에 기록하고,
|
||||
// 날짜가 바뀌면 새 파일로 전환하며 retention일보다 오래된 파일을 삭제한다.
|
||||
// 외부 의존성 없이 표준 라이브러리만 사용(저사양 타깃 정책 — 회전을 위해 lumberjack 등을 쓰지 않음).
|
||||
type dailyWriter struct { |
||||
mu sync.Mutex |
||||
dir string |
||||
prefix string // 예: "app"
|
||||
ext string // 예: ".log"
|
||||
retention int // 보존 일수(오늘 포함)
|
||||
|
||||
curDate string |
||||
file *os.File |
||||
} |
||||
|
||||
// newDailyWriter는 logFile(예: logs/app.log)을 기준으로 날짜별 writer를 만든다.
|
||||
// → logs/app-2026-06-11.log 형태로 기록.
|
||||
func newDailyWriter(logFile string, retention int) (*dailyWriter, error) { |
||||
if retention <= 0 { |
||||
retention = 7 |
||||
} |
||||
base := filepath.Base(logFile) |
||||
ext := filepath.Ext(base) // ".log" (없으면 "")
|
||||
w := &dailyWriter{ |
||||
dir: filepath.Dir(logFile), |
||||
prefix: strings.TrimSuffix(base, ext), |
||||
ext: ext, |
||||
retention: retention, |
||||
} |
||||
if err := w.rotate(time.Now()); err != nil { |
||||
return nil, err |
||||
} |
||||
return w, nil |
||||
} |
||||
|
||||
func (w *dailyWriter) filename(date string) string { |
||||
return filepath.Join(w.dir, w.prefix+"-"+date+w.ext) |
||||
} |
||||
|
||||
func (w *dailyWriter) Write(p []byte) (int, error) { |
||||
w.mu.Lock() |
||||
defer w.mu.Unlock() |
||||
if err := w.rotate(time.Now()); err != nil { |
||||
return 0, err |
||||
} |
||||
return w.file.Write(p) |
||||
} |
||||
|
||||
// rotate은 now 날짜의 파일을 연다. 이미 같은 날짜 파일이 열려 있으면 아무것도 안 한다.
|
||||
// (newDailyWriter에서 최초 1회, 이후 Write에서 lock 보유 상태로 호출)
|
||||
func (w *dailyWriter) rotate(now time.Time) error { |
||||
date := now.Format("2006-01-02") |
||||
if w.file != nil && date == w.curDate { |
||||
return nil |
||||
} |
||||
if w.dir != "" && w.dir != "." { |
||||
if err := os.MkdirAll(w.dir, 0o755); err != nil { |
||||
return err |
||||
} |
||||
} |
||||
f, err := os.OpenFile(w.filename(date), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
if w.file != nil { |
||||
_ = w.file.Close() |
||||
} |
||||
w.file = f |
||||
w.curDate = date |
||||
w.cleanup(now) |
||||
return nil |
||||
} |
||||
|
||||
// cleanup은 retention일치(오늘 포함)만 남기고 오래된 날짜 파일을 삭제한다.
|
||||
func (w *dailyWriter) cleanup(now time.Time) { |
||||
cutoff := now.AddDate(0, 0, -(w.retention - 1)).Format("2006-01-02") |
||||
matches, err := filepath.Glob(filepath.Join(w.dir, w.prefix+"-*"+w.ext)) |
||||
if err != nil { |
||||
return |
||||
} |
||||
for _, path := range matches { |
||||
name := filepath.Base(path) |
||||
ds := strings.TrimSuffix(strings.TrimPrefix(name, w.prefix+"-"), w.ext) |
||||
if _, err := time.Parse("2006-01-02", ds); err != nil { |
||||
continue // 날짜 형식이 아니면 우리 파일이 아님 — 건드리지 않음
|
||||
} |
||||
if ds < cutoff { // YYYY-MM-DD는 사전식 비교 = 날짜순 비교
|
||||
_ = os.Remove(path) |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,63 @@
|
||||
package logging |
||||
|
||||
import ( |
||||
"os" |
||||
"path/filepath" |
||||
"testing" |
||||
"time" |
||||
) |
||||
|
||||
// 오래된 날짜 파일은 지우고 최근 retention일치만 남기는지 확인.
|
||||
func TestDailyWriterCleanup(t *testing.T) { |
||||
dir := t.TempDir() |
||||
now := time.Date(2026, 6, 11, 10, 0, 0, 0, time.UTC) |
||||
|
||||
// 14일치 더미 파일 생성 (app-YYYY-MM-DD.log)
|
||||
for i := 0; i < 14; i++ { |
||||
d := now.AddDate(0, 0, -i).Format("2006-01-02") |
||||
if err := os.WriteFile(filepath.Join(dir, "app-"+d+".log"), []byte("x"), 0o644); err != nil { |
||||
t.Fatal(err) |
||||
} |
||||
} |
||||
// 우리 형식이 아닌 파일은 보존돼야 함
|
||||
keep := filepath.Join(dir, "other.txt") |
||||
_ = os.WriteFile(keep, []byte("x"), 0o644) |
||||
|
||||
w := &dailyWriter{dir: dir, prefix: "app", ext: ".log", retention: 7} |
||||
w.cleanup(now) |
||||
|
||||
// 오늘 포함 7일치(today..today-6)만 남아야 함
|
||||
for i := 0; i < 14; i++ { |
||||
d := now.AddDate(0, 0, -i).Format("2006-01-02") |
||||
_, err := os.Stat(filepath.Join(dir, "app-"+d+".log")) |
||||
if i < 7 && err != nil { |
||||
t.Errorf("day-%d (%s) 가 삭제됨 — 보존돼야 함", i, d) |
||||
} |
||||
if i >= 7 && err == nil { |
||||
t.Errorf("day-%d (%s) 가 남아있음 — 삭제돼야 함", i, d) |
||||
} |
||||
} |
||||
if _, err := os.Stat(keep); err != nil { |
||||
t.Errorf("형식이 다른 파일 other.txt 가 삭제됨 — 보존돼야 함") |
||||
} |
||||
} |
||||
|
||||
// Write가 날짜 파일을 만들고 거기에 기록하는지 확인.
|
||||
func TestDailyWriterWrite(t *testing.T) { |
||||
dir := t.TempDir() |
||||
w, err := newDailyWriter(filepath.Join(dir, "app.log"), 7) |
||||
if err != nil { |
||||
t.Fatal(err) |
||||
} |
||||
if _, err := w.Write([]byte("hello\n")); err != nil { |
||||
t.Fatal(err) |
||||
} |
||||
today := time.Now().Format("2006-01-02") |
||||
data, err := os.ReadFile(filepath.Join(dir, "app-"+today+".log")) |
||||
if err != nil { |
||||
t.Fatalf("오늘 날짜 파일이 없음: %v", err) |
||||
} |
||||
if string(data) != "hello\n" { |
||||
t.Errorf("기록 내용 불일치: %q", string(data)) |
||||
} |
||||
} |
||||
Loading…
Reference in new issue