20181027

React Hooks 알파 — 리팩터링 계획

React Conf에서 Hooks 공개됐다. 16.7 alpha에 들어있음. 일단 사이드 프로젝트에 붙여봤는데, 이게 class 문법 없이 상태 가진 컴포넌트를 만드는 거라 코드 모양이 확 바뀐다.

import React, { useState, useEffect } from 'react';

function Counter() {
  const [n, setN] = useState(0);
  useEffect(() => {
    document.title = `count ${n}`;
  }, [n]);
  return (
    <button onClick={() => setN(n + 1)}>+{n}</button>
  );
}

이거 실화냐... lifecycle 뭘로 쪼개서 썼나 싶었던 코드들이 다 정리됨. componentDidMount + componentDidUpdate로 중복 써야 했던 fetch 로직이 useEffect 하나로 합쳐지는 게 크다.

리팩터 계획 메모:

  • 순수하게 상태/effect만 쓰는 함수 컴포넌트는 언제든 hooks로. 빠르게 전환 가능
  • HOC로 감싸던 withAuth, withTheme → custom hook(useAuth, useTheme)으로 교체 검토
  • class 컴포넌트에서 라이프사이클 복잡하게 얽힌 것(ex. resize + subscription + timer)은 단계별로
  • redux connect 쓰는 건 일단 놔두기. react-redux 쪽 hooks 버전 기다림

아직 alpha라 prod 투입은 당연히 안 함. 근데 API 자체는 거의 확정된 느낌이라 지금 써봐도 학습 가치가 있다. 내년 초엔 stable 올 듯.

20180916

PWA lighthouse 점수 끌어올리기

Lighthouse 3.0이 Chrome 69 DevTools에 번들로 들어오면서 점수 체계가 꽤 빡빡해졌다. 사내 PWA 프로젝트 점수가 갑자기 떨어져서 원인 분석 + 개선한 기록. 2.x 시절 80점 찍던 앱이 3.0에선 65까지 떨어진다. 이게 버그가 아니라 기준 변화.

현재 상태 (개선 전)

  • Performance 71
  • Accessibility 88
  • Best Practices 93
  • PWA 77
  • SEO 90

Lighthouse 3.0에서 Performance 산출식이 바뀌었다. FCP, FMP, Speed Index, TTI, First CPU Idle, Estimated Input Latency — 이 6개의 가중 평균인데, 3.0부터 TTI와 Speed Index 가중치가 커졌다. 우리 앱은 initial JS 번들이 큰 편이라 TTI가 밀려서 점수 하락.

고친 것들 순서대로

1) manifest.json에 background_color 누락. 이게 없으면 Android 홈에서 앱 런치 시 splash screen이 안 뜨고, Lighthouse PWA 검사에서 "Splash screen" 항목이 감점. 이 한 줄 추가로 PWA 총점 +6.

{
  "name": "Reindeers Admin",
  "short_name": "RDAdmin",
  "start_url": "/?utm_source=homescreen",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#0e47a1",
  "icons": [
    { "src": "/static/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "/static/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
  ]
}

icons는 192와 512 둘 다 필수. purpose: "maskable"은 안드로이드 O 이상에서 아이콘 모양 테마 대응(safe zone 안에 로고 배치). 이 속성 모르는 팀이 많음.

2) Service Worker 등록 경로와 scope. 기본적으로 /sw.js는 최상위에 있어야 scope가 사이트 전체("/")로 잡힌다. 우리는 정적 파일 디렉터리 구조상 /static/sw.js에 있었는데 이 경우 기본 scope가 /static/이 되어 루트 경로들을 제어 못 함. 두 가지 해법이 있다.

  1. 빌드 산출물을 루트로 복사(또는 nginx location = /sw.js로 별칭)
  2. Service-Worker-Allowed 헤더로 scope 확장
# nginx
location = /sw.js {
    alias /var/www/app/static/sw.js;
    add_header Service-Worker-Allowed "/" always;
    add_header Cache-Control "no-cache" always;
}

Cache-Control도 중요. sw.js는 반드시 max-age 0 혹은 no-cache. sw는 변경되지 않으면 브라우저가 새 버전을 감지 못하고, 감지 경로가 이 파일 자체의 HTTP fetch 결과 diff라서 긴 캐시가 걸리면 업데이트 절대 안 내려간다.

3) offline fallback. 네트워크 죽었을 때 뭐라도 보여야 PWA "Works offline" 체크 통과. 가장 간단한 네트워크 우선 + 오프라인 fallback.

const RUNTIME_CACHE = 'rt-v3';
const OFFLINE_URL = '/offline.html';
const PRECACHE = ['/offline.html', '/static/icons/icon-192.png'];

self.addEventListener('install', e => {
  e.waitUntil(caches.open('core-v3').then(c => c.addAll(PRECACHE)));
  self.skipWaiting();
});

self.addEventListener('activate', e => {
  e.waitUntil(self.clients.claim());
});

self.addEventListener('fetch', e => {
  if (e.request.mode === 'navigate') {
    // 네트워크 우선, 실패 시 오프라인 페이지
    e.respondWith(
      fetch(e.request).catch(() => caches.match(OFFLINE_URL))
    );
    return;
  }
  // static 리소스는 stale-while-revalidate
  e.respondWith(
    caches.match(e.request).then(cached => {
      const fetchP = fetch(e.request).then(res => {
        const clone = res.clone();
        caches.open(RUNTIME_CACHE).then(c => c.put(e.request, clone));
        return res;
      }).catch(() => cached);
      return cached || fetchP;
    })
  );
});

navigate 요청(HTML)과 정적 리소스 전략을 분리. 네비게이션 실패는 오프라인 페이지, 정적 리소스는 stale-while-revalidate로 즉시 응답 + 백그라운드 갱신.

4) 이미지 lazy load — IntersectionObserver로 교체. 기존에는 scroll 이벤트에서 getBoundingClientRect로 체크하는 관용구. 이게 메인 스레드 점유가 쏠쏠했다. IntersectionObserver로 바꾸니 스크롤 이벤트 자체가 제거되고 TTI 800ms 감소. long task가 거의 사라졌다.

const io = new IntersectionObserver(entries => {
  for (const e of entries) {
    if (!e.isIntersecting) continue;
    const img = e.target;
    img.src = img.dataset.src;
    if (img.dataset.srcset) img.srcset = img.dataset.srcset;
    io.unobserve(img);
  }
}, { rootMargin: '100px' });

document.querySelectorAll('img[data-src]').forEach(img => io.observe(img));

5) initial JS 번들 쪼개기. webpack 4의 splitChunks로 vendor/common/app 3분리. 라우트별 dynamic import(import('./page/orders'))로 관리자 권한 페이지를 떼어냄. 메인 route 초기 JS가 420KB → 180KB(parsed). TTI가 또 1.1초 빠짐.

6) 폰트 FOIT 제거. font-display: swap 선언과 <link rel="preload" as="font" crossorigin> 조합. FCP가 240ms 개선.

결과

  • Performance 71 → 91
  • Accessibility 88 → 95 (aria-label, color contrast 몇 개)
  • Best Practices 93 → 100
  • PWA 77 → 97
  • SEO 90 → 100 (meta description, hreflang)

남은 3점은 "maskable icon purpose" 속성이 Lighthouse 3.0 기준 아직 flaky하게 감지됨. 재검사하면 올라오기도 함. 미세한 문제라 일단 두기로.

교훈

  • Lighthouse 버전 올라갈 때마다 기준이 바뀐다. 점수 변화를 "회귀"로 오인하지 말 것
  • Performance는 JS 크기와 메인 스레드 점유가 거의 전부. Service Worker로 요리조리 캐시한다고 TTI가 극적으로 오지는 않는다
  • DevTools의 Coverage 탭으로 실제로 안 쓰는 코드 비율 확인. 대부분 40~60%가 첫 페이지에 미사용
  • CI에 lighthouse-ci 붙여서 PR마다 감시하면 회귀 예방에 효과적. 점수 임계값 넘으면 fail

20180824

Django 2.1 async view 기대

Django 2.1이 지난주에 릴리즈됐다. 제목처럼 async view 기대하고 있었는데 결론부터 쓰면 2.1에 async는 아직 안 들어왔다. 2.1은 admin/permission 쪽 다듬기 위주. 관련해서 장기 그림과 당장 어떻게 버틸지 메모.

2.1 실제 변경점

  • Model view permission. 기존엔 add/change/delete 3종이었는데 view가 추가되어 총 4종. 관리자 화면에서 "보긴 되는데 수정은 불가" 권한이 드디어 1급 시민으로. ContentType 마이그레이션이 돌면서 기존 perm codename이 view_modelname으로 자동 추가
  • PostgreSQL distinct on field. qs.order_by("a").distinct("a") 같은 필드 단위 DISTINCT ON이 ORM 선언으로 가능. Postgres 전용이라 MySQL엔 해당 없음
  • ContentType natural key 캐시. 중복 쿼리 잘라냄. 자잘한 개선
  • Python 3.5 드롭. 3.6 이상이 최소 요구. 2.1은 3.7도 공식 지원. 3.7의 dataclass가 이제 Django 쪽 서비스 레이어에서 안전하게 쓸 수 있다
  • QuerySet.iterator()의 chunk_size 파라미터 추가. 대용량 배치 처리 시 명시적으로 청크 크기 조정 가능

2.1 업그레이드 자체는 작업량 적음. pip install -U django==2.1 하고 makemigrations 돌려서 view perm 추가 마이그 수락하는 정도. admin 커스터마이즈한 곳에서 has_view_permission 오버라이드 필요한 경우가 있을 수 있으니 점검.

async는 어디까지 왔나

DEP 0009(Django Enhancement Proposal) 로드맵에 비동기 지원이 올라가 있다. 단계별로 가는 게 맞는 그림이고, ORM 마지막. 순서는 대략:

  1. ASGI 표준과 핸들러 레이어 정의
  2. Middleware async 호환 계층
  3. View가 async def 수용
  4. ORM async 메서드(aget, acreate...)
  5. 기타 부대 기능(cache, email, templates 일부)

1~2단계가 Django 3.0 타겟. 3.0 GA가 2019년 말 예정이고, view async는 3.1~3.2 사이, ORM async는 더 뒤가 될 공산이 크다. 커뮤니티 메일링 리스트에서 Andrew Godwin이 Channels 경험을 기반으로 직접 끌고 가는 중이라 방향은 명확.

왜 어려운가

Django ORM이 지연 평가 + 동기 쿼리 전제로 설계되어 있어서 async 전환이 단순히 async/await을 더하는 문제가 아니다. QuerySet은 이터레이션 시점에 쿼리를 날리는데, sync 이터레이터와 async 이터레이터의 인터페이스가 다르다. 기존 API를 깨지 않으면서 async for obj in qs를 지원하려면 별도 메서드(.aiter()) 추가가 필요. 커넥션도 psycopg2가 동기라 asyncpg나 aiopg로 가야 하는데 기존 DB feature 매트릭스 유지가 만만치 않다.

그 사이 어떻게 버틸까

우리 서비스에서 async가 아쉬운 엔드포인트는 대개 "외부 API 호출이 많고, 응답 시간의 대부분이 I/O 대기"인 애들. 실시간 스펠체크 프록시, 외부 검색 어그리게이션, 푸시 알림 fanout 같은 것. 지금 당장의 해법 세 가지 정도 쓰고 있음.

  1. aiohttp 별도 서비스로 분리. 비동기 I/O 헤비한 엔드포인트만 aiohttp 앱으로 빼고, 같은 도메인의 경로 하나를 nginx에서 upstream 분기. 인증은 JWT(앱 서비스에서 발급 + Redis 세션 공유)로 Django 세션과 호환. 신규 기능 중 I/O 집중 부분이 이렇게 3개 쪼개져 있음
  2. Channels로 WebSocket 쪽만. HTTP 쪽은 Django 그대로, WS 쪽은 Channels 2.x(ASGI). Redis backing
  3. Celery + 동기 view. view에서 긴 작업 직접 하지 않고 태스크로 던지고 202 Accepted. 이게 정석 중 정석. 이미 적용되어 있는 부분이 많음

1번이 제일 실용적인데 운영 부담이 올라간다는 단점. 두 개 프레임워크의 배포/로깅/메트릭 파이프라인이 나란히 간다. Django에 async가 붙으면 이 중 상당 부분을 회수할 수 있어서 기다려진다.

짧은 결론

2.1은 패치 레벨 업그레이드 + view perm 정리 정도의 체감. async 열광은 3.x 본격 도입 시점까지 참는 게 맞겠다. 2.1 자체는 production 올리는 데 큰 장애물 없음.

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로 만들어 두는 중. 신규 차트 만들 때 이 기반에서 파생.

20180423

Redis 4 모듈 — RediSearch 시험

redis 4.0에서 모듈 API가 정식으로 들어오면서 RediSearch, ReJSON, ReBloom 같은 모듈이 쓸 만해졌다. RediSearch 1.0이 작년에 GA, 지금 최신이 1.0.9. 사내 관리자 페이지 제품 검색을 Elasticsearch에서 걷어낼 수 있을지 궁금해서 실험.

띄우기. 모듈 직접 빌드해서 --loadmodule로 붙이거나 redislabs 배포 이미지.

# redis.conf
loadmodule /usr/lib/redis/modules/redisearch.so

# or docker
docker run -p 6379:6379 redislabs/redisearch:1.0.9

인덱스 정의. 스키마 기반이라는 게 Elasticsearch의 mapping과 비슷. FT.CREATE로 필드별 타입(TEXT/TAG/NUMERIC/GEO)과 옵션(WEIGHT, SORTABLE, NOINDEX)을 지정.

FT.CREATE products
  SCHEMA
    name   TEXT WEIGHT 5.0 PHONETIC dm:en
    desc   TEXT WEIGHT 1.0
    brand  TAG  SEPARATOR ","
    cat    TAG  SEPARATOR "|"
    price  NUMERIC SORTABLE
    stock  NUMERIC
    loc    GEO

FT.ADD products p:1 1.0 FIELDS
  name  "acme drill 18v"
  desc  "cordless drill set with battery"
  brand "acme"
  cat   "tools|power"
  price 129.0
  stock 42
  loc   "-73.98,40.76"

FT.SEARCH products "drill @price:[50 150] @brand:{acme}" LIMIT 0 10

동작 원리 메모. 내부적으로 전형적인 역색인을 레디스 해시 자료구조로 구현. 각 토큰별로 posting list(문서 ID + 위치)를 압축해 저장하고, 쿼리 시 intersection을 byte-level로 뛰면서 스코어 계산. scoring은 기본 TF-IDF이고 SCORER로 BM25 전환 가능. 1.0.x 기준 BM25는 preview 수준이라 대체로 기본값 사용.

Aggregation 지원(FT.AGGREGATE)이 의외로 쓸 만함. GROUPBY/REDUCE로 패싯 카운트 뽑기 가능.

FT.AGGREGATE products "@brand:{acme}"
  GROUPBY 1 @cat
    REDUCE COUNT 0 AS cnt
  SORTBY 2 @cnt DESC
  LIMIT 0 20

벤치. 우리 레포 제품 110만 건.

  • 인덱싱 처리량: 약 9,500 docs/sec(1노드, 코어 4). bulk는 pipeline으로 1000건 단위. ES(5.6) 같은 머신 싱글 샤드 기준 약 14,000 docs/sec. 인덱싱은 ES가 우위
  • 검색 latency: 단일 키워드 쿼리 p50 1.3ms, p99 4.2ms. ES는 p50 3.1ms, p99 12ms. 복합 필터 있는 쿼리에서도 RediSearch가 대략 2배 빠름
  • 메모리: RediSearch가 전부 메모리. 110만 건 색인이 대략 1.9GB. ES는 JVM + OS page cache 합쳐 3GB 수준인데 디스크 기반이라 훨씬 큰 데이터를 다룰 수 있다는 장점
  • 디스크: RediSearch RDB 저장 시 850MB. ES는 세그먼트 포함 4.3GB

한계 — 우리 케이스 기준.

  • 한글 형태소 분석 없음. 1.0은 영어 + 중국어(실험적)만. 공백 기준 tokenize라 "무선 드릴"을 검색해도 "무선 드릴셋트" 같은 복합어가 매칭 안 됨. ES + nori(지금 개발 중) 혹은 은전한닢 같은 대안이 필요
  • Phonetic matching(PHONETIC dm:en)은 영어 대상. 영문 제품명 오타 내성에는 도움
  • Suggestion API(FT.SUGADD/SUGGET)는 별도 자료구조(Trie). 자동완성은 깔끔
  • 분산 검색 아직 없음. 1.0은 단일 노드. 샤드 분산은 Enterprise 전용 기능. 오픈소스 1.x에서는 앱 레벨에서 샤딩하거나 데이터 크기가 단일 노드에 맞을 때만

운영 주의. 모듈이 포함된 RDB는 동일 모듈이 로드된 노드에서만 로드 가능. master/replica가 버전 미스매치면 replication 깨짐. 배포 시 모든 노드 동일 .so 버전 고정 필수. 또 DEBUG RELOAD로 로컬 검증해도 모듈 상태까지 돌아오는지 매번 확인.

인덱스 파괴(FT.DROP)는 기본 옵션이면 색인만 제거하고 원본 해시(FT.ADD로 넣은 데이터)는 보존. 오히려 DD 플래그로 문서까지 지워야 완전 삭제. 실수 방지용으로 좋기도 하고, 인덱스만 재구성할 땐 편함.

결론: 영문 + 소규모(단일 노드에 들어가는) 검색이면 RediSearch가 latency/간결함에서 이긴다. 다국어 + 수천만 건 규모면 ES가 여전히 맞는 선택. 우리는 B2B 관리자 내부 도구 제품 검색(영문 SKU 위주, 12만 건) 한 군데 먼저 빼서 올려 보고 판단 예정.

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 + 특정 버전 이상이라 그런 듯.

20180103

TensorFlow 1.5 CNN 이미지 분류 실험

연말에 시간 남아서 TF 1.5 rc 올라온 김에 CNN 돌려봤다. 데이터는 사내 제품 이미지 분류 토이 셋(8 클래스, 각 300장 정도). 별 거 아니지만 기록용.

모델은 그냥 평범하게.

import tensorflow as tf
from tensorflow.contrib import slim

def build(x, is_training):
    net = slim.conv2d(x, 32, [3,3], scope='conv1')
    net = slim.max_pool2d(net, [2,2], scope='pool1')
    net = slim.conv2d(net, 64, [3,3], scope='conv2')
    net = slim.max_pool2d(net, [2,2], scope='pool2')
    net = slim.flatten(net)
    net = slim.dropout(net, 0.5, is_training=is_training)
    return slim.fully_connected(net, 8, activation_fn=None)

tf.contrib 언제 없어진다는 얘기 계속 나오는데 아직까지는 slim 편함. 1.5에서 eager execution도 써보려다가 학습 속도 차이 너무 나서 접음. 아직은 graph 기반이 맘편하다.

결과는 val acc 0.81 정도. 데이터 augmentation(좌우반전, 살짝 zoom) 했을 때 0.86까지 올라감. 배치 사이즈는 32. GPU는 1080ti 한 장.

메모 — 다음에 할 것:
- pretrained resnet_v2_50 feature로 바꿔보기
- tf.data API 써서 큐 없애기 (1.4부터 정식)
- TensorBoard에 confusion matrix 넣는 방법